Files
classroomio/packages/storybook/src/molecules/page/page.stories.svelte
T
Rotimi Best 06c3a7feba feat(ui): Page.SettingsActions for settings save bar (#819)
* feat(ui): add Page.SettingsActions for settings save bar

Introduce a sticky bottom save/discard bar on Page and migrate settings
pages to use it instead of header or inline save buttons. Includes
profile, org, customize-lms, notifications, auth, AI tutor, course
settings, certificates, and cohort settings.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): always show Page.SettingsActions, disable when clean

Keep the settings save bar visible at all times and disable Save and
Discard when hasChanges is false instead of hiding the bar entirely.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): add bottom spacing and shadow to Page.SettingsActions

Wrap the actions card in a sticky container with bottom padding so it
does not sit flush against the viewport edge, and use shadow-md on the
card for clearer elevation.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): stop viewport-sticking Page.SettingsActions on scroll

Remove sticky bottom positioning so the save bar stays at the end of the
page content and scrolls away when viewing upper sections.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): use sticky bottom for Page.SettingsActions dock behavior

Replace the IntersectionObserver fixed/dock toggle that caused layout
thrashing and flipping with native position: sticky; bottom: 0. Move
overflow-x-hidden from Page.Root to Page.Body so sticky is not broken.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): keep Page.SettingsActions sticky and above editor content

Page.Root carried md:overflow-x-auto, which makes overflow-y compute to
auto, turning the root into the sticky bar's nearest scrolling ancestor.
Since the root sizes to its content it never scrolls internally, so the
bar rendered at its natural flow position instead of pinning to the
viewport. Drop the overflow so sticky resolves against the viewport;
horizontal containment stays on Page.Body.

Raise the bar to z-50 so it is not painted over by the editor root,
which is relative z-50. Matching rather than exceeding 50 keeps
body-portaled dialogs and sheets above the bar.

Show the shadow only while the bar is pinned away from its flow
position, tracked with an IntersectionObserver on a 1px sentinel
sibling. Unlike the previous observer this toggles a box-shadow only, so
it cannot feed back into layout.

* fix(ui): tighten settings actions spacing and tab visibility

- Remove mt-6 and lower z-index so popovers sit above the bar
- Wrap sentinel in a single root to avoid extra Page.Root flex gap
- Remove Page.Body mb-4 redundant with settings actions spacing
- Show certificate save bar only on the settings tab
- Raise theme color picker z-index via --picker-z-index

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): address PR review feedback for settings actions

- Capture profile/org saved baselines only on initial load, not on edits
- Reset course settings errors with explicit field shape in handleDiscard
- Restore VisitOrgSiteButton on org profile settings header
- Derive AI tutor dirty state from stores directly instead of mirrored state
- Use discard-specific French label and document SettingsActions props

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs(ui): clarify Page.Root ownership for Page.SettingsActions

"Last child of Page.Root" is a runtime relationship. Several settings
pages get their Page.Root from a +layout.svelte, so the page file holds
only Page.Header, Page.Body, and Page.SettingsActions. Document that this
is correct and list the pages, since adding a nested Page.Root stacks two
min-height containers and shortens the sticky bar's containing block.

Also document that Page.SettingsActions must never be rendered from
inside Page.Body: overflow-x-hidden makes Page.Body a scroll container
that never scrolls, so a sticky bar in it has no travel.

---------
2026-08-02 09:11:09 +01:00

445 lines
15 KiB
Svelte

<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="h-4xl w-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>
<Story name="Settings Actions">
{#snippet template()}
<Page.Root class="h-[32rem] w-[42rem] overflow-y-auto">
<Page.Header>
<Page.HeaderContent>
<Page.Title>Course settings</Page.Title>
<Page.Subtitle>Update publishing, enrollment, and visibility options.</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="space-y-4">
{#each Array.from({ length: 8 }) as _, index (index)}
<div class="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
Settings section {index + 1}
</div>
{/each}
</div>
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={true}
statusLabel="Unsaved changes"
discardLabel="Discard"
saveLabel="Save changes"
onSave={() => undefined}
onDiscard={() => undefined}
/>
</Page.Root>
{/snippet}
</Story>
<Story name="Settings Actions (no changes)">
{#snippet template()}
<Page.Root class="w-[42rem]">
<Page.Header>
<Page.HeaderContent>
<Page.Title>Course settings</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">All changes saved.</div>
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={false}
statusLabel="Unsaved changes"
discardLabel="Discard"
saveLabel="Save changes"
onSave={() => undefined}
onDiscard={() => undefined}
/>
</Page.Root>
{/snippet}
</Story>