@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.
- package/CHANGELOG.md +200 -0
- package/README.md +6 -3
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +322 -111
- package/instructions/publish.md +13 -7
- package/instructions/setup.md +38 -23
- package/instructions/visual-check.md +43 -0
- package/package.json +3 -3
- package/scripts/build-site.mjs +15 -3
- package/scripts/check.mjs +3 -1
- package/scripts/icon.mjs +47 -0
- package/scripts/lib/apps.mjs +56 -2
- package/scripts/lib/generate.mjs +55 -14
- package/scripts/lib/icon.mjs +192 -0
- package/scripts/lib/overview.mjs +5 -3
- package/scripts/lib/page.mjs +137 -0
- package/scripts/lib/themes.mjs +58 -0
- package/scripts/migrate.mjs +44 -14
- package/scripts/new-app.mjs +43 -10
- package/scripts/sdk-update.mjs +16 -1
- package/shared/files/{f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 → 44c114d4d2c8b172f4757100f79e8a1a00d30c96ba2ad8f75c3373e52f6b849f} +2 -2
- package/shared/files/{31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 → 843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845} +3 -2
- package/shared/files/{1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 → e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43} +17 -2
- package/shared/snapshot.json +4 -4
- package/skeleton/app/NOTES.md +20 -2
- package/skeleton/app/index.html +7 -3
- package/skeleton/app/package.json +6 -6
- package/skeleton/app/src/App.tsx +53 -21
- package/skeleton/app/src/NewVersionNotice.tsx +6 -11
- package/skeleton/app/src/examples/ListScreen.tsx +176 -0
- package/skeleton/app/src/index.css +86 -13
- package/skeleton/app/src/main.tsx +3 -0
- package/skeleton/app/src/pwa.ts +1 -1
- package/skeleton/app/src/routes.test.ts +19 -0
- package/skeleton/app/src/routes.ts +139 -0
- package/skeleton/app/src/texts.test.ts +28 -0
- package/skeleton/app/src/texts.ts +35 -0
- package/skeleton/app/src/themes/calm.css +11 -0
- package/skeleton/app/src/themes/fresh.css +11 -0
- package/skeleton/app/src/themes/playful.css +11 -0
- package/skeleton/app/src/themes/warm.css +11 -0
- package/skeleton/app/src/ui/Composer.tsx +18 -0
- package/skeleton/app/src/ui/ContextSwitch.tsx +94 -0
- package/skeleton/app/src/ui/Dialog.tsx +120 -0
- package/skeleton/app/src/ui/Menu.tsx +118 -0
- package/skeleton/app/src/ui/Tabs.tsx +39 -0
- package/skeleton/app/src/ui/dismiss.ts +30 -0
- package/skeleton/app/src/ui/icons.tsx +53 -0
- package/skeleton/app/src/ui/ui.css +249 -0
- package/skeleton/app/vite.config.ts +4 -4
- package/skills/update/SKILL.md +4 -2
- package/skeleton/app/public/favicon.svg +0 -1
- package/skeleton/app/public/icon-192.png +0 -0
- package/skeleton/app/public/icon-512.png +0 -0
- package/skeleton/app/src/start.test.ts +0 -15
- 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';
|
package/skeleton/app/src/pwa.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Adapted from the sempods TypeScript SDK v0.
|
|
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
|
+
}
|