@tristan2828/ui-foundation 4.20.0 → 4.22.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.
@@ -269,6 +269,15 @@ is a filled-in example while the Widgets demo is still here).
269
269
  Its badge fields sit beside it, each the same `Badge` and variant map
270
270
  as its table cell. Edit (a `Button` rendering a `Link` to
271
271
  `/<entity>/:id/edit`) and the delete action go in `actions`.
272
+ - **The browser tab** reads the record's title once it loads
273
+ ("Wireless Mouse · <app>"): `EntityView` sets it from `title`, and
274
+ puts the nav's ("Widgets · <app>") back while loading, on an error
275
+ and when you leave. Nothing to add. `documentTitle` names the tab
276
+ otherwise, when the title alone wouldn't say what the tab is (a
277
+ record named by a date, say: `documentTitle={`Entry ${title}`}`);
278
+ `false` leaves the nav's. Any other screen names its tab with
279
+ `useDocumentTitle('New Widget')`, which wins over the nav's while the
280
+ screen is mounted.
272
281
  - **Sections**, one per row of the plan's section table, in order.
273
282
  Fields are label/value rows (`fields`), labelled as on the form. A
274
283
  section that holds one long text or one sub-record list is a block
@@ -461,7 +470,12 @@ is a filled-in example while the Widgets demo is still here).
461
470
  values should stay grey, and colour is never the only signal.
462
471
  7. **Register routes** for `/<entity>`, `/<entity>/new`, `/<entity>/:id`
463
472
  (the view) and `/<entity>/:id/edit` in `src/App.tsx`, and add a nav
