* feat(ui): add AttachmentList component for lesson file attachments Introduce a reusable AttachmentList molecule with view and edit modes. View mode shows a header with file count and view/download actions; edit mode supports drag-to-reorder, view, and delete. Includes Storybook stories and package exports. Co-authored-by: Rotimi Best <[email protected]> * fix(ui): address AttachmentList PR review feedback - Sync edit-mode file state using full metadata signature, not IDs only - Clamp formatFileSize unit index and guard non-finite inputs - Recognize common MIME types for file-type icon styling - Scope role=list to row container; use dragHandleZone for reordering - Add reorder label for drag handle accessibility Co-authored-by: Rotimi Best <[email protected]> * fix(ui): guard AttachmentList against undefined files prop Co-authored-by: Rotimi Best <[email protected]> * fix(ui): render AttachmentList stories and guard missing props Use Storybook template snippets and default args so stories mount correctly. Default mode and labels in the component for autodocs, and align dragHandleZone items with rendered rows. Co-authored-by: Rotimi Best <[email protected]> * feat(dashboard): use AttachmentList on lesson documents tab Replace the horizontal DocumentCard grid with AttachmentList in view and edit modes, including drag-to-reorder in edit. Tighten the list header to p-2 with xs text, add document mapping helpers, and translate new labels. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): reset document upload modal state on close Clear selected file, success message, and upload progress when the add document modal closes so reopening starts fresh. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): raise PDF viewer above sidebar and AI chat Use z-modal (200) instead of z-100 so the full-screen PDF overlay stacks above z-app-bar-elevated (150) course sidebar and z-app-bar (100) AI panel. Add the missing z-modal utility to dashboard app.css to mirror the UI scale. Co-authored-by: Rotimi Best <[email protected]> * chore: minor changes * feat(dashboard): show spinner and snackbar on document download AttachmentList download button now shows the Button loading spinner immediately while the async download runs. On success, a snackbar confirms the download; on failure, an error snackbar is shown instead of opening a new tab. Co-authored-by: Rotimi Best <[email protected]> * fix(ui): address PR review feedback on AttachmentList - Reject negative byte counts in formatFileSize instead of using Math.abs - Make AttachmentList mode/files/labels props optional; disable cross-list drops - Include file name in drag-handle aria-label for screen readers - Prevent upload modal from closing while an upload is in progress - Use ICU plural rules for attachment file_count across all locales Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
332 lines
16 KiB
Markdown
332 lines
16 KiB
Markdown
# @cio/ui
|
|
|
|
UI component library for ClassroomIO, built with Svelte 5 and Tailwind CSS.
|
|
|
|
## Package Information
|
|
|
|
- **Name**: `@cio/ui`
|
|
- **Type**: Svelte component library
|
|
- **Framework**: Svelte 5
|
|
- **Styling**: Tailwind CSS with custom `ui` prefix
|
|
|
|
## Folder Structure
|
|
|
|
```
|
|
packages/ui/
|
|
├── src/
|
|
│ ├── base/ # Components from shadcn-svelte
|
|
│ ├── custom/ # Components from other sources or built on top of base components
|
|
│ ├── hooks/ # Reusable Svelte hooks
|
|
│ ├── tools/ # Utility functions (cn, sanitize, etc.)
|
|
│ ├── index.css # Global styles
|
|
│ └── index.ts # Main export file
|
|
├── package.json
|
|
├── components.json # shadcn-svelte configuration
|
|
└── jsrepo.json # jsrepo configuration
|
|
```
|
|
|
|
## Storybook
|
|
|
|
We maintain a Storybook instance at `packages/storybook` to make it easy to browse, visualize, and reuse every UI component in the app. Instead of digging through source code or guessing how a component looks, you can see each piece rendered in isolation with its variants, props, and states documented.
|
|
|
|
This is especially useful when you're building a new feature and need to find the right existing component — check Storybook first before creating something from scratch.
|
|
|
|
<img alt="Storybook Preview" src="https://brand.cdn.clsrio.com/storybook-preview.png" />
|
|
|
|
To run it locally:
|
|
|
|
```bash
|
|
cd packages/storybook && pnpm dev
|
|
```
|
|
|
|
See `packages/storybook/README.md` for detailed instructions on creating stories and publishing.
|
|
|
|
## Content Structure
|
|
|
|
### Base Components (`src/base/`)
|
|
|
|
All components in the `base` directory are primarily from [shadcn-svelte](https://www.shadcn-svelte.com/). These are the foundational UI components that follow the shadcn design system.
|
|
|
|
**Guidelines:**
|
|
|
|
- Any new component from shadcn-svelte should be added to the `base` directory
|
|
- Components are organized in their own folders with an `index.ts` file for exports
|
|
- Each component folder typically contains the main component file(s) and an `index.ts` export file
|
|
|
|
**Examples:**
|
|
|
|
- `base/button/` - Button component from shadcn-svelte
|
|
- `base/dialog/` - Dialog component from shadcn-svelte
|
|
- `base/tooltip/` - Tooltip component from shadcn-svelte
|
|
|
|
### Custom Components (`src/custom/`)
|
|
|
|
Components in the `custom` directory come from various sources or are built on top of base components:
|
|
|
|
1. **External Sources:**
|
|
- Components from [shadcn-svelte-extras](https://www.shadcn-svelte-extras.com/)
|
|
- Components from [Edra Editor](http://edra.tsuzat.com/) (rich text editor)
|
|
- Other third-party component libraries
|
|
|
|
2. **Extended Components:**
|
|
- Any component that builds on top of shadcn-svelte base components
|
|
- Custom implementations that extend base component functionality
|
|
|
|
**Examples:**
|
|
|
|
- `custom/code/` - From shadcn-svelte-extras (syntax-highlighted code block with copy and overflow)
|
|
- `custom/underline-tabs/` - From shadcn-svelte-extras
|
|
- `custom/editor/` - Rich text editor based on Edra/Tiptap
|
|
- `custom/image-cropper/` - Custom image cropping component
|
|
- `custom/checkbox-field/` - Field component built on top of base Checkbox
|
|
- `custom/newsfeed-reactions/` - Newsfeed reaction picker and summary used by course and program feeds
|
|
- `custom/course-creator/` - ChatGPT-style course creation input with level and type selects
|
|
|
|
### Exercise question (`src/custom/exercise-question/`)
|
|
|
|
Learner and author UIs for exercise questions (take, preview, review, submission, edit per question type).
|
|
|
|
- **`renderers/shared/`** — Cross-type presentation pieces reused by multiple question families (for example `labeled-value-row.svelte`, `mcq-preview-option-row.svelte`, `submission-response-pie-chart.svelte`). Mode entry files stay thin; add new shared pieces here when two or more types need the same markup.
|
|
- **`renderers/<question-type>/`** — One folder per type (`radio`, `numeric`, …) containing `take.svelte`, `preview.svelte`, and optional `review.svelte`, `submission.svelte`, `edit.svelte`, plus type-specific fragments (for example `numeric-expected-fields.svelte`, `ordering-display.svelte`).
|
|
- **`renderers/option-image.svelte`**, **`renderers/submission-utils.ts`** — Existing shared helpers at the `renderers/` root; keep importing from there unless consolidating in a later pass.
|
|
|
|
**Types with heavier or distinct UIs** (textarea editor, file upload, matching/hotspot authoring, link lists) intentionally keep separate `take` / `preview` implementations until a second consumer (for example a dedicated `review` mode) justifies extracting more shared fragments.
|
|
|
|
### Question type picker (`src/custom/question-type-picker/`)
|
|
|
|
Marketing / demo widget: left-hand list of question types and a live **take**-mode preview using `ExerciseQuestion.QuestionRenderer`. Copy is English-only (no dashboard i18n). Also consumed by the **`@cio/embeds`** app as a CDN bundle (`apps/embeds`).
|
|
|
|
### Attachment list (`src/custom/attachment-list/`)
|
|
|
|
Presentational list for lesson (or similar) file attachments with **view** and **edit** modes. View mode shows a header (paperclip + title + file count) and rows with view/download icon buttons. Edit mode shows sortable rows (when `onReorder` is provided) with a drag handle, view, and delete actions. Copy is passed via the `labels: AttachmentListLabels` prop (including `reorder` for the drag handle) so dashboard wrappers can supply translated strings. `AttachmentListFile.type` accepts a file extension or MIME type for icon styling. See `Molecules/AttachmentList` in Storybook.
|
|
|
|
### Live session card (`src/custom/live-session-card/`)
|
|
|
|
Presentational card for a live-class lesson with three states (`live`, `upcoming`, `ended`) derived from `lessonAt` + `durationMinutes`, or forced via the `status` prop (used by Storybook). Shows a join/copy action set when live, an "Add to calendar" combo button (Google, Outlook.com, Office 365, Yahoo, plus an `.ics` download for Apple) and a countdown when upcoming. All copy is passed in via the `labels: LiveSessionLabels` prop, so the dashboard wrapper supplies translated strings; `onCopyLink` fires after the link is copied (e.g. for a snackbar). See `Molecules/LiveSessionCard` in Storybook.
|
|
|
|
### Hooks (`src/hooks/`)
|
|
|
|
Reusable Svelte hooks are located in the `src/hooks/` directory. These are Svelte 5 runes-based utilities that can be used across components.
|
|
|
|
**Available Hooks:**
|
|
|
|
- `is-mobile.svelte.ts` - Media query hook for mobile breakpoints
|
|
- `use-clipboard.svelte.ts` - Clipboard copy functionality with state management
|
|
|
|
**Usage:**
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
|
import { UseClipboard } from '@cio/ui/hooks/use-clipboard.svelte';
|
|
</script>
|
|
```
|
|
|
|
### Tools (`src/tools/`)
|
|
|
|
Utility functions and helpers are located in `src/tools/`. The main utility is the `cn` function for class name merging.
|
|
|
|
### Page layout (`src/base/page/`)
|
|
|
|
Composable page shell used across dashboard list and settings screens. Import as `import * as Page from '@cio/ui/base/page'`.
|
|
|
|
| Export | Purpose |
|
|
| ------------------------------ | ------------------------------------------------------------ |
|
|
| `Page.Root` | Flex column wrapper with minimum viewport height |
|
|
| `Page.Header` | Title row; pass `isSticky` to pin the header while scrolling |
|
|
| `Page.HeaderContent` | Title + subtitle column |
|
|
| `Page.Title` / `Page.Subtitle` | Page heading and description |
|
|
| `Page.Action` | Right-aligned header actions |
|
|
| `Page.Body` | Main content area (`child` snippet) |
|
|
| `Page.BodyHeader` | Toolbar inside the body |
|
|
| `Page.SettingsActions` | Save/discard bar for settings forms (floats, then docks) |
|
|
|
|
**Settings pages:** Place `Page.SettingsActions` as the last child inside `Page.Root`, after `Page.Body`. The bar uses `position: sticky; bottom: 0` so it stays pinned to the viewport bottom while you scroll, then settles into normal flow at the end of the page. Do not put `overflow` on `Page.Root` that would break sticky positioning (horizontal overflow on `Page.Body` is fine). Disable Save and Discard via `hasChanges={false}` when the form is clean. Pass translated `statusLabel`, `discardLabel`, and `saveLabel` props from the dashboard. Use `disabled` to block Save only (Discard still follows `hasChanges`). Use `contentClass` for extra classes on the inner card when needed.
|
|
|
|
**`Page.Root` can come from a layout.** "Last child of `Page.Root`" is a runtime relationship, not a same-file one. Where a `+layout.svelte` owns the `Page.Root` and renders pages into it via `{@render children?.()}`, the page file itself contains only `Page.Header`, `Page.Body`, and `Page.SettingsActions` — that is correct and already satisfies the rule. Do **not** add a second `Page.Root` in the page: nesting them stacks two `min-h-[calc(100vh-48px)]` containers, drops the layout's width constraints, and shortens the sticky bar's containing block so it stops floating. Pages that rely on a layout-owned `Page.Root`:
|
|
|
|
- `routes/(app)/org/[slug]/settings/+page.svelte` and `settings/org/+page.svelte`, `settings/customize-lms/+page.svelte`, `settings/notifications/+page.svelte` → `routes/(app)/org/[slug]/settings/+layout.svelte`
|
|
- `routes/(app)/lms/settings/+page.svelte`, `routes/(app)/lms/settings/notifications/+page.svelte` → `routes/(app)/lms/settings/+layout.svelte`
|
|
|
|
**Never render `Page.SettingsActions` from inside `Page.Body`.** That includes rendering it from a feature component that a route passes into `Page.Body`'s `child` snippet. `Page.Body` sets `overflow-x-hidden`, and CSS computes `overflow-y` to `auto` whenever the other axis is not `visible` — so `Page.Body` is a scroll container. It is sized by its content and never scrolls internally, so a sticky bar inside it has no travel and renders flat at the end of the content. Keep the bar a sibling of `Page.Body` and lift the dirty/saving state up to the route with `bind:this` and `bind:hasUnsavedChanges`, the way `routes/(app)/courses/[id]/settings/+page.svelte` does.
|
|
|
|
The bar also sits at `z-50`, matching the editor root so it paints above editor content (later in DOM wins the tie) while staying below body-portaled dialogs and sheets. Its shadow appears only while it is pinned away from its flow position, tracked by an `IntersectionObserver` on a 1px sentinel rendered as its next sibling.
|
|
|
|
| Prop | Description |
|
|
| -------------- | -------------------------------------------------------- |
|
|
| `hasChanges` | Enables Save and Discard when the form is dirty |
|
|
| `loading` | Shows loading state on Save |
|
|
| `disabled` | Disables Save only (e.g. while not initialized) |
|
|
| `contentClass` | Extra classes on the inner card (width, padding, etc.) |
|
|
| `statusLabel` | Left-side status text (e.g. "Unsaved changes") |
|
|
| `discardLabel` | Discard button label |
|
|
| `saveLabel` | Save button label |
|
|
| `onSave` | Save handler |
|
|
| `onDiscard` | Discard handler |
|
|
|
|
```svelte
|
|
<Page.Root>
|
|
<Page.Header>...</Page.Header>
|
|
<Page.Body>
|
|
{#snippet child()}
|
|
<!-- form sections -->
|
|
{/snippet}
|
|
</Page.Body>
|
|
<Page.SettingsActions
|
|
hasChanges={hasUnsavedChanges}
|
|
loading={isSaving}
|
|
statusLabel={$t('common.unsaved_changes.label')}
|
|
discardLabel={$t('common.discard')}
|
|
saveLabel={$t('common.save_changes')}
|
|
onSave={handleSave}
|
|
onDiscard={handleDiscard}
|
|
/>
|
|
</Page.Root>
|
|
```
|
|
|
|
See `Molecules/Page` → **Settings Actions** in Storybook.
|
|
|
|
## Importing `cn`
|
|
|
|
The `cn` utility function is used throughout the codebase for merging Tailwind CSS classes. It combines `clsx` and `tailwind-merge` to handle class conflicts intelligently.
|
|
|
|
**Import:**
|
|
|
|
```typescript
|
|
import { cn } from '../../tools';
|
|
```
|
|
|
|
**Example usage:**
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { cn } from '../../tools';
|
|
|
|
let { class: className } = $props();
|
|
</script>
|
|
|
|
<div class={cn('ui:bg-primary ui:text-white', className)}>Content</div>
|
|
```
|
|
|
|
The `cn` function is exported from `src/tools/index.ts` and is also available as a named export from the main package:
|
|
|
|
```typescript
|
|
import { cn } from '@cio/ui';
|
|
```
|
|
|
|
## Tailwind Prefix
|
|
|
|
All Tailwind CSS classes in this package use a custom prefix: **`ui`**
|
|
|
|
This ensures that styles don't conflict with other Tailwind configurations in consuming applications.
|
|
|
|
**Usage:**
|
|
|
|
```svelte
|
|
<div class="ui:bg-primary ui:text-primary-foreground ui:rounded-md">Content</div>
|
|
```
|
|
|
|
**Note:** When adding new components or styles, always use the `ui:` prefix for all Tailwind classes.
|
|
|
|
## Component Exports
|
|
|
|
Components are exported from the main `src/index.ts` file in an alphabetical order. There are two main export patterns:
|
|
|
|
### Base Components
|
|
|
|
Base components are exported as namespaced exports:
|
|
|
|
```typescript
|
|
export * as Button from './base/button';
|
|
export * as Dialog from './base/dialog';
|
|
export * as Tooltip from './base/tooltip';
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { Button, Dialog, Tooltip } from '@cio/ui';
|
|
</script>
|
|
|
|
<Button.Root>Click me</Button.Root>
|
|
<Dialog.Root>...</Dialog.Root>
|
|
```
|
|
|
|
### Custom Components
|
|
|
|
Custom components may use namespaced exports or direct exports depending on their structure:
|
|
|
|
```typescript
|
|
// Namespaced
|
|
export * as UnderlineTabs from './custom/underline-tabs';
|
|
|
|
// Direct exports
|
|
export { Chip } from './custom/chip';
|
|
export { IconButton } from './custom/icon-button';
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { UnderlineTabs, Chip, IconButton } from '@cio/ui';
|
|
</script>
|
|
```
|
|
|
|
### Component Index Files
|
|
|
|
Each component directory contains an `index.ts` file that exports the component and its types:
|
|
|
|
```typescript
|
|
// base/button/index.ts
|
|
import Root, { type ButtonProps, buttonVariants } from './button.svelte';
|
|
|
|
export { Root, Root as Button, type ButtonProps, buttonVariants };
|
|
```
|
|
|
|
## Adding New Components
|
|
|
|
When adding a new component to this package, follow these steps:
|
|
|
|
1. **Determine the location:**
|
|
- If it's from shadcn-svelte → add to `src/base/`
|
|
- If it's from another source or extends base components → add to `src/custom/`
|
|
|
|
2. **Create the component structure:**
|
|
- Create a folder with the component name (kebab-case)
|
|
- Add the component file(s)
|
|
- Create an `index.ts` file for exports
|
|
|
|
3. **Export from main index:**
|
|
- Add the export to `src/index.ts`
|
|
|
|
4. **Add Tailwind prefix:**
|
|
- Run `pnpm prefix` to automatically add the `ui:` prefix to all Tailwind classes
|
|
|
|
5. **Create a Storybook story:**
|
|
- **Required:** After adding a new component, you must add a story for it in `packages/storybook`
|
|
- See `packages/storybook/README.md` for detailed instructions on how to create stories
|
|
- Stories help document component usage and enable visual testing
|
|
|
|
## Development
|
|
|
|
### Scripts
|
|
|
|
- `pnpm generate-css` - Generate CSS from Tailwind
|
|
- `pnpm build` - Build the package
|
|
- `pnpm dev` - Watch mode for CSS generation
|
|
- `pnpm format` - Format code with Prettier
|
|
- `pnpm prefix:list` - List files that need the `ui:` prefix (dry-run, exit 0)
|
|
- `pnpm prefix:check` - Fail if any file under `src/` needs the `ui:` prefix
|
|
- `pnpm prefix:check:staged` - Fail if **staged** `packages/ui` files need the prefix (pre-commit)
|
|
- `pnpm prefix:check:changed` - Fail if **changed** `packages/ui` files need the prefix (CI; set `UI_PREFIX_GIT_BASE`)
|
|
- `pnpm prefix` - Add `ui:` prefix to all Tailwind classes and format
|
|
|
|
### Configuration
|
|
|
|
- `components.json` - shadcn-svelte configuration
|
|
- `jsrepo.json` - jsrepo configuration for component installation
|
|
- `svelte.config.js` - Svelte configuration
|
|
- `tsconfig.json` - TypeScript configuration
|