feat(ui): restyle unsaved-changes bar as a compact inverted card (#893)

* feat(ui): restyle unsaved-changes bar as a compact inverted card

Show Page.SettingsActions only when the form is dirty, as a centered
dark card with a secondary Discard button and a primary Save button.

Co-authored-by: best <[email protected]>

* fix(ui): announce settings actions from a persistent live region

Keep role=status mounted outside the dirty-state card so screen readers
still hear the status when the bar appears, without live-region wrapping
the Discard/Save buttons.

Co-authored-by: best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
Rotimi Best
2026-08-14 10:53:28 +01:00
committed by GitHub
co-authored by Cursor Agent
parent 7ba551aa43
commit 41d810d4f7
4 changed files with 56 additions and 54 deletions
@@ -428,7 +428,9 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">All changes saved.</div>
<div class="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
All changes saved. The compact save card stays hidden until the form is dirty.
</div>
{/snippet}
</Page.Body>
<Page.SettingsActions
+4 -4
View File
@@ -139,9 +139,9 @@ Composable page shell used across dashboard list and settings screens. Import as
| `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) |
| `Page.SettingsActions` | Compact save/discard card for dirty settings forms |
**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.
**Settings pages:** Place `Page.SettingsActions` as the last child inside `Page.Root`, after `Page.Body`. The card 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). The card is compact and centered (not full width) and **only renders when `hasChanges` is true**. Pass translated `statusLabel`, `discardLabel`, and `saveLabel` props from the dashboard. Save is a primary button; Discard is a secondary button. Use `disabled` to block Save only (Discard still follows `loading`). 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`:
@@ -150,11 +150,11 @@ Composable page shell used across dashboard list and settings screens. Import as
**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.
The card 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. The sticky wrapper is full width with `pointer-events: none` so it does not block clicks beside the compact card.
| Prop | Description |
| -------------- | ------------------------------------------------------ |
| `hasChanges` | Enables Save and Discard when the form is dirty |
| `hasChanges` | When true, shows the floating save/discard card |
| `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.) |
@@ -27,54 +27,53 @@
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}
data-slot="page-settings-actions"
class={cn('ui:sticky ui:bottom-0 ui:z-50 ui:pb-4 ui:shrink-0', className)}
{...restProps}
>
<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 class="ui:sr-only" role="status" aria-atomic="true" aria-live="polite">
{hasChanges ? statusLabel : ''}
</div>
<div
bind:this={dockSentinel}
data-slot="page-settings-actions-sentinel"
class="ui:h-px ui:shrink-0"
aria-hidden="true"
></div>
{#if hasChanges}
<div
bind:this={ref}
data-slot="page-settings-actions"
class={cn(
'ui:pointer-events-none ui:sticky ui:bottom-0 ui:z-50 ui:flex ui:shrink-0 ui:justify-center ui:px-2 ui:pb-4',
className
)}
{...restProps}
>
<div
class={cn(
'ui:pointer-events-auto ui:flex ui:w-fit ui:max-w-full ui:flex-wrap ui:items-center ui:justify-center ui:gap-x-6 ui:gap-y-2 ui:rounded-lg ui:bg-foreground ui:px-3.5 ui:py-2 ui:text-background ui:shadow-lg',
contentClassName
)}
>
<div class="ui:flex ui:min-w-0 ui:items-center ui:gap-2">
<span
class="ui:flex ui:size-5 ui:shrink-0 ui:items-center ui:justify-center ui:rounded-full ui:bg-primary ui:text-[11px] ui:font-semibold ui:leading-none ui:text-primary-foreground"
aria-hidden="true"
>
!
</span>
<p class="ui:text-sm ui:font-medium">{statusLabel}</p>
</div>
<div class="ui:flex ui:shrink-0 ui:items-center ui:gap-2">
<Button
variant="secondary"
size="sm"
type="button"
class="ui:bg-background ui:text-foreground ui:hover:bg-background/80"
disabled={loading}
onclick={onDiscard}
>
{discardLabel}
</Button>
<Button variant="default" size="sm" type="button" {loading} disabled={loading || disabled} onclick={onSave}>
{saveLabel}
</Button>
</div>
</div>
</div>
{/if}