464
- entry to `src/nav.ts`.
473
+ entry to `src/nav.ts`. Its label names the browser tab on the list and
474
+ every page under it ("Widgets · <app>"; the home entry, '/', gives the
475
+ app's name alone). For a list that should open on one of its saved
476
+ views, `to` carries that view's query string
477
+ (`/widgets?status=active`): the entry is still matched on its path, so
478
+ it stays current on the list and the records under it.
465
479
  8. **Add Playwright specs for all three screens**, one test per state
466
480
  (`loading`, `empty`, `error`, `validation`, `success`), forced through
467
481
  MSW overrides — `forceMswOverride`, `waitForMswReady` and the
@@ -10,6 +10,7 @@ package has the parts, so a screen like this is assembled, not rebuilt:
10
10
  | `useSessionClock` | A clock that doesn't drift, with pause and resume: `idle`, `running`, `done` |
11
11
  | `useWakeLock` | Keeps the screen on while a session runs (`SessionView`'s `keepAwake` uses it) |
12
12
  | `unlockAudio`, `chime`, `sequence`, `say`, `hush`, `vibrate` | Sound cues with no audio files |
13
+ | `canVibrate` | Whether this browser can vibrate, to offer a Vibrate setting only where it works |
13
14
  | `useMuted` | A mute toggle remembered on this device |
14
15
  | `usePrefersReducedMotion` | Whether to swap movement for a fade |
15
16
 
@@ -95,6 +96,13 @@ Browsers block sound until the person has clicked or tapped. So:
95
96
  line still being spoken. `hush()` stops it. Call `hush()` when the
96
97
  session stops or the view closes.
97
98
  - `vibrate(pattern)` only works on Android. Elsewhere it does nothing.
99
+ - **A Vibrate setting is offered only where `canVibrate()` is true.**
100
+ iPhone browsers can't vibrate, so a toggle there would set something
101
+ that does nothing. `canVibrate()` is the same test `vibrate()` makes, so
102
+ the two never disagree. Read it once, since it doesn't change during a
103
+ session: `const [vibrates] = useState(canVibrate)`. Show the toggle (a
104
+ pressed button named "Vibrate", like Mute) only when `vibrates` is
105
+ true, and check the setting before each `vibrate()` call.
98
106
  - Every one of them is a no-op where the browser can't make the sound, so
99
107
  none needs a check around it.
100
108
  - **Mute** is the app's: `const [muted, setMuted] = useMuted('my-app:routine:muted')`,
@@ -159,18 +167,18 @@ test('running, paused and done', async ({ page }) => {
159
167
  steps, so every reading picks up however long the machine took since the
160
168
  last `fastForward`. A count-up with no end (a stopwatch, a hold that
161
169
  ends on a tap) has nothing to stop at, so under load its readings are
162
- off by a second or more. Pause the clock once the session opens and
163
- move it only with `fastForward`:
170
+ off by a second or more. Pause the clock before the session starts, at a
171
+ time fixed from the install, and move it only with `fastForward`:
164
172
 
165
173
  ```ts
166
174
  test('the hold counts up until the tap', async ({ page }) => {
167
- await page.clock.install()
175
+ const t0 = Date.now()
176
+ await page.clock.install({ time: t0 })
168
177
  await page.goto('/routine')
178
+ // The page's clock runs no faster than real time, so a minute on is still
179
+ // ahead for the whole test timeout; nothing on the start screen is timed.
180
+ await page.clock.pauseAt(t0 + 60_000)
169
181
  await page.getByRole('button', { name: 'Start' }).click()
170
- // Slightly ahead, never Date.now() itself: the page's clock has already
171
- // moved past the test's, and pauseAt() refuses an earlier time with
172
- // "Cannot fast-forward to the past".
173
- await page.clock.pauseAt(Date.now() + 50)
174
182
 
175
183
  await page.clock.fastForward('00:42')
176
184
  await expect(page.getByRole('timer', { name: 'Held for' })).toHaveText('0:42')
@@ -183,10 +191,12 @@ test('the hold counts up until the tap', async ({ page }) => {
183
191
  })
184
192
  ```
185
193
 
186
- - **`pauseAt(Date.now() + 50)`, not `pauseAt(Date.now())`.** The page's
187
- fake time is already a little ahead of the test's `Date.now()`, and
188
- `pauseAt` fast-forwards to the time it's given, so a time already
189
- passed fails with "Cannot fast-forward to the past".
194
+ - **Pause at a time fixed from `install`, before the session starts.**
195
+ Never at one read after it (`pauseAt(Date.now() + 50)` after Start):
196
+ until the pause lands the page's fake clock runs in real time, so a
197
+ slow round trip or a busy first frame puts it past that time, and
198
+ `pauseAt` fails with "Cannot fast-forward to the past". A time a minute
199
+ on from `install` is still ahead however slow the machine is.
190
200
  - **`clock.resume()` before axe or closing the view.** With the clock
191
201
  paused, axe's scan never finishes and the dialog's close animation
192
202
  never ends, so the test times out with nothing pointing at the clock.
@@ -1,6 +1,10 @@
1
1
  import type { ComponentType } from 'react';
2
2
  export type NavItem = {
3
- /** The route this entry links to. */
3
+ /**
4
+ * The route this entry links to. It may carry a query string, to open a
5
+ * list on a saved view ('/widgets?status=active'): the entry is matched
6
+ * on its path alone, so it's still current anywhere under /widgets.
7
+ */
4
8
  to: string;
5
9
  label: string;
6
10
  icon: ComponentType;
@@ -39,8 +43,25 @@ export type NavGroup = {
39
43
  */
40
44
  export declare function navGroups(nav: readonly NavEntry[]): NavGroup[];
41
45
  /**
42
- * The `to` of the one entry to mark active: the most specific that matches.
43
- * With '/tasks' and '/tasks/categories' both in the nav, '/tasks/categories/2'
44
- * marks Categories only, and '/tasks/3' marks Tasks.
46
+ * The path a `to` names, without its query string or hash: what an entry is
47
+ * matched on, and what the a11y suite compares with the rendered sidebar.
48
+ */
49
+ export declare function navPathname(to: string): string;
50
+ /** Every `to` in a nav, each once, in order: the pages its sidebar links. */
51
+ export declare function navTargets(nav: readonly NavEntry[]): string[];
52
+ /**
53
+ * The one entry to mark active: the most specific that matches, by the path
54
+ * of its `to` (its query string ignored). With '/tasks' and
55
+ * '/tasks/categories' both in the nav, '/tasks/categories/2' marks
56
+ * Categories only, and '/tasks/3' marks Tasks. Of two entries with the same
57
+ * path, the first.
45
58
  */
59
+ export declare function activeNavItem(nav: readonly NavEntry[], pathname: string): NavItem | undefined;
60
+ /** The `to` of the one entry to mark active (`activeNavItem`). */
46
61
  export declare function activeNavTo(nav: readonly NavEntry[], pathname: string): string | undefined;
62
+ /**
63
+ * The page the nav names for `pathname`, for the browser tab: the active
64
+ * entry's label. None on the home entry ('/'), whose page is the app
65
+ * itself, or on a page no entry covers.
66
+ */
67
+ export declare function navPageLabel(nav: readonly NavEntry[], pathname: string): string | undefined;
@@ -30,27 +30,58 @@ export function navGroups(nav) {
30
30
  groups.push({ items: run });
31
31
  return groups;
32
32
  }
33
- function matches(item, pathname) {
34
- if (item.end)
35
- return pathname === item.to;
36
- if (pathname === item.to)
33
+ /**
34
+ * The path a `to` names, without its query string or hash: what an entry is
35
+ * matched on, and what the a11y suite compares with the rendered sidebar.
36
+ */
37
+ export function navPathname(to) {
38
+ const end = to.search(/[?#]/);
39
+ return end === -1 ? to : to.slice(0, end);
40
+ }
41
+ /** Every `to` in a nav, each once, in order: the pages its sidebar links. */
42
+ export function navTargets(nav) {
43
+ return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
44
+ }
45
+ function matches(path, end, pathname) {
46
+ if (end)
47
+ return pathname === path;
48
+ if (pathname === path)
37
49
  return true;
38
50
  // On a path-segment boundary: '/tasks' is active on '/tasks/3', not on
39
51
  // '/tasks-archive'.
40
- return pathname.startsWith(item.to.endsWith('/') ? item.to : `${item.to}/`);
52
+ return pathname.startsWith(path.endsWith('/') ? path : `${path}/`);
41
53
  }
42
54
  /**
43
- * The `to` of the one entry to mark active: the most specific that matches.
44
- * With '/tasks' and '/tasks/categories' both in the nav, '/tasks/categories/2'
45
- * marks Categories only, and '/tasks/3' marks Tasks.
55
+ * The one entry to mark active: the most specific that matches, by the path
56
+ * of its `to` (its query string ignored). With '/tasks' and
57
+ * '/tasks/categories' both in the nav, '/tasks/categories/2' marks
58
+ * Categories only, and '/tasks/3' marks Tasks. Of two entries with the same
59
+ * path, the first.
46
60
  */
47
- export function activeNavTo(nav, pathname) {
61
+ export function activeNavItem(nav, pathname) {
48
62
  let best;
63
+ let bestLength = -1;
49
64
  for (const entry of nav) {
50
65
  for (const item of isNavSection(entry) ? entry.items : [entry]) {
51
- if (matches(item, pathname) && (best === undefined || item.to.length > best.length))
52
- best = item.to;
66
+ const path = navPathname(item.to);
67
+ if (matches(path, item.end, pathname) && path.length > bestLength) {
68
+ best = item;
69
+ bestLength = path.length;
70
+ }
53
71
  }
54
72
  }
55
73
  return best;
56
74
  }
75
+ /** The `to` of the one entry to mark active (`activeNavItem`). */
76
+ export function activeNavTo(nav, pathname) {
77
+ return activeNavItem(nav, pathname)?.to;
78
+ }
79
+ /**
80
+ * The page the nav names for `pathname`, for the browser tab: the active
81
+ * entry's label. None on the home entry ('/'), whose page is the app
82
+ * itself, or on a page no entry covers.
83
+ */
84
+ export function navPageLabel(nav, pathname) {
85
+ const item = activeNavItem(nav, pathname);
86
+ return item && navPathname(item.to) !== '/' ? item.label : undefined;
87
+ }
@@ -4,14 +4,21 @@ import { type AppShellVersion } from './app-shell-version.js';
4
4
  export type { NavEntry, NavItem, NavSection } from './app-shell-nav.js';
5
5
  export type { AppShellVersion } from './app-shell-version.js';
6
6
  export type AppShellProps = {
7
- /** Shown at the top of the sidebar (hidden on the collapsed icon rail). */
7
+ /**
8
+ * Shown at the top of the sidebar (hidden on the collapsed icon rail), and
9
+ * the app's part of the browser tab's title: "<page> · <title>", the page
10
+ * named by the current nav entry, or the title alone on the home entry
11
+ * ('/'). A screen names its own page with `useDocumentTitle` (EntityView
12
+ * does, with its record's title).
13
+ */
8
14
  title: string;
9
15
  /**
10
16
  * The app's primary navigation, in order: pages, and labelled sections of
11
17
  * pages (`{ label, items }`, one per module, say). A nav with no sections
12
18
  * is the single "Navigation" group. One entry is marked active: the most
13
19
  * specific whose route matches, so '/tasks' and '/tasks/categories' can
14
- * both be listed. A section marked `collapsible` folds under its label,
20
+ * both be listed. An entry is matched on its `to`'s path, so `to` can
21
+ * open a list on a saved view ('/tasks?status=open'). A section marked `collapsible` folds under its label,
15
22
  * remembered per browser (`defaultOpen` for the first visit); the one
16
23
  * holding the current page stays open, and the icon rail shows every
17
24
  * section's items.
@@ -4,11 +4,12 @@ import { useTheme } from 'next-themes';
4
4
  import { useEffect, useId, useState } from 'react';
5
5
  import { Link, Navigate, Outlet, useLocation } from 'react-router';
6
6
  import { useAuth } from '../../auth/use-auth.js';
7
- import { activeNavTo, navGroups } from './app-shell-nav.js';
7
+ import { activeNavTo, navGroups, navPageLabel } from './app-shell-nav.js';
8
8
  import { versionHref, versionLabel } from './app-shell-version.js';
9
9
  import { ErrorState } from './error-state.js';
10
10
  import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
11
11
  import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
12
+ import { useShellDocumentTitle } from '../../hooks/use-document-title.js';
12
13
  import { cn } from 'cn';
13
14
  import { Button } from '../ui/button.js';
14
15
  import { Skeleton } from '../ui/skeleton.js';
@@ -118,6 +119,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true,
118
119
  // resolves: that runs before React re-renders, and the <Navigate> this
119
120
  // component then renders starts a second navigation that wins.
120
121
  const [isLoggingOut, setIsLoggingOut] = useState(false);
122
+ useShellDocumentTitle(title, navPageLabel(nav, location.pathname));
121
123
  if (status === 'loading') {
122
124
  return _jsx(AppShellSkeleton, {});
123
125
  }
@@ -53,8 +53,15 @@ export type EntityViewPlacement = 'main' | 'rail';
53
53
  */
54
54
  export type EntityViewLayout = 'column' | 'rail';
55
55
  export type EntityViewProps = {
56
- /** The record's name or title, the page's <h1>. */
56
+ /** The record's name or title, the page's <h1>, and the browser tab's once it loads (`documentTitle`). */
57
57
  title?: string;
58
+ /**
59
+ * The browser tab's name for this record once it has loaded: `title`
60
+ * unless this says otherwise, read as "<this> · <app>" inside `AppShell`.
61
+ * `false` leaves the tab to the nav ("Widgets · <app>"). Loading, an
62
+ * error, and leaving the view all show the nav's title.
63
+ */
64
+ documentTitle?: string | false;
58
65
  /** The title is editable in place; `label` is the field's ("Name"). */
59
66
  titleEdit?: EditInPlace & {
60
67
  label: string;
@@ -94,4 +101,4 @@ export type EntityViewProps = {
94
101
  label: string;
95
102
  };
96
103
  };
97
- export declare function EntityView({ title, titleEdit, badges, actions, notice, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
104
+ export declare function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -49,6 +49,7 @@ import { Button } from '../ui/button.js';
49
49
  import { Card, CardContent, CardHeader } from '../ui/card.js';
50
50
  import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
51
51
  import { Skeleton } from '../ui/skeleton.js';
52
+ import { useDocumentTitle } from '../../hooks/use-document-title.js';
52
53
  import { EditInPlaceContext } from './edit-in-place.js';
53
54
  import { EditInPlaceStore } from './edit-in-place-store.js';
54
55
  import { EditableList } from './editable-list.js';
@@ -140,11 +141,12 @@ function Rail({ label, sections }) {
140
141
  const [ref, fits] = useFitsWindow();
141
142
  return (_jsx("aside", { ref: ref, "aria-label": label, "data-fits": fits ? '' : undefined, className: cn(RAIL_LAYOUT.rail, RAIL_LAYOUT.sticky, 'flex min-w-0 flex-col gap-6'), children: sections.map((section) => (_jsx(ViewSection, { section: section, inRail: true }, section.title))) }));
142
143
  }
143
- export function EntityView({ title, titleEdit, badges, actions, notice, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
144
+ export function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
144
145
  // Editing in place: one store per view, and the leave-page prompt only
145
146
  // while a field is open (or one is always editable).
146
147
  const [store] = useState(() => new EditInPlaceStore());
147
148
  const editing = useSyncExternalStore(store.subscribe, () => store.editing, () => store.editing);
149
+ useDocumentTitle(isLoading || error || documentTitle === false ? undefined : (documentTitle ?? title));
148
150
  if (error?.kind === 'notfound') {
149
151
  // A record that doesn't exist and one that belongs to another user are
150
152
  // the same plain 404, so this never says which. Retrying can't help.
@@ -0,0 +1,27 @@
1
+ export declare const DOCUMENT_TITLE_SEPARATOR = " \u00B7 ";
2
+ type TitleTarget = {
3
+ title: string;
4
+ };
5
+ export type DocumentTitles = {
6
+ /** AppShell's: the app's name and the nav's page (none on the home page). `null` once it unmounts. */
7
+ setShell: (shell: {
8
+ app: string;
9
+ page?: string;
10
+ } | null) => void;
11
+ /** A view's page title, by the view's id. `undefined` (not loaded yet) leaves the tab to the others; its place is kept. */
12
+ setView: (id: string, page: string | undefined) => void;
13
+ /** The view unmounted. */
14
+ releaseView: (id: string) => void;
15
+ };
16
+ export declare function createDocumentTitles(target: TitleTarget): DocumentTitles;
17
+ /**
18
+ * Names the browser tab after this screen while it's mounted: "New Widget"
19
+ * reads "New Widget · <app>" inside `AppShell`. Wins over the page the nav
20
+ * names, whichever arrives first; when the screen goes, the nav's title
21
+ * comes back. `undefined` (a record still loading) leaves the nav's.
22
+ * `EntityView` already does this with its `title`.
23
+ */
24
+ export declare function useDocumentTitle(title: string | undefined): void;
25
+ /** AppShell's side: the app's name and the page its nav names. */
26
+ export declare function useShellDocumentTitle(app: string, page: string | undefined): void;
27
+ export {};
@@ -0,0 +1,72 @@
1
+ import { useEffect, useId } from 'react';
2
+ // The browser tab's title, "<page> · <app>", from two sources that can
3
+ // arrive in either order: AppShell's (the app's name, and the page its nav
4
+ // names for the current path) and the views' own (useDocumentTitle, and
5
+ // EntityView's record once it loads). A view's title wins over the nav's
6
+ // while that view is mounted, whichever was set first, and the nav's comes
7
+ // back when it goes. With neither, the tab keeps the title it had before
8
+ // (index.html's).
9
+ //
10
+ // createDocumentTitles takes the document, so the rules are unit tested
11
+ // against a fake one (tests/use-document-title.test.ts).
12
+ export const DOCUMENT_TITLE_SEPARATOR = ' · ';
13
+ export function createDocumentTitles(target) {
14
+ let shell = null;
15
+ // In the order views first set a title: the latest one showing wins.
16
+ const views = new Map();
17
+ // The title before anything here set one, put back once nothing sets one.
18
+ let before = null;
19
+ function apply() {
20
+ const view = [...views.values()].reverse().find((page) => page);
21
+ const page = view ?? shell?.page;
22
+ const title = shell ? (page ? `${page}${DOCUMENT_TITLE_SEPARATOR}${shell.app}` : shell.app) : page;
23
+ if (title) {
24
+ before ??= target.title;
25
+ target.title = title;
26
+ }
27
+ else if (before !== null) {
28
+ target.title = before;
29
+ before = null;
30
+ }
31
+ }
32
+ return {
33
+ setShell(next) {
34
+ shell = next;
35
+ apply();
36
+ },
37
+ setView(id, page) {
38
+ views.set(id, page || undefined);
39
+ apply();
40
+ },
41
+ releaseView(id) {
42
+ views.delete(id);
43
+ apply();
44
+ },
45
+ };
46
+ }
47
+ // One per page, made on first use (never while rendering on a server).
48
+ let documentTitles;
49
+ const titles = () => (documentTitles ??= createDocumentTitles(document));
50
+ /**
51
+ * Names the browser tab after this screen while it's mounted: "New Widget"
52
+ * reads "New Widget · <app>" inside `AppShell`. Wins over the page the nav
53
+ * names, whichever arrives first; when the screen goes, the nav's title
54
+ * comes back. `undefined` (a record still loading) leaves the nav's.
55
+ * `EntityView` already does this with its `title`.
56
+ */
57
+ export function useDocumentTitle(title) {
58
+ const id = useId();
59
+ // Two effects, so a changed title keeps this screen's place among others
60
+ // and only unmounting gives it up.
61
+ useEffect(() => {
62
+ titles().setView(id, title);
63
+ }, [id, title]);
64
+ useEffect(() => () => titles().releaseView(id), [id]);
65
+ }
66
+ /** AppShell's side: the app's name and the page its nav names. */
67
+ export function useShellDocumentTitle(app, page) {
68
+ useEffect(() => {
69
+ titles().setShell({ app, page });
70
+ }, [app, page]);
71
+ useEffect(() => () => titles().setShell(null), []);
72
+ }
package/dist/index.d.ts CHANGED
@@ -46,6 +46,7 @@ export { createQueryClient } from './api/query-client.js';
46
46
  export { useTableUrlState } from './hooks/use-table-url-state.js';
47
47
  export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
48
48
  export { useDebouncedValue } from './hooks/use-debounced-value.js';
49
+ export { useDocumentTitle } from './hooks/use-document-title.js';
49
50
  export { useRecordUpdate, type RecordChange, type RecordUpdateOptions, } from './hooks/use-record-update.js';
50
51
  export { useIsMobile } from './hooks/use-mobile.js';
51
52
  export { useWakeLock } from './hooks/use-wake-lock.js';
@@ -54,4 +55,4 @@ export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
54
55
  export { useMuted } from './hooks/use-muted.js';
55
56
  export { createPushController, usePushPermission, type PushController, type PushControllerOptions, type PushEnvironment, type PushPermission, type PushStatus, } from './hooks/use-push-permission.js';
56
57
  export { cn } from './lib/utils.js';
57
- export { chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
58
+ export { canVibrate, chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
package/dist/index.js CHANGED
@@ -50,6 +50,7 @@ export { createQueryClient } from './api/query-client.js';
50
50
  // Hooks
51
51
  export { useTableUrlState } from './hooks/use-table-url-state.js';
52
52
  export { useDebouncedValue } from './hooks/use-debounced-value.js';
53
+ export { useDocumentTitle } from './hooks/use-document-title.js';
53
54
  export { useRecordUpdate, } from './hooks/use-record-update.js';
54
55
  export { useIsMobile } from './hooks/use-mobile.js';
55
56
  // Timed screens (docs/foundation/timed-screens.md)
@@ -60,4 +61,4 @@ export { useMuted } from './hooks/use-muted.js';
60
61
  export { createPushController, usePushPermission, } from './hooks/use-push-permission.js';
61
62
  // Utilities
62
63
  export { cn } from './lib/utils.js';
63
- export { chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
64
+ export { canVibrate, chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
@@ -38,6 +38,13 @@ export type AudioCues = {
38
38
  vibrate: (pattern: number | number[]) => void;
39
39
  };
40
40
  export declare function createAudioCues(env: AudioCuesEnvironment): AudioCues;
41
+ /**
42
+ * Whether this browser can vibrate: Android browsers can, iPhone browsers
43
+ * can't. The same test vibrate() makes, so a Vibrate setting offered only
44
+ * where this is true never sets something that does nothing. Read it once;
45
+ * it doesn't change during a session.
46
+ */
47
+ export declare function canVibrate(): boolean;
41
48
  /** Call from the click or tap that starts a session; sound is allowed from then on. */
42
49
  export declare const unlockAudio: AudioCues['unlockAudio'];
43
50
  /** A soft sine chime; dropped before unlockAudio() or where there's no Web Audio. */
@@ -109,6 +109,15 @@ export function createAudioCues(env) {
109
109
  },
110
110
  };
111
111
  }
112
+ /**
113
+ * Whether this browser can vibrate: Android browsers can, iPhone browsers
114
+ * can't. The same test vibrate() makes, so a Vibrate setting offered only
115
+ * where this is true never sets something that does nothing. Read it once;
116
+ * it doesn't change during a session.
117
+ */
118
+ export function canVibrate() {
119
+ return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
120
+ }
112
121
  let shared = null;
113
122
  function browserCues() {
114
123
  if (shared)
@@ -118,9 +127,7 @@ function browserCues() {
118
127
  AudioContext: win?.AudioContext ?? win?.webkitAudioContext,
119
128
  speechSynthesis: win?.speechSynthesis,
120
129
  SpeechSynthesisUtterance: win?.SpeechSynthesisUtterance,
121
- vibrate: typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'
122
- ? (pattern) => navigator.vibrate(pattern)
123
- : undefined,
130
+ vibrate: canVibrate() ? (pattern) => navigator.vibrate(pattern) : undefined,
124
131
  });
125
132
  return shared;
126
133
  }
@@ -13,7 +13,7 @@
13
13
  // are optional peer dependencies, needed only by apps that import this.
14
14
  import AxeBuilder from '@axe-core/playwright';
15
15
  import { expect, test } from '@playwright/test';
16
- import { isNavSection } from '../components/app/app-shell-nav.js';
16
+ import { navPathname, navTargets } from '../components/app/app-shell-nav.js';
17
17
  /**
18
18
  * Forces a response for the page's *first* load: set before navigation, so
19
19
  * it wins the race against the app's own first fetch. For states forced
@@ -80,7 +80,8 @@ export async function expectNoAxeViolations(page, options = {}) {
80
80
  const results = await builder.analyze();
81
81
  expect(results.violations).toEqual([]);
82
82
  }
83
- // The app's own pages, read from the Primary nav. Below the sidebar's
83
+ // The app's own pages, read from the Primary nav: each link's path and
84
+ // query string, as it would be visited. Below the sidebar's
84
85
  // mobile breakpoint the nav renders into an off-canvas Sheet *portaled to
85
86
  // document.body*, outside the <nav aria-label="Primary"> landmark, so the
86
87
  // links are read from the sheet there. Keyed off the `isMobile` fixture,
@@ -100,13 +101,14 @@ async function navRoutes(page, isMobile) {
100
101
  .evaluateAll((links) => links.map((link) => link.href));
101
102
  // Only this app's pages: a sidebarExtra group may link to other sites.
102
103
  const origin = new URL(page.url()).origin;
103
- return [...new Set(hrefs.filter((href) => new URL(href).origin === origin).map((href) => new URL(href).pathname))];
104
+ const own = hrefs.map((href) => new URL(href)).filter((url) => url.origin === origin);
105
+ return [...new Set(own.map((url) => `${url.pathname}${url.search}`))];
104
106
  }
107
+ // The pages a list of routes names, by path: a `to` that opens a list on a
108
+ // saved view ('/widgets?status=active') is the /widgets page.
109
+ const pagePaths = (routes) => [...new Set(routes.map(navPathname))].sort();
105
110
  // A page's share of the one discovering test's time: load, data, axe.
106
111
  const NAV_PAGE_BUDGET_MS = 10_000;
107
- function navEntryRoutes(nav) {
108
- return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
109
- }
110
112
  /**
111
113
  * Every data view on the page has loaded: at least one shows its data (or,
112
114
  * with `{ success: false }`, the page has its screen up), and none still
@@ -144,11 +146,13 @@ async function expectNavPageAccessible(page, route, colorScheme) {
144
146
  * emulateMedia switches it.
145
147
  */
146
148
  export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes, nav }) {
147
- const listed = nav && navEntryRoutes(nav);
149
+ // Each `to` as given, query string and all, so a page that opens on a
150
+ // saved view is checked on it.
151
+ const listed = nav && navTargets(nav);
148
152
  if (listed) {
149
153
  test('the sidebar links exactly the pages in nav', async ({ page, isMobile }) => {
150
154
  const rendered = await navRoutes(page, isMobile);
151
- expect([...rendered].sort(), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual([...listed].sort());
155
+ expect(pagePaths(rendered), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual(pagePaths(listed));
152
156
  });
153
157
  }
154
158
  for (const colorScheme of ['light', 'dark']) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.20.0",
3
+ "version": "4.22.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": {