@tristan2828/ui-foundation 4.43.0 → 4.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,7 +87,8 @@ Every data view handles: loading, empty, error, and success. Use
87
87
  `<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does,
88
88
  and `EntityView` (one record) adds not found: a plain 404, never a retry.
89
89
  A small widget on a hub page is a `WidgetCard`, which holds all four for
90
- its own data, its error inside the card; a hub's place (a module, with
90
+ its own data, its error inside the card (folded, its `summary` follows
91
+ the same states); a hub's place (a module, with
91
92
  one live number) is a `ModuleCard` in a `ModuleStrip`, the same way.
92
93
  Every screen has one Playwright test per state, forced via MSW overrides
93
94
  (`@tristan2828/ui-foundation/testing`).
@@ -12,14 +12,14 @@
12
12
  - [Typography](#typography) · lines 245–327
13
13
  - [Written text (notes)](#written-text-notes) · lines 276–327
14
14
  - [Density](#density) · lines 329–386
15
- - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 388–466
16
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 468–526
17
- - [Notices](#notices) · lines 528–556
18
- - [A value over time](#a-value-over-time) · lines 558–591
19
- - [A list as a board](#a-list-as-a-board) · lines 593–644
20
- - [A list as cards](#a-list-as-cards) · lines 646–692
21
- - [Picking at random](#picking-at-random) · lines 694–725
22
- - [Filling in what's missing](#filling-in-whats-missing) · lines 727–777
15
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 388–502
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 504–562
17
+ - [Notices](#notices) · lines 564–592
18
+ - [A value over time](#a-value-over-time) · lines 594–627
19
+ - [A list as a board](#a-list-as-a-board) · lines 629–680
20
+ - [A list as cards](#a-list-as-cards) · lines 682–728
21
+ - [Picking at random](#picking-at-random) · lines 730–761
22
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 763–813
23
23
 
24
24
  <!-- /index -->
25
25
 
@@ -446,7 +446,42 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
446
446
  (`"<app>:hub.<widget>"`) so a card folded stays folded on that device,
447
447
  and `defaultCollapsed` for one that should start folded. The chevron
448
448
  at the header's end is named by the title; the header's link stays
449
- while folded.
449
+ while folded. Give a card that folds a `summary`: one short value its
450
+ header shows while folded (`summary={data?.total}`, `"Due today"`),
451
+ so a hub of folded cards still reads at a glance. It follows the
452
+ card's state: a small skeleton while loading, a muted dash on an error
453
+ (the error itself is in the body, opened), and `emptySummary`
454
+ (`"None"`) or nothing when empty.
455
+ - **One open at a time on a phone:** put the cards in a
456
+ `WidgetCardGroup accordion="mobile"` (its `className` is the grid),
457
+ give each an `id`, and name the one to start open in `defaultOpen`.
458
+ Below the sidebar's breakpoint every card folds, opening one folds the
459
+ others, and a tap anywhere on a header (not its link) toggles it; on a
460
+ desktop the cards keep their own `collapsible`. `accordion` alone does
461
+ it at every width.
462
+ - **Letting someone order the cards:** keep the order in
463
+ `useWidgetLayout('<app>:hub', ids)` (every card's id, in the default
464
+ order), render `layout.visible`, and put a `WidgetLayoutSheet` (its
465
+ Customize button) beside the page's title, with each card's title by id.
466
+ Its rows have Up, Down and a Show switch, with Reset: the same on a
467
+ phone, a desktop and a keyboard (the focus stays on the button
468
+ pressed). The order is kept on the device; a removed id drops out and
469
+ a new one falls in at its default place. To follow the person between
470
+ devices, pass `{ store }`, a `WidgetLayoutStore` over the app's
471
+ backend.
472
+
473
+ ```tsx
474
+ const HUB_CARDS = { ready: 'Ready to start', waiting: 'Waiting on', links: 'Saved links' }
475
+ const layout = useWidgetLayout('my-app:hub', Object.keys(HUB_CARDS))
476
+
477
+ <div className="flex items-center justify-between gap-3">
478
+ <h1 className="type-page-title text-foreground">Home</h1>
479
+ <WidgetLayoutSheet layout={layout} titles={HUB_CARDS} />
480
+ </div>
481
+ <WidgetCardGroup accordion="mobile" defaultOpen="ready" className="grid … gap-4">
482
+ {layout.visible.map((id) => <HubCard key={id} id={id} />)}
483
+ </WidgetCardGroup>
484
+ ```
450
485
  - **A row is one line:** its text is a link (`to` for a route in the
451
486
  app, `href` for an external address, which opens in a new tab with the
452
487
  external-link mark) or plain text, cut with an ellipsis, the whole of
@@ -462,7 +497,8 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
462
497
  many 288px-or-wider columns as fit). Each card is as tall as its rows;
463
498
  it doesn't stretch to its tallest neighbour.
464
499
  - Storybook's `app/WidgetCard` shows three widgets on a page and every
465
- one in each state; its checks run axe and the token and glyph checks in
500
+ one in each state, a folded summary in each state and an accordion,
501
+ and `app/WidgetLayoutSheet` a hub with its Customize sheet; its checks run axe and the token and glyph checks in
466
502
  both themes and at `large`.
467
503
 
468
504
  ## Module cards (a hub's places)
@@ -6,11 +6,11 @@
6
6
  - [The view](#the-view) · lines 36–109
7
7
  - [The dark stage](#the-dark-stage) · lines 73–109
8
8
  - [The clock](#the-clock) · lines 111–141
9
- - [Sound](#sound) · lines 143–190
10
- - [Background tabs](#background-tabs) · lines 192–246
11
- - [Interruptions](#interruptions) · lines 226–246
12
- - [Motion](#motion) · lines 248–252
13
- - [Testing a timed screen in Playwright](#testing-a-timed-screen-in-playwright) · lines 254–408
9
+ - [Sound](#sound) · lines 143–193
10
+ - [Background tabs](#background-tabs) · lines 195–249
11
+ - [Interruptions](#interruptions) · lines 229–249
12
+ - [Motion](#motion) · lines 251–255
13
+ - [Testing a timed screen in Playwright](#testing-a-timed-screen-in-playwright) · lines 257–411
14
14
 
15
15
  <!-- /index -->
16
16
 
@@ -176,7 +176,10 @@ Browsers block sound until the person has clicked or tapped. So:
176
176
  another app's music. It returns `{ pause(), stop() }`: `pause()` shows
177
177
  Play (without calling `onToggle`), `stop()` ends it (on done, on leaving);
178
178
  to start again after `pause()`, call `keepAudioAlive` again from a click.
179
- Only one runs at a time.
179
+ Only one runs at a time. Where Safari has an audio session
180
+ (`navigator.audioSession`) it's set to `"playback"` while the track
181
+ runs, so the ringer switch doesn't mute the cues either, and put back on
182
+ `stop()`.
180
183
  - Every one of them is a no-op where the browser can't make the sound, so
181
184
  none needs a check around it.
182
185
  - **Mute** is the app's: `const [muted, setMuted] = useMuted('my-app:routine:muted')`,
@@ -4,6 +4,11 @@ import type { AppError } from '../../api/contracts.js';
4
4
  import type { IconComponent } from './icon-component.js';
5
5
  export interface WidgetCardProps {
6
6
  title: string;
7
+ /**
8
+ * The card's id, on its region: the one WidgetCardGroup's `defaultOpen`
9
+ * names and useWidgetLayout orders (`"ready"`). Unique on the page.
10
+ */
11
+ id?: string;
7
12
  /**
8
13
  * An icon shown before the title: a Lucide icon, or an app's own mark (an
9
14
  * outside service's logo on a card about that service). Decorative: the
@@ -39,6 +44,14 @@ export interface WidgetCardProps {
39
44
  * Left out, it starts at `defaultCollapsed` on every visit.
40
45
  */
41
46
  storageKey?: string;
47
+ /**
48
+ * One short value shown in the header while the card is folded: a count
49
+ * (`3`), or a word or two (`"Due today"`). A skeleton while loading, a
50
+ * muted dash on an error. Ignored while open.
51
+ */
52
+ summary?: ReactNode;
53
+ /** What a folded card's summary says when it's empty (`"None"`); nothing when left out. */
54
+ emptySummary?: ReactNode;
42
55
  children?: ReactNode;
43
56
  className?: string;
44
57
  }
@@ -51,7 +64,22 @@ export type WidgetCardLink = {
51
64
  label: string;
52
65
  to?: never;
53
66
  };
54
- export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, children, className, }: WidgetCardProps): import("react").JSX.Element;
67
+ export declare function WidgetCard({ id, title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, summary, emptySummary, children, className, }: WidgetCardProps): import("react").JSX.Element;
68
+ export interface WidgetCardGroupProps {
69
+ /**
70
+ * One card open at a time: `true` always, `"mobile"` only below the
71
+ * sidebar's breakpoint (a desktop hub keeps each card's own
72
+ * `collapsible`), `false` never (the group is a plain wrapper).
73
+ */
74
+ accordion?: boolean | 'mobile';
75
+ /** The `id` of the card open at first; all folded (showing their summaries) when left out. */
76
+ defaultOpen?: string | null;
77
+ children: ReactNode;
78
+ /** The wrapper's classes: the hub's grid of cards, usually. */
79
+ className?: string;
80
+ }
81
+ /** Wraps a hub's WidgetCards so that opening one folds the others (on a phone only, with `accordion="mobile"`). */
82
+ export declare function WidgetCardGroup({ accordion, defaultOpen, children, className }: WidgetCardGroupProps): import("react").JSX.Element;
55
83
  /** The rows of a WidgetCard: a list with a divider between rows. */
56
84
  export declare function WidgetList({ children, className }: {
57
85
  children: ReactNode;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // A small titled card on a hub page (issue #121): a few rows from one part
3
3
  // of the app, loaded on their own. It owns the four required states the
4
4
  // way DataTable and EntityView do, so every widget on a page skeletons,
@@ -24,6 +24,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  // where that's blocked). The body is hidden, not unmounted, so whatever
25
25
  // the app keeps in it stays put.
26
26
  //
27
+ // `summary` (#263) is one short value a folded card shows in its header
28
+ // ("3", "Due today"), so a hub of folded cards still reads at a glance.
29
+ // It follows the card's state: a small skeleton while loading, a muted
30
+ // dash ("Couldn't load" to a screen reader) on an error, `emptySummary`
31
+ // (or nothing) when empty. Opened, the summary goes and the body shows,
32
+ // the error in it as before. The chevron is described by it.
33
+ //
34
+ // WidgetCardGroup (#263) makes its cards an accordion: every card in it
35
+ // folds, opening one folds the others, and a click anywhere on a header
36
+ // (not on its link or another control) toggles it; the chevron stays the
37
+ // keyboard's way in. `accordion="mobile"` does that only below the
38
+ // sidebar's breakpoint (useIsMobile), so a desktop hub keeps each card's
39
+ // own `collapsible`. While the accordion is on it owns the folding and a
40
+ // card's `storageKey` is left alone.
41
+ //
27
42
  // The header's link is an app route (`to`) or, for a widget about a
28
43
  // service outside the app, its address (`href`, #248): that one opens in a
29
44
  // new tab, with the outside-link mark and a screen reader's "(opens in a
@@ -39,7 +54,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
39
54
  // a row's to 4px, for a dense hub; text stays at type-body. On a phone a
40
55
  // compact row is 44px tall and its link fills that height, so it stays a
41
56
  // finger's target.
42
- import { useId } from 'react';
57
+ import { createContext, useContext, useId, useState } from 'react';
43
58
  import { Link } from 'react-router';
44
59
  import { ChevronDownIcon, ExternalLinkIcon } from 'lucide-react';
45
60
  import { cn } from 'cn';
@@ -47,21 +62,62 @@ import { ErrorState } from './error-state.js';
47
62
  import { Button } from '../ui/button.js';
48
63
  import { Card, CardContent, CardHeader } from '../ui/card.js';
49
64
  import { Skeleton } from '../ui/skeleton.js';
65
+ import { useIsMobile } from '../../hooks/use-mobile.js';
50
66
  import { useRememberedFlag } from '../../hooks/use-remembered-flag.js';
51
67
  const HEADER_LINK_CLASS = 'shrink-0 rounded-sm type-label text-foreground underline underline-offset-4 outline-none ' +
52
68
  'hover:decoration-2 focus-visible:ring-3 focus-visible:ring-ring/50';
53
- export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, collapsible = false, defaultCollapsed = false, storageKey, children, className, }) {
69
+ export function WidgetCard({ id, title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, collapsible = false, defaultCollapsed = false, storageKey, summary, emptySummary, children, className, }) {
54
70
  const headingId = useId();
55
71
  const contentId = useId();
72
+ const summaryId = useId();
73
+ const ownId = useId();
74
+ const cardId = id ?? ownId;
75
+ const group = useContext(GroupContext);
76
+ const inAccordion = group !== null && group.active;
56
77
  const Heading = `h${headingLevel}`;
57
78
  const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
58
79
  const [folded, setFolded] = useRememberedFlag(collapsible && storageKey !== undefined ? `${storageKey}:collapsed` : undefined, defaultCollapsed);
59
- const collapsed = collapsible && folded;
60
- return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, "data-collapsed": collapsed || undefined, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), (link || collapsible) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [link?.href !== undefined ? (_jsxs("a", { href: link.href, target: "_blank", rel: "noreferrer", "aria-describedby": headingId, className: cn(HEADER_LINK_CLASS, 'inline-flex items-center gap-1'), children: [link.label, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))), collapsible && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: () => setFolded(!collapsed),
80
+ const collapsed = inAccordion ? group.openId !== cardId : collapsible && folded;
81
+ const foldable = inAccordion || collapsible;
82
+ const toggle = () => (inAccordion ? group.setOpenId(collapsed ? cardId : null) : setFolded(!collapsed));
83
+ const summaryShown = collapsed && (summary !== undefined || emptySummary !== undefined);
84
+ // In an accordion the whole header toggles, except where the click lands
85
+ // on its link or another control (the chevron toggles on its own).
86
+ const onHeaderClick = (event) => {
87
+ if (!inAccordion)
88
+ return;
89
+ if (event.target.closest('a, button, input, select, textarea, [role="switch"]'))
90
+ return;
91
+ toggle();
92
+ };
93
+ return (_jsx("section", { id: id, "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, "data-collapsed": collapsed || undefined, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { onClick: onHeaderClick, className: cn('flex items-center justify-between gap-3', inAccordion && 'cursor-pointer select-none'), children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), (link || foldable || summaryShown) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [summaryShown && (_jsx("span", { id: summaryId, "data-slot": "widget-card-summary", className: "mr-2 flex items-center", children: _jsx(WidgetCardSummary, { state: state, summary: summary, emptySummary: emptySummary }) })), link?.href !== undefined ? (_jsxs("a", { href: link.href, target: "_blank", rel: "noreferrer", "aria-describedby": headingId, className: cn(HEADER_LINK_CLASS, 'inline-flex items-center gap-1'), children: [link.label, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))), foldable && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-describedby": summaryShown ? summaryId : undefined, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: toggle,
61
94
  // The button's own open look (a filled background) would
62
95
  // read as pressed: the chevron says it.
63
96
  className: "-my-1 -mr-1.5 aria-expanded:not-hover:bg-transparent", children: _jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('transition-transform', collapsed && '-rotate-90') }) }))] }))] }), _jsx(CardContent, { id: contentId, hidden: collapsed, "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
64
97
  }
98
+ // A folded card's one value, by state. Loading's skeleton is about one
99
+ // number wide; an error's dash says "Couldn't load" to a screen reader,
100
+ // the error itself waiting in the body.
101
+ function WidgetCardSummary({ state, summary, emptySummary, }) {
102
+ if (state === 'loading') {
103
+ return (_jsxs(_Fragment, { children: [_jsx(Skeleton, { "data-state": "loading", className: "h-4 w-8" }), _jsx("span", { className: "sr-only", children: "Loading" })] }));
104
+ }
105
+ if (state === 'error') {
106
+ return (_jsxs("span", { "data-state": "error", className: "type-label text-muted-foreground", children: [_jsx("span", { "aria-hidden": "true", children: "\u2014" }), _jsx("span", { className: "sr-only", children: "Couldn't load" })] }));
107
+ }
108
+ if (state === 'empty') {
109
+ return emptySummary === undefined ? null : (_jsx("span", { "data-state": "empty", className: "type-label text-muted-foreground", children: emptySummary }));
110
+ }
111
+ return _jsx("span", { className: "type-label tabular-nums text-foreground", children: summary });
112
+ }
113
+ const GroupContext = createContext(null);
114
+ /** Wraps a hub's WidgetCards so that opening one folds the others (on a phone only, with `accordion="mobile"`). */
115
+ export function WidgetCardGroup({ accordion = true, defaultOpen = null, children, className }) {
116
+ const isMobile = useIsMobile();
117
+ const [openId, setOpenId] = useState(defaultOpen);
118
+ const active = accordion === 'mobile' ? isMobile : accordion;
119
+ return (_jsx(GroupContext.Provider, { value: { active, openId, setOpenId }, children: _jsx("div", { "data-slot": "widget-card-group", "data-accordion": active || undefined, className: className, children: children }) }));
120
+ }
65
121
  // One row's height, shared by the skeleton and the list so the card doesn't
66
122
  // jump: py-2 around one line of type-body (or a small control). Compact
67
123
  // is 32px (6px around the line); on a phone it's 44px, the link's own
@@ -0,0 +1,25 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { WidgetLayout } from '../../hooks/use-widget-layout.js';
3
+ export interface WidgetLayoutSheetProps {
4
+ /** The page's layout, from useWidgetLayout. */
5
+ layout: WidgetLayout;
6
+ /** Each card's title by its id: the row's name. An id without one shows as the id. */
7
+ titles: Readonly<Record<string, string>>;
8
+ /** The sheet's heading. */
9
+ title?: string;
10
+ /** A line under the heading. */
11
+ description?: ReactNode;
12
+ /** The trigger button's label. */
13
+ triggerLabel?: string;
14
+ /** Opens and closes the sheet from outside (then the trigger can be left out with `trigger={false}`). */
15
+ open?: boolean;
16
+ /** Whether the sheet starts open, when `open` isn't given. */
17
+ defaultOpen?: boolean;
18
+ onOpenChange?: (open: boolean) => void;
19
+ /** Show the Customize button; `false` when the app opens the sheet itself. */
20
+ trigger?: boolean;
21
+ /** Which side the sheet comes from: the right on a desktop, `bottom` suits a phone. */
22
+ side?: 'right' | 'bottom';
23
+ className?: string;
24
+ }
25
+ export declare function WidgetLayoutSheet({ layout, titles, title, description, triggerLabel, open, defaultOpen, onOpenChange, trigger, side, className, }: WidgetLayoutSheetProps): import("react").JSX.Element;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A hub page's Customize sheet (#263): every card the page has, in the
3
+ // person's order, each row with Up and Down buttons and a Show switch,
4
+ // and Reset at the foot. It edits a useWidgetLayout, which keeps the
5
+ // order (on this device by default) and which the page renders its cards
6
+ // by (`layout.visible`).
7
+ //
8
+ // It's the same on a phone, a desktop and a keyboard: no dragging. Up and
9
+ // Down keep the focus on the button pressed, so pressing it again moves
10
+ // the card on. At either end the button is `aria-disabled`, not
11
+ // `disabled`, so the focus stays on it there too, and a polite live region
12
+ // says where the card went ("Saved links, 1 of 3").
13
+ //
14
+ // The trigger is a Customize button for the page's header; pass `open`
15
+ // and `onOpenChange` to open it from elsewhere instead.
16
+ import { useLayoutEffect, useRef, useState } from 'react';
17
+ import { ArrowDownIcon, ArrowUpIcon, SlidersHorizontalIcon } from 'lucide-react';
18
+ import { cn } from 'cn';
19
+ import { Button } from '../ui/button.js';
20
+ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from '../ui/sheet.js';
21
+ import { Switch } from '../ui/switch.js';
22
+ export function WidgetLayoutSheet({ layout, titles, title = 'Customize this page', description = 'Order the cards, and hide the ones you don’t need.', triggerLabel = 'Customize', open, defaultOpen, onOpenChange, trigger = true, side = 'right', className, }) {
23
+ const listRef = useRef(null);
24
+ const [moved, setMoved] = useState(null);
25
+ const [announcement, setAnnouncement] = useState('');
26
+ const { order, hidden } = layout;
27
+ const nameOf = (id) => titles[id] ?? id;
28
+ // Moving a row re-orders the list, which can take the focus off the
29
+ // button that moved it: put it back.
30
+ useLayoutEffect(() => {
31
+ if (moved === null)
32
+ return;
33
+ const button = listRef.current?.querySelector(`[data-widget-id="${CSS.escape(moved.id)}"] [data-move="${moved.by}"]`);
34
+ if (button && document.activeElement !== button)
35
+ button.focus();
36
+ }, [moved, order]);
37
+ const move = (id, by) => {
38
+ const from = order.indexOf(id);
39
+ const to = from + by;
40
+ if (from === -1 || to < 0 || to >= order.length)
41
+ return;
42
+ layout.move(id, by);
43
+ setMoved({ id, by });
44
+ setAnnouncement(`${nameOf(id)}, ${to + 1} of ${order.length}`);
45
+ };
46
+ return (_jsxs(Sheet, { open: open, defaultOpen: defaultOpen, onOpenChange: onOpenChange ? (next) => onOpenChange(next) : undefined, children: [trigger && (_jsxs(SheetTrigger, { render: _jsx(Button, { variant: "outline", size: "sm" }), children: [_jsx(SlidersHorizontalIcon, { "aria-hidden": "true" }), triggerLabel] })), _jsxs(SheetContent, { side: side, className: cn(side === 'bottom' && 'max-h-[85dvh]', className), children: [_jsxs(SheetHeader, { children: [_jsx(SheetTitle, { children: title }), description && _jsx(SheetDescription, { children: description })] }), _jsx("ol", { ref: listRef, "data-slot": "widget-layout-list", className: "flex flex-col divide-y divide-border overflow-y-auto px-4", children: order.map((id, index) => (_jsx(WidgetLayoutRow, { id: id, name: nameOf(id), shown: !hidden.includes(id), first: index === 0, last: index === order.length - 1, onMove: (by) => move(id, by), onToggle: () => layout.toggle(id) }, id))) }), _jsx("div", { "aria-live": "polite", className: "sr-only", children: announcement }), _jsx(SheetFooter, { children: _jsx(Button, { variant: "outline", "aria-disabled": layout.isDefault || undefined, onClick: () => {
47
+ if (layout.isDefault)
48
+ return;
49
+ layout.reset();
50
+ setAnnouncement('Back to the default order');
51
+ }, className: "aria-disabled:opacity-50", children: "Reset" }) })] })] }));
52
+ }
53
+ function WidgetLayoutRow({ id, name, shown, first, last, onMove, onToggle, }) {
54
+ return (_jsxs("li", { "data-widget-id": id, "data-hidden": !shown || undefined, className: "flex min-h-12 items-center gap-2 py-2", children: [_jsx("span", { className: cn('min-w-0 flex-1 truncate type-body', shown ? 'text-foreground' : 'text-muted-foreground'), children: name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "data-move": "-1", "aria-label": `Move ${name} up`, "aria-disabled": first || undefined, onClick: () => onMove(-1), className: "aria-disabled:opacity-50", children: _jsx(ArrowUpIcon, { "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "data-move": "1", "aria-label": `Move ${name} down`, "aria-disabled": last || undefined, onClick: () => onMove(1), className: "aria-disabled:opacity-50", children: _jsx(ArrowDownIcon, { "aria-hidden": "true" }) }), _jsx(Switch, { "aria-label": `Show ${name}`, checked: shown, onCheckedChange: onToggle, className: "ml-2" })] }));
55
+ }
@@ -0,0 +1,45 @@
1
+ /** What's kept for a page: the ids in the person's order, and the hidden ones. */
2
+ export type SavedWidgetLayout = {
3
+ order: readonly string[];
4
+ hidden: readonly string[];
5
+ };
6
+ export type WidgetLayoutStore = {
7
+ /** What's saved under `key`, or null. The same object until it changes. */
8
+ get: (key: string) => SavedWidgetLayout | null;
9
+ /** Saves `layout` under `key`; null forgets it (a reset). */
10
+ set: (key: string, layout: SavedWidgetLayout | null) => void;
11
+ subscribe: (key: string, listener: () => void) => () => void;
12
+ };
13
+ type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
14
+ export declare function createWidgetLayoutStore(getStorage: () => StorageLike | undefined): WidgetLayoutStore;
15
+ /** The order and hidden ids to show: `saved` merged with the app's `defaultIds`. */
16
+ export declare function mergeWidgetLayout(defaultIds: readonly string[], saved: SavedWidgetLayout | null): SavedWidgetLayout;
17
+ /** `order` with `id` one place earlier (`-1`) or later (`1`); unchanged at either end. */
18
+ export declare function moveWidget(order: readonly string[], id: string, by: -1 | 1): string[];
19
+ export type WidgetLayoutOptions = {
20
+ /** Where the layout is kept; this device's localStorage when left out. */
21
+ store?: WidgetLayoutStore;
22
+ };
23
+ export type WidgetLayout = {
24
+ /** Every card's id, in the person's order. */
25
+ order: readonly string[];
26
+ /** The ids the person hid. */
27
+ hidden: readonly string[];
28
+ /** `order` without the hidden ones: the cards to render. */
29
+ visible: readonly string[];
30
+ /** Moves a card one place up (`-1`) or down (`1`). */
31
+ move: (id: string, by: -1 | 1) => void;
32
+ /** Hides a shown card, or shows a hidden one. */
33
+ toggle: (id: string) => void;
34
+ /** Back to the default order, everything shown. */
35
+ reset: () => void;
36
+ /** True while the layout is the default one. */
37
+ isDefault: boolean;
38
+ };
39
+ /**
40
+ * The order of a page's cards and which are hidden, remembered under
41
+ * `pageKey` (name it for the app and the page: `"my-app:hub"`).
42
+ * `defaultIds` is every card the page has, in its default order.
43
+ */
44
+ export declare function useWidgetLayout(pageKey: string, defaultIds: readonly string[], { store }?: WidgetLayoutOptions): WidgetLayout;
45
+ export {};
@@ -0,0 +1,128 @@
1
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
2
+ function parse(raw) {
3
+ if (raw === null)
4
+ return null;
5
+ try {
6
+ const value = JSON.parse(raw);
7
+ if (typeof value !== 'object' || value === null)
8
+ return null;
9
+ const { order, hidden } = value;
10
+ const strings = (list) => (Array.isArray(list) ? list.filter((id) => typeof id === 'string') : []);
11
+ return { order: strings(order), hidden: strings(hidden) };
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ export function createWidgetLayoutStore(getStorage) {
18
+ // Layouts this page set, for when storage refuses them (null: a reset).
19
+ const memory = new Map();
20
+ // The last string read per key and what it parsed to, so `get` hands
21
+ // back the same object until the stored string changes.
22
+ const parsed = new Map();
23
+ const listeners = new Map();
24
+ return {
25
+ get(key) {
26
+ if (memory.has(key))
27
+ return memory.get(key) ?? null;
28
+ let raw = null;
29
+ try {
30
+ raw = getStorage()?.getItem(key) ?? null;
31
+ }
32
+ catch {
33
+ // Storage blocked: nothing saved.
34
+ }
35
+ const cached = parsed.get(key);
36
+ if (cached && cached.raw === raw)
37
+ return cached.layout;
38
+ const layout = parse(raw);
39
+ parsed.set(key, { raw, layout });
40
+ return layout;
41
+ },
42
+ set(key, layout) {
43
+ memory.set(key, layout);
44
+ try {
45
+ const storage = getStorage();
46
+ if (layout === null)
47
+ storage?.removeItem(key);
48
+ else
49
+ storage?.setItem(key, JSON.stringify(layout));
50
+ }
51
+ catch {
52
+ // Storage blocked or full: kept in memory for this page only.
53
+ }
54
+ for (const listener of listeners.get(key) ?? [])
55
+ listener();
56
+ },
57
+ subscribe(key, listener) {
58
+ let set = listeners.get(key);
59
+ if (!set)
60
+ listeners.set(key, (set = new Set()));
61
+ set.add(listener);
62
+ return () => set.delete(listener);
63
+ },
64
+ };
65
+ }
66
+ /** The order and hidden ids to show: `saved` merged with the app's `defaultIds`. */
67
+ export function mergeWidgetLayout(defaultIds, saved) {
68
+ if (saved === null)
69
+ return { order: [...defaultIds], hidden: [] };
70
+ const known = new Set(defaultIds);
71
+ const order = [];
72
+ for (const id of saved.order)
73
+ if (known.has(id) && !order.includes(id))
74
+ order.push(id);
75
+ defaultIds.forEach((id, index) => {
76
+ if (order.includes(id))
77
+ return;
78
+ // After the nearest card before it in the default order.
79
+ let at = 0;
80
+ for (let before = index - 1; before >= 0; before--) {
81
+ const placed = order.indexOf(defaultIds[before]);
82
+ if (placed !== -1) {
83
+ at = placed + 1;
84
+ break;
85
+ }
86
+ }
87
+ order.splice(at, 0, id);
88
+ });
89
+ const hidden = order.filter((id) => saved.hidden.includes(id));
90
+ return { order, hidden };
91
+ }
92
+ /** `order` with `id` one place earlier (`-1`) or later (`1`); unchanged at either end. */
93
+ export function moveWidget(order, id, by) {
94
+ const next = [...order];
95
+ const from = next.indexOf(id);
96
+ const to = from + by;
97
+ if (from === -1 || to < 0 || to >= next.length)
98
+ return next;
99
+ [next[from], next[to]] = [next[to], next[from]];
100
+ return next;
101
+ }
102
+ const browserStore = createWidgetLayoutStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
103
+ /**
104
+ * The order of a page's cards and which are hidden, remembered under
105
+ * `pageKey` (name it for the app and the page: `"my-app:hub"`).
106
+ * `defaultIds` is every card the page has, in its default order.
107
+ */
108
+ export function useWidgetLayout(pageKey, defaultIds, { store = browserStore } = {}) {
109
+ const key = `${pageKey}:widgets`;
110
+ const subscribe = useCallback((listener) => store.subscribe(key, listener), [store, key]);
111
+ const saved = useSyncExternalStore(subscribe, () => store.get(key), () => null);
112
+ // The ids as a string, so a new array with the same ids each render doesn't re-merge.
113
+ const idsKey = defaultIds.join('\u0000');
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- idsKey stands for defaultIds
115
+ const { order, hidden } = useMemo(() => mergeWidgetLayout(defaultIds, saved), [idsKey, saved]);
116
+ const move = useCallback((id, by) => store.set(key, { order: moveWidget(order, id, by), hidden }), [store, key, order, hidden]);
117
+ const toggle = useCallback((id) => store.set(key, { order, hidden: hidden.includes(id) ? hidden.filter((other) => other !== id) : [...hidden, id] }), [store, key, order, hidden]);
118
+ const reset = useCallback(() => store.set(key, null), [store, key]);
119
+ return {
120
+ order,
121
+ hidden,
122
+ visible: order.filter((id) => !hidden.includes(id)),
123
+ move,
124
+ toggle,
125
+ reset,
126
+ isDefault: hidden.length === 0 && order.every((id, index) => id === defaultIds[index]),
127
+ };
128
+ }
package/dist/index.d.ts CHANGED
@@ -44,7 +44,8 @@ export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from '.
44
44
  export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
45
45
  export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
46
46
  export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
47
- export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
47
+ export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, type WidgetCardGroupProps, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
48
+ export { WidgetLayoutSheet, type WidgetLayoutSheetProps } from './components/app/widget-layout-sheet.js';
48
49
  export { ThemeProvider } from './components/theme-provider.js';
49
50
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
50
51
  export { RegisterRoute } from './routes/register.js';
@@ -56,6 +57,7 @@ export type { ErrorReportingOptions } from './error-reporting/reporter.js';
56
57
  export { createQueryClient } from './api/query-client.js';
57
58
  export { useTableUrlState } from './hooks/use-table-url-state.js';
58
59
  export { useTableLayout, type TableLayoutOptions } from './hooks/use-table-layout.js';
60
+ export { useWidgetLayout, createWidgetLayoutStore, mergeWidgetLayout, type SavedWidgetLayout, type WidgetLayout, type WidgetLayoutOptions, type WidgetLayoutStore, } from './hooks/use-widget-layout.js';
59
61
  export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
60
62
  export { useDebouncedValue } from './hooks/use-debounced-value.js';
61
63
  export { useDocumentTitle } from './hooks/use-document-title.js';
package/dist/index.js CHANGED
@@ -47,7 +47,8 @@ export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
47
47
  export { formatPlainDate } from './lib/plain-date.js';
48
48
  export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
49
49
  export { ModuleCard, ModuleStrip, } from './components/app/module-card.js';
50
- export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
50
+ export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
51
+ export { WidgetLayoutSheet } from './components/app/widget-layout-sheet.js';
51
52
  export { ThemeProvider } from './components/theme-provider.js';
52
53
  // Screens outside the app shell
53
54
  export { LoginRoute } from './routes/login.js';
@@ -59,6 +60,7 @@ export { createQueryClient } from './api/query-client.js';
59
60
  // Hooks
60
61
  export { useTableUrlState } from './hooks/use-table-url-state.js';
61
62
  export { useTableLayout } from './hooks/use-table-layout.js';
63
+ export { useWidgetLayout, createWidgetLayoutStore, mergeWidgetLayout, } from './hooks/use-widget-layout.js';
62
64
  export { useDebouncedValue } from './hooks/use-debounced-value.js';
63
65
  export { useDocumentTitle } from './hooks/use-document-title.js';
64
66
  export { useRecordUpdate, } from './hooks/use-record-update.js';
@@ -22,6 +22,10 @@ export type KeepAudioAliveEnvironment = {
22
22
  MediaMetadata?: new (init?: MediaMetadataInit) => MediaMetadata;
23
23
  createObjectURL?: (blob: Blob) => string;
24
24
  revokeObjectURL?: (url: string) => void;
25
+ /** Safari's `navigator.audioSession`, where it has one. */
26
+ audioSession?: {
27
+ type: string;
28
+ };
25
29
  };
26
30
  /** One second of silent 8-bit mono audio as a WAV file. */
27
31
  export declare function silentWav(): Uint8Array;
@@ -30,6 +34,8 @@ export declare function createKeepAudioAlive(env: KeepAudioAliveEnvironment): ({
30
34
  * Plays a silent looped track so iOS keeps the page's sound going with the
31
35
  * phone locked, with `title` and Pause and Play on the lock screen. Call it
32
36
  * from the click that starts the run, behind a setting that's off by default:
33
- * it pauses other apps' music. A no-op where the browser can't play audio.
37
+ * it pauses other apps' music. Where Safari has an audio session it's set to
38
+ * "playback" meanwhile, so the ringer switch doesn't mute the cues. A no-op
39
+ * where the browser can't play audio.
34
40
  */
35
41
  export declare const keepAudioAlive: (options: KeepAudioAliveOptions) => KeepAudioAlive;
@@ -1,9 +1,12 @@
1
- // Keeps a page's audio going with the phone locked (#217). iOS pauses a
1
+ // Keeps a page's audio going with the phone locked (#217, #261). iOS pauses a
2
2
  // page's sound on the lock screen unless the page is playing media, so
3
3
  // keepAudioAlive() plays a silent looped track, which also puts the page's
4
4
  // title and a Pause and Play on the lock screen (the Media Session). It's
5
5
  // opt-in and off by default: it takes the device's audio session, which
6
6
  // pauses another app's music (docs/foundation/timed-screens.md "Sound").
7
+ // Where Safari has an audio session (navigator.audioSession, iOS 17+) it's
8
+ // set to "playback" while the track runs, so the ringer switch doesn't mute
9
+ // the page's cues either, and put back on stop().
7
10
  //
8
11
  // The track is a few bytes of silence made here, so there is no file to
9
12
  // ship. createKeepAudioAlive takes the browser's APIs, so the rules are
@@ -55,6 +58,18 @@ export function createKeepAudioAlive(env) {
55
58
  }
56
59
  audio.loop = true;
57
60
  const session = env.mediaSession;
61
+ const audioSession = env.audioSession;
62
+ let typeBefore = null;
63
+ try {
64
+ if (audioSession) {
65
+ typeBefore = audioSession.type;
66
+ audioSession.type = 'playback';
67
+ }
68
+ }
69
+ catch {
70
+ // A type this Safari doesn't take: the silent track alone.
71
+ typeBefore = null;
72
+ }
58
73
  const play = () => {
59
74
  // Refused without a click: the lock screen then shows nothing to keep.
60
75
  Promise.resolve(audio.play()).catch(() => { });
@@ -102,6 +117,14 @@ export function createKeepAudioAlive(env) {
102
117
  audio.removeAttribute('src');
103
118
  audio.load();
104
119
  env.revokeObjectURL?.(url);
120
+ if (audioSession && typeBefore !== null) {
121
+ try {
122
+ audioSession.type = typeBefore;
123
+ }
124
+ catch {
125
+ // Left as playback: harmless once the track has stopped.
126
+ }
127
+ }
105
128
  if (session) {
106
129
  handle('play', null);
107
130
  handle('pause', null);
@@ -119,7 +142,9 @@ let shared = null;
119
142
  * Plays a silent looped track so iOS keeps the page's sound going with the
120
143
  * phone locked, with `title` and Pause and Play on the lock screen. Call it
121
144
  * from the click that starts the run, behind a setting that's off by default:
122
- * it pauses other apps' music. A no-op where the browser can't play audio.
145
+ * it pauses other apps' music. Where Safari has an audio session it's set to
146
+ * "playback" meanwhile, so the ringer switch doesn't mute the cues. A no-op
147
+ * where the browser can't play audio.
123
148
  */
124
149
  export const keepAudioAlive = (options) => {
125
150
  if (!shared) {
@@ -130,6 +155,7 @@ export const keepAudioAlive = (options) => {
130
155
  MediaMetadata: hasWindow ? window.MediaMetadata : undefined,
131
156
  createObjectURL: typeof URL !== 'undefined' ? (blob) => URL.createObjectURL(blob) : undefined,
132
157
  revokeObjectURL: typeof URL !== 'undefined' ? (url) => URL.revokeObjectURL(url) : undefined,
158
+ audioSession: typeof navigator !== 'undefined' ? navigator.audioSession : undefined,
133
159
  });
134
160
  }
135
161
  return shared(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.43.0",
3
+ "version": "4.45.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {