@sempods/apps 0.7.1 → 0.8.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +6 -3
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +322 -111
  5. package/instructions/publish.md +13 -7
  6. package/instructions/setup.md +38 -23
  7. package/instructions/visual-check.md +43 -0
  8. package/package.json +3 -3
  9. package/scripts/build-site.mjs +15 -3
  10. package/scripts/check.mjs +3 -1
  11. package/scripts/icon.mjs +47 -0
  12. package/scripts/lib/apps.mjs +56 -2
  13. package/scripts/lib/generate.mjs +55 -14
  14. package/scripts/lib/icon.mjs +192 -0
  15. package/scripts/lib/overview.mjs +5 -3
  16. package/scripts/lib/page.mjs +137 -0
  17. package/scripts/lib/themes.mjs +58 -0
  18. package/scripts/migrate.mjs +44 -14
  19. package/scripts/new-app.mjs +43 -10
  20. package/scripts/sdk-update.mjs +16 -1
  21. package/shared/files/{f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 → 44c114d4d2c8b172f4757100f79e8a1a00d30c96ba2ad8f75c3373e52f6b849f} +2 -2
  22. package/shared/files/{31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 → 843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845} +3 -2
  23. package/shared/files/{1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 → e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43} +17 -2
  24. package/shared/snapshot.json +4 -4
  25. package/skeleton/app/NOTES.md +20 -2
  26. package/skeleton/app/index.html +7 -3
  27. package/skeleton/app/package.json +6 -6
  28. package/skeleton/app/src/App.tsx +53 -21
  29. package/skeleton/app/src/NewVersionNotice.tsx +6 -11
  30. package/skeleton/app/src/examples/ListScreen.tsx +176 -0
  31. package/skeleton/app/src/index.css +86 -13
  32. package/skeleton/app/src/main.tsx +3 -0
  33. package/skeleton/app/src/pwa.ts +1 -1
  34. package/skeleton/app/src/routes.test.ts +19 -0
  35. package/skeleton/app/src/routes.ts +139 -0
  36. package/skeleton/app/src/texts.test.ts +28 -0
  37. package/skeleton/app/src/texts.ts +35 -0
  38. package/skeleton/app/src/themes/calm.css +11 -0
  39. package/skeleton/app/src/themes/fresh.css +11 -0
  40. package/skeleton/app/src/themes/playful.css +11 -0
  41. package/skeleton/app/src/themes/warm.css +11 -0
  42. package/skeleton/app/src/ui/Composer.tsx +18 -0
  43. package/skeleton/app/src/ui/ContextSwitch.tsx +94 -0
  44. package/skeleton/app/src/ui/Dialog.tsx +120 -0
  45. package/skeleton/app/src/ui/Menu.tsx +118 -0
  46. package/skeleton/app/src/ui/Tabs.tsx +39 -0
  47. package/skeleton/app/src/ui/dismiss.ts +30 -0
  48. package/skeleton/app/src/ui/icons.tsx +53 -0
  49. package/skeleton/app/src/ui/ui.css +249 -0
  50. package/skeleton/app/vite.config.ts +4 -4
  51. package/skills/update/SKILL.md +4 -2
  52. package/skeleton/app/public/favicon.svg +0 -1
  53. package/skeleton/app/public/icon-192.png +0 -0
  54. package/skeleton/app/public/icon-512.png +0 -0
  55. package/skeleton/app/src/start.test.ts +0 -15
  56. package/skeleton/app/src/start.ts +0 -9
@@ -1,6 +1,9 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
+ // The app's theme: one of src/themes/; switch by changing this line.
4
+ import './themes/__APP_THEME__.css';
3
5
  import './index.css';
6
+ import './ui/ui.css';
4
7
  import App from './App.tsx';
5
8
  import { registerAppWorker } from './pwa.ts';
6
9
  import { app } from './sempods.generated.ts';
@@ -1,4 +1,4 @@
1
- // Adapted from the sempods TypeScript SDK v0.2.0 recipe
1
+ // Adapted from the sempods TypeScript SDK v0.6.0 recipe
2
2
  // examples/todo/pwa/register.tsx, licensed under Apache-2.0; see
3
3
  // the LICENSE and NOTICE of the @sempods/app-sdk package. Changes: this header; the notice
