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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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.

---------
This commit is contained in:
Rotimi Best
2026-08-02 09:11:09 +01:00
committed by GitHub
parent 73f39da54a
commit 06c3a7feba
40 changed files with 751 additions and 240 deletions
@@ -385,3 +385,60 @@
</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>
+60
View File
@@ -122,6 +122,66 @@ Reusable Svelte hooks are located in the `src/hooks/` directory. These are Svelt
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.
+4 -1
View File
@@ -4,6 +4,7 @@ import BodyHeader from './page-body-header.svelte';
import Header from './page-header.svelte';
import HeaderContent from './page-header-content.svelte';
import Root from './page.svelte';
import SettingsActions from './page-settings-actions.svelte';
import Subtitle from './page-subtitle.svelte';
import Title from './page-title.svelte';
@@ -16,6 +17,7 @@ export {
Subtitle,
Body,
BodyHeader,
SettingsActions,
//
Root as Page,
Header as PageHeader,
@@ -24,5 +26,6 @@ export {
Title as PageTitle,
Subtitle as PageSubtitle,
Body as PageBody,
BodyHeader as PageBodyHeader
BodyHeader as PageBodyHeader,
SettingsActions as PageSettingsActions
};
+1 -1
View File
@@ -16,7 +16,7 @@
<div
bind:this={ref}
data-slot="page-body"
class={cn('ui:flex-1 ui:flex ui:gap-4 ui:flex-col ui:mb-4', className)}
class={cn('ui:flex-1 ui:flex ui:gap-4 ui:flex-col ui:overflow-x-hidden', className)}
{...restProps}
>
{#if child}
@@ -0,0 +1,77 @@
<script lang="ts">
import { Button } from '../button';
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
contentClass: contentClassName,
hasChanges = false,
loading = false,
disabled = false,
statusLabel,
discardLabel,
saveLabel,
onSave,
onDiscard,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
contentClass?: string;
hasChanges?: boolean;
loading?: boolean;
disabled?: boolean;
statusLabel: string;
discardLabel: string;
saveLabel: string;
onSave?: () => void | Promise<void>;
onDiscard?: () => void;
} = $props();
const actionsDisabled = $derived(!hasChanges || loading);
let dockSentinel: HTMLDivElement | null = $state(null);
let isDocked = $state(true);
$effect(() => {
if (!dockSentinel) return;
const observer = new IntersectionObserver(([entry]) => {
isDocked = entry.isIntersecting;
});
observer.observe(dockSentinel);
return () => observer.disconnect();
});
</script>
<div bind:this={ref} class={cn('ui:shrink-0', className)} {...restProps}>
<div data-slot="page-settings-actions" class="ui:sticky ui:bottom-0 ui:z-10 ui:pb-4">
<div
class={cn(
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between',
'ui:rounded-lg ui:border ui:border-border ui:bg-background ui:p-4 ui:transition-shadow',
!isDocked && 'ui:shadow-md',
contentClassName
)}
>
<p class="ui:text-sm ui:text-muted-foreground">{statusLabel}</p>
<div class="ui:flex ui:shrink-0 ui:items-center ui:justify-end ui:gap-2">
<Button variant="ghost" type="button" disabled={actionsDisabled} onclick={onDiscard}>
{discardLabel}
</Button>
<Button type="button" {loading} disabled={actionsDisabled || disabled} onclick={onSave}>
{saveLabel}
</Button>
</div>
</div>
</div>
<div
bind:this={dockSentinel}
data-slot="page-settings-actions-sentinel"
class="ui:h-px ui:shrink-0"
aria-hidden="true"
></div>
</div>
+1 -4
View File
@@ -13,10 +13,7 @@
<div
bind:this={ref}
data-slot="page"
class={cn(
'ui:flex ui:flex-col ui:gap-4 ui:min-h-[calc(100vh-48px)] ui:w-full ui:overflow-x-hidden ui:md:overflow-x-auto',
className
)}
class={cn('ui:flex ui:flex-col ui:gap-4 ui:min-h-[calc(100vh-48px)] ui:w-full', className)}
{...restProps}
>
{@render children?.()}