feat(ux): replace individual save buttons with single "unsaved changes bar" (#1757)

This commit is contained in:
Elias Schneider
2026-09-19 15:23:06 +00:00
committed by GitHub
parent 818333fb38
commit 63050add4e
58 changed files with 1916 additions and 1184 deletions
@@ -1,4 +1,5 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
@@ -28,7 +29,7 @@
function onDismiss() {
const url = new URL(page.url);
url.searchParams.delete('emailVerificationState');
history.replaceState(null, '', url.toString());
replaceState(url, page.state);
emailVerificationState = null;
}
@@ -40,10 +40,25 @@
let unit = $state<DurationUnit>(preferredUnit(input.value));
let amount = $state(formatAmount(input.value / minutesPerUnit[unit]));
// The minutes that `amount` and `unit` currently represent. Only a value that arrives from
// outside (e.g. after discarding changes) re-derives them, so that switching the unit isn't
// undone by the rounding of the displayed amount.
let displayedMinutes = input.value;
$effect(() => {
const minutes = input.value;
if (Object.is(minutes, displayedMinutes)) return;
displayedMinutes = minutes;
if (!Number.isFinite(minutes)) return;
unit = preferredUnit(minutes);
amount = formatAmount(minutes / minutesPerUnit[unit]);
});
function updateAmount(event: Event) {
amount = (event.currentTarget as HTMLInputElement).value;
input.value = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
displayedMinutes = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
input.value = displayedMinutes;
}
function updateUnit(value: string | undefined) {
@@ -85,7 +85,15 @@
{@render children()}
{:else if input}
{#if type === 'date'}
<DatePicker {id} bind:value={input.value as Date} />
<DatePicker
{id}
bind:value={
() => input.value as Date | undefined,
(value) => {
input.value = value;
}
}
/>
{:else}
<Input
aria-invalid={!!input.error}
@@ -93,7 +101,12 @@
{id}
{placeholder}
{type}
bind:value={input.value}
bind:value={
() => input.value as string | number | undefined,
(value) => {
input.value = value;
}
}
{disabled}
oninput={(e) => onInput?.(e)}
{readonly}
@@ -4,6 +4,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import unsavedChangesStoreSvelte from '$lib/stores/unsaved-changes-store.svelte';
import userStore from '$lib/stores/user-store';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { LucideLogOut, LucideUser } from '@lucide/svelte';
@@ -11,7 +12,12 @@
const webauthnService = new WebAuthnService();
async function logout() {
await webauthnService.logout();
if (!unsavedChangesStoreSvelte.hasChanges) {
await webauthnService.logout();
}
// Navigate regardless of unsaved changes status
// If there are unsaved changes, the save changes bar will shake and navigation will be canceled
goto('/login');
}
</script>
@@ -1,4 +1,6 @@
<script lang="ts" generics="T extends {id:string}">
import { replaceState } from '$app/navigation';
import { page as currentPage } from '$app/state';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import * as Pagination from '$lib/components/ui/pagination';
import * as Select from '$lib/components/ui/select';
@@ -151,7 +153,7 @@
function changePageState(page: number) {
const url = new URL(window.location.href);
url.searchParams.set(`${id}-page`, page.toString());
history.replaceState(history.state, '', url.toString());
replaceState(url, currentPage.state);
requestOptions.pagination!.page = page;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import { cn } from '$lib/utils/style.js';
import { Tabs as TabsPrimitive } from 'bits-ui';
@@ -21,8 +22,8 @@
});
function onTabChange(newValue: string) {
if (useHash && page.url.hash !== newValue) {
history.replaceState(history.state, '', location.pathname + location.search + `#${newValue}`);
if (useHash && page.url.hash.substring(1) !== newValue) {
replaceState(location.pathname + location.search + `#${newValue}`, page.state);
}
}
</script>
@@ -0,0 +1,247 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import unsavedChanges from '$lib/stores/unsaved-changes-store.svelte';
import { cn } from '$lib/utils/style';
import { LucideCircleAlert } from '@lucide/svelte';
import { tick } from 'svelte';
import { cubicOut } from 'svelte/easing';
const ROW_HEIGHT = 44;
const BOTTOM_OFFSET = 20;
const VIEWPORT_MARGIN = 16;
const INSET = 18;
const INSET_BUTTONS = 8;
const MOTION =
'duration-[380ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none';
const LAYER = 'absolute top-0 flex items-center whitespace-nowrap';
type BloomTint = 'pending' | 'success' | 'error';
let shake = $state(false);
let bloom = $state<{ tint: BloomTint }>();
let pillWidth = $state<number>();
let compact = $state(false);
let frame = $state<HTMLElement>();
let pendingMessage = $state<HTMLElement>();
let statusMessage = $state<HTMLElement>();
let actions = $state<HTMLElement>();
const animate = $derived(!$appConfigStore?.disableAnimations);
const status = $derived(unsavedChanges.status);
const showingStatus = $derived(!!status);
const showingActions = $derived(unsavedChanges.hasChanges);
const barOpen = $derived(showingActions || showingStatus);
// Settled means in view; the other message waits a row height away
const pendingSettled = $derived(!showingStatus && !compact);
const statusSettled = $derived(showingStatus && !compact);
// A new object every time, so {#key} remounts the layer and replays the animation
function flashBloom(tint: BloomTint) {
bloom = { tint };
}
$effect(() => {
if (barOpen) flashBloom('pending');
});
$effect(() => {
if (status) flashBloom(status.type === 'error' ? 'error' : 'success');
});
function measure() {
const message = showingStatus ? statusMessage : pendingMessage;
if (!message || !actions || !frame) return;
// offsetWidth ignores the scale the buttons carry while fading, and rounding the text up keeps its last glyph
const end = showingActions ? actions.offsetWidth + INSET_BUTTONS : INSET;
const roomy = INSET + Math.ceil(message.getBoundingClientRect().width) + end;
// An unmeasured frame reports no width, which must not read as "nothing fits"
const available = frame.clientWidth ? frame.clientWidth - 2 * VIEWPORT_MARGIN : Infinity;
compact = showingActions && roomy > available;
pillWidth = Math.min(compact ? actions.offsetWidth + 2 * INSET_BUTTONS : roomy, available);
}
$effect(measure);
// Catches what no state change announces: a longer message, a font loading, the viewport resizing
$effect(() => {
const layers = [pendingMessage, statusMessage, actions, frame].filter((layer) => !!layer);
if (layers.length === 0) return;
const observer = new ResizeObserver(measure);
for (const layer of layers) observer.observe(layer);
return () => observer.disconnect();
});
function rise(_node: HTMLElement, { duration = 220 } = {}) {
return {
duration: animate ? duration : 0,
easing: cubicOut,
css: (t: number, u: number) =>
`opacity: ${t}; transform: translateY(${u * 8}px) scale(${0.98 + t * 0.02})`
};
}
beforeNavigate((nav) => {
if (!unsavedChanges.hasChanges) return;
// Cancelling a full page unload makes the browser show its own "leave site?" prompt.
nav.cancel();
if (nav.type === 'leave') return;
shake = true;
flashBloom('pending');
});
// A fresh edit supersedes any status message left over from a previous save.
$effect(() => {
if (unsavedChanges.hasChanges) unsavedChanges.clearStatus();
});
function handleSaveShortcut(event: KeyboardEvent) {
if (event.key.toLowerCase() !== 's' || !(event.metaKey || event.ctrlKey)) return;
if (!unsavedChanges.hasChanges || unsavedChanges.saving) return;
event.preventDefault();
void saveAll();
}
async function saveAll() {
if ((await unsavedChanges.saveAll()) === 'invalid') await revealFirstInvalidField();
}
// Brings the first field that failed validation into view, switching to the tab that contains
// it if necessary, since the bar is global while the field may sit on a hidden tab.
async function revealFirstInvalidField() {
await tick();
const field = document.querySelector<HTMLElement>(
'[aria-invalid="true"], [data-slot="field-error"]'
);
if (!field) return;
for (
let panel = field.closest<HTMLElement>('[role="tabpanel"]');
panel;
panel = panel.parentElement?.closest<HTMLElement>('[role="tabpanel"]') ?? null
) {
const value = panel.dataset.value;
if (value === undefined) continue;
panel
.closest('[data-slot="tabs"]')
?.querySelector<HTMLElement>(`[role="tab"][data-value="${CSS.escape(value)}"]`)
?.click();
}
await tick();
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
field.focus({ preventScroll: true });
}
</script>
<svelte:window onkeydown={handleSaveShortcut} />
<!-- Belongs to the message, so it leaves with it and never counts towards the buttons' width -->
{#snippet divider()}
<span class="bg-border ml-2.5 mr-2 h-4.5 w-px shrink-0"></span>
{/snippet}
{#if barOpen}
<div
bind:this={frame}
class={cn('fixed inset-x-0 z-50 flex', compact ? 'justify-end' : 'justify-center')}
style:bottom="{BOTTOM_OFFSET}px"
style:padding-inline="{VIEWPORT_MARGIN}px"
transition:rise
class:animate-shake={shake}
onanimationend={() => (shake = false)}
>
<div
role="status"
aria-live="polite"
class={cn(
'bg-popover text-popover-foreground shadow-raised dark:bg-secondary relative isolate max-w-full overflow-hidden rounded-full border',
animate && `transition-[width] ${MOTION}`
)}
style:height="{ROW_HEIGHT}px"
style:width={pillWidth ? `${pillWidth}px` : undefined}
>
{#if animate && bloom}
{#key bloom}
<span
aria-hidden="true"
class="animate-bloom pointer-events-none absolute inset-0 -z-1 bloom-{bloom.tint}"
></span>
{/key}
{/if}
<span
bind:this={pendingMessage}
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
style:height="{ROW_HEIGHT}px"
style:left="{INSET}px"
style:translate={pendingSettled ? '0' : `0 -${ROW_HEIGHT}px`}
inert={!pendingSettled}
>
{#if unsavedChanges.dirtyCount > 1}
<span
class="bg-primary/15 text-foreground flex size-5 items-center justify-center rounded-full font-bold tabular-nums"
>
{unsavedChanges.dirtyCount}
</span>
{/if}
<span class="text-sm font-semibold tracking-tight">{m.you_have_unsaved_changes()}</span>
{@render divider()}
</span>
<span
bind:this={statusMessage}
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
style:height="{ROW_HEIGHT}px"
style:left="{INSET}px"
style:translate={statusSettled ? '0' : `0 ${ROW_HEIGHT}px`}
inert={!statusSettled}
>
{#if status?.type === 'error'}
<LucideCircleAlert class="text-destructive size-4 shrink-0" />
{/if}
<span class="text-sm font-semibold tracking-tight">{status?.message ?? ''}</span>
{#if showingActions}
{@render divider()}
{/if}
</span>
<span
bind:this={actions}
class={cn(
LAYER,
animate && `transition-[opacity,scale] ${MOTION}`,
!showingActions && 'scale-[0.96] opacity-0'
)}
style:height="{ROW_HEIGHT}px"
style:right="{INSET_BUTTONS}px"
inert={!showingActions}
>
<Button
variant="ghost"
size="sm"
class="hover:bg-foreground/10 dark:hover:bg-foreground/10"
disabled={unsavedChanges.saving}
onclick={() => unsavedChanges.discardAll()}
>
{m.discard()}
</Button>
<Button class="ml-1.5" size="sm" isLoading={unsavedChanges.saving} onclick={saveAll}>
{m.save()}
</Button>
</span>
</div>
</div>
{/if}