4
4
  // component moved to NewVersionNotice.tsx.
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pathOf, routeOf } from './routes.ts';
3
+
4
+ describe('routes', () => {
5
+ const routes = ['open', 'bought'] as const;
6
+
7
+ it('reads the route of an address under the app path', () => {
8
+ expect(routeOf('/konsum/bought', routes, '/konsum/')).toBe('bought');
9
+ expect(routeOf('/konsum/bought/', routes, '/konsum/')).toBe('bought');
10
+ expect(routeOf('/konsum/', routes, '/konsum/')).toBeUndefined();
11
+ expect(routeOf('/konsum/other', routes, '/konsum/')).toBeUndefined();
12
+ expect(routeOf('/notes/bought', routes, '/konsum/')).toBeUndefined();
13
+ });
14
+
15
+ it('writes the address of a route', () => {
16
+ expect(pathOf('bought', '/konsum/')).toBe('/konsum/bought');
17
+ expect(pathOf(undefined, '/konsum/')).toBe('/konsum/');
18
+ });
19
+ });
@@ -0,0 +1,139 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useSyncExternalStore,
5
+ type MouseEvent,
6
+ } from 'react';
7
+ import { useApp } from '@sempods/app-sdk/react';
8
+ import { app } from './sempods.generated.ts';
9
+
10
+ // The app's views as addresses: the start page at the app's path and one
11
+ // route per entry in apps.json `routes` (for example /<id>/bought). Links,
12
+ // bookmarks and the back button work, and the published site answers every
13
+ // route without rewrites.
14
+
15
+ /** A route listed in apps.json; undefined is the start page. */
16
+ export type Route = (typeof app.routes)[number];
17
+
18
+ /** The route of an address; undefined for the start page and unknown paths. */
19
+ export function routeOf<R extends string>(
20
+ path: string,
21
+ routes: readonly R[],
22
+ base: string,
23
+ ): R | undefined {
24
+ if (!path.startsWith(base)) return undefined;
25
+ const segment = path.slice(base.length).replace(/\/$/, '');
26
+ return routes.find((route) => route === segment);
27
+ }
28
+
29
+ /** The address of a route; the start page is the app's path. */
30
+ export function pathOf(route: string | undefined, base: string): string {
31
+ return route === undefined ? base : `${base}${route}`;
32
+ }
33
+
34
+ // The address the app shows. It changes only through the functions below, so
35
+ // a declined navigation keeps the current view.
36
+ let shown = typeof location === 'object' ? location.pathname : '';
37
+ // The position of the shown entry in the session history, kept in its state,
38
+ // so a declined back or forward can return to it without a new entry.
39
+ let position: number =
40
+ typeof history === 'object' ? (history.state?.position ?? 0) : 0;
41
+ // Set while the router itself undoes a step, whose popstate it ignores.
42
+ let undoing = false;
43
+ const listeners = new Set<() => void>();
44
+ function show(path: string) {
45
+ shown = path;
46
+ for (const listener of listeners) listener();
47
+ }
48
+ function subscribe(listener: () => void) {
49
+ listeners.add(listener);
50
+ return () => void listeners.delete(listener);
51
+ }
52
+
53
+ /**
54
+ * Replaces the address without a new history entry. Pass it to the runtime
55
+ * (`replaceUrl`), so the return to the original route after a sign-in shows
56
+ * that route.
57
+ */
58
+ export function replaceUrl(url: string) {
59
+ history.replaceState({ position }, '', url);
60
+ show(location.pathname);
61
+ }
62
+
63
+ /**
64
+ * The current route, `go` to switch it and `link(route)`, the props of an
65
+ * `<a>` that switches to it. Switching, back and forward run
66
+ * under the SDK's leave policy: an unsaved edit asks first, and staying keeps
67
+ * the view, its address and the history. An unsent creation draft is not
68
+ * asked about; keep such input (a Composer) outside the switched views. Call it once, in the app's frame, and pass the
69
+ * route down.
70
+ */
71
+ export function useRoute() {
72
+ const path = useSyncExternalStore(subscribe, () => shown);
73
+ const route: Route | undefined = routeOf(path, app.routes, app.basePath);
74
+ const { navigate } = useApp();
75
+
76
+ useEffect(() => {
77
+ const onPop = () => {
78
+ if (undoing) {
79
+ undoing = false;
80
+ return;
81
+ }
82
+ const to = location.pathname;
83
+ const target: number = history.state?.position ?? 0;
84
+ void navigate(() => {
85
+ position = target;
86
+ show(to);
87
+ }).then((left) => {
88
+ // Staying: step back to the shown entry; the history stays intact.
89
+ if (left || target === position) return;
90
+ undoing = true;
91
+ history.go(position - target);
92
+ });
93
+ };
94
+ addEventListener('popstate', onPop);
95
+ return () => removeEventListener('popstate', onPop);
96
+ }, [navigate]);
97
+
98
+ // One address per view: unknown paths show the start page, and a trailing
99
+ // slash goes. The callback keeps its address until the runtime has read it.
100
+ useEffect(() => {
101
+ const normal = pathOf(route, app.basePath);
102
+ if (path !== normal && path !== `${app.basePath}callback`)
103
+ replaceUrl(normal + location.search + location.hash);
104
+ }, [path, route]);
105
+
106
+ const go = useCallback(
107
+ (next: Route | undefined) => {
108
+ if (next === route) return;
109
+ void navigate(() => {
110
+ position += 1;
111
+ history.pushState({ position }, '', pathOf(next, app.basePath));
112
+ show(location.pathname);
113
+ });
114
+ },
115
+ [navigate, route],
116
+ );
117
+ // Props for a link to a route: its own address, so it opens in a new tab or
118
+ // gets bookmarked, while a plain click switches in place through `go`
119
+ // instead of loading the page past the leave policy.
120
+ const link = useCallback(
121
+ (next: Route | undefined) => ({
122
+ href: pathOf(next, app.basePath),
123
+ onClick: (event: MouseEvent) => {
124
+ if (
125
+ event.button !== 0 ||
126
+ event.metaKey ||
127
+ event.ctrlKey ||
128
+ event.shiftKey ||
129
+ event.altKey
130
+ )
131
+ return;
132
+ event.preventDefault();
133
+ go(next);
134
+ },
135
+ }),
136
+ [go],
137
+ );
138
+ return { route, go, link };
139
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { app } from './sempods.generated.ts';
3
+ import { texts, uiLocale } from './texts.ts';
4
+
5
+ describe('texts', () => {
6
+ it('has every text in every language', () => {
7
+ const keys = Object.keys(texts.en);
8
+ for (const catalogue of [texts.en, texts.de]) {
9
+ expect(Object.keys(catalogue)).toEqual(keys);
10
+ for (const text of Object.values(catalogue)) expect(text).not.toBe('');
11
+ }
12
+ });
13
+
14
+ it('takes the language from the browser unless one is configured', () => {
15
+ expect(uiLocale('auto', ['de-AT', 'en'])).toEqual({
16
+ locale: 'de-AT',
17
+ language: 'de',
18
+ });
19
+ // An unsupported language keeps its regional formats with English texts.
20
+ expect(uiLocale('auto', ['fr-FR'])).toEqual({
21
+ locale: 'fr-FR',
22
+ language: 'en',
23
+ });
24
+ expect(uiLocale('de', ['en-US']).language).toBe('de');
25
+ // The generated configuration imports without a browser.
26
+ expect(['en', 'de']).toContain(uiLocale(app.language, []).language);
27
+ });
28
+ });
@@ -0,0 +1,35 @@
1
+ import { resolveLocale, type Language } from '@sempods/app-sdk';
2
+
3
+ /**
4
+ * The UI language and the regional formats. With "auto" in apps.json they
5
+ * come from the browser: German or English, otherwise English, with the
6
+ * browser's region for dates and numbers. A fixed language keeps its own.
7
+ */
8
+ export function uiLocale(
9
+ configured: string,
10
+ browser: readonly string[],
11
+ ): { readonly locale: string; readonly language: Language } {
12
+ return resolveLocale(configured === 'auto' ? undefined : configured, browser);
13
+ }
14
+
15
+ // The app's own texts in English and German, read in components with the
16
+ // SDK's useMessages(texts); SDK components bring their own, and SDK texts are
17
+ // reused where they fit (useSdkLocale().messages.controls). Both languages
18
+ // have every key, so TypeScript reports a missing translation. Add the app's
19
+ // texts here, never inline in components.
20
+ const en = {
21
+ start: 'Connected. Tell your assistant what this app should do.',
22
+ newVersion:
23
+ 'A new version is ready. It is used once every window of this app has been closed.',
24
+ menu: 'Menu',
25
+ };
26
+
27
+ export const texts: { readonly en: typeof en; readonly de: typeof en } = {
28
+ en,
29
+ de: {
30
+ start: 'Verbunden. Beschreibe deinem Assistenten, was diese App tun soll.',
31
+ newVersion:
32
+ 'Eine neue Version ist bereit. Sie wird verwendet, sobald alle Fenster dieser App geschlossen sind.',
33
+ menu: 'Menü',
34
+ },
35
+ };
@@ -0,0 +1,11 @@
1
+ /* Calm: graphite on white, quiet and neutral; the default.
2
+ The --sempods-* tokens colour the app and the SDK components alike, in
3
+ light and dark mode; each pair keeps at least 4.5:1 contrast. */
4
+ :root {
5
+ --sempods-bg: light-dark(#ffffff, #111315);
6
+ --sempods-text: light-dark(#1f2933, #e5e7eb);
7
+ --sempods-muted: light-dark(#6b7280, #9ca3af);
8
+ --sempods-line: light-dark(#e5e7eb, #2d3138);
9
+ --sempods-accent: light-dark(#1f2933, #e5e7eb);
10
+ --sempods-on-accent: light-dark(#ffffff, #111315);
11
+ }
@@ -0,0 +1,11 @@
1
+ /* Fresh: clear teal and cool greys, light and open.
2
+ The --sempods-* tokens colour the app and the SDK components alike, in
3
+ light and dark mode; each pair keeps at least 4.5:1 contrast. */
4
+ :root {
5
+ --sempods-bg: light-dark(#ffffff, #0e171d);
6
+ --sempods-text: light-dark(#11293a, #e1edf3);
7
+ --sempods-muted: light-dark(#58697a, #94a8b6);
8
+ --sempods-line: light-dark(#dce8ef, #22343f);
9
+ --sempods-accent: light-dark(#0a6680, #5ec6e4);
10
+ --sempods-on-accent: light-dark(#ffffff, #06212b);
11
+ }
@@ -0,0 +1,11 @@
1
+ /* Playful: violet and soft lilac, lively and friendly.
2
+ The --sempods-* tokens colour the app and the SDK components alike, in
3
+ light and dark mode; each pair keeps at least 4.5:1 contrast. */
4
+ :root {
5
+ --sempods-bg: light-dark(#ffffff, #14111c);
6
+ --sempods-text: light-dark(#241a3a, #ece6f8);
7
+ --sempods-muted: light-dark(#665e7c, #a49cbc);
8
+ --sempods-line: light-dark(#e9e4f3, #2e2742);
9
+ --sempods-accent: light-dark(#6a3bd2, #b59cff);
10
+ --sempods-on-accent: light-dark(#ffffff, #1b1036);
11
+ }
@@ -0,0 +1,11 @@
1
+ /* Warm: terracotta on cream, cosy and homely.
2
+ The --sempods-* tokens colour the app and the SDK components alike, in
3
+ light and dark mode; each pair keeps at least 4.5:1 contrast. */
4
+ :root {
5
+ --sempods-bg: light-dark(#fdfaf6, #1a1512);
6
+ --sempods-text: light-dark(#2b211b, #efe6dd);
7
+ --sempods-muted: light-dark(#74675c, #ab9d90);
8
+ --sempods-line: light-dark(#ece2d8, #3a2f28);
9
+ --sempods-accent: light-dark(#9c4128, #f0a184);
10
+ --sempods-on-accent: light-dark(#ffffff, #2a120a);
11
+ }
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * A bar fixed at the bottom of the screen, in thumb reach, for the input that
5
+ * is always there (for example "add an item"). The page leaves room for it,
6
+ * it sits above the home indicator, and with the skeleton's viewport setting
7
+ * (interactive-widget=resizes-content) it stays above the on-screen keyboard
8
+ * in browsers that support that. iOS Safari overlays the keyboard instead.
9
+ * Render it in the frame, outside views that tabs switch: switching views
10
+ * does not ask about an unsent creation draft, and unmounting loses it.
11
+ */
12
+ export function Composer({ children }: { readonly children: ReactNode }) {
13
+ return (
14
+ <div className="composer">
15
+ <div className="composer-content">{children}</div>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,94 @@
1
+ import type { ReactNode } from 'react';
2
+ import { useContexts, useSdkLocale } from '@sempods/app-sdk/react';
3
+ import { ChevronIcon } from './icons.tsx';
4
+ import { Menu } from './Menu.tsx';
5
+
6
+ /**
7
+ * The everyday switch for an app that works in one of several contexts at a
8
+ * time (for example lists): the app icon and the current context's name in
9
+ * place of the header's `<h1>`, opening a menu with the readable contexts,
10
+ * their access and a way to share more. The SDK's `useContexts` loads the
11
+ * contexts, selects a sensible one and switches under the leave policy; this
12
+ * component only shows them. Until a context is selected it shows the app's
13
+ * icon and `title` as the `<h1>`; then the `<h1>` names the app for screen
14
+ * readers only, outside the menu. Name things in the person's words: `label`
15
+ * "List", `shareMore` "Share more lists …".
16
+ */
17
+ export function ContextSwitch({
18
+ icon,
19
+ title,
20
+ label,
21
+ texts,
22
+ }: {
23
+ readonly icon: ReactNode;
24
+ /** The app's name. */
25
+ readonly title: string;
26
+ /** What a context is in this app, for example "List". */
27
+ readonly label: string;
28
+ readonly texts: { readonly shareMore: string };
29
+ }) {
30
+ const {
31
+ contexts,
32
+ selected,
33
+ select,
34
+ failed,
35
+ retry,
36
+ canShare,
37
+ share,
38
+ shareProblem,
39
+ } = useContexts();
40
+ const { messages } = useSdkLocale();
41
+ if (!selected)
42
+ return (
43
+ <h1>
44
+ {icon}
45
+ {title}
46
+ </h1>
47
+ );
48
+ // A failed reload keeps the known contexts and offers to try again; a
49
+ // failed request to share says why below the switch.
50
+ const actions = [
51
+ ...(failed
52
+ ? [{ label: messages.controls.retry, onSelect: () => void retry() }]
53
+ : []),
54
+ ...(canShare
55
+ ? [{ label: texts.shareMore, onSelect: () => void share() }]
56
+ : []),
57
+ ];
58
+ return (
59
+ <div className="context-switch-wrap">
60
+ <h1 className="visually-hidden">{title}</h1>
61
+ <Menu
62
+ className="context-switch"
63
+ align="start"
64
+ label={`${label}: ${selected.name}`}
65
+ trigger={
66
+ <>
67
+ {icon}
68
+ <span className="context-name">{selected.name}</span>
69
+ <ChevronIcon />
70
+ </>
71
+ }
72
+ items={[
73
+ ...contexts.map((context) => ({
74
+ label: context.name,
75
+ // The SDK's own words, as in its management dialog.
76
+ hint: context.writable
77
+ ? messages.controls.canChange
78
+ : messages.controls.canRead,
79
+ checked: context.iri === selected.iri,
80
+ onSelect: () => {
81
+ if (context.iri !== selected.iri) void select(context.iri);
82
+ },
83
+ })),
84
+ ...(actions.length > 0 ? [null, ...actions] : []),
85
+ ]}
86
+ />
87
+ {shareProblem && (
88
+ <p role="alert" className="context-switch-problem">
89
+ {shareProblem}
90
+ </p>
91
+ )}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,120 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type MouseEvent,
6
+ type ReactNode,
7
+ } from 'react';
8
+ import { CloseIcon } from './icons.tsx';
9
+
10
+ /**
11
+ * A modal dialog, a bottom sheet on phones, for example around an item's
12
+ * `ResourceEditor`. Escape, the × button and a press beside the dialog (on
13
+ * its backdrop) ask the owner to close it; it
14
+ * only goes away when the owner unmounts it, so a declined close (unsaved
15
+ * changes) keeps it open. The SDK's leave confirmation shows above it.
16
+ * "Pods & sharing" needs none: `AppAccess` with `onClose` brings its own.
17
+ */
18
+ export function Dialog({
19
+ label,
20
+ closeLabel,
21
+ onClose,
22
+ children,
23
+ }: {
24
+ readonly label: string;
25
+ readonly closeLabel: string;
26
+ /** Asked to close; unmount the dialog to close it, keep it to decline. */
27
+ readonly onClose: () => void | Promise<void>;
28
+ readonly children: ReactNode;
29
+ }) {
30
+ const dialog = useRef<HTMLDialogElement>(null);
31
+ // What had focus before the dialog opened, for example the item it shows.
32
+ const [opener] = useState(() => document.activeElement);
33
+ // Open as a modal once mounted; unmounting removes it. (Closing it in an
34
+ // effect cleanup would fire `close` under React's StrictMode re-run.)
35
+ // Removing an open dialog does not restore focus as close() would, so the
36
+ // cleanup returns it to the opener unless it has moved on elsewhere.
37
+ useEffect(() => {
38
+ const element = dialog.current;
39
+ if (element && !element.open) element.showModal();
40
+ return () => {
41
+ const focused = document.activeElement;
42
+ if (
43
+ opener instanceof HTMLElement &&
44
+ opener.isConnected &&
45
+ (!focused || focused === document.body || element?.contains(focused))
46
+ )
47
+ restoreFocus(opener);
48
+ };
49
+ }, [opener]);
50
+ // A press on the backdrop targets the dialog element outside its box. Both
51
+ // ends of the press count, so selecting text inside and releasing beside it
52
+ // does not close.
53
+ const beside = (event: MouseEvent) => {
54
+ const box = dialog.current?.getBoundingClientRect();
55
+ return (
56
+ event.target === dialog.current &&
57
+ !!box &&
58
+ (event.clientX < box.left ||
59
+ event.clientX > box.right ||
60
+ event.clientY < box.top ||
61
+ event.clientY > box.bottom)
62
+ );
63
+ };
64
+ const pressedBeside = useRef(false);
65
+ // Ask the owner; if it keeps the dialog, make sure it is open again.
66
+ const request = async () => {
67
+ await onClose();
68
+ const element = dialog.current;
69
+ if (element && !element.open) element.showModal();
70
+ };
71
+ return (
72
+ <dialog
73
+ ref={dialog}
74
+ className="dialog"
75
+ aria-label={label}
76
+ onCancel={(event) => {
77
+ // Escape: the browser would close the dialog before the owner decides.
78
+ event.preventDefault();
79
+ void request();
80
+ }}
81
+ // Browsers may still close it (a repeated Escape without a user
82
+ // activation); then the owner decides as well.
83
+ onClose={() => void request()}
84
+ onPointerDown={(event) => {
85
+ pressedBeside.current = beside(event);
86
+ }}
87
+ onClick={(event) => {
88
+ if (pressedBeside.current && beside(event)) void request();
89
+ pressedBeside.current = false;
90
+ }}
91
+ >
92
+ <button
93
+ className="dialog-close"
94
+ aria-label={closeLabel}
95
+ title={closeLabel}
96
+ onClick={() => void request()}
97
+ >
98
+ <CloseIcon />
99
+ </button>
100
+ <div className="dialog-body">{children}</div>
101
+ </dialog>
102
+ );
103
+ }
104
+
105
+ // The SDK keeps the page inert for a moment while a guarded change settles,
106
+ // for example clearing the selection that closed the dialog. Focus waits for
107
+ // it a few frames; each frame gives up once focus has moved on meanwhile.
108
+ function restoreFocus(opener: HTMLElement, frames = 10) {
109
+ if (!opener.isConnected) return;
110
+ if (!opener.closest('[inert]')) {
111
+ opener.focus();
112
+ return;
113
+ }
114
+ if (frames > 0)
115
+ requestAnimationFrame(() => {
116
+ const focused = document.activeElement;
117
+ if (!focused || focused === document.body)
118
+ restoreFocus(opener, frames - 1);
119
+ });
120
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ type RefObject,
9
+ } from 'react';
10
+ import { useDismiss } from './dismiss.ts';
11
+
12
+ export type MenuItem = {
13
+ readonly label: string;
14
+ /** A second, muted line, for example the access to a context. */
15
+ readonly hint?: string;
16
+ /** For a choice among several: marks the current one. */
17
+ readonly checked?: boolean;
18
+ readonly onSelect: () => void;
19
+ };
20
+
21
+ /**
22
+ * A button that opens a short list of actions or choices. It closes on a
23
+ * choice, Escape or a press outside; a choice and Escape return focus to the
24
+ * button.
25
+ * `null` in `items` draws a separator.
26
+ */
27
+ export function Menu({
28
+ label,
29
+ trigger,
30
+ items,
31
+ align = 'end',
32
+ button,
33
+ className,
34
+ }: {
35
+ /** The button's name for screen readers; also its tooltip. */
36
+ readonly label: string;
37
+ /** The button's content, usually an icon. */
38
+ readonly trigger: ReactNode;
39
+ readonly items: readonly (MenuItem | null)[];
40
+ /** Which edge of the button the list lines up with. */
41
+ readonly align?: 'start' | 'end';
42
+ /** The button, for example to return focus to it later. */
43
+ readonly button?: RefObject<HTMLButtonElement | null>;
44
+ readonly className?: string;
45
+ }) {
46
+ const [open, setOpen] = useState(false);
47
+ const container = useRef<HTMLDivElement>(null);
48
+ const own = useRef<HTMLButtonElement>(null);
49
+ const ref = button ?? own;
50
+ const close = useCallback(() => setOpen(false), []);
51
+ useDismiss(open, close, container, ref);
52
+ const list = useRef<HTMLDivElement>(null);
53
+ const choice = items.some((item) => item?.checked !== undefined);
54
+ // Focus moves into the list on opening; the arrow keys move within it.
55
+ useEffect(() => {
56
+ if (open) list.current?.querySelector('button')?.focus();
57
+ }, [open]);
58
+ const onKeyDown = (event: KeyboardEvent) => {
59
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
60
+ event.preventDefault();
61
+ const entries = [...(list.current?.querySelectorAll('button') ?? [])];
62
+ const at = entries.indexOf(document.activeElement as HTMLButtonElement);
63
+ const step = event.key === 'ArrowDown' ? 1 : -1;
64
+ entries[(at + step + entries.length) % entries.length]?.focus();
65
+ };
66
+
67
+ return (
68
+ <div className={`menu ${className ?? ''}`} ref={container}>
69
+ <button
70
+ ref={ref}
71
+ aria-label={label}
72
+ title={label}
73
+ aria-haspopup="menu"
74
+ aria-expanded={open}
75
+ onClick={() => setOpen(!open)}
76
+ >
77
+ {trigger}
78
+ </button>
79
+ {open && (
80
+ <div
81
+ role="menu"
82
+ ref={list}
83
+ className={`menu-items menu-${align}`}
84
+ onKeyDown={onKeyDown}
85
+ >
86
+ {items.map((item, i) =>
87
+ item ? (
88
+ <button
89
+ key={i}
90
+ role={item.checked === undefined ? 'menuitem' : 'menuitemradio'}
91
+ aria-checked={item.checked}
92
+ onClick={() => {
93
+ close();
94
+ // Focus goes back to the button before the action, which
95
+ // may move it on, for example into a dialog it opens.
96
+ ref.current?.focus();
97
+ item.onSelect();
98
+ }}
99
+ >
100
+ {choice && (
101
+ <span className="menu-check" aria-hidden="true">
102
+ {item.checked ? '✓' : ''}
103
+ </span>
104
+ )}
105
+ <span>
106
+ {item.label}
107
+ {item.hint && <small>{item.hint}</small>}
108
+ </span>
109
+ </button>
110
+ ) : (
111
+ <hr key={i} />
112
+ ),
113
+ )}
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,39 @@
1
+ import type { useRoute, Route } from '../routes.ts';
2
+
3
+ export type View = {
4
+ /** The route; undefined for the start page. */
5
+ readonly route: Route | undefined;
6
+ readonly label: string;
7
+ };
8
+
9
+ /**
10
+ * The app's views as tabs on one line: links with their own addresses, so
11
+ * they open in a new tab or get bookmarked; a plain click switches in place
12
+ * under the SDK's leave policy. Pass `route` and `link` from `useRoute()`.
13
+ */
14
+ export function Tabs({
15
+ label,
16
+ views,
17
+ current,
18
+ link,
19
+ }: {
20
+ /** The name of the navigation for screen readers. */
21
+ readonly label: string;
22
+ readonly views: readonly View[];
23
+ readonly current: Route | undefined;
24
+ readonly link: ReturnType<typeof useRoute>['link'];
25
+ }) {
26
+ return (
27
+ <nav aria-label={label} className="tabs">
28
+ {views.map(({ route, label }) => (
29
+ <a
30
+ key={route ?? ''}
31
+ {...link(route)}
32
+ aria-current={route === current ? 'page' : undefined}
33
+ >
34
+ {label}
35
+ </a>
36
+ ))}
37
+ </nav>
38
+ );
39
+ }