@sempods/apps 0.8.0 → 0.9.0-dev.1
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 +166 -0
- package/README.md +13 -6
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +145 -72
- package/instructions/publish.md +29 -11
- package/instructions/setup.md +2 -2
- package/instructions/visual-check.md +20 -2
- package/package.json +5 -3
- package/scripts/build-site.mjs +37 -9
- package/scripts/check-theme.mjs +398 -0
- package/scripts/check.mjs +16 -5
- package/scripts/icon.mjs +1 -1
- package/scripts/lib/apps.mjs +115 -27
- package/scripts/lib/generate.mjs +58 -28
- package/scripts/lib/json.mjs +6 -0
- package/scripts/lib/kit.mjs +81 -0
- package/scripts/lib/merge.mjs +12 -9
- package/scripts/lib/sdk.mjs +10 -2
- package/scripts/lib/themes.mjs +45 -19
- package/scripts/migrate.mjs +6 -1
- package/scripts/new-app.mjs +2 -7
- package/scripts/sdk-update.mjs +32 -10
- package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
- package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
- package/shared/snapshot.json +3 -3
- package/skeleton/app/package.json +3 -0
- package/skeleton/app/src/App.tsx +53 -21
- package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
- package/skeleton/app/src/examples/ListScreen.tsx +154 -56
- package/skeleton/app/src/index.css +10 -110
- package/skeleton/app/src/main.tsx +1 -3
- package/skeleton/app/src/places.test.ts +128 -0
- package/skeleton/app/src/places.ts +146 -0
- package/skeleton/app/src/texts.ts +8 -0
- package/skeleton/app/src/themes/calm.css +22 -8
- package/skeleton/app/src/themes/fresh.css +22 -8
- package/skeleton/app/src/themes/playful.css +22 -8
- package/skeleton/app/src/themes/warm.css +22 -8
- package/skeleton/app/vite.config.ts +12 -2
- package/skills/update/SKILL.md +6 -1
- package/update-policy.json +1 -1
- package/skeleton/app/src/routes.test.ts +0 -19
- package/skeleton/app/src/routes.ts +0 -139
- package/skeleton/app/src/ui/Composer.tsx +0 -18
- package/skeleton/app/src/ui/ContextSwitch.tsx +0 -94
- package/skeleton/app/src/ui/Dialog.tsx +0 -120
- package/skeleton/app/src/ui/Menu.tsx +0 -118
- package/skeleton/app/src/ui/Tabs.tsx +0 -39
- package/skeleton/app/src/ui/dismiss.ts +0 -30
- package/skeleton/app/src/ui/icons.tsx +0 -53
- package/skeleton/app/src/ui/ui.css +0 -249
package/scripts/sdk-update.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// One SDK release for the root and every registered app: the manifests name
|
|
3
|
-
// it, pnpm installs it, and the update
|
|
4
|
-
//
|
|
3
|
+
// it for both SDK packages and the UI kit, pnpm installs it, and the update
|
|
4
|
+
// counts only once node_modules has it and check passes.
|
|
5
|
+
// Usage: pnpm run sdk-update <version|latest>
|
|
5
6
|
import {
|
|
6
7
|
existsSync,
|
|
7
8
|
readFileSync,
|
|
@@ -11,17 +12,20 @@ import {
|
|
|
11
12
|
import { join, resolve } from 'node:path';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
import { parseArgs } from 'node:util';
|
|
15
|
+
import semver from 'semver';
|
|
14
16
|
import { readApps } from './lib/apps.mjs';
|
|
15
|
-
import { formatJson, ifPresent, readJson } from './lib/json.mjs';
|
|
17
|
+
import { formatJson, ifPresent, readJson, sortedKeys } from './lib/json.mjs';
|
|
16
18
|
import { migrationState } from './migrate.mjs';
|
|
17
19
|
import { repositoryRoot } from './lib/paths.mjs';
|
|
18
20
|
import { INSTALL, pnpm, pnpmJson, scriptArgs } from './lib/pnpm.mjs';
|
|
19
21
|
import {
|
|
22
|
+
appSdk,
|
|
20
23
|
compareVersions,
|
|
21
24
|
EXACT_VERSION,
|
|
22
25
|
PENDING_UPDATE,
|
|
23
26
|
REFERENCE,
|
|
24
27
|
SDK,
|
|
28
|
+
UI_KIT,
|
|
25
29
|
} from './lib/sdk.mjs';
|
|
26
30
|
|
|
27
31
|
|
|
@@ -39,6 +43,7 @@ export function registryLookup(root, name, version) {
|
|
|
39
43
|
`${name}@${version}`,
|
|
40
44
|
'version',
|
|
41
45
|
'dependencies',
|
|
46
|
+
'peerDependencies',
|
|
42
47
|
'--json',
|
|
43
48
|
]);
|
|
44
49
|
} catch (error) {
|
|
@@ -100,21 +105,37 @@ export function planUpdate(
|
|
|
100
105
|
throw new Error(
|
|
101
106
|
`${SDK[0]}@${version} must depend on exactly ${SDK[1]}@${version}; found ${JSON.stringify(app.dependencies?.[SDK[1]])}`,
|
|
102
107
|
);
|
|
108
|
+
// The UI kit is released with the SDK, at the same version.
|
|
109
|
+
const kit = lookup(UI_KIT, version);
|
|
110
|
+
if (kit?.version !== version)
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${UI_KIT}@${version} is missing or returned an unexpected version; the UI kit is released with the SDK since 0.7.0`,
|
|
113
|
+
);
|
|
114
|
+
const peer = kit.peerDependencies?.[SDK[0]];
|
|
115
|
+
if (!peer || !semver.satisfies(version, peer, { includePrerelease: true }))
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${UI_KIT}@${version} must support ${SDK[0]}@${version}; it asks for ${JSON.stringify(peer)}`,
|
|
118
|
+
);
|
|
103
119
|
|
|
120
|
+
// The root declares every SDK package and gains the UI kit when it lacks
|
|
121
|
+
// it; an app moves only what it declares.
|
|
104
122
|
const entries = [
|
|
105
|
-
['package.json', 'devDependencies'],
|
|
123
|
+
['package.json', 'devDependencies', true],
|
|
106
124
|
...readApps(root).apps.map(({ id }) => [
|
|
107
125
|
`apps/${id}/package.json`,
|
|
108
126
|
'dependencies',
|
|
127
|
+
false,
|
|
109
128
|
]),
|
|
110
129
|
];
|
|
111
130
|
const changes = [];
|
|
112
|
-
for (const [file, section] of entries) {
|
|
131
|
+
for (const [file, section, isRoot] of entries) {
|
|
113
132
|
const path = join(root, file);
|
|
114
133
|
const original = readFileSync(path, 'utf8');
|
|
115
134
|
const manifest = JSON.parse(original);
|
|
116
|
-
|
|
135
|
+
const names = isRoot ? SDK : appSdk(manifest[section]);
|
|
136
|
+
for (const name of names) {
|
|
117
137
|
const current = manifest[section]?.[name];
|
|
138
|
+
if (name === UI_KIT && current === undefined) continue;
|
|
118
139
|
if (!EXACT_VERSION.test(current ?? ''))
|
|
119
140
|
throw new Error(
|
|
120
141
|
`${file}: ${section}.${name} must already be an exact semver`,
|
|
@@ -124,8 +145,9 @@ export function planUpdate(
|
|
|
124
145
|
`${file}: downgrade from ${current} to ${version} requires --allow-downgrade`,
|
|
125
146
|
);
|
|
126
147
|
}
|
|
127
|
-
if (
|
|
128
|
-
for (const name of
|
|
148
|
+
if (names.every((name) => manifest[section]?.[name] === version)) continue;
|
|
149
|
+
for (const name of names) manifest[section][name] = version;
|
|
150
|
+
manifest[section] = sortedKeys(manifest[section]);
|
|
129
151
|
changes.push({
|
|
130
152
|
path,
|
|
131
153
|
original,
|
|
@@ -136,7 +158,7 @@ export function planUpdate(
|
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
|
|
139
|
-
/** Whether node_modules has
|
|
161
|
+
/** Whether node_modules has the SDK packages at `version` and the reference. */
|
|
140
162
|
export function installedStateComplete(root, version) {
|
|
141
163
|
try {
|
|
142
164
|
return (
|
|
@@ -187,7 +209,7 @@ export function applyUpdate(
|
|
|
187
209
|
run(INSTALL);
|
|
188
210
|
if (!installed())
|
|
189
211
|
throw new Error(
|
|
190
|
-
`node_modules does not have
|
|
212
|
+
`node_modules does not have ${SDK.join(', ')} at ${plan.version} with ${REFERENCE}; fix the cause and run the same command again`,
|
|
191
213
|
);
|
|
192
214
|
run(['run', 'check']);
|
|
193
215
|
rmSync(pending);
|
|
@@ -50,20 +50,23 @@ updates:
|
|
|
50
50
|
- dependency-name: '@types/node'
|
|
51
51
|
update-types:
|
|
52
52
|
- 'version-update:semver-major'
|
|
53
|
-
# A dependency joins the first group it matches.
|
|
54
|
-
# together in one PR; the tooling has its own, since it brings
|
|
55
|
-
# migration (update skill). Security updates move only affected
|
|
56
|
-
# their SDK group joins advisories for
|
|
57
|
-
# completed with sdk-update (Update the SDK in the app
|
|
53
|
+
# A dependency joins the first group it matches. The SDK packages and the
|
|
54
|
+
# UI kit move together in one PR; the tooling has its own, since it brings
|
|
55
|
+
# a starter migration (update skill). Security updates move only affected
|
|
56
|
+
# packages: their SDK group joins advisories for several, and a PR that
|
|
57
|
+
# moves only some is completed with sdk-update (Update the SDK in the app
|
|
58
|
+
# workflow).
|
|
58
59
|
groups:
|
|
59
60
|
sempods-sdk:
|
|
60
61
|
patterns:
|
|
61
62
|
- '@sempods/app-sdk'
|
|
63
|
+
- '@sempods/app-ui'
|
|
62
64
|
- '@sempods/client-sdk'
|
|
63
65
|
sempods-sdk-security:
|
|
64
66
|
applies-to: 'security-updates'
|
|
65
67
|
patterns:
|
|
66
68
|
- '@sempods/app-sdk'
|
|
69
|
+
- '@sempods/app-ui'
|
|
67
70
|
- '@sempods/client-sdk'
|
|
68
71
|
sempods-apps:
|
|
69
72
|
patterns:
|
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
"configure-site": "sempods-apps configure-site",
|
|
15
15
|
"build-site": "sempods-apps build-site",
|
|
16
16
|
"check": "sempods-apps check",
|
|
17
|
+
"check:theme": "sempods-apps check-theme",
|
|
17
18
|
"sdk-update": "sempods-apps sdk-update",
|
|
18
19
|
"update": "sempods-apps update",
|
|
19
20
|
"migrate": "sempods-apps migrate"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
|
-
"@sempods/app-sdk": "0.
|
|
23
|
+
"@sempods/app-sdk": "0.7.0-dev.2",
|
|
24
|
+
"@sempods/app-ui": "0.7.0-dev.2",
|
|
23
25
|
"@sempods/apps": "0.0.0",
|
|
24
|
-
"@sempods/client-sdk": "0.
|
|
26
|
+
"@sempods/client-sdk": "0.7.0-dev.2"
|
|
25
27
|
}
|
|
26
28
|
}
|
package/shared/snapshot.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": 1,
|
|
3
|
-
"revision": "
|
|
3
|
+
"revision": "e49f59f05c9dbe752680cd6986a428b544398e8bef7fe3d555111f0054cb46a5",
|
|
4
4
|
"files": {
|
|
5
5
|
".agents/skills/app-workflow/SKILL.md": "0221b70340152ba3cc615342254603b29b89c4d7d944d7b3b3e647873bdcefa0",
|
|
6
6
|
".agents/skills/update/SKILL.md": "d3ca3a13c6524a2ee9382977ddc0539790813225f366306024a58c957a4716ee",
|
|
7
7
|
".claude/skills/app-workflow/SKILL.md": "0221b70340152ba3cc615342254603b29b89c4d7d944d7b3b3e647873bdcefa0",
|
|
8
8
|
".claude/skills/update/SKILL.md": "d3ca3a13c6524a2ee9382977ddc0539790813225f366306024a58c957a4716ee",
|
|
9
9
|
".github/codeql/codeql-config.yml": "528d7544990228d6d66fa62423775338fe1716326fc68371fb2665aee86e39b0",
|
|
10
|
-
".github/dependabot.yml": "
|
|
10
|
+
".github/dependabot.yml": "3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334",
|
|
11
11
|
".github/workflows/check.yml": "8b7567319e18694aeca8722107281a760e3100239c6d0f548acb71e1806fa172",
|
|
12
12
|
".github/workflows/codeql.yml": "7a7b7c820db3a2946fd2b3148972ada8073bcc5752f03793ea2069f5b7a3f260",
|
|
13
13
|
".gitignore": "c2a4b026a87f6c8e7473e7fc456c6020b63c7e675087f6b58013ed5163c2f6ee",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"apps.json": "9945f9bd573e5af8aad2e65cc8b4284fbb1d7e8b96c4efdd42eeb49132fb4327",
|
|
20
20
|
"apps/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
21
21
|
"docs/start.md": "e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43",
|
|
22
|
-
"package.json": "
|
|
22
|
+
"package.json": "5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43",
|
|
23
23
|
"pnpm-workspace.yaml": "4660ca1887261ae658c7f587190e2b71ac91d15b1b6c4cf04dec823fdd0da654"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -11,15 +11,18 @@
|
|
|
11
11
|
"preview": "vite preview"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
+
"lucide-react": "1.65.0",
|
|
14
15
|
"react": "19.3.1",
|
|
15
16
|
"react-dom": "19.3.1"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
19
|
+
"@tailwindcss/vite": "4.3.3",
|
|
18
20
|
"@types/node": "24.19.2",
|
|
19
21
|
"@types/react": "19.3.0",
|
|
20
22
|
"@types/react-dom": "19.3.0",
|
|
21
23
|
"@vitejs/plugin-react": "6.1.2",
|
|
22
24
|
"oxlint": "1.89.0",
|
|
25
|
+
"tailwindcss": "4.3.3",
|
|
23
26
|
"typescript": "7.0.2",
|
|
24
27
|
"vite": "8.3.5",
|
|
25
28
|
"vite-plugin-pwa": "2.0.0",
|
package/skeleton/app/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from 'react';
|
|
2
2
|
import { createBrowserRuntime } from '@sempods/app-sdk';
|
|
3
3
|
import {
|
|
4
4
|
AccessNotice,
|
|
@@ -9,16 +9,25 @@ import {
|
|
|
9
9
|
useMessages,
|
|
10
10
|
useSdkLocale,
|
|
11
11
|
} from '@sempods/app-sdk/react';
|
|
12
|
+
import {
|
|
13
|
+
ActionButton,
|
|
14
|
+
HeaderMenu,
|
|
15
|
+
ScreenDialog,
|
|
16
|
+
replaceUrl,
|
|
17
|
+
useNav,
|
|
18
|
+
} from '@sempods/app-ui';
|
|
12
19
|
import { NewVersionNotice } from './NewVersionNotice.tsx';
|
|
13
|
-
import {
|
|
20
|
+
import { inApp, places } from './places.ts';
|
|
14
21
|
import { app, runtimeOptions } from './sempods.generated.ts';
|
|
15
22
|
import { texts, uiLocale } from './texts.ts';
|
|
16
|
-
import { MenuIcon } from './ui/icons.tsx';
|
|
17
|
-
import { Menu } from './ui/Menu.tsx';
|
|
18
23
|
|
|
19
24
|
// One runtime for the app's lifetime, created outside rendering. Its return
|
|
20
|
-
// after a sign-in goes through the router, which then shows that
|
|
21
|
-
|
|
25
|
+
// after a sign-in goes through the router, which then shows that address,
|
|
26
|
+
// always one in the app.
|
|
27
|
+
const runtime = createBrowserRuntime({
|
|
28
|
+
...runtimeOptions,
|
|
29
|
+
replaceUrl: (url) => replaceUrl(inApp(url)),
|
|
30
|
+
});
|
|
22
31
|
|
|
23
32
|
// One UI language for the app's texts, the SDK components and the page.
|
|
24
33
|
const { locale, language } = uiLocale(app.language, navigator.languages);
|
|
@@ -28,7 +37,7 @@ document.documentElement.lang = language;
|
|
|
28
37
|
if (import.meta.hot) import.meta.hot.dispose(() => runtime.dispose());
|
|
29
38
|
|
|
30
39
|
// The app icon (public/favicon.svg), decorative next to the app's name.
|
|
31
|
-
const icon = <img src={`${app.basePath}favicon.svg`} alt="" />;
|
|
40
|
+
const icon = <img src={`${app.basePath}favicon.svg`} alt="" className="size-7" />;
|
|
32
41
|
|
|
33
42
|
export default function App({
|
|
34
43
|
registration,
|
|
@@ -46,29 +55,34 @@ export default function App({
|
|
|
46
55
|
// The app owns its layout: a header with the app's icon and name, its icon
|
|
47
56
|
// actions and a menu; AppAccess beside the content for sign-in and recovery,
|
|
48
57
|
// and as a dialog for managing Pods and sharing from the menu. An app that
|
|
49
|
-
// switches between contexts (lists) puts
|
|
50
|
-
// h1.
|
|
58
|
+
// switches between contexts (lists) puts src/examples/ContextSwitch.tsx in
|
|
59
|
+
// place of the h1.
|
|
51
60
|
function Frame() {
|
|
61
|
+
// The shown place of the app's address model (places.ts). Call it once,
|
|
62
|
+
// here, and pass `nav` to the screens that link or switch.
|
|
63
|
+
const nav = useNav(places);
|
|
52
64
|
const [managing, setManaging] = useState(false);
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
// `pod` is the active signed-in Pod; a saved connection that needs a new
|
|
66
|
+
// sign-in has none.
|
|
67
|
+
const { connections, pod } = useAppState();
|
|
55
68
|
const { messages } = useSdkLocale();
|
|
56
69
|
const t = useMessages(texts);
|
|
57
70
|
const header = connections.length > 0;
|
|
71
|
+
const { place } = nav;
|
|
72
|
+
const toStart = () => nav.go({ kind: 'start' }, { replace: true });
|
|
58
73
|
return (
|
|
59
|
-
<main>
|
|
74
|
+
<main className="mx-auto max-w-3xl p-4">
|
|
60
75
|
{header && (
|
|
61
|
-
<header>
|
|
62
|
-
<h1>
|
|
76
|
+
<header className="flex flex-wrap items-center justify-between gap-2">
|
|
77
|
+
<h1 className="flex items-center gap-2 text-2xl font-semibold">
|
|
63
78
|
{icon}
|
|
64
79
|
{app.title}
|
|
65
80
|
</h1>
|
|
66
|
-
<div className="
|
|
67
|
-
{/* The app's own icon actions go here
|
|
68
|
-
|
|
81
|
+
<div className="flex items-center gap-2">
|
|
82
|
+
{/* The app's own icon actions go here, as
|
|
83
|
+
<ActionButton variant="ghost" shape="round-sm" label="…">. */}
|
|
84
|
+
<HeaderMenu
|
|
69
85
|
label={t.menu}
|
|
70
|
-
trigger={<MenuIcon />}
|
|
71
|
-
button={menuButton}
|
|
72
86
|
items={[
|
|
73
87
|
{
|
|
74
88
|
label: messages.controls.dataAccess,
|
|
@@ -88,12 +102,30 @@ function Frame() {
|
|
|
88
102
|
headingLevel={header ? 2 : 1}
|
|
89
103
|
open={managing}
|
|
90
104
|
onClose={() => setManaging(false)}
|
|
91
|
-
focusTarget={menuButton}
|
|
92
105
|
/>
|
|
93
106
|
<AccessNotice only="write" />
|
|
94
107
|
<TargetScreen>
|
|
95
108
|
<Start />
|
|
96
109
|
</TargetScreen>
|
|
110
|
+
{/* An explicit page once the person is signed in; a modal before that
|
|
111
|
+
would cover the sign-in, and closing it would lose the link. */}
|
|
112
|
+
{place.kind === 'not-found' && pod && (
|
|
113
|
+
<ScreenDialog
|
|
114
|
+
title={t.notFound}
|
|
115
|
+
open
|
|
116
|
+
onRequestClose={toStart}
|
|
117
|
+
>
|
|
118
|
+
<div className="grid gap-4">
|
|
119
|
+
<p>{place.reason === 'invalid' ? t.invalidLink : t.notFoundText}</p>
|
|
120
|
+
<ActionButton
|
|
121
|
+
shape="pill"
|
|
122
|
+
onPress={toStart}
|
|
123
|
+
>
|
|
124
|
+
{t.toStart}
|
|
125
|
+
</ActionButton>
|
|
126
|
+
</div>
|
|
127
|
+
</ScreenDialog>
|
|
128
|
+
)}
|
|
97
129
|
</main>
|
|
98
130
|
);
|
|
99
131
|
}
|
|
@@ -101,5 +133,5 @@ function Frame() {
|
|
|
101
133
|
// Replace this screen with your app. It renders once a Pod context is chosen.
|
|
102
134
|
function Start() {
|
|
103
135
|
const t = useMessages(texts);
|
|
104
|
-
return <p>{t.start}</p>;
|
|
136
|
+
return <p className="mt-6">{t.start}</p>;
|
|
105
137
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// An example, not mounted: the everyday switch for an app that works in one
|
|
2
|
+
// of several contexts at a time (for example lists), built with the UI kit's
|
|
3
|
+
// menu parts. Copy it next to App.tsx and render it in place of the header's
|
|
4
|
+
// <h1>, with the app's icon and title.
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
import { useContexts, useSdkLocale } from '@sempods/app-sdk/react';
|
|
7
|
+
import {
|
|
8
|
+
Button,
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuGroup,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from '@sempods/app-ui';
|
|
15
|
+
import { ChevronDownIcon } from 'lucide-react';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The app icon and the current context's name in place of the header's
|
|
19
|
+
* `<h1>`, opening a menu with the readable contexts, their access and a way
|
|
20
|
+
* to share more. The SDK's `useContexts` loads the contexts, selects a
|
|
21
|
+
* sensible one and switches under the leave policy; this component only shows
|
|
22
|
+
* them. Until a context is selected it shows the app's icon and `title` as the
|
|
23
|
+
* `<h1>`; then the `<h1>` names the app for screen readers only, outside the
|
|
24
|
+
* menu. Name things in the person's words: `label` "List", `shareMore`
|
|
25
|
+
* "Share more lists …".
|
|
26
|
+
*/
|
|
27
|
+
export function ContextSwitch({
|
|
28
|
+
icon,
|
|
29
|
+
title,
|
|
30
|
+
label,
|
|
31
|
+
texts,
|
|
32
|
+
}: {
|
|
33
|
+
readonly icon: ReactNode;
|
|
34
|
+
/** The app's name. */
|
|
35
|
+
readonly title: string;
|
|
36
|
+
/** What a context is in this app, for example "List". */
|
|
37
|
+
readonly label: string;
|
|
38
|
+
readonly texts: { readonly shareMore: string };
|
|
39
|
+
}) {
|
|
40
|
+
const {
|
|
41
|
+
contexts,
|
|
42
|
+
selected,
|
|
43
|
+
select,
|
|
44
|
+
failed,
|
|
45
|
+
retry,
|
|
46
|
+
canShare,
|
|
47
|
+
share,
|
|
48
|
+
shareProblem,
|
|
49
|
+
} = useContexts();
|
|
50
|
+
const { messages } = useSdkLocale();
|
|
51
|
+
if (!selected)
|
|
52
|
+
return (
|
|
53
|
+
<h1 className="flex items-center gap-2 text-2xl font-semibold">
|
|
54
|
+
{icon}
|
|
55
|
+
{title}
|
|
56
|
+
</h1>
|
|
57
|
+
);
|
|
58
|
+
return (
|
|
59
|
+
<div className="grid min-w-0 gap-1">
|
|
60
|
+
<h1 className="sr-only">{title}</h1>
|
|
61
|
+
<DropdownMenuTrigger>
|
|
62
|
+
<Button
|
|
63
|
+
variant="ghost"
|
|
64
|
+
className="h-11 max-w-full justify-start gap-2 px-2 text-xl font-semibold"
|
|
65
|
+
aria-label={`${label}: ${selected.name}`}
|
|
66
|
+
>
|
|
67
|
+
{icon}
|
|
68
|
+
<span className="truncate">{selected.name}</span>
|
|
69
|
+
<ChevronDownIcon />
|
|
70
|
+
</Button>
|
|
71
|
+
<DropdownMenu placement="bottom start" className="min-w-56">
|
|
72
|
+
{/* The contexts as checked choices; the actions below are not. */}
|
|
73
|
+
<DropdownMenuGroup
|
|
74
|
+
aria-label={label}
|
|
75
|
+
selectionMode="single"
|
|
76
|
+
selectedKeys={[selected.iri]}
|
|
77
|
+
>
|
|
78
|
+
{contexts.map((context) => (
|
|
79
|
+
<DropdownMenuItem
|
|
80
|
+
key={context.iri}
|
|
81
|
+
id={context.iri}
|
|
82
|
+
className="min-h-11"
|
|
83
|
+
textValue={context.name}
|
|
84
|
+
onAction={() => {
|
|
85
|
+
if (context.iri !== selected.iri) void select(context.iri);
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<span className="grid">
|
|
89
|
+
{context.name}
|
|
90
|
+
{/* The SDK's own words, as in its management dialog. */}
|
|
91
|
+
<span className="text-sm text-muted-foreground">
|
|
92
|
+
{context.writable
|
|
93
|
+
? messages.controls.canChange
|
|
94
|
+
: messages.controls.canRead}
|
|
95
|
+
</span>
|
|
96
|
+
</span>
|
|
97
|
+
</DropdownMenuItem>
|
|
98
|
+
))}
|
|
99
|
+
</DropdownMenuGroup>
|
|
100
|
+
{(failed || canShare) && <DropdownMenuSeparator />}
|
|
101
|
+
{/* A failed reload keeps the known contexts and offers to try
|
|
102
|
+
again. */}
|
|
103
|
+
{failed && (
|
|
104
|
+
<DropdownMenuItem
|
|
105
|
+
id="retry"
|
|
106
|
+
className="min-h-11"
|
|
107
|
+
onAction={() => void retry()}
|
|
108
|
+
>
|
|
109
|
+
{messages.controls.retry}
|
|
110
|
+
</DropdownMenuItem>
|
|
111
|
+
)}
|
|
112
|
+
{canShare && (
|
|
113
|
+
<DropdownMenuItem
|
|
114
|
+
id="share"
|
|
115
|
+
className="min-h-11"
|
|
116
|
+
onAction={() => void share()}
|
|
117
|
+
>
|
|
118
|
+
{texts.shareMore}
|
|
119
|
+
</DropdownMenuItem>
|
|
120
|
+
)}
|
|
121
|
+
</DropdownMenu>
|
|
122
|
+
</DropdownMenuTrigger>
|
|
123
|
+
{/* A failed request to share says why below the switch. */}
|
|
124
|
+
{shareProblem && (
|
|
125
|
+
<p role="alert" className="text-sm text-destructive">
|
|
126
|
+
{shareProblem}
|
|
127
|
+
</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|