feat: redo settings and all org pages. (#601)

* feat: new components, deploy storybook

* refactor: simplify component field definitions and enhance button loading state

- Removed unnecessary type imports and simplified the FIELDS array definitions across multiple components in Storybook.
- Updated button component to use a loading state instead of isLoading, improving clarity and consistency in props.
- Adjusted button stories to reflect the new loading prop and enhanced the button's loading visual feedback.
- Minor CSS adjustments for better layout and styling consistency.

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

* chore: update bits-ui to version 2.14.4 and all org related UI's

- Updated bits-ui dependency in pnpm-lock.yaml and package.json to version 2.14.4.
- Refactored imports for RoleBasedSecurity and UpgradeBanner components to use the new ui feature directory.
- Removed unused Course components and related files to clean up the codebase.
- Enhanced various components to improve consistency and maintainability.

* chore: move all setting buttons to the top and update auth ui styles

* chore: remove svelte-loading-spinners and replace with @cio/ui/base/spinner

- Removed all instances of svelte-loading-spinners from the project.
- Updated components to use the new Spinner from @cio/ui/base/spinner.
- Cleaned up pnpm-lock.yaml and package.json to reflect the removal of svelte-loading-spinners.
- Added new CSS animations for rocket launch effects in app.css.
- Enhanced email change flow with updated verification messages in translations.

* chore: remove supabase file upload from dashboard and move to r2

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
+10
View File
@@ -33,3 +33,13 @@ Now when I am ready to deploy the feature, I can use [option 2](https://orm.driz
## OR
I can make changes directly to the schema file and run `pnpm db push` to push the changes to the source db.
## Change email flow
1. User changes email on dashboard to new email
2. We send an email verification to the current, permitting the change
3. Meanwhile the frontend reverts to the previous email.
4. User clicks approve link in email
5. User is redirected to settings page with message that another email has been sent to the new email
6. User goes to new email inbox and clicks approve link in email
7. User is taken to the settings page with new email address.
+1
View File
@@ -11,6 +11,7 @@
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch",
"db": "drizzle-kit",
"db:setup": "tsx src/scripts/db-setup.ts",
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
"pull": "drizzle-kit pull --config drizzle.config.ts",
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
+3 -2
View File
@@ -15,8 +15,8 @@ export const auth = betterAuth({
baseURL: CONSTANTS.BASE_URL,
database: drizzleAdapter(db, {
provider: 'pg',
schema,
debugLogs: true
schema
// debugLogs: true
}),
emailAndPassword: emailAndPassword,
user: {
@@ -67,6 +67,7 @@ export const auth = betterAuth({
},
update: {
after: async (user) => {
console.log('update user', user);
await syncUserWithProfile(user);
}
}
+6 -3
View File
@@ -28,17 +28,20 @@ type ChangeEmailConfirmationOptions = Parameters<
*/
export const sendVerificationEmail = async (options: EmailVerificationOptions) => {
const { user, url } = options;
console.log('\nsendVerificationEmail');
console.log('\nsendVerificationEmail', options);
// The purpose of this is to prevent verification email trigger from better-auth because we already have a flow in the UI for triggering this email.
if (!url.includes('trigger=app') && !url.includes('trigger%3Dapp')) {
return;
}
// Remove trigger=app or trigger%3Dapp from the URL
const link = url.replace('trigger=app', '').replace('trigger%3Dapp', '');
await sendEmail('verifyEmail', {
to: user.email,
fields: {
link: url,
link,
userName: user.name || undefined
}
});
@@ -51,7 +54,7 @@ export const sendVerificationEmail = async (options: EmailVerificationOptions) =
*/
export const sendChangeEmailConfirmation = async (options: ChangeEmailConfirmationOptions) => {
const { user, newEmail, url } = options;
console.log('\nsendChangeEmailConfirmation');
console.log('\nsendChangeEmailConfirmation', options);
await sendEmail('verifyEmail', {
to: user.email,
+77 -3
View File
@@ -2,8 +2,8 @@ import * as schema from '@db/schema';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
import { TNewCourse } from '@db/types';
import { db } from '@db/drizzle';
/**
* Gets courses by organization siteName
@@ -50,15 +50,89 @@ export const getCoursesBySiteName = async (siteName: string) => {
}
}));
} catch (error) {
throw new Error(`Failed to get courses by site name "${siteName}": ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new Error(
`Failed to get courses by site name "${siteName}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Gets lessons by organization siteName
* @param siteName Organization site name
* @returns Array of lessons
*/
export const getLessonsBySiteName = async (siteName: string) => {
const result = await db
.select({
lesson: schema.lesson
})
.from(schema.lesson)
.innerJoin(schema.course, eq(schema.lesson.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
return result.map((row) => row.lesson);
};
/**
* Gets exercises by organization siteName
* @param siteName Organization site name
* @returns Array of exercises
*/
export const getExercisesBySiteName = async (siteName: string) => {
const result = await db
.select({
exercise: schema.exercise
})
.from(schema.exercise)
.innerJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.innerJoin(schema.course, eq(schema.lesson.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
return result.map((row) => row.exercise);
};
/**
* Gets courses by organization siteName (for setup - includes unpublished courses)
* @param siteName Organization site name
* @returns Array of courses with organization info
*/
export const getCoursesBySiteNameForSetup = async (siteName: string) => {
const result = await db
.select({
course: schema.course,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
avatarUrl: schema.organization.avatarUrl
}
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
return result.map((row) => ({
...row.course,
group: {
organization: row.organization
}
}));
};
export async function getCourseById(courseId: string) {
try {
return await db.select().from(schema.course).where(eq(schema.course.id, courseId)).limit(1);
} catch (error) {
throw new Error(`Failed to get course by ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new Error(
`Failed to get course by ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -8,7 +8,7 @@ import type {
TOrganization,
TOrganizationPlan
} from '@db/types';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
@@ -90,6 +90,17 @@ export const createOrganizationMember = async (data: TNewOrganizationmember) =>
return member;
};
/**
* Creates multiple organization members in a single query
* @param data Array of organization member creation data
* @returns Array of created members
*/
export const createOrganizationMembers = async (data: TNewOrganizationmember[]) => {
const members = await db.insert(schema.organizationmember).values(data).returning();
return members;
};
export const checkSiteNameExists = async (siteName: string): Promise<boolean> => {
const result = await db
.select({ id: schema.organization.id })
@@ -102,10 +113,132 @@ export const checkSiteNameExists = async (siteName: string): Promise<boolean> =>
return result.length > 0;
};
/**
* Gets an organization by siteName
* @param siteName Organization site name
* @returns Organization or null if not found
*/
export const getOrganizationBySiteName = async (siteName: string): Promise<TOrganization | null> => {
const [organization] = await db
.select()
.from(schema.organization)
.where(eq(schema.organization.siteName, siteName))
.limit(1);
return organization || null;
};
/**
* Gets an organization by ID
* @param id Organization ID
* @returns Organization or null if not found
*/
export const getOrganizationById = async (id: string): Promise<TOrganization | null> => {
const [organization] = await db.select().from(schema.organization).where(eq(schema.organization.id, id)).limit(1);
return organization || null;
};
export const deleteOrganizationById = async (id: string) => {
await db.delete(schema.organization).where(eq(schema.organization.id, id));
};
/**
* Checks if an email already exists as a team member in an organization
* @param orgId Organization ID
* @param email Email address to check
* @returns True if email exists, false otherwise
*/
export const checkEmailExistsInOrg = async (orgId: string, email: string): Promise<boolean> => {
const result = await db
.select({ id: schema.organizationmember.id })
.from(schema.organizationmember)
.where(
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.email, email.toLowerCase()))
)
.limit(1);
return result.length > 0;
};
/**
* Checks which emails already exist as team members in an organization (bulk check)
* Checks both organizationmember.email and profile.email (via organizationmember.profileId)
* @param orgId Organization ID
* @param emails Array of email addresses to check (should already be normalized)
* @returns Array of emails that already exist in the organization
*/
export const checkEmailsExistInOrg = async (orgId: string, emails: string[]): Promise<string[]> => {
if (emails.length === 0) {
return [];
}
const [orgMemberEmails, profileEmails] = await Promise.all([
db
.select({ email: schema.organizationmember.email })
.from(schema.organizationmember)
.where(
and(eq(schema.organizationmember.organizationId, orgId), inArray(schema.organizationmember.email, emails))
),
db
.select({ email: schema.profile.email })
.from(schema.organizationmember)
.innerJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(and(eq(schema.organizationmember.organizationId, orgId), inArray(schema.profile.email, emails)))
]);
const existingEmails = new Set<string>();
orgMemberEmails.forEach((r) => {
if (r.email) {
existingEmails.add(r.email.toLowerCase());
}
});
profileEmails.forEach((r) => {
if (r.email) {
existingEmails.add(r.email.toLowerCase());
}
});
return Array.from(existingEmails);
};
/**
* Deletes an organization member by ID
* @param orgId Organization ID
* @param memberId Member ID to delete
* @returns Deleted member or null if not found
*/
export const deleteOrganizationMember = async (orgId: string, memberId: number) => {
const [deleted] = await db
.delete(schema.organizationmember)
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.id, memberId)))
.returning();
return deleted || null;
};
/**
* Checks if a user is an admin of an organization
* @param orgId Organization ID
* @param profileId Profile ID to check
* @returns True if user is admin, false otherwise
*/
export const isUserOrgAdmin = async (orgId: string, profileId: string): Promise<boolean> => {
const result = await db
.select({ roleId: schema.organizationmember.roleId })
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.profileId, profileId),
eq(schema.organizationmember.roleId, ROLE.ADMIN)
)
)
.limit(1);
return result.length > 0;
};
/**
* Gets organization team members (non-students)
* @param orgId Organization ID
@@ -167,8 +300,8 @@ export const getOrganizationAudience = async (orgId: string) => {
id: profile.id,
name: profile.fullname,
email: profile.email || '',
avatar_url: profile.avatarUrl || '',
date_joined: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
avatarUrl: profile.avatarUrl || '',
createdAt: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
}));
};
@@ -265,7 +398,7 @@ export const getOrganizations = async (filters?: {
* @param data Organization plan creation data
* @returns Created organization plan record
*/
export const createOrganizationPlan = async (data: TNewOrganizationPlan) => {
export const createOrganizationPlan = async (data: TNewOrganizationPlan): Promise<TOrganizationPlan> => {
const [plan] = await db.insert(schema.organizationPlan).values(data).returning();
return plan;
};
@@ -276,7 +409,10 @@ export const createOrganizationPlan = async (data: TNewOrganizationPlan) => {
* @param payload Payload data to update
* @returns Updated organization plan record
*/
export const updateOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
export const updateOrganizationPlan = async (
subscriptionId: string,
payload: TOrganizationPlan['payload']
): Promise<TOrganizationPlan> => {
const [plan] = await db
.update(schema.organizationPlan)
.set({ payload, updatedAt: sql`timezone('utc'::text, now())` })
@@ -291,7 +427,10 @@ export const updateOrganizationPlan = async (subscriptionId: string, payload: TO
* @param payload Payload data to update
* @returns Updated organization plan record
*/
export const cancelOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
export const cancelOrganizationPlan = async (
subscriptionId: string,
payload: TOrganizationPlan['payload']
): Promise<TOrganizationPlan> => {
const [plan] = await db
.update(schema.organizationPlan)
.set({
@@ -305,3 +444,19 @@ export const cancelOrganizationPlan = async (subscriptionId: string, payload: TO
return plan;
};
/**
* Updates an organization
* @param id Organization ID
* @param data Partial organization data to update
* @returns Updated organization record
*/
export const updateOrganization = async (id: string, data: Partial<TOrganization>): Promise<TOrganization> => {
const [organization] = await db
.update(schema.organization)
.set(data)
.where(eq(schema.organization.id, id))
.returning();
return organization;
};
@@ -1,4 +1,4 @@
import { TOrganization, TOrganizationPlan, TOrganizationmember } from '@db/types';
import { TOrganization, TOrganizationPlan } from '@db/types';
export type OrganizationPlan = Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & {
customerId: string | null;
+139 -323
View File
@@ -8,7 +8,6 @@ import {
json,
jsonb,
pgEnum,
pgPolicy,
pgTable,
pgView,
serial,
@@ -28,7 +27,7 @@ export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
export const user = pgTable('user', {
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
name: text('name').notNull(),
@@ -49,7 +48,7 @@ export const user = pgTable('user', {
export const session = pgTable('session', {
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
expiresAt: timestamp('expires_at').notNull(),
@@ -68,7 +67,7 @@ export const session = pgTable('session', {
export const account = pgTable('account', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
accountId: text('account_id').notNull(),
@@ -91,7 +90,7 @@ export const account = pgTable('account', {
export const verification = pgTable('verification', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
identifier: text('identifier').notNull(),
@@ -108,7 +107,7 @@ export const analyticsLoginEvents = pgTable(
'analytics_login_events',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
userId: uuid('user_id').notNull(),
@@ -125,14 +124,7 @@ export const analyticsLoginEvents = pgTable(
foreignColumns: [user.id],
name: 'analytics_login_events_user_id_fkey'
}),
unique('analytics_login_events_user_id_unique').on(table.userId),
pgPolicy('Enable delete for users based on user_id', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = user_id)`
}),
pgPolicy('Users can insert their own login events', { as: 'permissive', for: 'insert', to: ['authenticated'] })
unique('analytics_login_events_user_id_unique').on(table.userId)
]
);
@@ -154,11 +146,7 @@ export const lessonSection = pgTable(
name: 'public_lesson_section_course_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
.onDelete('cascade')
]
);
@@ -166,7 +154,7 @@ export const group = pgTable(
'group',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
name: varchar().notNull(),
@@ -180,16 +168,7 @@ export const group = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'group_organization_id_fkey'
}),
pgPolicy('Enable insert for authenticated users only', {
as: 'permissive',
for: 'insert',
to: ['authenticated'],
withCheck: sql`true`
}),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('Only org admins can delete', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Only org admins can update', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -221,19 +200,7 @@ export const groupAttendance = pgTable(
columns: [table.studentId],
foreignColumns: [groupmember.id],
name: 'group_attendance_student_id_fkey'
}),
pgPolicy('User must be a course member to INSERT', {
as: 'permissive',
for: 'insert',
to: ['public'],
withCheck: sql`is_user_in_course_group_or_admin(( SELECT course.group_id
FROM course
WHERE (course.id = group_attendance.course_id)
LIMIT 1))`
}),
pgPolicy('User must be a course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('User must be a course member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] })
})
]
);
@@ -271,22 +238,7 @@ export const appsPollOption = pgTable(
columns: [table.pollId],
foreignColumns: [appsPoll.id],
name: 'apps_poll_option_poll_id_fkey'
}),
pgPolicy('User must be a course member to INSERT', {
as: 'permissive',
for: 'insert',
to: ['public'],
withCheck: sql`is_user_in_course_group_or_admin(( SELECT groupmember.group_id
FROM groupmember
WHERE (groupmember.id = ( SELECT apps_poll."authorId"
FROM apps_poll
WHERE (apps_poll.id = apps_poll_option.poll_id)
LIMIT 1))
LIMIT 1))`
}),
pgPolicy('User must be a course member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be a teacher to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] })
})
]
);
@@ -323,17 +275,7 @@ export const profile = pgTable(
unique('profile_username_key').on(table.username),
unique('profile_email_key').on(table.email),
index('idx_profile_id').on(table.id),
index('idx_profile_email').on(table.email),
pgPolicy('Only auth users can read profile', {
as: 'permissive',
for: 'select',
to: ['anon', 'authenticated'],
using: sql`true`
}),
pgPolicy('Public profiles are viewable by everyone.', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('User can only delete their profiles', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Users can insert their own profile.', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('Users can update own profile.', { as: 'permissive', for: 'update', to: ['public'] })
index('idx_profile_email').on(table.email)
]
);
@@ -352,7 +294,7 @@ export const option = pgTable(
isCorrect: boolean('is_correct').default(false).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
questionId: bigint('question_id', { mode: 'number' }).notNull(),
value: uuid().default(sql`extensions.gen_random_uuid()`),
value: uuid().default(sql`gen_random_uuid()`),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
@@ -361,16 +303,7 @@ export const option = pgTable(
columns: [table.questionId],
foreignColumns: [question.id],
name: 'option_question_id_fkey'
}).onDelete('cascade'),
pgPolicy('Allow authenticated users to SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
}).onDelete('cascade')
]
);
@@ -404,13 +337,7 @@ export const quizPlay = pgTable(
foreignColumns: [quiz.id],
name: 'quiz_play_quiz_id_fkey'
}),
unique('quiz_play_pin_key').on(table.pin),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['authenticated'],
using: sql`true`
})
unique('quiz_play_pin_key').on(table.pin)
]
);
@@ -437,12 +364,6 @@ export const organizationContacts = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'organization_contacts_organization_id_fkey'
}),
pgPolicy('Enable read access for all users', {
as: 'permissive',
for: 'insert',
to: ['public'],
withCheck: sql`true`
})
]
);
@@ -474,18 +395,7 @@ export const lessonComment = pgTable(
columns: [table.lessonId],
foreignColumns: [lesson.id],
name: 'lesson_comment_lesson_id_fkey'
}).onDelete('cascade'),
pgPolicy('Delete only your own comment', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = ( SELECT groupmember.profile_id
FROM groupmember
WHERE (groupmember.id = lesson_comment.groupmember_id)))`
}),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('Update only your own', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be in course group to INSERT', { as: 'permissive', for: 'insert', to: ['public'] })
}).onDelete('cascade')
]
);
@@ -493,7 +403,7 @@ export const quiz = pgTable(
'quiz',
{
id: uuid()
.default(sql`extensions.gen_random_uuid()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -509,12 +419,6 @@ export const quiz = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'quiz_organization_id_fkey'
}),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['authenticated'],
using: sql`true`
})
]
);
@@ -523,7 +427,7 @@ export const submission = pgTable(
'submission',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
@@ -559,16 +463,7 @@ export const submission = pgTable(
columns: [table.submittedBy],
foreignColumns: [groupmember.id],
name: 'submission_submitted_by_fkey'
}),
pgPolicy('Only authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['authenticated'],
using: sql`true`
}),
pgPolicy('User must be a course member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be a course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be a course member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -586,14 +481,7 @@ export const submissionstatus = pgTable(
label: varchar().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['authenticated'],
using: sql`true`
})
]
(table) => []
);
export const course = pgTable(
@@ -603,7 +491,7 @@ export const course = pgTable(
description: varchar().notNull(),
overview: varchar().default('Welcome to this amazing course 🚀 '),
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -630,16 +518,7 @@ export const course = pgTable(
foreignColumns: [group.id],
name: 'course_group_id_fkey'
}),
unique('course_slug_key').on(table.slug),
pgPolicy('Enable access to all users if PUBLIC or to course members when ', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(is_published OR is_user_in_course_group_or_admin(group_id))`
}),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
unique('course_slug_key').on(table.slug)
]
);
@@ -666,18 +545,7 @@ export const appsPoll = pgTable(
columns: [table.courseId],
foreignColumns: [course.id],
name: 'apps_poll_courseId_fkey'
}),
pgPolicy('Delete only your own poll', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = ( SELECT groupmember.profile_id
FROM groupmember
WHERE (groupmember.id = apps_poll."authorId")))`
}),
pgPolicy('Update only your own', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be a course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] })
})
]
);
@@ -717,12 +585,6 @@ export const organizationEmaillist = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'organization_emaillist_organization_id_fkey'
}),
pgPolicy('Enable read access for all users', {
as: 'permissive',
for: 'insert',
to: ['public'],
withCheck: sql`true`
})
]
);
@@ -735,7 +597,7 @@ export const lesson = pgTable(
slideUrl: varchar('slide_url'),
courseId: uuid('course_id').notNull(),
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -770,11 +632,7 @@ export const lesson = pgTable(
name: 'public_lesson_section_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
.onDelete('cascade')
]
);
@@ -810,16 +668,7 @@ export const appsPollSubmission = pgTable(
columns: [table.selectedById],
foreignColumns: [groupmember.id],
name: 'apps_poll_submission_selected_by_id_fkey'
}),
pgPolicy('Authenticated users can read', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Delete your own submission', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Update your own submission', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -832,7 +681,7 @@ export const exercise = pgTable(
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
dueBy: timestamp('due_by', { mode: 'string' })
@@ -842,11 +691,7 @@ export const exercise = pgTable(
columns: [table.lessonId],
foreignColumns: [lesson.id],
name: 'exercise_lesson_id_fkey'
}).onDelete('cascade'),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
}).onDelete('cascade')
]
);
@@ -854,7 +699,7 @@ export const groupmember = pgTable(
'groupmember',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
groupId: uuid('group_id').notNull(),
@@ -883,16 +728,7 @@ export const groupmember = pgTable(
}),
unique('unique_entries').on(table.groupId, table.profileId, table.email),
unique('unique_group_email').on(table.groupId, table.email),
unique('unique_group_profile').on(table.groupId, table.profileId),
pgPolicy('Enable insert for authenticated users only', {
as: 'permissive',
for: 'insert',
to: ['authenticated'],
withCheck: sql`true`
}),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
unique('unique_group_profile').on(table.groupId, table.profileId)
]
);
@@ -924,15 +760,7 @@ export const lessonCompletion = pgTable(
foreignColumns: [profile.id],
name: 'lesson_completion_profile_id_fkey'
}),
unique('unique_lesson_profile').on(table.lessonId, table.profileId),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an course member or Admin to perform ALL operation', {
as: 'permissive',
for: 'all',
to: ['public']
}),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
unique('unique_lesson_profile').on(table.lessonId, table.profileId)
]
);
@@ -940,7 +768,7 @@ export const communityAnswer = pgTable(
'community_answer',
{
id: uuid()
.default(sql`extensions.gen_random_uuid()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -968,16 +796,7 @@ export const communityAnswer = pgTable(
columns: [table.questionId],
foreignColumns: [communityQuestion.id],
name: 'community_answer_question_id_fkey'
}),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Delete your own answer', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Update your own answer', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -1024,16 +843,7 @@ export const communityQuestion = pgTable(
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'community_question_organization_id_fkey'
}),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Delete your own question', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Update your own question', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -1058,18 +868,7 @@ export const courseNewsfeed = pgTable(
columns: [table.courseId],
foreignColumns: [course.id],
name: 'course_newsfeed_course_id_fkey'
}),
pgPolicy('Delete your own comment', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = ( SELECT groupmember.profile_id
FROM groupmember
WHERE (groupmember.id = course_newsfeed.author_id)))`
}),
pgPolicy('Update only your own', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be a course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be a course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] })
})
]
);
@@ -1100,18 +899,7 @@ export const courseNewsfeedComment = pgTable(
foreignColumns: [courseNewsfeed.id],
name: 'course_newsfeed_comment_course_newsfeed_id_fkey'
}),
unique('course_newsfeed_comment_id_key').on(table.id),
pgPolicy('Delete your own', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() = ( SELECT groupmember.profile_id
FROM groupmember
WHERE (groupmember.id = course_newsfeed_comment.author_id)))`
}),
pgPolicy('Update only your own', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be a course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be a course member to SELECT', { as: 'permissive', for: 'select', to: ['public'] })
unique('course_newsfeed_comment_id_key').on(table.id)
]
);
@@ -1155,11 +943,7 @@ export const organizationPlan = pgTable(
name: 'organization_plan_triggered_by_fkey'
}),
unique('organization_plan_subscription_id_key').on(table.subscriptionId),
index('idx_organization_plan_org_id').on(table.orgId),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
index('idx_organization_plan_org_id').on(table.orgId)
]
);
@@ -1185,11 +969,7 @@ export const lessonLanguage = pgTable(
name: 'public_lesson_language_lesson_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
.onDelete('cascade')
]
);
@@ -1211,16 +991,7 @@ export const lessonLanguageHistory = pgTable(
name: 'public_lesson_language_history_lesson_language_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
pgPolicy('Authenticated users can SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
.onDelete('cascade')
]
);
@@ -1263,22 +1034,7 @@ export const organizationmember = pgTable(
}),
index('idx_organizationmember_profile_id').on(table.profileId),
index('idx_organizationmember_organization_id').on(table.organizationId),
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId),
pgPolicy('Allow authenticated users to read.', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Only admin can delete', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('Only admin can update', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('Only user can update their account via email', { as: 'permissive', for: 'update', to: ['public'] }),
pgPolicy('User must be an admin to INSERT or allow if no existing member', {
as: 'permissive',
for: 'insert',
to: ['public']
})
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId)
]
);
@@ -1299,7 +1055,7 @@ export const question = pgTable(
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
exerciseId: uuid('exercise_id').notNull(),
name: uuid().default(sql`extensions.gen_random_uuid()`),
name: uuid().default(sql`gen_random_uuid()`),
points: doublePrecision(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
order: bigint({ mode: 'number' })
@@ -1314,16 +1070,7 @@ export const question = pgTable(
columns: [table.questionTypeId],
foreignColumns: [questionType.id],
name: 'question_question_type_id_fkey'
}),
pgPolicy('Allow authenticated users to SELECT', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an org member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
})
]
);
@@ -1362,16 +1109,7 @@ export const questionAnswer = pgTable(
columns: [table.submissionId],
foreignColumns: [submission.id],
name: 'question_answer_submission_id_fkey'
}).onDelete('cascade'),
pgPolicy('Only authenticated users can select.', {
as: 'permissive',
for: 'select',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('User must be an course member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an course member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
pgPolicy('User must be an course member to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
}).onDelete('cascade')
]
);
@@ -1391,9 +1129,7 @@ export const questionType = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
typename: varchar()
},
(table) => [
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` })
]
(table) => []
);
export const role = pgTable(
@@ -1412,9 +1148,7 @@ export const role = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` })
]
(table) => []
);
export const waitinglist = pgTable(
@@ -1438,14 +1172,88 @@ export const organization = pgTable(
'organization',
{
id: uuid()
.default(sql`uuid_generate_v4()`)
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
name: varchar().notNull(),
siteName: text(),
avatarUrl: text('avatar_url'),
settings: jsonb().default({}),
landingpage: jsonb().default({}),
landingpage: jsonb().default({}).$type<{
header?: {
title: string;
titleHighlight: string;
subtitle: string;
action: {
label: string;
link: string;
redirect: boolean;
};
banner: {
video: string;
image: string;
type: string;
show: boolean;
};
background: {
image: string;
show: boolean;
};
show: boolean;
};
aboutUs?: {
title: string;
content: string;
imageUrl: string;
show: boolean;
};
courses?: {
title: string;
titleHighlight: string;
subtitle: string;
show: boolean;
};
faq?: {
title: string;
questions: Array<{
id: number;
title: string;
content: string;
}>;
show: boolean;
};
contact?: {
title: string;
titleHighlight: string;
subtitle: string;
address: string;
phone: string;
email: string;
show: boolean;
};
mailinglist?: {
title: string;
subtitle: string;
buttonLabel: string;
show: boolean;
};
customLinks?: {
show: boolean;
links: Array<{
id: number;
label: string;
url: string;
openInNewTab: boolean;
}>;
};
footer?: {
facebook: string;
instagram: string;
twitter: string;
linkedin: string;
show: boolean;
};
}>(),
theme: text(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
@@ -1456,6 +1264,22 @@ export const organization = pgTable(
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
})
.$type<{
dashboard: {
community: boolean;
exercise: boolean;
bannerImage: string;
bannerText: string;
};
course: {
newsfeed: boolean;
grading: boolean;
};
apps: {
poll: boolean;
comments: boolean;
};
}>()
.notNull(),
isRestricted: boolean('is_restricted').default(false).notNull(),
customCode: text(),
@@ -1465,18 +1289,10 @@ export const organization = pgTable(
},
(table) => [
unique('organization_siteName_key').on(table.siteName),
unique('organization_customDomain_key').on(table.customDomain),
pgPolicy('Authenticated users can delete', {
as: 'permissive',
for: 'delete',
to: ['public'],
using: sql`(auth.uid() IS NOT NULL)`
}),
pgPolicy('Enable insert for authenticated users only', { as: 'permissive', for: 'insert', to: ['authenticated'] }),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'] }),
pgPolicy('User must be admin to UPDATE', { as: 'permissive', for: 'update', to: ['public'] })
unique('organization_customDomain_key').on(table.customDomain)
]
);
export const dashOrgStats = pgView('dash_org_stats', {
orgId: uuid('org_id'),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
+50
View File
@@ -0,0 +1,50 @@
import 'dotenv/config';
import postgres from 'postgres';
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
if (!connectionString) {
console.error('DATABASE_URL or PRIVATE_DATABASE_URL environment variable is required');
process.exit(1);
}
async function dbSetup() {
const sql = postgres(connectionString);
try {
console.log('Setting up database roles...');
// Create authenticated role if it doesn't exist (used in RLS policies)
const authenticatedRoleExists = await sql`
SELECT 1 FROM pg_roles WHERE rolname = 'authenticated'
`;
if (authenticatedRoleExists.length === 0) {
await sql`CREATE ROLE authenticated`;
console.log('✓ authenticated role created');
} else {
console.log('✓ authenticated role already exists');
}
// Create anon role if it doesn't exist (used in RLS policies)
const anonRoleExists = await sql`
SELECT 1 FROM pg_roles WHERE rolname = 'anon'
`;
if (anonRoleExists.length === 0) {
await sql`CREATE ROLE anon`;
console.log('✓ anon role created');
} else {
console.log('✓ anon role already exists');
}
// Note: 'public' role exists by default in PostgreSQL
// Add other extensions here as needed
} catch (error) {
console.error('Failed to setup extensions:', error);
process.exit(1);
} finally {
await sql.end();
}
}
dbSetup();
-6
View File
@@ -617,12 +617,6 @@ async function seed() {
// Seed questions
console.log('📝 Seeding questions...');
const targetExerciseIds = [
'e2ea9fb8-6448-4f6c-a1d5-02c2b12cf862',
'e78bfd24-8ac3-43e9-a117-a2f9d00f74b1',
'bd6e81c7-3d28-4037-acf0-a3028c583771',
'd8cd1cf7-1951-46b3-ad1c-41e415185bc1'
];
const existingQuestions = await db
.select()
+3
View File
@@ -10,9 +10,11 @@ export * from './forgot-password';
export * from './welcome';
export * from './verify-email';
export * from './on-password-reset';
export * from './invite-teacher';
// Import types for schema mapping (must be after exports to avoid circular dependency)
import type { forgotPasswordEmail } from './forgot-password';
import type { inviteTeacherEmail } from './invite-teacher';
import type { onPasswordResetEmail } from './on-password-reset';
import type { verifyEmailEmail } from './verify-email';
import type { welcomeEmail } from './welcome';
@@ -28,4 +30,5 @@ export type EmailSchemas = {
welcome: typeof welcomeEmail.template.schema;
verifyEmail: typeof verifyEmailEmail.template.schema;
onPasswordReset: typeof onPasswordResetEmail.template.schema;
inviteTeacher: typeof inviteTeacherEmail.template.schema;
};
@@ -0,0 +1,26 @@
import { z } from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const inviteTeacherEmail = defineEmail({
id: 'inviteTeacher',
subject: 'Join us on ClassroomIO 😃',
schema: z.object({
email: z.string().email(),
orgName: z.string().min(1),
orgSiteName: z.string().min(1),
inviteLink: z.url()
}),
render: (fields) => {
const content = `
<p>Hey there,</p>
<p>You have been invited to join ${fields.orgName} on ClassroomIO 🎉🎉🎉.</p>
<div>
<a class="button" href="${fields.inviteLink}">Accept Invitation</a>
</div>
`;
return getDefaultTemplate(content);
}
});
+1 -1
View File
@@ -1,4 +1,4 @@
export const EMAIL_IDS = ['forgotPassword', 'welcome', 'verifyEmail', 'onPasswordReset'] as const;
export const EMAIL_IDS = ['forgotPassword', 'welcome', 'verifyEmail', 'onPasswordReset', 'inviteTeacher'] as const;
export const EMAIL_FROM = '"Best from ClassroomIO" <notify@mail.classroomio.com>';
export const EMAIL_REPLY_TO = '"Best from ClassroomIO" <help@classroomio.com>';
+458
View File
@@ -0,0 +1,458 @@
# How to Create Storybook Stories
This guide documents the patterns and conventions for creating Storybook stories in the ClassroomIO UI component library.
## Table of Contents
- [File Structure](#file-structure)
- [Basic Story Structure](#basic-story-structure)
- [Import Patterns](#import-patterns)
- [Story Definition](#story-definition)
- [Template Snippets](#template-snippets)
- [Fields File](#fields-file)
- [Component Categorization](#component-categorization)
- [CSS Class Conventions](#css-class-conventions)
- [LMS Content Guidelines](#lms-content-guidelines)
- [Examples](#examples)
## File Structure
### Directory Organization
Stories are organized into two main categories:
- **Atoms** (`src/atoms/`): Basic, indivisible UI components (Button, Input, Badge, etc.)
- **Molecules** (`src/molecules/`): Composite components made of multiple atoms (Card, Field, ButtonGroup, etc.)
### File Naming
1. Create a directory matching the component name (kebab-case)
2. Create a story file: `{component-name}.stories.svelte`
3. Optionally create a fields file: `fields.ts` (for controls configuration)
**Example:**
```
src/molecules/hover-card/
├── hover-card.stories.svelte
└── fields.ts
```
## Basic Story Structure
Every story file follows this structure:
```svelte
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
// Component imports
import * as ComponentName from '@cio/ui/base/component-name';
// Other imports (icons, utilities, etc.)
import IconName from '@lucide/svelte/icons/icon-name';
// Fields import (if using controls)
import { FIELDS } from './fields';
// Story definition
const { Story } = defineMeta({
title: 'Category/ComponentName',
component: ComponentName.Root, // or ComponentName for single components
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
// State variables (if needed)
let someState = $state('');
</script>
<Story name="StoryName">
{#snippet template()}
<!-- Component usage -->
{/snippet}
</Story>
```
## Import Patterns
### Component Imports
- **Namespace imports** for multi-part components:
```svelte
import * as HoverCard from '@cio/ui/base/hover-card';
import * as Card from '@cio/ui/base/card';
import * as Field from '@cio/ui/base/field';
```
- **Named imports** for single components:
```svelte
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { Avatar } from '@cio/ui/base/avatar';
```
### Icon Imports
Use Lucide Svelte icons:
```svelte
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
import UsersIcon from '@lucide/svelte/icons/users';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
```
## Story Definition
### Title Format
- **Atoms**: `'Atom/ComponentName'` (e.g., `'Atom/Button'`)
- **Molecules**: `'Molecules/ComponentName'` (e.g., `'Molecules/HoverCard'`)
### Component Reference
- For multi-part components, reference the `Root` component:
```svelte
component: HoverCard.Root
component: Card.Root
```
- For single components, reference directly:
```svelte
component: Button
component: Input
```
### Parameters
Standard parameters configuration:
```svelte
parameters: {
layout: 'centered',
controls: {
include: FIELDS // Only if using fields.ts
}
}
```
### Tags
Always include `tags: ['autodocs']` for automatic documentation generation.
## Template Snippets
### Basic Template
```svelte
<Story name="Default">
{#snippet template()}
<ComponentName.Root>
<!-- Component content -->
</ComponentName.Root>
{/snippet}
</Story>
```
### Template with Args (for controls)
For atoms that need to pass args to controls:
```svelte
<Story name="Default">
{#snippet template(args)}
<Button {...args}>Button Text</Button>
{/snippet}
</Story>
```
### Multiple Stories
Create multiple stories to showcase different use cases:
```svelte
<Story name="Default">
{#snippet template()}
<!-- Basic usage -->
{/snippet}
</Story>
<Story name="With Avatar">
{#snippet template()}
<!-- Avatar variant -->
{/snippet}
</Story>
<Story name="Custom Content">
{#snippet template()}
<!-- Custom content variant -->
{/snippet}
</Story>
```
## Fields File
Create a `fields.ts` file to control which props appear in Storybook controls:
```typescript
export const FIELDS = ['class'] as string[];
```
Common fields:
- `'class'` - CSS classes
- `'variant'` - Component variants
- `'size'` - Component sizes
- `'disabled'` - Disabled state
- Add more as needed based on component props
## Component Categorization
### Atoms
Basic, indivisible components:
- Button, Input, Badge, Avatar, Label, Checkbox, Switch, etc.
- Single-purpose components
- Usually don't compose other components
### Molecules
Composite components:
- Card, Field, ButtonGroup, HoverCard, Empty, etc.
- Made of multiple atoms or other components
- More complex functionality
**Guideline**: If a component uses multiple other components together, it's likely a molecule.
## CSS Class Conventions
### UI Prefix
Use the `ui:` prefix for utility classes to scope them properly:
```svelte
<!-- ✅ Correct -->
<div class="ui:flex ui:flex-col ui:gap-6">
<Button class="ui:w-full">
<!-- ❌ Incorrect -->
<div class="flex flex-col gap-6">
<Button class="w-full">
```
### When to Use UI Prefix
- **Always use** for utility classes (flex, grid, gap, padding, margin, etc.)
- **Don't use** for component-specific classes or data attributes
- **Don't use** for Tailwind classes that are part of component styling
### Examples
```svelte
<!-- Utility classes - use ui: prefix -->
<div class="ui:flex ui:items-center ui:gap-2">
<div class="ui:space-y-2">
<div class="ui:text-muted-foreground ui:text-sm">
<!-- Component classes - no prefix needed -->
<Card.Root class="w-full max-w-sm">
<HoverCard.Content class="w-80">
```
## LMS Content Guidelines
Since ClassroomIO is an LMS platform, all story content should be **LMS-related**:
### ✅ Good Examples
- **Courses**: "Introduction to Web Development", "Advanced React Patterns"
- **Users**: "Sarah Johnson (Instructor)", "Alex Martinez (Student)"
- **Content**: Course descriptions, assignment types, enrollment information
- **Actions**: "Create Course", "Enroll Student", "Submit Assignment"
- **States**: "No courses yet", "Student enrolled", "Assignment submitted"
### ❌ Avoid
- Generic examples (Svelte, GitHub profiles, etc.)
- Non-educational content
- Examples that don't relate to learning/teaching
### Content Patterns
**Instructor Profiles:**
```svelte
<h4 class="text-sm font-semibold">Sarah Johnson</h4>
<p class="text-sm">Senior Full-Stack Developer & Instructor</p>
<span class="text-muted-foreground text-xs">Teaching since January 2020</span>
```
**Course Information:**
```svelte
<h4 class="text-sm font-semibold">Advanced React Patterns</h4>
<p class="text-muted-foreground text-sm">
Master advanced React concepts including hooks, context, performance optimization...
</p>
```
**Student Information:**
```svelte
<h4 class="text-sm font-semibold">Alex Martinez</h4>
<p class="text-muted-foreground text-xs">5 courses enrolled</p>
<p class="text-muted-foreground text-xs">Active since March 2024</p>
```
## Examples
### Example 1: Simple Atom (Button)
```svelte
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Button } from '@cio/ui/base/button';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Atom/Button',
component: Button,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template(args)}
<Button {...args}>Create Course</Button>
{/snippet}
</Story>
```
### Example 2: Multi-Part Molecule (HoverCard)
```svelte
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
import * as HoverCard from '@cio/ui/base/hover-card';
import * as Avatar from '@cio/ui/base/avatar';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/HoverCard',
component: HoverCard.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Instructor Profile">
{#snippet template()}
<HoverCard.Root>
<HoverCard.Trigger
href="/instructors/sarah-johnson"
class="ui:rounded-sm ui:underline-offset-4 ui:hover:underline"
>
Sarah Johnson
</HoverCard.Trigger>
<HoverCard.Content class="w-80">
<div class="flex justify-between space-x-4">
<Avatar.Root>
<Avatar.Image src="https://github.com/shadcn.png" />
<Avatar.Fallback>SJ</Avatar.Fallback>
</Avatar.Root>
<div class="space-y-1">
<h4 class="text-sm font-semibold">Sarah Johnson</h4>
<p class="text-sm">Senior Full-Stack Developer & Instructor</p>
<div class="flex items-center pt-2">
<CalendarDaysIcon class="me-2 size-4 opacity-70" />
<span class="text-muted-foreground text-xs">Teaching since January 2020</span>
</div>
</div>
</div>
</HoverCard.Content>
</HoverCard.Root>
{/snippet}
</Story>
```
### Example 3: Complex Molecule with State (Field)
```svelte
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/Field',
component: Field.Field,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let email = $state('');
</script>
<Story name="Course Enrollment">
{#snippet template()}
<div class="w-lg">
<Field.Set>
<Field.Legend>Enroll Student</Field.Legend>
<Field.Description>Add a new student to your course</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label for="student-email">Student Email</Field.Label>
<Input id="student-email" type="email" bind:value={email} placeholder="student@example.com" />
</Field.Field>
</Field.Group>
</Field.Set>
</div>
{/snippet}
</Story>
```
## Checklist
When creating a new story, ensure:
- [ ] File is in the correct directory (`atoms/` or `molecules/`)
- [ ] File is named `{component-name}.stories.svelte`
- [ ] Imports use `@cio/ui/base/{component-name}` pattern
- [ ] Title follows `'Category/ComponentName'` format
- [ ] Component reference is correct (Root for multi-part, direct for single)
- [ ] `tags: ['autodocs']` is included
- [ ] All utility classes use `ui:` prefix
- [ ] Content is LMS-related (courses, students, instructors, etc.)
- [ ] Multiple stories showcase different use cases
- [ ] `fields.ts` is created if controls are needed
- [ ] State variables use `$state()` for reactivity
- [ ] Icons are imported from `@lucide/svelte/icons/`
## Additional Notes
- **State Management**: Use Svelte 5 runes (`$state()`, `$derived()`) for reactive state
- **Layout**: Most stories use `layout: 'centered'` in parameters
- **Styling**: Prefer Tailwind utility classes with `ui:` prefix
- **Accessibility**: Include proper ARIA labels and semantic HTML
- **Documentation**: Story names should be descriptive and reflect the use case
## Resources
- [Storybook Svelte CSF Addon](https://storybook.js.org/addons/@storybook/addon-svelte-csf)
- [Bits UI Documentation](https://bits-ui.com/docs/components)
- [Lucide Icons](https://lucide.dev/icons/)
+33 -1
View File
@@ -16,4 +16,36 @@ To build the Storybook:
```bash
pnpm build
```
```
## Build Storybook
To build the Storybook (alternative command):
```bash
pnpm build-storybook
```
## Upload
To upload the built Storybook to Cloudflare R2:
```bash
pnpm upload:storybook
```
**Note:** This requires the build to be completed first. Make sure you have the following environment variables set:
- `CLOUDFLARE_ACCESS_KEY`
- `CLOUDFLARE_SECRET_ACCESS_KEY`
- `CLOUDFLARE_ACCOUNT_ID`
## Publish
To build and upload the Storybook in one command:
```bash
pnpm publish:storybook
```
This will build the Storybook and then upload it to R2.
+10 -4
View File
@@ -7,14 +7,16 @@
"dev": "storybook dev -p 6006",
"build": "storybook build",
"preview": "storybook preview",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"upload:storybook": "tsx scripts/upload-storybook.ts",
"publish:storybook": "pnpm build-storybook && pnpm upload:storybook"
},
"dependencies": {
"@cio/ui": "workspace:*"
},
"devDependencies": {
"@tiptap/pm": "^3.7.1",
"@tiptap/core": "^3.7.1",
"@aws-sdk/client-s3": "^3.388.0",
"@cio/tsconfig": "workspace:*",
"@storybook/addon-essentials": "^8.4.2",
"@storybook/addon-interactions": "^8.4.2",
"@storybook/addon-links": "^8.4.2",
@@ -26,10 +28,14 @@
"@storybook/test": "^8.4.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
"@tiptap/core": "^3.7.1",
"@tiptap/pm": "^3.7.1",
"@zxcvbn-ts/core": "^3.0.4",
"dotenv": "^16.3.1",
"storybook": "^8.4.2",
"svelte": "^5.39.11",
"tailwindcss": "^4.1.14",
"@cio/tsconfig": "workspace:*",
"tsx": "^4.7.1",
"typescript-svelte-plugin": "^0.3.50",
"vite": "^7.1.4"
}
@@ -0,0 +1,198 @@
#!/usr/bin/env tsx
import 'dotenv/config';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { join, relative } from 'path';
import { readFileSync, readdirSync, statSync } from 'fs';
interface UploadOptions {
key: string;
content: Buffer | string;
contentType?: string;
}
class StorybookUploader {
private s3Client: S3Client | null = null;
private buildDir: string;
private bucket: string;
constructor() {
this.buildDir = join(process.cwd(), 'storybook-static');
this.bucket = 'assets';
this.initializeS3Client();
}
private initializeS3Client() {
const accessKey = process.env.CLOUDFLARE_ACCESS_KEY;
const secretKey = process.env.CLOUDFLARE_SECRET_ACCESS_KEY;
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
if (accessKey && secretKey && accountId) {
this.s3Client = new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretKey
}
});
console.log('✅ S3 client initialized for Cloudflare R2');
} else {
console.warn('⚠️ Cloudflare R2 credentials not found. Cannot upload to R2.');
throw new Error('Missing Cloudflare R2 credentials');
}
}
private getContentType(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase();
const contentTypes: Record<string, string> = {
html: 'text/html',
js: 'application/javascript',
json: 'application/json',
css: 'text/css',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
eot: 'application/vnd.ms-fontobject',
map: 'application/json',
txt: 'text/plain',
xml: 'application/xml'
};
return contentTypes[ext || ''] || 'application/octet-stream';
}
private getCacheControl(filePath: string): string {
// Cache static assets longer, HTML files shorter
if (filePath.endsWith('.html')) {
return 'public, max-age=300'; // 5 minutes for HTML
}
if (filePath.match(/\.(js|css|woff|woff2|ttf|png|jpg|jpeg|gif|svg|ico)$/)) {
return 'public, max-age=31536000, immutable'; // 1 year for assets
}
return 'public, max-age=3600'; // 1 hour default
}
private getAllFiles(dir: string, baseDir: string = dir): string[] {
const files: string[] = [];
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const relativePath = relative(baseDir, fullPath);
if (entry.isDirectory()) {
files.push(...this.getAllFiles(fullPath, baseDir));
} else {
files.push(relativePath);
}
}
return files;
}
async uploadFile(filePath: string): Promise<void> {
if (!this.s3Client) {
throw new Error('S3 client not initialized. Check your Cloudflare R2 credentials.');
}
try {
const fullPath = join(this.buildDir, filePath);
const content = readFileSync(fullPath);
const contentType = this.getContentType(filePath);
const cacheControl = this.getCacheControl(filePath);
// Upload to storybook/ directory in R2
const key = `storybook/${filePath}`;
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: content,
ContentType: contentType,
CacheControl: cacheControl
});
await this.s3Client.send(command);
console.log(`✅ Uploaded: ${filePath}`);
} catch (error) {
console.error(`❌ Error uploading ${filePath}:`, error);
throw error;
}
}
async uploadAll(): Promise<void> {
try {
// Check if build directory exists
try {
statSync(this.buildDir);
} catch {
throw new Error(`Build directory not found: ${this.buildDir}. Please run 'pnpm build' first.`);
}
console.log('🔄 Starting Storybook upload to R2...');
console.log(`📁 Build directory: ${this.buildDir}`);
const files = this.getAllFiles(this.buildDir);
console.log(`📦 Found ${files.length} files to upload`);
// Upload files in batches to avoid overwhelming the API
const batchSize = 10;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
await Promise.all(batch.map((file) => this.uploadFile(file)));
console.log(`📤 Uploaded batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(files.length / batchSize)}`);
}
console.log('🎉 Storybook upload completed successfully!');
console.log(`📊 Total files uploaded: ${files.length}`);
} catch (error) {
console.error('❌ Failed to upload Storybook:', error);
throw error;
}
}
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: tsx scripts/upload-storybook.ts
Examples:
tsx scripts/upload-storybook.ts
Environment Variables Required for R2 Upload:
CLOUDFLARE_ACCESS_KEY
CLOUDFLARE_SECRET_ACCESS_KEY
CLOUDFLARE_ACCOUNT_ID
`);
return;
}
const uploader = new StorybookUploader();
await uploader.uploadAll();
}
// Run the script if called directly
if (process.argv[1] && process.argv[1].endsWith('upload-storybook.ts')) {
main()
.then(() => {
console.log('Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('Script failed:', error);
process.exit(1);
});
}
export { StorybookUploader };
@@ -1,7 +1,4 @@
import { type ComponentProps } from 'svelte';
import { Accordion } from '@cio/ui/base/accordion';
export const FIELDS: (keyof ComponentProps<typeof Accordion>)[] = [
export const FIELDS = [
'type',
'value',
'onValueChange',
@@ -9,4 +6,4 @@ export const FIELDS: (keyof ComponentProps<typeof Accordion>)[] = [
'name',
'loop',
'orientation'
];
] as string[];
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import { Avatar } from '@cio/ui/base/avatar';
export const FIELDS: (keyof ComponentProps<typeof Avatar>)[] = ['loadingStatus', 'onLoadingStatusChange', 'delayMs'];
export const FIELDS = ['loadingStatus', 'onLoadingStatusChange', 'delayMs'] as string[];
@@ -17,8 +17,8 @@
</script>
<Story name="Default">
{#snippet template()}
<Button>Default Button</Button>
{#snippet template(args)}
<Button {...args}>Default Button</Button>
{/snippet}
</Story>
@@ -72,7 +72,7 @@
<Story name="Loading">
{#snippet template(args)}
<Button {...args} isLoading>Loading Button</Button>
<Button {...args} loading>Loading Button</Button>
{/snippet}
</Story>
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import { Button } from '@cio/ui/base/button';
export const FIELDS: (keyof ComponentProps<typeof Button>)[] = ['variant', 'size', 'disabled', 'class', 'type', 'href'];
export const FIELDS = ['variant', 'size', 'disabled', 'class', 'type', 'href', 'loading'] as string[];
@@ -1,11 +1,8 @@
import { type ComponentProps } from 'svelte';
import { Checkbox } from '@cio/ui/base/checkbox';
export const FIELDS: (keyof ComponentProps<typeof Checkbox>)[] = [
export const FIELDS = [
'checked',
'onCheckedChange',
'disabled',
'name',
'value',
'required'
];
] as string[];
@@ -1,9 +1,6 @@
import { type ComponentProps } from 'svelte';
import { Collapsible } from '@cio/ui/base/collapsible';
export const FIELDS: (keyof ComponentProps<typeof Collapsible>)[] = [
export const FIELDS = [
'open',
'onOpenChange',
'onOpenChangeComplete',
'disabled'
];
] as string[];
@@ -1,11 +1,8 @@
import { type ComponentProps } from 'svelte';
import { Command } from '@cio/ui/base/command';
export const FIELDS: (keyof ComponentProps<typeof Command>)[] = [
export const FIELDS = [
'value',
'onValueChange',
'label',
'filter',
'columns',
'disablePointerSelection'
];
] as string[];
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import * as Dialog from '@cio/ui/base/dialog';
export const FIELDS: (keyof ComponentProps<typeof Dialog.Root>)[] = ['open', 'onOpenChange'];
export const FIELDS = ['open', 'onOpenChange'] as string[];
+2 -5
View File
@@ -1,7 +1,4 @@
import { type ComponentProps } from 'svelte';
import { Input } from '@cio/ui/base/input';
export const FIELDS: (keyof ComponentProps<typeof Input>)[] = [
export const FIELDS = [
'value',
'type',
'placeholder',
@@ -11,4 +8,4 @@ export const FIELDS: (keyof ComponentProps<typeof Input>)[] = [
'name',
'required',
'readonly'
];
] as string[];
@@ -0,0 +1,416 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as Item from '@cio/ui/base/item';
import { Button } from '@cio/ui/base/button';
import * as Avatar from '@cio/ui/base/avatar';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import PlusIcon from '@lucide/svelte/icons/plus';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
const { Story } = defineMeta({
title: 'Atom/Item',
component: Item.Root,
parameters: {
layout: 'centered',
controls: {
include: []
}
},
tags: ['autodocs']
});
const music = [
{
title: 'Midnight City Lights',
artist: 'Neon Dreams',
album: 'Electric Nights',
duration: '3:45'
},
{
title: 'Coffee Shop Conversations',
artist: 'The Morning Brew',
album: 'Urban Stories',
duration: '4:05'
},
{
title: 'Digital Rain',
artist: 'Cyber Symphony',
album: 'Binary Beats',
duration: '3:30'
}
];
const people = [
{
username: 'shadcn',
avatar: 'https://github.com/shadcn.png',
email: 'shadcn@vercel.com'
},
{
username: 'maxleiter',
avatar: 'https://github.com/maxleiter.png',
email: 'maxleiter@vercel.com'
},
{
username: 'evilrabbit',
avatar: 'https://github.com/evilrabbit.png',
email: 'evilrabbit@vercel.com'
}
];
const models = [
{
name: 'v0-1.5-sm',
description: 'Everyday tasks and UI generation.',
image:
'https://images.unsplash.com/photo-1650804068570-7fb2e3dbf888?q=80&w=640&auto=format&fit=crop',
credit: 'Valeria Reverdo on Unsplash'
},
{
name: 'v0-1.5-lg',
description: 'Advanced thinking or reasoning.',
image:
'https://images.unsplash.com/photo-1610280777472-54133d004c8c?q=80&w=640&auto=format&fit=crop',
credit: 'Michael Oeser on Unsplash'
},
{
name: 'v0-2.0-mini',
description: 'Open Source model for everyone.',
image:
'https://images.unsplash.com/photo-1602146057681-08560aee8cde?q=80&w=640&auto=format&fit=crop',
credit: 'Cherry Laithang on Unsplash'
}
];
</script>
<Story name="Basic Item">
{#snippet template()}
<div class="flex w-full max-w-md flex-col gap-6">
<Item.Root variant="outline">
<Item.Content>
<Item.Title>Basic Item</Item.Title>
<Item.Description>A simple item with title and description.</Item.Description>
</Item.Content>
<Item.Actions>
<Button variant="outline" size="sm">Action</Button>
</Item.Actions>
</Item.Root>
<Item.Root variant="outline" size="sm">
{#snippet child({ props })}
<a href="#/" {...props}>
<Item.Media>
<BadgeCheckIcon class="size-5" />
</Item.Media>
<Item.Content>
<Item.Title>Your profile has been verified.</Item.Title>
</Item.Content>
<Item.Actions>
<ChevronRightIcon class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
</div>
{/snippet}
</Story>
<Story name="Variants">
{#snippet template()}
<div class="flex flex-col gap-6">
<Item.Root>
<Item.Content>
<Item.Title>Default Variant</Item.Title>
<Item.Description>Standard styling with subtle background and borders.</Item.Description>
</Item.Content>
<Item.Actions>
<Button variant="outline" size="sm">Open</Button>
</Item.Actions>
</Item.Root>
<Item.Root variant="outline">
<Item.Content>
<Item.Title>Outline Variant</Item.Title>
<Item.Description>Outlined style with clear borders and transparent background.</Item.Description>
</Item.Content>
<Item.Actions>
<Button variant="outline" size="sm">Open</Button>
</Item.Actions>
</Item.Root>
<Item.Root variant="muted">
<Item.Content>
<Item.Title>Muted Variant</Item.Title>
<Item.Description>Subdued appearance with muted colors for secondary content.</Item.Description>
</Item.Content>
<Item.Actions>
<Button variant="outline" size="sm">Open</Button>
</Item.Actions>
</Item.Root>
</div>
{/snippet}
</Story>
<Story name="Size">
{#snippet template()}
<div class="flex w-full max-w-md flex-col gap-6">
<Item.Root variant="outline">
<Item.Content>
<Item.Title>Basic Item</Item.Title>
<Item.Description>A simple item with title and description.</Item.Description>
</Item.Content>
<Item.Actions>
<Button variant="outline" size="sm">Action</Button>
</Item.Actions>
</Item.Root>
<Item.Root variant="outline" size="sm">
{#snippet child({ props })}
<a href="#/" {...props}>
<Item.Media>
<BadgeCheckIcon class="size-5" />
</Item.Media>
<Item.Content>
<Item.Title>Your profile has been verified.</Item.Title>
</Item.Content>
<Item.Actions>
<ChevronRightIcon class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
</div>
{/snippet}
</Story>
<Story name="Icon">
{#snippet template()}
<div class="flex w-full max-w-lg flex-col gap-6">
<Item.Root variant="outline">
<Item.Media variant="icon">
<ShieldAlertIcon />
</Item.Media>
<Item.Content>
<Item.Title>Security Alert</Item.Title>
<Item.Description>New login detected from unknown device.</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" variant="outline">Review</Button>
</Item.Actions>
</Item.Root>
</div>
{/snippet}
</Story>
<Story name="Avatar">
{#snippet template()}
<div class="flex w-full max-w-lg flex-col gap-6">
<Item.Root variant="outline">
<Item.Media>
<Avatar.Root class="size-10">
<Avatar.Image src="https://github.com/evilrabbit.png" />
<Avatar.Fallback>ER</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content>
<Item.Title>Evil Rabbit</Item.Title>
<Item.Description>Last seen 5 months ago</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="icon" variant="outline" class="rounded-full" aria-label="Invite">
<PlusIcon />
</Button>
</Item.Actions>
</Item.Root>
<Item.Root variant="outline">
<Item.Media>
<div
class="*:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:grayscale"
>
<Avatar.Root class="hidden sm:flex">
<Avatar.Image src="https://github.com/shadcn.png" alt="@shadcn" />
<Avatar.Fallback>CN</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root class="hidden sm:flex">
<Avatar.Image src="https://github.com/maxleiter.png" alt="@maxleiter" />
<Avatar.Fallback>LR</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root>
<Avatar.Image src="https://github.com/evilrabbit.png" alt="@evilrabbit" />
<Avatar.Fallback>ER</Avatar.Fallback>
</Avatar.Root>
</div>
</Item.Media>
<Item.Content>
<Item.Title>No Team Members</Item.Title>
<Item.Description>Invite your team to collaborate on this project.</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" variant="outline">Invite</Button>
</Item.Actions>
</Item.Root>
</div>
{/snippet}
</Story>
<Story name="Image">
{#snippet template()}
<div class="flex w-full max-w-md flex-col gap-6">
<div class="flex w-full max-w-md flex-col gap-4">
{#each music as song (song)}
<Item.Root variant="outline">
{#snippet child({ props })}
<a href="#/" {...props}>
<Item.Media variant="image">
<img
src={`https://avatar.vercel.sh/${song.title}`}
alt={song.title}
width="32"
height="32"
class="size-8 rounded object-cover grayscale"
/>
</Item.Media>
<Item.Content>
<Item.Title class="line-clamp-1">
{song.title} - <span class="text-muted-foreground">{song.album}</span>
</Item.Title>
<Item.Description>{song.artist}</Item.Description>
</Item.Content>
<Item.Content class="flex-none text-center">
<Item.Description>{song.duration}</Item.Description>
</Item.Content>
</a>
{/snippet}
</Item.Root>
{/each}
</div>
</div>
{/snippet}
</Story>
<Story name="Group">
{#snippet template()}
<div class="flex w-full max-w-md flex-col gap-6">
<Item.Group>
{#each people as person, index (person.username)}
<Item.Root>
<Item.Media>
<Avatar.Root>
<Avatar.Image src={person.avatar} class="grayscale" />
<Avatar.Fallback>{person.username.charAt(0)}</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content class="gap-1">
<Item.Title>{person.username}</Item.Title>
<Item.Description>{person.email}</Item.Description>
</Item.Content>
<Item.Actions>
<Button variant="ghost" size="icon" class="rounded-full">
<PlusIcon />
</Button>
</Item.Actions>
</Item.Root>
{#if index !== people.length - 1}
<Item.Separator />
{/if}
{/each}
</Item.Group>
</div>
{/snippet}
</Story>
<Story name="Header">
{#snippet template()}
<div class="flex w-full max-w-xl flex-col gap-6">
<Item.Group class="grid grid-cols-3 gap-4">
{#each models as model (model.name)}
<Item.Root variant="outline">
<Item.Header>
<img
src={model.image}
alt={model.name}
width="128"
height="128"
class="aspect-square w-full rounded-sm object-cover"
/>
</Item.Header>
<Item.Content>
<Item.Title>{model.name}</Item.Title>
<Item.Description>{model.description}</Item.Description>
</Item.Content>
</Item.Root>
{/each}
</Item.Group>
</div>
{/snippet}
</Story>
<Story name="Link">
{#snippet template()}
<div class="flex w-full max-w-md flex-col gap-4">
<Item.Root>
{#snippet child({ props })}
<a href="#/" {...props}>
<Item.Content>
<Item.Title>Visit our documentation</Item.Title>
<Item.Description>Learn how to get started with our components.</Item.Description>
</Item.Content>
<Item.Actions>
<ChevronRightIcon class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
<Item.Root variant="outline">
{#snippet child({ props })}
<a href="#/" target="_blank" rel="noopener noreferrer" {...props}>
<Item.Content>
<Item.Title>External resource</Item.Title>
<Item.Description>Opens in a new tab with security attributes.</Item.Description>
</Item.Content>
<Item.Actions>
<ExternalLinkIcon class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
</div>
{/snippet}
</Story>
<Story name="Dropdown">
{#snippet template()}
<div class="flex min-h-64 w-full max-w-md flex-col items-center gap-6">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="w-fit">
Select <ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-72 [--radius:0.65rem]" align="end">
{#each people as person (person.username)}
<DropdownMenu.Item class="p-0">
<Item.Root size="sm" class="w-full p-2">
<Item.Media>
<Avatar.Root class="size-8">
<Avatar.Image src={person.avatar} class="grayscale" />
<Avatar.Fallback>{person.username.charAt(0)}</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content class="gap-0.5">
<Item.Title>{person.username}</Item.Title>
<Item.Description>{person.email}</Item.Description>
</Item.Content>
</Item.Root>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/snippet}
</Story>
+1 -4
View File
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import { Label } from '@cio/ui/base/label';
export const FIELDS: (keyof ComponentProps<typeof Label>)[] = ['children', 'child'];
export const FIELDS = ['children', 'child'] as string[];
@@ -1,10 +1,7 @@
import { type ComponentProps } from 'svelte';
import { Menubar } from '@cio/ui/base/menubar';
export const FIELDS: (keyof ComponentProps<typeof Menubar>)[] = [
export const FIELDS = [
'onValueChange',
'value',
'dir',
'onOpenChange',
'disabled'
];
] as string[];
@@ -1,10 +1,7 @@
import { type ComponentProps } from 'svelte';
import { Pagination } from '@cio/ui/base/pagination';
export const FIELDS: (keyof ComponentProps<typeof Pagination>)[] = [
export const FIELDS = [
'count',
'page',
'onPageChange',
'perPage',
'orientation'
];
] as string[];
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import { Progress } from '@cio/ui/base/progress';
export const FIELDS: (keyof ComponentProps<typeof Progress>)[] = ['max', 'min', 'value'];
export const FIELDS = ['max', 'min', 'value'] as string[];
@@ -1,7 +1,4 @@
import { type ComponentProps } from 'svelte';
import { RadioGroup } from '@cio/ui/base/radio-group';
export const FIELDS: (keyof ComponentProps<typeof RadioGroup>)[] = [
export const FIELDS = [
'value',
'onValueChange',
'disabled',
@@ -9,4 +6,4 @@ export const FIELDS: (keyof ComponentProps<typeof RadioGroup>)[] = [
'name',
'loop',
'orientation'
];
] as string[];
@@ -1,7 +1,4 @@
import { type ComponentProps } from 'svelte';
import * as Select from '@cio/ui/base/select';
export const FIELDS: (keyof ComponentProps<typeof Select.Root>)[] = [
export const FIELDS = [
'value',
'onValueChange',
'disabled',
@@ -11,4 +8,4 @@ export const FIELDS: (keyof ComponentProps<typeof Select.Root>)[] = [
'required',
'autocomplete',
'allowDeselect'
];
] as string[];
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import { Separator } from '@cio/ui/base/separator';
export const FIELDS: (keyof ComponentProps<typeof Separator>)[] = ['orientation', 'decorative'];
export const FIELDS = ['orientation', 'decorative'] as string[];
+1 -4
View File
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import { Sheet } from '@cio/ui/base/sheet';
export const FIELDS: (keyof ComponentProps<typeof Sheet>)[] = ['open', 'onOpenChange'];
export const FIELDS = ['open', 'onOpenChange'] as string[];
@@ -39,7 +39,7 @@
>Make changes to your profile here. Click save when you're done.</Sheet.SheetDescription
>
</Sheet.SheetHeader>
<div class="grid gap-4 py-4">
<div class="grid gap-4 p-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label.Root for="name" class="text-right">Name</Label.Root>
<Input.Root id="name" value="Pedro Duarte" class="col-span-3" />
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import { Skeleton } from '@cio/ui/base/skeleton';
export const FIELDS: (keyof ComponentProps<typeof Skeleton>)[] = ['class'];
export const FIELDS = ['class'] as string[];
@@ -1,4 +1 @@
import { type ComponentProps } from 'svelte';
import { Toaster as Sonner } from '@cio/ui/base/sonner';
export const FIELDS: (keyof ComponentProps<typeof Sonner>)[] = ['types', 'position', 'expand'];
export const FIELDS = ['types', 'position', 'expand'] as string[];
@@ -1,6 +1,13 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Spinner } from '@cio/ui/base/spinner';
import * as Item from '@cio/ui/base/item';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import * as InputGroup from '@cio/ui/base/input-group';
import * as Empty from '@cio/ui/base/empty';
import { Progress } from '@cio/ui/base/progress';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
const { Story } = defineMeta({
title: 'Atom/Spinner',
@@ -18,34 +25,144 @@
{/snippet}
</Story>
<Story name="Small">
<Story name="Size">
{#snippet template()}
<Spinner class="h-4 w-4" />
{/snippet}
</Story>
<Story name="Large">
{#snippet template()}
<Spinner class="h-12 w-12" />
{/snippet}
</Story>
<Story name="Custom Color">
{#snippet template()}
<div class="flex gap-4">
<Spinner class="text-blue-500" />
<Spinner class="text-green-500" />
<Spinner class="text-red-500" />
<Spinner class="text-purple-500" />
<div class="flex items-center gap-6">
<Spinner class="size-3" />
<Spinner class="size-4" />
<Spinner class="size-6" />
<Spinner class="size-8" />
</div>
{/snippet}
</Story>
<Story name="With Text">
<Story name="Color">
{#snippet template()}
<div class="flex items-center gap-2">
<Spinner />
<span>Loading...</span>
<div class="flex items-center gap-6">
<Spinner class="size-6 text-red-500" />
<Spinner class="size-6 text-green-500" />
<Spinner class="size-6 text-blue-500" />
<Spinner class="size-6 text-yellow-500" />
<Spinner class="size-6 text-purple-500" />
</div>
{/snippet}
</Story>
<Story name="Button">
{#snippet template()}
<div class="flex flex-col items-center gap-4">
<Button disabled size="sm">
<Spinner />
Loading...
</Button>
<Button variant="outline" disabled size="sm">
<Spinner />
Please wait
</Button>
<Button variant="secondary" disabled size="sm">
<Spinner />
Processing
</Button>
</div>
{/snippet}
</Story>
<Story name="Badge">
{#snippet template()}
<div class="flex items-center gap-4 [--radius:1.2rem]">
<Badge>
<Spinner />
Syncing
</Badge>
<Badge variant="secondary">
<Spinner />
Updating
</Badge>
<Badge variant="outline">
<Spinner />
Processing
</Badge>
</div>
{/snippet}
</Story>
<Story name="Input Group">
{#snippet template()}
<div class="flex w-full max-w-md flex-col gap-4">
<InputGroup.Root>
<InputGroup.Input placeholder="Send a message..." disabled />
<InputGroup.Addon align="inline-end">
<Spinner />
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Textarea placeholder="Send a message..." disabled />
<InputGroup.Addon align="block-end">
<Spinner />
Validating...
<InputGroup.Button class="ms-auto" variant="default">
<ArrowUpIcon />
<span class="sr-only">Send</span>
</InputGroup.Button>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Empty">
{#snippet template()}
<Empty.Root class="w-full">
<Empty.Header>
<Empty.Media variant="icon">
<Spinner />
</Empty.Media>
<Empty.Title>Processing your request</Empty.Title>
<Empty.Description>Please wait while we process your request. Do not refresh the page.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" size="sm">Cancel</Button>
</Empty.Content>
</Empty.Root>
{/snippet}
</Story>
<Story name="Item">
{#snippet template()}
<div class="flex w-full max-w-md flex-col gap-4 [--radius:1rem]">
<Item.Root variant="outline">
<Item.Media variant="icon">
<Spinner />
</Item.Media>
<Item.Content>
<Item.Title>Downloading...</Item.Title>
<Item.Description>129 MB / 1000 MB</Item.Description>
</Item.Content>
<Item.Actions class="hidden sm:flex">
<Button variant="outline" size="sm">Cancel</Button>
</Item.Actions>
<Item.Footer>
<Progress value={75} />
</Item.Footer>
</Item.Root>
</div>
{/snippet}
</Story>
<Story name="Item with Payment">
{#snippet template()}
<div class="flex w-full max-w-xs flex-col gap-4 [--radius:1rem]">
<Item.Root variant="muted">
<Item.Media>
<Spinner />
</Item.Media>
<Item.Content>
<Item.Title class="line-clamp-1">Processing payment...</Item.Title>
</Item.Content>
<Item.Content class="flex-none justify-end">
<span class="text-sm tabular-nums">$100.00</span>
</Item.Content>
</Item.Root>
</div>
{/snippet}
</Story>
@@ -1,11 +1,8 @@
import { type ComponentProps } from 'svelte';
import { Switch } from '@cio/ui/base/switch';
export const FIELDS: (keyof ComponentProps<typeof Switch>)[] = [
export const FIELDS = [
'checked',
'onCheckedChange',
'disabled',
'name',
'required',
'value'
];
] as string[];
+2 -5
View File
@@ -1,10 +1,7 @@
import { type ComponentProps } from 'svelte';
import { Tabs } from '@cio/ui/base/tabs';
export const FIELDS: (keyof ComponentProps<typeof Tabs>)[] = [
export const FIELDS = [
'value',
'onValueChange',
'disabled',
'activationMode',
'orientation'
];
] as string[];
@@ -1,9 +1,6 @@
import { type ComponentProps } from 'svelte';
import { Tooltip } from '@cio/ui/base/tooltip';
export const FIELDS: (keyof ComponentProps<typeof Tooltip>)[] = [
export const FIELDS = [
'disableHoverableContent',
'delayDuration',
'ignoreNonKeyboardFocus',
'disabled'
];
] as string[];
+2
View File
@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap');
@import 'tailwindcss';
@import '@cio/ui/output.css';
@@ -0,0 +1,400 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Bot from '@lucide/svelte/icons/bot';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import Check from '@lucide/svelte/icons/check';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Copy from '@lucide/svelte/icons/copy';
import Share from '@lucide/svelte/icons/share';
import Trash from '@lucide/svelte/icons/trash';
import UserRoundX from '@lucide/svelte/icons/user-round-x';
import VolumeOff from '@lucide/svelte/icons/volume-off';
import AudioLines from '@lucide/svelte/icons/audio-lines';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import Search from '@lucide/svelte/icons/search';
import Archive from '@lucide/svelte/icons/archive';
import CalendarPlus from '@lucide/svelte/icons/calendar-plus';
import Clock from '@lucide/svelte/icons/clock';
import ListFilter from '@lucide/svelte/icons/list-filter';
import MailCheck from '@lucide/svelte/icons/mail-check';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import Tag from '@lucide/svelte/icons/tag';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Minus from '@lucide/svelte/icons/minus';
import Plus from '@lucide/svelte/icons/plus';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { Separator } from '@cio/ui/base/separator';
import * as Popover from '@cio/ui/base/popover';
import * as Select from '@cio/ui/base/select';
import * as InputGroup from '@cio/ui/base/input-group';
import * as Tooltip from '@cio/ui/base/tooltip';
import * as ButtonGroup from '@cio/ui/base/button-group';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/ButtonGroup',
component: ButtonGroup.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let label = $state('personal');
let voiceEnabled = $state(false);
const CURRENCIES = [
{
value: '$',
label: 'US Dollar'
},
{
value: '€',
label: 'Euro'
},
{
value: '£',
label: 'British Pound'
}
];
let currency = $state('$');
</script>
<Story name="Default">
{#snippet template()}
<ButtonGroup.Root>
<ButtonGroup.Root class="hidden sm:flex">
<Button variant="outline" size="icon" aria-label="Go Back">
<ArrowLeft />
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline">Archive</Button>
<Button variant="outline">Report</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline">Snooze</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="icon" aria-label="More Options">
<MoreHorizontal />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-52">
<DropdownMenu.Group>
<DropdownMenu.Item>
<MailCheck />
Mark as Read
</DropdownMenu.Item>
<DropdownMenu.Item>
<Archive />
Archive
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<Clock />
Snooze
</DropdownMenu.Item>
<DropdownMenu.Item>
<CalendarPlus />
Add to Calendar
</DropdownMenu.Item>
<DropdownMenu.Item>
<ListFilter />
Add to List
</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<Tag />
Label As...
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.RadioGroup bind:value={label}>
<DropdownMenu.RadioItem value="personal">Personal</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="work">Work</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="other">Other</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item class="text-destructive focus:text-destructive">
<Trash2 />
Trash
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Orientation">
{#snippet template()}
<ButtonGroup.Root orientation="vertical" aria-label="Media controls" class="h-fit">
<Button variant="outline" size="icon">
<Plus />
</Button>
<Button variant="outline" size="icon">
<Minus />
</Button>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Sizes">
{#snippet template()}
<div class="flex flex-col items-start gap-8">
<ButtonGroup.Root>
<Button variant="outline" size="sm">Small</Button>
<Button variant="outline" size="sm">Button</Button>
<Button variant="outline" size="sm">Group</Button>
<Button variant="outline" size="icon-sm">
<Plus />
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline">Default</Button>
<Button variant="outline">Button</Button>
<Button variant="outline">Group</Button>
<Button variant="outline" size="icon">
<Plus />
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline" size="lg">Large</Button>
<Button variant="outline" size="lg">Button</Button>
<Button variant="outline" size="lg">Group</Button>
<Button variant="outline" size="icon-lg">
<Plus />
</Button>
</ButtonGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Nested">
{#snippet template()}
<ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline" size="sm">1</Button>
<Button variant="outline" size="sm">2</Button>
<Button variant="outline" size="sm">3</Button>
<Button variant="outline" size="sm">4</Button>
<Button variant="outline" size="sm">5</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline" size="icon-sm" aria-label="Previous">
<ArrowLeft />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Next">
<ArrowRight />
</Button>
</ButtonGroup.Root>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Separator">
{#snippet template()}
<ButtonGroup.Root>
<Button variant="secondary" size="sm">Copy</Button>
<ButtonGroup.Separator />
<Button variant="secondary" size="sm">Paste</Button>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Split">
{#snippet template()}
<ButtonGroup.Root>
<Button variant="secondary">Button</Button>
<ButtonGroup.Separator />
<Button variant="secondary" size="icon">
<Plus />
</Button>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Input">
{#snippet template()}
<ButtonGroup.Root>
<Input placeholder="Search..." />
<Button variant="outline" size="icon" aria-label="Search">
<Search />
</Button>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Input Group">
{#snippet template()}
<ButtonGroup.Root class="[--radius:9999rem]">
<ButtonGroup.Root>
<Button variant="outline" size="icon">
<Plus />
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<InputGroup.Root>
<InputGroup.Input
placeholder={voiceEnabled ? 'Record and send audio...' : 'Send a message...'}
disabled={voiceEnabled}
/>
<InputGroup.Addon align="inline-end">
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<InputGroup.Button
{...props}
onclick={() => (voiceEnabled = !voiceEnabled)}
size="icon-xs"
data-active={voiceEnabled}
class="ui:data-[active=true]:bg-orange-100 ui:data-[active=true]:text-orange-700 ui:dark:data-[active=true]:bg-orange-800 ui:dark:data-[active=true]:text-orange-100"
aria-pressed={voiceEnabled}
>
<AudioLines />
</InputGroup.Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>Voice Mode</Tooltip.Content>
</Tooltip.Root></Tooltip.Provider
>
</InputGroup.Addon>
</InputGroup.Root>
</ButtonGroup.Root>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Dropdown Menu">
{#snippet template()}
<ButtonGroup.Root>
<Button variant="outline">Follow</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="ps-2!">
<ChevronDown />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="[--radius:1rem]">
<DropdownMenu.Group>
<DropdownMenu.Item>
<VolumeOff />
Mute Conversation
</DropdownMenu.Item>
<DropdownMenu.Item>
<Check />
Mark as Read
</DropdownMenu.Item>
<DropdownMenu.Item>
<AlertTriangle />
Report Conversation
</DropdownMenu.Item>
<DropdownMenu.Item>
<UserRoundX />
Block User
</DropdownMenu.Item>
<DropdownMenu.Item>
<Share />
Share Conversation
</DropdownMenu.Item>
<DropdownMenu.Item>
<Copy />
Copy Conversation
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item variant="destructive">
<Trash />
Delete Conversation
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Select">
{#snippet template()}
<ButtonGroup.Root>
<ButtonGroup.Root>
<Select.Root type="single" bind:value={currency}>
<Select.Trigger class="font-mono">
{currency}
</Select.Trigger>
<Select.Content class="min-w-24">
{#each CURRENCIES as currencyOption (currencyOption.value)}
<Select.Item value={currencyOption.value}>
{currencyOption.value}
<span class="text-muted-foreground">{currencyOption.label}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Input placeholder="10.00" pattern="[0-9]*" />
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button aria-label="Send" size="icon" variant="outline">
<ArrowRight />
</Button>
</ButtonGroup.Root>
</ButtonGroup.Root>
{/snippet}
</Story>
<Story name="Popover">
{#snippet template()}
<ButtonGroup.Root>
<Button variant="outline">
<Bot />
Copilot
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="icon" aria-label="Open Popover">
<ChevronDown />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="end" class="rounded-xl p-0 text-sm">
<div class="px-4 py-3">
<div class="text-sm font-medium">Agent Tasks</div>
</div>
<Separator />
<div class="*:[p:not(:last-child)]:mb-2 p-4 text-sm">
<Textarea placeholder="Describe your task in natural language." class="mb-4 resize-none" />
<p class="font-medium">Start a new task with Copilot</p>
<p class="text-muted-foreground">
Describe your task in natural language. Copilot will work in the background and open a pull request for
your review.
</p>
</div>
</Popover.Content>
</Popover.Root>
</ButtonGroup.Root>
{/snippet}
</Story>
@@ -0,0 +1 @@
export const FIELDS = ['class', 'orientation'] as string[];
@@ -0,0 +1,123 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as Card from '@cio/ui/base/card';
import { Button } from '@cio/ui/base/button';
import { Label } from '@cio/ui/base/label';
import { Input } from '@cio/ui/base/input';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/Card',
component: Card.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let email = $state('');
let password = $state('');
</script>
<Story name="Default">
{#snippet template()}
<Card.Root class="w-full max-w-sm">
<Card.Header>
<Card.Title>Login to your account</Card.Title>
<Card.Description>Enter your email below to login to your account</Card.Description>
<Card.Action>
<Button variant="link">Sign Up</Button>
</Card.Action>
</Card.Header>
<Card.Content>
<form>
<div class="ui:flex ui:flex-col ui:gap-6">
<div class="ui:grid ui:gap-2">
<Label for="email">Email</Label>
<Input id="email" type="email" bind:value={email} placeholder="m@example.com" required />
</div>
<div class="ui:grid ui:gap-2">
<div class="ui:flex ui:items-center">
<Label for="password">Password</Label>
<a href="#" class="ui:ms-auto ui:inline-block ui:text-sm ui:underline-offset-4 hover:ui:underline">
Forgot your password?
</a>
</div>
<Input id="password" type="password" bind:value={password} required />
</div>
</div>
</form>
</Card.Content>
<Card.Footer class="ui:flex-col ui:gap-2">
<Button type="submit" class="ui:w-full">Login</Button>
<Button variant="outline" class="ui:w-full">Login with Google</Button>
</Card.Footer>
</Card.Root>
{/snippet}
</Story>
<Story name="Simple">
{#snippet template()}
<Card.Root class="ui:w-full ui:max-w-sm">
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Description>Card description goes here.</Card.Description>
</Card.Header>
<Card.Content>
<p>Card content goes here.</p>
</Card.Content>
</Card.Root>
{/snippet}
</Story>
<Story name="With Footer">
{#snippet template()}
<Card.Root class="ui:w-full ui:max-w-sm">
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Description>Card description goes here.</Card.Description>
</Card.Header>
<Card.Content>
<p>Card content goes here.</p>
</Card.Content>
<Card.Footer>
<Button size="sm">Action</Button>
</Card.Footer>
</Card.Root>
{/snippet}
</Story>
<Story name="With Action">
{#snippet template()}
<Card.Root class="ui:w-full ui:max-w-sm">
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Description>Card description goes here.</Card.Description>
<Card.Action>
<Button variant="ghost" size="sm">More</Button>
</Card.Action>
</Card.Header>
<Card.Content>
<p>Card content goes here.</p>
</Card.Content>
</Card.Root>
{/snippet}
</Story>
<Story name="No Header">
{#snippet template()}
<Card.Root class="ui:w-full ui:max-w-sm">
<Card.Content>
<p>Card content without header.</p>
</Card.Content>
<Card.Footer>
<Button size="sm">Action</Button>
</Card.Footer>
</Card.Root>
{/snippet}
</Story>
@@ -0,0 +1 @@
export const FIELDS = ['class'] as string[];
@@ -0,0 +1,102 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { CopyButton } from '@cio/ui/base/copy-button';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import * as InputGroup from '@cio/ui/base/input-group';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/CopyButton',
component: CopyButton,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let textToCopy = $state('Hello, World!');
let copiedText = $state('');
</script>
<Story name="Default">
{#snippet template()}
<CopyButton text="Copy this text to clipboard" />
{/snippet}
</Story>
<Story name="With Text">
{#snippet template()}
<CopyButton text="npm install @cio/ui" variant="outline">Copy Command</CopyButton>
{/snippet}
</Story>
<Story name="Variants">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:items-start">
<CopyButton text="Default variant" variant="default" />
<CopyButton text="Outline variant" variant="outline" />
<CopyButton text="Secondary variant" variant="secondary" />
<CopyButton text="Ghost variant" variant="ghost" />
<CopyButton text="Destructive variant" variant="destructive" />
</div>
{/snippet}
</Story>
<Story name="With Input Group">
{#snippet template()}
<InputGroup.Root class="ui:w-96">
<InputGroup.Input value="https://classroomio.com/courses/123" readonly />
<InputGroup.Addon align="inline-end">
<CopyButton text="https://classroomio.com/courses/123" size="icon-sm" />
</InputGroup.Addon>
</InputGroup.Root>
{/snippet}
</Story>
<Story name="Reactive Text">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<InputGroup.Root>
<InputGroup.Input bind:value={textToCopy} placeholder="Enter text to copy" />
<InputGroup.Addon align="inline-end">
<CopyButton text={textToCopy} size="icon-sm" />
</InputGroup.Addon>
</InputGroup.Root>
<div class="ui:text-sm ui:text-muted-foreground">
<p>Text to copy: <code class="ui:bg-muted ui:px-2 ui:py-1 ui:rounded">{textToCopy}</code></p>
</div>
</div>
{/snippet}
</Story>
<Story name="Code Block">
{#snippet template()}
<div class="ui:rounded-lg ui:border ui:bg-muted/50 ui:p-4 ui:w-full ui:max-w-2xl">
<div class="ui:flex ui:items-center ui:justify-between ui:mb-2">
<span class="ui:text-sm ui:font-medium">Installation</span>
<CopyButton
text="pnpm add @cio/ui"
size="sm"
variant="ghost"
onCopy={(status) => {
if (status === 'success') {
copiedText = 'pnpm add @cio/ui';
}
}}
>
Copy
</CopyButton>
</div>
<pre class="ui:text-sm"><code>pnpm add @cio/ui</code></pre>
{#if copiedText}
<p class="ui:mt-2 ui:text-green-600">Last copied: {copiedText}</p>
{/if}
</div>
{/snippet}
</Story>
@@ -0,0 +1,11 @@
export const FIELDS = [
'ref',
'children',
'text',
'icon',
'animationDuration',
'variant',
'size',
'onCopy',
'class'
] as string[];
@@ -0,0 +1,146 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import FolderCodeIcon from '@lucide/svelte/icons/folder-code';
import CloudIcon from '@lucide/svelte/icons/cloud';
import BellIcon from '@lucide/svelte/icons/bell';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import * as Empty from '@cio/ui/base/empty';
import * as Avatar from '@cio/ui/base/avatar';
import { Button } from '@cio/ui/base/button';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/Empty',
component: Empty.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<FolderCodeIcon />
</Empty.Media>
<Empty.Title>No Courses Yet</Empty.Title>
<Empty.Description>
You haven't created any courses yet. Get started by creating your first course.
</Empty.Description>
</Empty.Header>
<Empty.Content>
<div class="flex gap-2">
<Button>Create Course</Button>
<Button variant="outline">Import Project</Button>
</div>
</Empty.Content>
<Button variant="link" class="ui:text-muted-foreground" size="sm">
<a href="#/">
Learn More <ArrowUpRightIcon class="inline" />
</a>
</Button>
</Empty.Root>
{/snippet}
</Story>
<Story name="Outline">
{#snippet template()}
<Empty.Root class="border border-dashed">
<Empty.Header>
<Empty.Media variant="icon">
<CloudIcon />
</Empty.Media>
<Empty.Title>Cloud Storage Empty</Empty.Title>
<Empty.Description>Upload files to your cloud storage to access them anywhere.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" size="sm">Upload Files</Button>
</Empty.Content>
</Empty.Root>
{/snippet}
</Story>
<Story name="Background">
{#snippet template()}
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
<Empty.Header>
<Empty.Media variant="icon">
<BellIcon />
</Empty.Media>
<Empty.Title>No Notifications</Empty.Title>
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
</Button>
</Empty.Content>
</Empty.Root>
{/snippet}
</Story>
<Story name="Avatar">
{#snippet template()}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="default">
<Avatar.Root class="size-12">
<Avatar.Image src="https://github.com/shadcn.png" class="grayscale" />
<Avatar.Fallback>LR</Avatar.Fallback>
</Avatar.Root>
</Empty.Media>
<Empty.Title>User Offline</Empty.Title>
<Empty.Description>
This user is currently offline. You can leave a message to notify them or try again later.
</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button size="sm">Leave Message</Button>
</Empty.Content>
</Empty.Root>
{/snippet}
</Story>
<Story name="Avatar Groups">
{#snippet template()}
<Empty.Root>
<Empty.Header>
<Empty.Media>
<div class="*:ring-background flex -space-x-2 *:size-12 *:ring-2 *:grayscale">
<Avatar.Root>
<Avatar.Image src="https://github.com/shadcn.png" alt="@shadcn" />
<Avatar.Fallback>CN</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root>
<Avatar.Image src="https://github.com/maxleiter.png" alt="@maxleiter" />
<Avatar.Fallback>LR</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root>
<Avatar.Image src="https://github.com/evilrabbit.png" alt="@evilrabbit" />
<Avatar.Fallback>ER</Avatar.Fallback>
</Avatar.Root>
</div>
</Empty.Media>
<Empty.Title>No Team Members</Empty.Title>
<Empty.Description>Invite your team to collaborate on this project.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button size="sm">
<PlusIcon />
Invite Members
</Button>
</Empty.Content>
</Empty.Root>
{/snippet}
</Story>
@@ -0,0 +1 @@
export const FIELDS = ['class'] as string[];
@@ -0,0 +1,508 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Button } from '@cio/ui/base/button';
import { Checkbox } from '@cio/ui/base/checkbox';
import * as Select from '@cio/ui/base/select';
import { Switch } from '@cio/ui/base/switch';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/Field',
component: Field.Field,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let courseType = $state();
let courseVersion = $state();
let assignmentType = $state();
let notificationPreference = $state('email');
let deliveryMethod = $state('live-class');
const courseTypes = [
{ value: 'live-class', label: 'Live Class' },
{ value: 'self-paced', label: 'Self-Paced' },
{ value: 'hybrid', label: 'Hybrid' }
];
const courseVersions = [
{ value: 'v1', label: 'Version 1' },
{ value: 'v2', label: 'Version 2' },
{ value: 'v3', label: 'Version 3' }
];
const assignmentTypes = [
{ value: 'essay', label: 'Essay' },
{ value: 'quiz', label: 'Quiz' },
{ value: 'project', label: 'Project' },
{ value: 'presentation', label: 'Presentation' }
];
const courseTypeLabel = $derived(courseTypes.find((t) => t.value === courseType)?.label ?? 'Select course type');
</script>
<Story name="Payment Form">
{#snippet template()}
<form class="w-lg">
<Field.Group>
<Field.Set>
<Field.Legend>Course Information</Field.Legend>
<Field.Description>Create a new course for your students</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label for="course-title">Course Title</Field.Label>
<Input id="course-title" placeholder="Introduction to Web Development" required />
</Field.Field>
<Field.Field>
<Field.Label for="course-description">Description</Field.Label>
<Textarea
id="course-description"
placeholder="A comprehensive course covering HTML, CSS, and JavaScript fundamentals..."
rows={4}
class="resize-none"
/>
<Field.Description>Provide a brief overview of what students will learn</Field.Description>
</Field.Field>
<div class="grid grid-cols-2 gap-4">
<Field.Field>
<Field.Label for="course-type">Course Type</Field.Label>
<Select.Root type="single" bind:value={courseType}>
<Select.Trigger id="course-type">
<span>{courseTypeLabel}</span>
</Select.Trigger>
<Select.Content>
{#each courseTypes as type (type.value)}
<Select.Item value={type.value}>{type.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label for="course-version">Version</Field.Label>
<Select.Root type="single" bind:value={courseVersion}>
<Select.Trigger id="course-version">
<span>{courseVersion || 'Select version'}</span>
</Select.Trigger>
<Select.Content>
{#each courseVersions as version (version.value)}
<Select.Item value={version.value}>{version.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
</div>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>Course Settings</Field.Legend>
<Field.Description>Configure how your course will be delivered and accessed</Field.Description>
<Field.Group>
<Field.Field orientation="horizontal">
<Checkbox id="course-published" checked={false} />
<Field.Label for="course-published" class="font-normal">Publish course immediately</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="course-certificate" checked={true} />
<Field.Label for="course-certificate" class="font-normal">
Enable certificate download upon completion
</Field.Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Group>
<Field.Field>
<Field.Label for="course-overview">Course Overview</Field.Label>
<Textarea
id="course-overview"
placeholder="Welcome to this amazing course! In this course, you'll learn..."
class="resize-none"
rows={3}
/>
<Field.Description
>This message will be displayed to students when they first access the course</Field.Description
>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Field orientation="horizontal">
<Button type="submit">Create Course</Button>
<Button variant="outline" type="button">Cancel</Button>
</Field.Field>
</Field.Group>
</form>
{/snippet}
</Story>
<Story name="Basic Usage">
{#snippet template()}
<div class="w-lg">
<Field.Set>
<Field.Legend>Student Profile</Field.Legend>
<Field.Description>Update your profile information visible to teachers and classmates</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label for="student-name">Full name</Field.Label>
<Input id="student-name" autoComplete="off" placeholder="John Doe" />
<Field.Description
>This will be displayed on your certificates and course completion records</Field.Description
>
</Field.Field>
<Field.Field>
<Field.Label for="student-email">Email</Field.Label>
<Input id="student-email" type="email" autoComplete="off" aria-invalid />
<Field.Error>This email is already registered. Please use a different email.</Field.Error>
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="email-notifications" checked={true} />
<Field.Label for="email-notifications">Receive email notifications about course updates</Field.Label>
</Field.Field>
</Field.Group>
</Field.Set>
</div>
{/snippet}
</Story>
<Story name="Input">
{#snippet template()}
<div class="w-lg">
<Field.Set>
<Field.Group>
<Field.Field>
<Field.Label for="login-email">Email</Field.Label>
<Input id="login-email" type="email" placeholder="student@example.com" />
<Field.Description>Enter the email address associated with your account</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label for="login-password">Password</Field.Label>
<Field.Description>Must be at least 8 characters long</Field.Description>
<Input id="login-password" type="password" placeholder="********" />
</Field.Field>
</Field.Group>
</Field.Set>
</div>
{/snippet}
</Story>
<Story name="Textarea">
{#snippet template()}
<div class="w-lg">
<Field.Set>
<Field.Group>
<Field.Field>
<Field.Label for="assignment-answer">Assignment Answer</Field.Label>
<Textarea
id="assignment-answer"
placeholder="Write your response here. Be sure to address all parts of the question..."
rows={8}
/>
<Field.Description>
Submit your completed assignment. You can edit your submission before the deadline.
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
</div>
{/snippet}
</Story>
<Story name="Select">
{#snippet template()}
<div class="w-lg">
<Field.Field>
<Field.Label for="assignment-type">Assignment Type</Field.Label>
<Select.Root type="single" bind:value={assignmentType}>
<Select.Trigger id="assignment-type">
<span>{assignmentType || 'Select assignment type'}</span>
</Select.Trigger>
<Select.Content>
{#each assignmentTypes as type (type.value)}
<Select.Item value={type.value}>{type.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Field.Description>Choose the type of assignment you want to create for your students</Field.Description>
</Field.Field>
</div>
{/snippet}
</Story>
<Story name="Field Set">
{#snippet template()}
<div class="@container w-lg">
<!-- <div class="max-w-sm" style="width: 512px;"> -->
<Field.Set>
<Field.Legend>Enrollment Information</Field.Legend>
<Field.Description>Add a new student to your course</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label for="student-email-enroll">Student Email</Field.Label>
<Input id="student-email-enroll" type="email" placeholder="student@example.com" />
</Field.Field>
<div class="flex justify-between gap-4">
<Field.Field>
<Field.Label for="enrollment-date">Enrollment Date</Field.Label>
<Input id="enrollment-date" type="date" />
</Field.Field>
<Field.Field>
<Field.Label for="access-level">Access Level</Field.Label>
<Select.Root type="single">
<Select.Trigger id="access-level">
<span>Full Access</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="full">Full Access</Select.Item>
<Select.Item value="limited">Limited Access</Select.Item>
<Select.Item value="readonly">Read Only</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
</div>
</Field.Group>
</Field.Set>
</div>
{/snippet}
</Story>
<Story name="Checkbox">
{#snippet template()}
<div class="w-lg">
<Field.Group>
<Field.Set>
<Field.Legend variant="label">Course Features</Field.Legend>
<Field.Description>Enable features available to students in this course</Field.Description>
<Field.Group class="gap-3">
<Field.Field orientation="horizontal">
<Checkbox id="course-forum" checked />
<Field.Label for="course-forum" class="font-normal">Enable course forum</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="course-quiz" checked />
<Field.Label for="course-quiz" class="font-normal">Enable live quizzes</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="course-assignments" checked />
<Field.Label for="course-assignments" class="font-normal">Enable assignments</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="course-certificates" />
<Field.Label for="course-certificates" class="font-normal">Enable certificate generation</Field.Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Field orientation="horizontal">
<Checkbox id="ai-assistance" checked />
<Field.Content>
<Field.Label for="ai-assistance">AI-Powered Course Creation</Field.Label>
<Field.Description>
Use AI to generate course content, lesson outlines, and assignments from your notes. This feature helps
you create comprehensive courses faster.
</Field.Description>
</Field.Content>
</Field.Field>
</Field.Group>
</div>
{/snippet}
</Story>
<Story name="Radio">
{#snippet template()}
<div class="w-lg">
<Field.Set>
<Field.Label>Course Delivery Method</Field.Label>
<Field.Description>Choose how your course will be delivered to students</Field.Description>
<RadioGroup.Root bind:value={deliveryMethod}>
<Field.Field orientation="horizontal">
<RadioGroup.Item value="live-class" id="type-live" />
<Field.Label for="type-live" class="font-normal">
Live Class - Scheduled sessions with real-time interaction
</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<RadioGroup.Item value="self-paced" id="type-self-paced" />
<Field.Label for="type-self-paced" class="font-normal">
Self-Paced - Students learn at their own speed
</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<RadioGroup.Item value="hybrid" id="type-hybrid" />
<Field.Label for="type-hybrid" class="font-normal">
Hybrid - Combination of live sessions and self-paced content
</Field.Label>
</Field.Field>
</RadioGroup.Root>
</Field.Set>
</div>
{/snippet}
</Story>
<Story name="Switch">
{#snippet template()}
<div class="w-lg">
<Field.Field orientation="horizontal">
<Field.Content>
<Field.Label for="publish-course">Publish Course</Field.Label>
<Field.Description>
Make this course visible to enrolled students. Unpublished courses are only visible to teachers and admins.
</Field.Description>
</Field.Content>
<Switch id="publish-course" />
</Field.Field>
</div>
{/snippet}
</Story>
<Story name="Choice Card">
{#snippet template()}
<div class="w-lg">
<Field.Group>
<Field.Set>
<Field.Label for="delivery-method-p8w">Course Delivery Method</Field.Label>
<Field.Description>Choose how students will access and interact with your course content</Field.Description>
<RadioGroup.Root bind:value={deliveryMethod}>
<Field.Label for="live-class-r2h">
<Field.Field orientation="horizontal">
<Field.Content>
<Field.Title>Live Class</Field.Title>
<Field.Description>
Scheduled sessions with real-time interaction. Perfect for bootcamps and structured learning
programs.
</Field.Description>
</Field.Content>
<RadioGroup.Item value="live-class" id="live-class-r2h" />
</Field.Field>
</Field.Label>
<Field.Label for="self-paced-z4k">
<Field.Field orientation="horizontal">
<Field.Content>
<Field.Title>Self-Paced</Field.Title>
<Field.Description>
Students learn at their own speed. Access all content immediately and complete on their schedule.
</Field.Description>
</Field.Content>
<RadioGroup.Item value="self-paced" id="self-paced-z4k" />
</Field.Field>
</Field.Label>
</RadioGroup.Root>
</Field.Set>
</Field.Group>
</div>
{/snippet}
</Story>
<Story name="Field Group">
{#snippet template()}
<div class="w-lg">
<Field.Group>
<Field.Set>
<Field.Label>Course Updates</Field.Label>
<Field.Description>
Get notified when new lessons, assignments, or announcements are added to your enrolled courses.
</Field.Description>
<Field.Group data-slot="checkbox-group">
<Field.Field orientation="horizontal">
<Checkbox id="course-updates-email" checked disabled />
<Field.Label for="course-updates-email" class="font-normal">Email notifications</Field.Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Label>Assignment Reminders</Field.Label>
<Field.Description>
Get notified about upcoming assignment deadlines and when teachers grade your submissions.
<a href="#/">Manage preferences</a>
</Field.Description>
<Field.Group data-slot="checkbox-group">
<Field.Field orientation="horizontal">
<Checkbox id="assignment-reminders-email" />
<Field.Label for="assignment-reminders-email" class="font-normal">Email notifications</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="assignment-reminders-push" />
<Field.Label for="assignment-reminders-push" class="font-normal">Push notifications</Field.Label>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
</div>
{/snippet}
</Story>
<Story name="Responsive Layout">
{#snippet template()}
<div class="w-full max-w-4xl">
<form>
<Field.Set>
<Field.Legend>Teacher Profile</Field.Legend>
<Field.Description>Update your teacher profile information</Field.Description>
<Field.Separator />
<Field.Group>
<Field.Field orientation="responsive">
<Field.Content>
<Field.Label for="teacher-name">Name</Field.Label>
<Field.Description>Your name as it will appear to students</Field.Description>
</Field.Content>
<Input id="teacher-name" placeholder="Dr. Jane Smith" required />
</Field.Field>
<Field.Separator />
<Field.Field orientation="responsive">
<Field.Content>
<Field.Label for="teacher-bio">Bio</Field.Label>
<Field.Description>
Write a brief bio about your teaching experience and expertise. This will be visible to students.
</Field.Description>
</Field.Content>
<Textarea
id="teacher-bio"
placeholder="I'm an experienced educator with 10+ years of teaching web development..."
required
class="min-h-[100px] resize-none sm:min-w-[300px]"
/>
</Field.Field>
<Field.Separator />
<Field.Field orientation="responsive">
<Button type="submit">Save Profile</Button>
<Button type="button" variant="outline">Cancel</Button>
</Field.Field>
</Field.Group>
</Field.Set>
</form>
</div>
{/snippet}
</Story>
<Story name="Validation and Errors">
{#snippet template()}
<div class="w-sm">
<Field.Set>
<Field.Group>
<Field.Field data-invalid>
<Field.Label for="student-email-invalid">Student Email</Field.Label>
<Input id="student-email-invalid" type="email" aria-invalid placeholder="invalid-email" />
<Field.Error>Enter a valid email address to enroll the student.</Field.Error>
</Field.Field>
<Field.Field>
<Field.Label for="student-email-valid">Student Email</Field.Label>
<Input id="student-email-valid" type="email" placeholder="student@example.com" />
<Field.Description>This email address is valid and ready for enrollment.</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
</div>
{/snippet}
</Story>
@@ -0,0 +1 @@
export const FIELDS = ['class'] as string[];
@@ -0,0 +1,2 @@
export const FIELDS = ['class'] as string[];
@@ -0,0 +1,121 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
import UsersIcon from '@lucide/svelte/icons/users';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import * as HoverCard from '@cio/ui/base/hover-card';
import * as Avatar from '@cio/ui/base/avatar';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/HoverCard',
component: HoverCard.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<HoverCard.Root>
<HoverCard.Trigger>Introduction to Web Development</HoverCard.Trigger>
<HoverCard.Content>Learn the fundamentals of modern web development with hands-on projects.</HoverCard.Content>
</HoverCard.Root>
{/snippet}
</Story>
<Story name="Instructor Profile">
{#snippet template()}
<HoverCard.Root>
<HoverCard.Trigger
href="/instructors/sarah-johnson"
class="ui:rounded-sm ui:underline-offset-4 ui:hover:underline ui:focus-visible:outline-2 ui:focus-visible:outline-offset-8 ui:focus-visible:outline-black ui:flex ui:items-center ui:gap-2"
>
<Avatar.Root>
<Avatar.Image src="https://github.com/shadcn.png" />
<Avatar.Fallback>SJ</Avatar.Fallback>
</Avatar.Root> @sarah-johnson
</HoverCard.Trigger>
<HoverCard.Content class="w-80">
<div class="flex justify-between space-x-4">
<Avatar.Root>
<Avatar.Image src="https://github.com/shadcn.png" />
<Avatar.Fallback>SJ</Avatar.Fallback>
</Avatar.Root>
<div class="space-y-1">
<h4 class="text-sm font-semibold">Sarah Johnson</h4>
<p class="text-sm">Senior Full-Stack Developer & Instructor</p>
<div class="flex items-center pt-2">
<CalendarDaysIcon class="me-2 size-4 opacity-70" />
<span class="text-muted-foreground text-xs">Teaching since January 2020</span>
</div>
</div>
</div>
</HoverCard.Content>
</HoverCard.Root>
{/snippet}
</Story>
<Story name="Course Preview">
{#snippet template()}
<HoverCard.Root>
<HoverCard.Trigger href="/courses/advanced-react" class="text-primary underline-offset-4 hover:underline">
Advanced React Patterns
</HoverCard.Trigger>
<HoverCard.Content class="w-64">
<div class="space-y-2">
<h4 class="text-sm font-semibold">Advanced React Patterns</h4>
<p class="text-muted-foreground text-sm">
Master advanced React concepts including hooks, context, performance optimization, and modern patterns used
in production applications.
</p>
</div>
</HoverCard.Content>
</HoverCard.Root>
{/snippet}
</Story>
<Story name="Student Profile">
{#snippet template()}
<HoverCard.Root>
<HoverCard.Trigger class="text-primary ui:flex ui:items-center ui:gap-2 underline-offset-4 hover:underline">
<Avatar.Root>
<Avatar.Image src="https://github.com/rotimi-best.png" />
<Avatar.Fallback>AM</Avatar.Fallback>
</Avatar.Root>
@rotimi-best
</HoverCard.Trigger>
<HoverCard.Content class="w-80">
<div class="space-y-3">
<div class="flex items-center gap-3">
<Avatar.Root>
<Avatar.Image src="https://github.com/rotimi-best.png" />
<Avatar.Fallback>AM</Avatar.Fallback>
</Avatar.Root>
<div>
<h4 class="text-sm font-semibold">Alex Martinez</h4>
<p class="text-muted-foreground text-xs">Student</p>
</div>
</div>
<div class="space-y-2">
<div class="text-muted-foreground flex items-center gap-2 text-xs">
<BookOpenIcon class="size-4" />
<span>5 courses enrolled</span>
</div>
<div class="text-muted-foreground flex items-center gap-2 text-xs">
<UsersIcon class="size-4" />
<span>Active since March 2024</span>
</div>
</div>
</div>
</HoverCard.Content>
</HoverCard.Root>
{/snippet}
</Story>
@@ -0,0 +1,2 @@
export const FIELDS = ['class'] as string[];
@@ -0,0 +1,411 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import SearchIcon from '@lucide/svelte/icons/search';
import MailIcon from '@lucide/svelte/icons/mail';
import LockIcon from '@lucide/svelte/icons/lock';
import CreditCardIcon from '@lucide/svelte/icons/credit-card';
import UserIcon from '@lucide/svelte/icons/user';
import MessageSquareIcon from '@lucide/svelte/icons/message-square';
import GlobeIcon from '@lucide/svelte/icons/globe';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import EyeIcon from '@lucide/svelte/icons/eye';
import EyeOffIcon from '@lucide/svelte/icons/eye-off';
import KeyIcon from '@lucide/svelte/icons/key';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import PlusIcon from '@lucide/svelte/icons/plus';
import CopyIcon from '@lucide/svelte/icons/copy';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import StarIcon from '@lucide/svelte/icons/star';
import InfoIcon from '@lucide/svelte/icons/info';
import LoaderIcon from '@lucide/svelte/icons/loader';
import Link2Icon from '@lucide/svelte/icons/link-2';
import { CopyButton } from '@cio/ui/base/copy-button';
import * as InputGroup from '@cio/ui/base/input-group';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Label } from '@cio/ui/base/label';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import * as Tooltip from '@cio/ui/base/tooltip';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as ButtonGroup from '@cio/ui/base/button-group';
import { Kbd } from '@cio/ui/base/kbd';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/InputGroup',
component: InputGroup.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let email = $state('');
let password = $state('');
let showPassword = $state(false);
let apiKey = $state('');
let searchQuery = $state('');
let fileName = $state('');
let username = $state('');
let message = $state('');
let code = $state("console.log('Hello, world!');");
let customInput = $state('Autoresize textarea...');
let domain = $state('example.com');
let amount = $state('0.00');
let cardNumber = $state('');
let fileType = $state('txt');
</script>
<Story name="Icon">
{#snippet template()}
<div class="grid w-full max-w-sm gap-6">
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<SearchIcon />
</InputGroup.Addon>
<InputGroup.Input placeholder="Search..." />
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<MailIcon />
</InputGroup.Addon>
<InputGroup.Input type="email" placeholder="Enter your email" />
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<CreditCardIcon />
</InputGroup.Addon>
<InputGroup.Input placeholder="Card number" />
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input placeholder="Card number" />
<InputGroup.Addon align="inline-end">
<StarIcon />
<InfoIcon />
</InputGroup.Addon>
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Text">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<InputGroup.Text>https://</InputGroup.Text>
</InputGroup.Addon>
<InputGroup.Input placeholder="example.com" />
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<InputGroup.Text>$</InputGroup.Text>
</InputGroup.Addon>
<InputGroup.Input type="number" placeholder="0.00" />
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<InputGroup.Text>@</InputGroup.Text>
</InputGroup.Addon>
<InputGroup.Input placeholder="Enter your username" />
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Addon align="block-start">
<InputGroup.Text>Note</InputGroup.Text>
</InputGroup.Addon>
<InputGroup.Textarea placeholder="Enter your message" />
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Button">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<InputGroup.Root>
<InputGroup.Input placeholder="https://x.com/shadcn" />
<InputGroup.Addon align="inline-end">
<InputGroup.Button>
<CopyIcon />
</InputGroup.Button>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input placeholder="Type to search..." />
<InputGroup.Addon align="inline-end">
<InputGroup.Button>
<SearchIcon />
</InputGroup.Button>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Tooltip">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<LockIcon />
</InputGroup.Addon>
<InputGroup.Input type={showPassword ? 'text' : 'password'} placeholder="Enter password" />
<InputGroup.Addon align="inline-end">
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<InputGroup.Button
{...props}
onclick={() => (showPassword = !showPassword)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{#if showPassword}
<EyeOffIcon />
{:else}
<EyeIcon />
{/if}
</InputGroup.Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>{showPassword ? 'Hide password' : 'Show password'}</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<MailIcon />
</InputGroup.Addon>
<InputGroup.Input type="email" placeholder="Your email address" />
<InputGroup.Addon align="inline-end">
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<InputGroup.Button {...props} aria-label="Help">
<MessageSquareIcon />
</InputGroup.Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>We'll never share your email</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Addon align="inline-start">
<KeyIcon />
</InputGroup.Addon>
<InputGroup.Input type="password" placeholder="Enter API key" />
<InputGroup.Addon align="inline-end">
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<InputGroup.Button {...props} aria-label="Generate API key">
<KeyIcon />
</InputGroup.Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>Generate a new API key</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Textarea">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<InputGroup.Root>
<InputGroup.Addon align="block-start">
<InputGroup.Text>Code</InputGroup.Text>
</InputGroup.Addon>
<InputGroup.Textarea placeholder="console.log('Hello, world!');" />
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Addon align="block-end">
<InputGroup.Text>Note</InputGroup.Text>
</InputGroup.Addon>
<InputGroup.Textarea placeholder="Enter your message" />
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Spinner">
{#snippet template()}
<div class="grid w-full max-w-sm gap-4">
<InputGroup.Root data-disabled>
<InputGroup.Input placeholder="Searching..." disabled />
<InputGroup.Addon align="inline-end">
<Spinner />
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root data-disabled>
<InputGroup.Input placeholder="Processing..." disabled />
<InputGroup.Addon>
<Spinner />
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root data-disabled>
<InputGroup.Input placeholder="Saving changes..." disabled />
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Saving...</InputGroup.Text>
<Spinner />
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root data-disabled>
<InputGroup.Input placeholder="Refreshing data..." disabled />
<InputGroup.Addon>
<LoaderIcon class="animate-spin" />
</InputGroup.Addon>
<InputGroup.Addon align="inline-end">
<InputGroup.Text class="text-muted-foreground">Please wait...</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Label">
{#snippet template()}
<div class="grid w-full max-w-sm gap-4">
<InputGroup.Root>
<InputGroup.Input id="email" placeholder="shadcn" />
<InputGroup.Addon>
<Label for="email">@</Label>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input id="email-2" placeholder="shadcn@vercel.com" />
<InputGroup.Addon align="block-start">
<Label for="email-2" class="ui:text-foreground">Email</Label>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<InputGroup.Button
{...props}
variant="ghost"
aria-label="Help"
class="ms-auto rounded-full"
size="icon-xs"
>
<InfoIcon />
</InputGroup.Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>We'll use this to send you notifications</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Dropdown">
{#snippet template()}
<div class="grid w-full max-w-sm gap-4">
<InputGroup.Root>
<InputGroup.Input placeholder="Enter file name" />
<InputGroup.Addon align="inline-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<InputGroup.Button {...props} variant="ghost" aria-label="More" size="icon-xs">
<MoreHorizontalIcon />
</InputGroup.Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item>Settings</DropdownMenu.Item>
<DropdownMenu.Item>Copy path</DropdownMenu.Item>
<DropdownMenu.Item>Open location</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root class="[--radius:1rem]">
<InputGroup.Input placeholder="Enter search query" />
<InputGroup.Addon align="inline-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<InputGroup.Button {...props} variant="ghost" class="!pe-1.5 text-xs">
Search In... <ChevronDownIcon class="size-3" />
</InputGroup.Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="[--radius:0.95rem]">
<DropdownMenu.Item>Documentation</DropdownMenu.Item>
<DropdownMenu.Item>Blog Posts</DropdownMenu.Item>
<DropdownMenu.Item>Changelog</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Button Group">
{#snippet template()}
<div class="grid w-full max-w-sm gap-6">
<ButtonGroup.Root>
<ButtonGroup.Text>
<Label for="url">https://</Label>
</ButtonGroup.Text>
<InputGroup.Root>
<InputGroup.Input id="url" />
<InputGroup.Addon align="inline-end">
<Link2Icon />
</InputGroup.Addon>
</InputGroup.Root>
<ButtonGroup.Text>.com</ButtonGroup.Text>
</ButtonGroup.Root>
</div>
{/snippet}
</Story>
<Story name="Custom Input">
{#snippet template()}
<div class="w-lg grid gap-6">
<InputGroup.Root>
<textarea
data-slot="input-group-control"
class="field-sizing-content flex min-h-16 w-full resize-none rounded-md bg-transparent px-3 py-2.5 text-base outline-none transition-[color,box-shadow] md:text-sm"
placeholder="Autoresize textarea..."
></textarea>
<InputGroup.Addon align="block-end">
<InputGroup.Button class="ms-auto" size="sm" variant="default">Submit</InputGroup.Button>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{/snippet}
</Story>
@@ -0,0 +1,2 @@
export const FIELDS = ['class'] as string[];
@@ -0,0 +1,387 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import BookIcon from '@lucide/svelte/icons/book';
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
import UsersIcon from '@lucide/svelte/icons/users';
import SearchIcon from '@lucide/svelte/icons/search';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import PlusIcon from '@lucide/svelte/icons/plus';
import FilterIcon from '@lucide/svelte/icons/filter';
import DownloadIcon from '@lucide/svelte/icons/download';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import FolderCodeIcon from '@lucide/svelte/icons/folder-code';
import UserPlusIcon from '@lucide/svelte/icons/user-plus';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import SettingsIcon from '@lucide/svelte/icons/settings';
import TrashIcon from '@lucide/svelte/icons/trash';
import ShareIcon from '@lucide/svelte/icons/share';
import ArchiveIcon from '@lucide/svelte/icons/archive';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import * as Page from '@cio/ui/base/page';
import * as ButtonGroup from '@cio/ui/base/button-group';
import * as Empty from '@cio/ui/base/empty';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as InputGroup from '@cio/ui/base/input-group';
import { Button } from '@cio/ui/base/button';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/Page',
component: Page.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Page.Root class="w-4xl h-4xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>Courses</Page.Title>
<Page.Subtitle>Manage and organize your course offerings.</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button variant="default">
<PlusIcon />
New Course
</Button>
<Button variant="outline">
Visit Site
<ArrowUpRightIcon />
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<Empty.Root class="border border-dashed">
<Empty.Header>
<Empty.Media variant="icon">
<FolderCodeIcon />
</Empty.Media>
<Empty.Title>No Courses Yet</Empty.Title>
<Empty.Description>
You haven't created any courses yet. Get started by creating your first course.
</Empty.Description>
</Empty.Header>
<Empty.Content>
<div class="flex gap-2">
<Button>
<PlusIcon />
Create Course
</Button>
<Button variant="outline">Import Course</Button>
</div>
</Empty.Content>
</Empty.Root>
{/snippet}
</Page.Body>
</Page.Root>
{/snippet}
</Story>
<Story name="Courses">
{#snippet template()}
<Page.Root>
<Page.Header>
<Page.HeaderContent>
<Page.Title>Courses</Page.Title>
<Page.Subtitle>Manage and organize your course offerings.</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button>
<PlusIcon />
New Course
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
<Page.BodyHeader align="right">
<ButtonGroup.Root>
<InputGroup.Root>
<InputGroup.Input placeholder="Search courses..." />
<InputGroup.Addon>
<SearchIcon />
</InputGroup.Addon>
</InputGroup.Root>
<ButtonGroup.Root>
<Button variant="outline">
<FilterIcon />
Filter
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="ps-2!">
<ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="[--radius:1rem]">
<DropdownMenu.Group>
<DropdownMenu.Item>
<DownloadIcon />
Export Courses
</DropdownMenu.Item>
<DropdownMenu.Item>
<ShareIcon />
Share Courses
</DropdownMenu.Item>
<DropdownMenu.Item>
<ArchiveIcon />
Archive Selected
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item variant="destructive">
<TrashIcon />
Delete Selected
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
</ButtonGroup.Root>
</Page.BodyHeader>
{#snippet child()}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<FolderCodeIcon />
</Empty.Media>
<Empty.Title>No Courses Yet</Empty.Title>
<Empty.Description>
You haven't created any courses yet. Get started by creating your first course.
</Empty.Description>
</Empty.Header>
<Empty.Content>
<div class="flex gap-2">
<Button>
<PlusIcon />
Create Course
</Button>
<Button variant="outline">Import Course</Button>
</div>
</Empty.Content>
</Empty.Root>
{/snippet}
</Page.Body>
</Page.Root>
{/snippet}
</Story>
<Story name="Classes">
{#snippet template()}
<Page.Root>
<Page.Header>
<Page.HeaderContent>
<Page.Title>Classes</Page.Title>
<Page.Subtitle>View and manage your class schedules and sessions.</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<ButtonGroup.Root>
<ButtonGroup.Root>
<Input placeholder="Search classes..." />
<Button variant="outline" size="icon" aria-label="Search">
<SearchIcon />
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline">
<FilterIcon />
Filter
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="ps-2!">
<ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="[--radius:1rem]">
<DropdownMenu.Group>
<DropdownMenu.Item>
<DownloadIcon />
Export Schedule
</DropdownMenu.Item>
<DropdownMenu.Item>
<SettingsIcon />
Class Settings
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
</ButtonGroup.Root>
<Button>
<PlusIcon />
New Class
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<GraduationCapIcon />
</Empty.Media>
<Empty.Title>No Classes Scheduled</Empty.Title>
<Empty.Description>
Create your first class session to start teaching. Classes help you organize your teaching schedule.
</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button>
<PlusIcon />
Create Class
</Button>
</Empty.Content>
</Empty.Root>
{/snippet}
</Page.Body>
</Page.Root>
{/snippet}
</Story>
<Story name="Students">
{#snippet template()}
<Page.Root>
<Page.Header>
<Page.HeaderContent>
<Page.Title>Students</Page.Title>
<Page.Subtitle>Manage student enrollments and track progress.</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<ButtonGroup.Root>
<ButtonGroup.Root>
<Input placeholder="Search students..." />
<Button variant="outline" size="icon" aria-label="Search">
<SearchIcon />
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline">
<FilterIcon />
Filter
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="ps-2!">
<ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="[--radius:1rem]">
<DropdownMenu.Group>
<DropdownMenu.Item>
<DownloadIcon />
Export Student List
</DropdownMenu.Item>
<DropdownMenu.Item>
<FileTextIcon />
Generate Report
</DropdownMenu.Item>
<DropdownMenu.Item>
<ShareIcon />
Share Student List
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
</ButtonGroup.Root>
<Button>
<UserPlusIcon />
Add Student
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<UsersIcon />
</Empty.Media>
<Empty.Title>No Students Enrolled</Empty.Title>
<Empty.Description>
Start by adding students to your courses. You can invite them via email or import from a CSV file.
</Empty.Description>
</Empty.Header>
<Empty.Content>
<div class="flex gap-2">
<Button>
<UserPlusIcon />
Add Student
</Button>
<Button variant="outline">Import CSV</Button>
</div>
</Empty.Content>
</Empty.Root>
{/snippet}
</Page.Body>
</Page.Root>
{/snippet}
</Story>
<Story name="Header Only">
{#snippet template()}
<Page.Root>
<Page.Header>
<Page.HeaderContent>
<Page.Title>Analytics Dashboard</Page.Title>
<Page.Subtitle>View and analyze your learning management data.</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<ButtonGroup.Root>
<ButtonGroup.Root>
<Input placeholder="Search..." />
<Button variant="outline" size="icon" aria-label="Search">
<SearchIcon />
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline">
<DownloadIcon />
Export
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="ps-2!">
<ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="[--radius:1rem]">
<DropdownMenu.Group>
<DropdownMenu.Item>
<ShareIcon />
Share Dashboard
</DropdownMenu.Item>
<DropdownMenu.Item>
<SettingsIcon />
Dashboard Settings
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
</ButtonGroup.Root>
</Page.Action>
</Page.Header>
</Page.Root>
{/snippet}
</Story>
@@ -0,0 +1,97 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Password } from '@cio/ui/custom/password';
import * as Field from '@cio/ui/base/field';
const { Story } = defineMeta({
title: 'Molecules/Password',
component: Password,
parameters: {
layout: 'centered',
controls: {
include: ['placeholder', 'disabled', 'class']
}
},
tags: ['autodocs']
});
let password = $state('');
let passwordWithError = $state('');
let passwordDisabled = $state('password123');
</script>
<Story name="Default">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<Password bind:value={password} placeholder="Enter password" />
<p class="ui:text-sm ui:text-muted-foreground">Value: {password || '(empty)'}</p>
</div>
{/snippet}
</Story>
<Story name="With Field">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<Field.Content>
<Password id="password" bind:value={password} placeholder="Enter your password" />
</Field.Content>
<Field.Description>Password must be more than 8 characters</Field.Description>
</Field.Field>
</div>
{/snippet}
</Story>
<Story name="With Error">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<Field.Field>
<Field.Label for="password-error">Password</Field.Label>
<Field.Content>
<Password
id="password-error"
bind:value={passwordWithError}
placeholder="Enter your password"
aria-invalid="true"
/>
</Field.Content>
<Field.Error>Password is required</Field.Error>
</Field.Field>
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<Field.Field>
<Field.Label for="password-disabled">Password</Field.Label>
<Field.Content>
<Password
id="password-disabled"
bind:value={passwordDisabled}
placeholder="Enter your password"
disabled={true}
/>
</Field.Content>
</Field.Field>
</div>
{/snippet}
</Story>
<Story name="Custom Labels">
{#snippet template()}
<div class="ui:flex ui:flex-col ui:gap-4 ui:w-96">
<Password
bind:value={password}
placeholder="Enter your password"
showPasswordTooltip="Click to show password"
hidePasswordTooltip="Click to hide password"
showPasswordAriaLabel="Show password"
hidePasswordAriaLabel="Hide password"
/>
</div>
{/snippet}
</Story>
+5 -4
View File
@@ -17,9 +17,9 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"paths": {
"@cio/ui": ["../ui/src"],
"@cio/ui/*": ["../ui/src/*"],
"$src/*": ["../ui/*"]
"@cio/ui": ["./node_modules/@cio/ui/src"],
"@cio/ui/*": ["./node_modules/@cio/ui/src/*"],
"$src/*": ["./node_modules/@cio/ui/*"]
},
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"],
"types": ["node"],
@@ -34,5 +34,6 @@
]
},
"include": ["src/**/*", ".storybook/**/*", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist"],
"references": [{ "path": "../ui" }]
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/jsrepo@2.5.2/schemas/project-config.json",
"repos": ["@ieedan/shadcn-svelte-extras"],
"includeTests": false,
"includeDocs": false,
"watermark": true,
"formatter": "prettier",
"configFiles": {},
"paths": {
"*": "$src",
"ui": "$src/base",
"actions": "$src/actions",
"hooks": "$src/hooks",
"utils": "$src/tools"
}
}
+10 -6
View File
@@ -12,19 +12,24 @@
},
"devDependencies": {
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.544.0",
"@lucide/svelte": "^0.554.0",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.18",
"bits-ui": "^2.11.7",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"layerchart": "2.0.0-next.27",
"mode-watcher": "^1.1.0",
"runed": "^0.36.0",
"svelte": "^5.39.11",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"svelte-toolbelt": "^0.10.6",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"vaul-svelte": "1.0.0-next.7"
@@ -32,7 +37,6 @@
"dependencies": {
"@aarkue/tiptap-math-extension": "^1.4.0",
"@floating-ui/dom": "^1.7.4",
"@lucide/svelte": "^0.546.0",
"@tiptap/core": "^3.7.1",
"@tiptap/extension-bubble-menu": "^3.7.1",
"@tiptap/extension-code-block-lowlight": "^3.7.1",
@@ -59,6 +63,6 @@
"tiptap-markdown": "^0.9.0"
},
"peerDependencies": {
"bits-ui": "^2.14.1"
"bits-ui": "^2.14.4"
}
}
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '../../tools';
import type { ComponentProps } from 'svelte';
import { Separator } from '../separator';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="button-group-separator"
{orientation}
class={cn('ui:bg-input ui:relative ui:m-0! ui:self-stretch ui:data-[orientation=vertical]:h-auto', className)}
{...restProps}
/>
@@ -0,0 +1,30 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
class: className,
child,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
...restProps,
class: cn(
"ui:bg-muted ui:shadow-xs ui:flex ui:items-center ui:gap-2 ui:rounded-md ui:border ui:px-4 ui:text-sm ui:font-medium ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none",
className
)
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
@@ -0,0 +1,46 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "ui:flex ui:w-fit ui:items-stretch ui:has-[>[data-slot=button-group]]:gap-2 ui:[&>*]:focus-visible:relative ui:[&>*]:focus-visible:z-10 ui:has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md ui:[&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit ui:[&>input]:flex-1",
variants: {
orientation: {
horizontal:
'ui:[&>*:not(:first-child)]:rounded-s-none ui:[&>*:not(:first-child)]:border-s-0 ui:[&>*:not(:last-child)]:rounded-e-none',
vertical:
'ui:flex-col ui:[&>*:not(:first-child)]:rounded-t-none ui:[&>*:not(:first-child)]:border-t-0 ui:[&>*:not(:last-child)]:rounded-b-none'
}
},
defaultVariants: {
orientation: 'horizontal'
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
orientation = 'horizontal',
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
class={cn(buttonGroupVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,13 @@
import Root from "./button-group.svelte";
import Text from "./button-group-text.svelte";
import Separator from "./button-group-separator.svelte";
export {
Root,
Text,
Separator,
//
Root as ButtonGroup,
Text as ButtonGroupText,
Separator as ButtonGroupSeparator,
};
+85 -51
View File
@@ -1,21 +1,20 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '../../tools';
import type { WithChildren, WithoutChildren } from 'bits-ui';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
import { Spinner } from '../spinner';
export const buttonVariants = tv({
base: "ui:focus-visible:border-ring ui:cursor-pointer ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:shrink-0 ui:items-center ui:justify-center ui:gap-2 ui:whitespace-nowrap ui:rounded-md ui:text-sm ui:font-medium ui:outline-none ui:transition-all ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:aria-disabled:pointer-events-none ui:aria-disabled:opacity-50 ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0",
base: "ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:relative ui:inline-flex ui:shrink-0 ui:items-center ui:justify-center ui:gap-2 ui:overflow-hidden ui:rounded-md ui:text-sm ui:font-medium ui:whitespace-nowrap ui:outline-hidden ui:transition-all ui:select-none ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:[&_svg:not([class*='size-'])]:size-4 ui:cursor-pointer",
variants: {
variant: {
default: 'ui:bg-primary ui:text-primary-foreground ui:shadow-xs ui:hover:bg-primary/90',
default: 'ui:bg-primary ui:text-primary-foreground ui:[a&]:hover:bg-primary/90 ui:shadow-2xs',
destructive:
'ui:bg-destructive ui:shadow-xs ui:hover:bg-destructive/90 ui:focus-visible:ring-destructive/20 ui:dark:focus-visible:ring-destructive/40 ui:dark:bg-destructive/60 ui:text-white',
'ui:bg-destructive ui:[a&]:hover:bg-destructive/90 ui:focus-visible:ring-destructive/20 ui:dark:bg-destructive/60 ui:dark:focus-visible:ring-destructive/40 ui:text-white ui:shadow-2xs',
outline:
'ui:bg-background ui:shadow-xs ui:hover:bg-accent ui:hover:text-accent-foreground ui:dark:bg-input/30 ui:dark:border-input ui:dark:hover:bg-input/50 ui:border',
secondary: 'ui:bg-secondary ui:text-secondary-foreground ui:shadow-xs ui:hover:bg-secondary/80',
ghost: 'ui:hover:bg-accent ui:hover:text-accent-foreground ui:dark:hover:bg-accent/50',
link: 'ui:text-primary ui:underline-offset-4 ui:hover:underline'
'ui:bg-background ui:[a&]:hover:bg-accent ui:[a&]:hover:text-accent-foreground ui:dark:border-input ui:dark:bg-input/30 ui:dark:hover:bg-input/50 ui:border ui:shadow-2xs',
secondary: 'ui:bg-secondary ui:text-secondary-foreground ui:[a&]:hover:bg-secondary/80 ui:shadow-2xs',
ghost: 'ui:[a&]:hover:bg-accent ui:[a&]:hover:text-accent-foreground ui:dark:[a&]:hover:bg-accent/50',
link: 'ui:text-primary ui:underline-offset-4 ui:[a&]:hover:underline'
},
size: {
default: 'ui:h-9 ui:px-4 ui:py-2 ui:has-[>svg]:px-3',
@@ -35,58 +34,93 @@
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
export type ButtonPropsWithoutHTML = WithChildren<{
ref?: HTMLElement | null;
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
onClickPromise?: (
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => Promise<void>;
}>;
export type AnchorElementProps = ButtonPropsWithoutHTML &
WithoutChildren<Omit<HTMLAnchorAttributes, 'href' | 'type'>> & {
href: HTMLAnchorAttributes['href'];
type?: never;
disabled?: HTMLButtonAttributes['disabled'];
};
export type ButtonElementProps = ButtonPropsWithoutHTML &
WithoutChildren<Omit<HTMLButtonAttributes, 'type' | 'href'>> & {
type?: HTMLButtonAttributes['type'];
href?: never;
disabled?: HTMLButtonAttributes['disabled'];
};
export type ButtonProps = AnchorElementProps | ButtonElementProps;
</script>
<script lang="ts">
import { cn } from '../../tools';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
let {
class: className,
ref = $bindable(null),
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
isLoading = false,
type = 'button',
loading = false,
disabled = false,
tabindex = 0,
onclick,
onClickPromise,
class: className,
children,
...restProps
...rest
}: ButtonProps = $props();
let disabled = $derived(restProps.disabled || isLoading);
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{#if isLoading}
<Spinner />
{/if}
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{#if isLoading}
<Spinner />
{/if}
{@render children?.()}
</button>
{/if}
<!-- This approach to disabled links is inspired by bits-ui see: https://github.com/huntabyte/bits-ui/pull/1055 -->
<svelte:element
this={href ? 'a' : 'button'}
{...rest}
data-slot="button"
type={href ? undefined : type}
href={href && !disabled ? href : undefined}
disabled={href ? undefined : disabled || loading}
aria-disabled={href ? disabled : undefined}
role={href && disabled ? 'link' : undefined}
tabindex={href && disabled ? -1 : tabindex}
class={cn(buttonVariants({ variant, size }), className)}
bind:this={ref}
onclick={async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
e: any
) => {
onclick?.(e);
if (type === undefined) return;
if (onClickPromise) {
loading = true;
await onClickPromise(e);
loading = false;
}
}}
>
{#if type !== undefined && loading}
<div class="ui:absolute ui:flex ui:size-full ui:place-items-center ui:justify-center ui:bg-inherit">
<div class="ui:flex ui:animate-spin ui:place-items-center ui:justify-center">
<LoaderCircleIcon class="ui:size-4" />
</div>
</div>
<span class="ui:sr-only">Loading</span>
{/if}
{@render children?.()}
</svelte:element>
+23 -13
View File
@@ -1,17 +1,27 @@
/*
Installed from @ieedan/shadcn-svelte-extras
*/
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
type ButtonProps,
type ButtonSize,
type ButtonVariant,
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML,
buttonVariants
} from './button.svelte';
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn('ui:col-start-2 ui:row-span-2 ui:row-start-1 ui:self-start ui:justify-self-end', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn('ui:px-6', className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn('ui:text-muted-foreground ui:text-sm', className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn('ui:[.border-t]:pt-6 ui:flex ui:items-center ui:px-6', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
'@container/card-header ui:has-data-[slot=card-action]:grid-cols-[1fr_auto] ui:[.border-b]:pb-6 ui:grid ui:auto-rows-min ui:grid-rows-[auto_auto] ui:items-start ui:gap-1.5 ui:px-6',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-title" class={cn('ui:font-semibold ui:leading-none', className)} {...restProps}>
{@render children?.()}
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
'ui:bg-card ui:text-card-foreground ui:flex ui:flex-col ui:gap-6 ui:rounded-xl ui:border ui:py-6 ui:shadow-sm',
className
)}
{...restProps}
>
{@render children?.()}
</div>
+25
View File
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
@@ -0,0 +1,74 @@
<!--
Installed from @ieedan/shadcn-svelte-extras
-->
<script lang="ts">
import { Button } from '../button';
import { UseClipboard } from '../../hooks/use-clipboard.svelte';
import { cn } from '../../tools';
import CheckIcon from '@lucide/svelte/icons/check';
import CopyIcon from '@lucide/svelte/icons/copy';
import XIcon from '@lucide/svelte/icons/x';
import { scale } from 'svelte/transition';
import type { CopyButtonProps } from './types';
let {
ref = $bindable(null),
text,
icon,
animationDuration = 500,
variant = 'ghost',
size = 'icon',
onCopy,
class: className,
tabindex = -1,
children,
...rest
}: CopyButtonProps = $props();
// this way if the user passes text then the button will be the default size
if (size === 'icon' && children) {
size = 'default';
}
const clipboard = new UseClipboard();
</script>
<Button
{...rest}
bind:ref
{variant}
{size}
{tabindex}
class={cn('ui:flex ui:items-center ui:gap-2', className)}
type="button"
name="copy"
onclick={async () => {
// text prop is reactive, so it will use the latest value when clicked
const status = await clipboard.copy(text);
onCopy?.(status);
}}
>
{#if clipboard.status === 'success'}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
<CheckIcon tabindex={-1} />
<span class="ui:sr-only">Copied</span>
</div>
{:else if clipboard.status === 'failure'}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
<XIcon tabindex={-1} />
<span class="sr-only">Failed to copy</span>
</div>
{:else}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
{#if icon}
{@render icon()}
{:else}
<CopyIcon tabindex={-1} />
{/if}
<span class="sr-only">Copy</span>
</div>
{/if}
{@render children?.()}
</Button>
@@ -0,0 +1,7 @@
/*
Installed from @ieedan/shadcn-svelte-extras
*/
import CopyButton from './copy-button.svelte';
export { CopyButton };
+22
View File
@@ -0,0 +1,22 @@
/*
Installed from @ieedan/shadcn-svelte-extras
*/
import type { WithChildren, WithoutChildren } from 'bits-ui';
import type { ButtonPropsWithoutHTML } from '../button';
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
import type { UseClipboard } from '../../hooks/use-clipboard.svelte';
export type CopyButtonPropsWithoutHTML = WithChildren<
Pick<ButtonPropsWithoutHTML, 'size' | 'variant'> & {
ref?: HTMLButtonElement | null;
text: string;
icon?: Snippet<[]>;
animationDuration?: number;
onCopy?: (status: UseClipboard['status']) => void;
}
>;
export type CopyButtonProps = CopyButtonPropsWithoutHTML & WithoutChildren<HTMLAttributes<HTMLButtonElement>>;
@@ -3,8 +3,8 @@
import MoonIcon from '@lucide/svelte/icons/moon';
import { resetMode, setMode } from 'mode-watcher';
import * as DropdownMenu from '$src/base/dropdown-menu';
import { buttonVariants } from '$src/base/button';
import * as DropdownMenu from '../dropdown-menu';
import { buttonVariants } from '../button';
</script>
<DropdownMenu.Root>
@@ -33,7 +33,7 @@
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ui:ring-offset-background ui:focus:ring-ring ui:rounded-xs ui:focus:outline-hidden ui:absolute ui:top-4 ui:opacity-70 ui:transition-opacity ui:hover:opacity-100 ui:focus:ring-2 ui:focus:ring-offset-2 ui:disabled:pointer-events-none ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 end-4"
class="ui:ring-offset-background ui:focus:ring-ring ui:rounded-xs ui:focus:outline-hidden ui:absolute ui:top-4 ui:opacity-70 ui:transition-opacity ui:hover:opacity-100 ui:focus:ring-2 ui:focus:ring-offset-2 ui:disabled:pointer-events-none ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0 ui:end-4 ui:cursor-pointer"
>
<XIcon />
<span class="sr-only">Close</span>
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="dialog-header"
class={cn('ui:flex ui:flex-col ui:gap-2 ui:text-center ui:sm:text-left', className)}
class={cn('ui:flex ui:flex-col ui:gap-2 ui:text-left', className)}
{...restProps}
>
{@render children?.()}
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-content"
class={cn(
'ui:flex ui:w-full ui:min-w-0 ui:max-w-sm ui:flex-col ui:items-center ui:gap-4 ui:text-balance ui:text-sm',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-description"
class={cn(
'ui:text-muted-foreground ui:[&>a:hover]:text-primary ui:text-sm/relaxed ui:[&>a]:underline ui:[&>a]:underline-offset-4',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-header"
class={cn('ui:flex ui:max-w-sm ui:flex-col ui:items-center ui:gap-2 ui:text-center', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,41 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const emptyMediaVariants = tv({
base: 'ui:mb-2 ui:flex ui:shrink-0 ui:items-center ui:justify-center ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0',
variants: {
variant: {
default: 'ui:bg-transparent',
icon: "ui:bg-muted ui:text-foreground ui:flex ui:size-10 ui:shrink-0 ui:items-center ui:justify-center ui:rounded-lg ui:[&_svg:not([class*='size-'])]:size-6"
}
},
defaultVariants: {
variant: 'default'
}
});
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>['variant'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
variant = 'default',
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="empty-icon"
data-variant={variant}
class={cn(emptyMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-title"
class={cn('ui:text-lg ui:font-medium ui:tracking-tight', className)}
{...restProps}
>
{@render children?.()}
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty"
class={cn(
'ui:flex ui:min-w-0 ui:flex-1 ui:flex-col ui:items-center ui:justify-center ui:gap-6 ui:text-balance ui:rounded-lg ui:border-dashed ui:p-6 ui:text-center ui:md:p-12',
className
)}
{...restProps}
>
{@render children?.()}
</div>
+22
View File
@@ -0,0 +1,22 @@
import Root from "./empty.svelte";
import Header from "./empty-header.svelte";
import Media from "./empty-media.svelte";
import Title from "./empty-title.svelte";
import Description from "./empty-description.svelte";
import Content from "./empty-content.svelte";
export {
Root,
Header,
Media,
Title,
Description,
Content,
//
Root as Empty,
Header as EmptyHeader,
Media as EmptyMedia,
Title as EmptyTitle,
Description as EmptyDescription,
Content as EmptyContent,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-content"
class={cn('ui:group/field-content ui:flex ui:flex-1 ui:flex-col ui:gap-1.5 ui:leading-snug', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="field-description"
class={cn(
'ui:text-muted-foreground ui:text-sm ui:font-normal ui:leading-normal ui:group-has-data-[orientation=horizontal]/field:text-balance',
'ui:nth-last-2:-mt-1 ui:last:mt-0 ui:[[data-variant=legend]+&]:-mt-1.5',
'ui:[&>a:hover]:text-primary ui:[&>a]:underline ui:[&>a]:underline-offset-4',
className
)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,58 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
class: className,
children,
errors,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
children?: Snippet;
errors?: { message?: string }[];
} = $props();
const hasContent = $derived.by(() => {
// has slotted error
if (children) return true;
// no errors
if (!errors) return false;
// has an error but no message
if (errors.length === 1 && !errors[0]?.message) {
return false;
}
return true;
});
const isMultipleErrors = $derived(errors && errors.length > 1);
const singleErrorMessage = $derived(errors && errors.length === 1 && errors[0]?.message);
</script>
{#if hasContent}
<div
bind:this={ref}
role="alert"
data-slot="field-error"
class={cn('ui:text-destructive ui:text-sm ui:font-normal', className)}
{...restProps}
>
{#if children}
{@render children()}
{:else if singleErrorMessage}
{singleErrorMessage}
{:else if isMultipleErrors}
<ul class="ui:ms-4 ui:flex ui:list-disc ui:flex-col ui:gap-1">
{#each errors ?? [] as error, index (index)}
{#if error?.message}
<li>{error.message}</li>
{/if}
{/each}
</ul>
{/if}
</div>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-group"
class={cn(
'ui:group/field-group ui:@container/field-group ui:flex ui:w-full ui:flex-col ui:gap-7 ui:data-[slot=checkbox-group]:gap-3 ui:*:data-[slot=field-group]:gap-4',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Label } from '../label';
import { cn } from '../../tools';
import type { ComponentProps } from 'svelte';
let { ref = $bindable(null), class: className, children, ...restProps }: ComponentProps<typeof Label> = $props();
</script>
<Label
bind:ref
data-slot="field-label"
class={cn(
'ui:group/field-label ui:peer/field-label ui:flex ui:w-fit ui:gap-2 ui:leading-snug ui:group-data-[disabled=true]/field:opacity-50',
'ui:has-[>[data-slot=field]]:w-full ui:has-[>[data-slot=field]]:flex-col ui:has-[>[data-slot=field]]:rounded-md ui:has-[>[data-slot=field]]:border ui:*:data-[slot=field]:p-4',
'ui:has-data-[state=checked]:bg-primary/5 ui:has-data-[state=checked]:border-primary ui:dark:has-data-[state=checked]:bg-primary/10',
className
)}
{...restProps}
>
{@render children?.()}
</Label>
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
variant = 'legend',
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLegendElement>> & {
variant?: 'legend' | 'label';
} = $props();
</script>
<legend
bind:this={ref}
data-slot="field-legend"
data-variant={variant}
class={cn(
'ui:mb-3 ui:font-medium',
'ui:data-[variant=legend]:text-base',
'ui:data-[variant=label]:text-sm',
className
)}
{...restProps}
>
{@render children?.()}
</legend>
@@ -0,0 +1,35 @@
<script lang="ts">
import { Separator } from '../separator';
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
children?: Snippet;
} = $props();
const hasContent = $derived(!!children);
</script>
<div
bind:this={ref}
data-slot="field-separator"
data-content={hasContent}
class={cn('ui:relative ui:-my-2 ui:h-5 ui:text-sm ui:group-data-[variant=outline]/field-group:-mb-2', className)}
{...restProps}
>
<Separator class="ui:absolute ui:inset-0 ui:top-1/2" />
{#if children}
<span
class="ui:bg-background ui:text-muted-foreground ui:relative ui:mx-auto ui:block ui:w-fit ui:px-2"
data-slot="field-separator-content"
>
{@render children()}
</span>
{/if}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLFieldsetAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLFieldsetAttributes> = $props();
</script>
<fieldset
bind:this={ref}
data-slot="field-set"
class={cn(
'ui:flex ui:flex-col ui:gap-6',
'ui:has-[>[data-slot=checkbox-group]]:gap-3 ui:has-[>[data-slot=radio-group]]:gap-3',
className
)}
{...restProps}
>
{@render children?.()}
</fieldset>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-title"
class={cn(
'ui:flex ui:w-fit ui:items-center ui:gap-2 ui:text-sm ui:font-medium ui:leading-snug ui:group-data-[disabled=true]/field:opacity-50',
className
)}
{...restProps}
>
{@render children?.()}
</div>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const fieldVariants = tv({
base: 'ui:group/field ui:data-[invalid=true]:text-destructive ui:flex ui:w-full ui:gap-3',
variants: {
orientation: {
vertical: 'ui:flex-col ui:*:w-full ui:*.sr-only:w-auto',
horizontal: [
'ui:flex-row ui:items-center',
'[&>[data-slot=field-label]]:flex-auto',
'ui:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px ui:has-[>[data-slot=field-content]]:items-start'
],
responsive: [
'@md/field-group:ui:flex-row @md/field-group:ui:items-center @md/field-group:ui:[&>*]:w-auto ui:flex-col ui:*:w-full ui:*.sr-only:w-auto',
'@md/field-group:ui:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:ui:has-[>[data-slot=field-content]]:items-start @md/field-group:ui:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px'
]
}
},
defaultVariants: {
orientation: 'vertical'
}
});
export type FieldOrientation = VariantProps<typeof fieldVariants>['orientation'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: FieldOrientation;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="field"
data-orientation={orientation}
class={cn(fieldVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>

Some files were not shown because too many files have changed in this diff Show More