@mdbase-dev/ui 0.1.0-beta.147 → 0.1.0-beta.148
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/README.md +9 -0
- package/css/controls.css +7 -0
- package/dist/CollectionModeControl.d.ts +11 -0
- package/dist/CollectionModeControl.js +290 -0
- package/dist/collections/CollectionSwitcher.d.ts +31 -6
- package/dist/collections/CollectionSwitcher.js +36 -17
- package/dist/collections/SetupReview.js +48 -7
- package/dist/collections/mount.d.ts +2 -1
- package/dist/locales/de/messages.json +1 -1
- package/dist/locales/en/messages.json +1 -1
- package/dist/locales/es/messages.json +1 -1
- package/dist/locales/fr/messages.json +1 -1
- package/dist/locales/it/messages.json +1 -1
- package/dist/locales/ja/messages.json +1 -1
- package/dist/locales/ko/messages.json +1 -1
- package/dist/locales/pt/messages.json +1 -1
- package/dist/locales/ru/messages.json +1 -1
- package/dist/locales/zh/messages.json +1 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -102,6 +102,15 @@ import "@mdbase-dev/ui/collections.css";
|
|
|
102
102
|
switcher: a popover, and a bottom sheet below 640px. `icon` puts the app's
|
|
103
103
|
icon before the name, or `icon="mark"` the open collection's initials. In
|
|
104
104
|
the `icon` variant it replaces the folder glyph.
|
|
105
|
+
- Pass `open` and `onOpenChange` to control the switcher; `onOpenChange` is
|
|
106
|
+
called for every open and close it makes itself, controlled or not.
|
|
107
|
+
`variant="none"` renders no trigger, for an app that opens the switcher from
|
|
108
|
+
its own control, such as an item in an overflow menu. It requires
|
|
109
|
+
`anchorRef`, an element that stays mounted while the switcher is open (the
|
|
110
|
+
menu's button, say): the popover is placed against it, and focus returns to
|
|
111
|
+
it when the switcher or a row dialog opened from it closes. Close your menu
|
|
112
|
+
without moving focus back to that button; the switcher takes focus as it
|
|
113
|
+
opens.
|
|
105
114
|
- `rowDetail(row)` adds a second line to each collection row, such as where it
|
|
106
115
|
lives or which account it belongs to. `row.entry` is the SDK's entry.
|
|
107
116
|
- `sections` lists collections the app knows about and the SDK doesn't (local
|
package/css/controls.css
CHANGED
|
@@ -786,3 +786,10 @@ textarea.mdbase-field {
|
|
|
786
786
|
transition: none;
|
|
787
787
|
}
|
|
788
788
|
}
|
|
789
|
+
|
|
790
|
+
/* Mode changes stay inline; progress is text, never an optimistic toggle. */
|
|
791
|
+
.mdbase-collection-mode { display: grid; gap: var(--space-3); min-inline-size: 0; }
|
|
792
|
+
.mdbase-collection-mode :is(h3, p, ul) { margin-block: 0; }
|
|
793
|
+
.mdbase-collection-mode h3 { font-size: var(--font-size-section); font-weight: var(--font-weight-medium); }
|
|
794
|
+
.mdbase-collection-mode > div:last-child { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
|
795
|
+
.mdbase-collection-mode code { overflow-wrap: anywhere; }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Mode = "private" | "cloud_copy";
|
|
2
|
+
/** Owner controls only. Visibility follows actual executor registration, not a
|
|
3
|
+
* mode flag. Browser intent cannot produce native key/source consent. */
|
|
4
|
+
export declare function CollectionModeControl({ collectionId, load, change, cancel, native }: {
|
|
5
|
+
collectionId: string;
|
|
6
|
+
load(): Promise<unknown>;
|
|
7
|
+
change(target: Mode, requestId: string): Promise<unknown>;
|
|
8
|
+
native?: boolean;
|
|
9
|
+
cancel?(target: Mode, requestId: string): Promise<unknown>;
|
|
10
|
+
}): import("react").JSX.Element | null;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { i18n, useLocaleRevision } from "./i18n.js";
|
|
4
|
+
function read(value) {
|
|
5
|
+
if (!value || typeof value !== "object") return null;
|
|
6
|
+
const v = value;
|
|
7
|
+
if (v.execution_available !== true || v.current_mode !== "private" && v.current_mode !== "cloud_copy") return null;
|
|
8
|
+
if (v.change !== null) {
|
|
9
|
+
if (!v.change || typeof v.change !== "object") return null;
|
|
10
|
+
const c = v.change;
|
|
11
|
+
if (c.target_mode !== "private" && c.target_mode !== "cloud_copy" || typeof c.phase !== "string") return null;
|
|
12
|
+
}
|
|
13
|
+
return v;
|
|
14
|
+
}
|
|
15
|
+
/** Owner controls only. Visibility follows actual executor registration, not a
|
|
16
|
+
* mode flag. Browser intent cannot produce native key/source consent. */
|
|
17
|
+
export function CollectionModeControl({
|
|
18
|
+
collectionId,
|
|
19
|
+
load,
|
|
20
|
+
change,
|
|
21
|
+
cancel,
|
|
22
|
+
native = false
|
|
23
|
+
}) {
|
|
24
|
+
useLocaleRevision();
|
|
25
|
+
const [status, setStatus] = useState(null);
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const [failure, setFailure] = useState(false);
|
|
28
|
+
const [step, setStep] = useState(null);
|
|
29
|
+
const intent = useRef(null);
|
|
30
|
+
const generation = useRef(0);
|
|
31
|
+
// Callback identity changes with parent renders; it must not restart I/O.
|
|
32
|
+
const callbacks = useRef({
|
|
33
|
+
load,
|
|
34
|
+
change,
|
|
35
|
+
cancel
|
|
36
|
+
});
|
|
37
|
+
callbacks.current = {
|
|
38
|
+
load,
|
|
39
|
+
change,
|
|
40
|
+
cancel
|
|
41
|
+
};
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const active = ++generation.current;
|
|
44
|
+
setStatus(null);
|
|
45
|
+
setStep(null);
|
|
46
|
+
setFailure(false);
|
|
47
|
+
setBusy(false);
|
|
48
|
+
intent.current = null;
|
|
49
|
+
void callbacks.current.load().then(value => {
|
|
50
|
+
if (generation.current === active) setStatus(read(value));
|
|
51
|
+
}).catch(() => {});
|
|
52
|
+
return () => {
|
|
53
|
+
++generation.current;
|
|
54
|
+
};
|
|
55
|
+
}, [collectionId]);
|
|
56
|
+
async function refresh() {
|
|
57
|
+
const active = generation.current;
|
|
58
|
+
setBusy(true);
|
|
59
|
+
setFailure(false);
|
|
60
|
+
try {
|
|
61
|
+
const next = read(await callbacks.current.load());
|
|
62
|
+
if (active !== generation.current) return;
|
|
63
|
+
setStatus(next);
|
|
64
|
+
if (next && !next.change) {
|
|
65
|
+
setStep(null);
|
|
66
|
+
intent.current = null;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
if (active === generation.current) setFailure(true);
|
|
70
|
+
} finally {
|
|
71
|
+
if (active === generation.current) setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function commit(target) {
|
|
75
|
+
const active = generation.current;
|
|
76
|
+
if (intent.current?.target !== target) intent.current = {
|
|
77
|
+
target,
|
|
78
|
+
id: crypto.randomUUID()
|
|
79
|
+
};
|
|
80
|
+
setBusy(true);
|
|
81
|
+
setFailure(false);
|
|
82
|
+
try {
|
|
83
|
+
const result = await callbacks.current.change(target, intent.current.id);
|
|
84
|
+
if (active !== generation.current) return;
|
|
85
|
+
const next = read(await callbacks.current.load());
|
|
86
|
+
if (active !== generation.current) return;
|
|
87
|
+
setStatus(next);
|
|
88
|
+
if (next && !next.change) intent.current = null;
|
|
89
|
+
setStep(result && typeof result === "object" ? result : null);
|
|
90
|
+
} catch {
|
|
91
|
+
if (active === generation.current) setFailure(true);
|
|
92
|
+
} finally {
|
|
93
|
+
if (active === generation.current) setBusy(false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function cancelPending() {
|
|
97
|
+
if (!status?.change?.request_id || !callbacks.current.cancel) return;
|
|
98
|
+
const active = generation.current;
|
|
99
|
+
setBusy(true);
|
|
100
|
+
setFailure(false);
|
|
101
|
+
try {
|
|
102
|
+
await callbacks.current.cancel(status.change.target_mode, status.change.request_id);
|
|
103
|
+
const next = read(await callbacks.current.load());
|
|
104
|
+
if (active !== generation.current) return;
|
|
105
|
+
setStatus(next);
|
|
106
|
+
setStep(null);
|
|
107
|
+
intent.current = null;
|
|
108
|
+
} catch {
|
|
109
|
+
if (active === generation.current) setFailure(true);
|
|
110
|
+
} finally {
|
|
111
|
+
if (active === generation.current) setBusy(false);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!status) return null;
|
|
115
|
+
const pending = status.change;
|
|
116
|
+
const target = pending?.target_mode ?? (status.current_mode === "private" ? "cloud_copy" : "private");
|
|
117
|
+
const blocked = step?.phase === "waiting_for_devices" && Array.isArray(step.devices) ? step.devices.filter(v => typeof v === "string") : [];
|
|
118
|
+
const retryAt = typeof step?.retry_at === "string" ? step.retry_at : typeof pending?.retry_at === "string" ? pending.retry_at : undefined;
|
|
119
|
+
return _jsxs("section", {
|
|
120
|
+
className: "mdbase-collection-mode",
|
|
121
|
+
"aria-label": i18n._(
|
|
122
|
+
/** i18n */
|
|
123
|
+
{
|
|
124
|
+
id: "4GiuPT",
|
|
125
|
+
message: "Collection storage mode",
|
|
126
|
+
context: "Collection storage mode"
|
|
127
|
+
}),
|
|
128
|
+
"aria-busy": busy,
|
|
129
|
+
children: [_jsx("h3", {
|
|
130
|
+
children: i18n._(
|
|
131
|
+
/** i18n */
|
|
132
|
+
{
|
|
133
|
+
id: "vAr5Dp",
|
|
134
|
+
message: "Storage mode",
|
|
135
|
+
context: "Collection storage mode"
|
|
136
|
+
})
|
|
137
|
+
}), _jsx("p", {
|
|
138
|
+
children: status.current_mode === "private" ? i18n._(
|
|
139
|
+
/** i18n */
|
|
140
|
+
{
|
|
141
|
+
id: "EIDHh1",
|
|
142
|
+
message: "Private",
|
|
143
|
+
context: "Collection storage mode"
|
|
144
|
+
}) : i18n._(
|
|
145
|
+
/** i18n */
|
|
146
|
+
{
|
|
147
|
+
id: "oKQdii",
|
|
148
|
+
message: "Cloud copy",
|
|
149
|
+
context: "Collection storage mode"
|
|
150
|
+
})
|
|
151
|
+
}), _jsx("p", {
|
|
152
|
+
children: target === "private" ? i18n._(
|
|
153
|
+
/** i18n */
|
|
154
|
+
{
|
|
155
|
+
id: "FHz4md",
|
|
156
|
+
message: "Only your devices can read new content after the switch finishes. mdbase may still be able to read older content.",
|
|
157
|
+
context: "Collection storage mode"
|
|
158
|
+
}) : i18n._(
|
|
159
|
+
/** i18n */
|
|
160
|
+
{
|
|
161
|
+
id: "bcDV8T",
|
|
162
|
+
message: "mdbase will be able to read the collection, including its existing history, and serve it while your devices are off.",
|
|
163
|
+
context: "Collection storage mode"
|
|
164
|
+
})
|
|
165
|
+
}), pending && _jsx("p", {
|
|
166
|
+
role: "status",
|
|
167
|
+
children: blocked.length > 0 ? i18n._(
|
|
168
|
+
/** i18n */
|
|
169
|
+
{
|
|
170
|
+
id: "3w1RWH",
|
|
171
|
+
message: "Waiting for devices to update.",
|
|
172
|
+
context: "Collection storage mode"
|
|
173
|
+
}) : pending.phase === "waiting_for_token_expiry" ? i18n._(
|
|
174
|
+
/** i18n */
|
|
175
|
+
{
|
|
176
|
+
id: "lx5IrT",
|
|
177
|
+
message: "Waiting for older device connections to expire. Cloud copy remains on until the switch finishes.",
|
|
178
|
+
context: "Collection storage mode"
|
|
179
|
+
}) : pending.phase === "waiting_for_device" ? i18n._(
|
|
180
|
+
/** i18n */
|
|
181
|
+
{
|
|
182
|
+
id: "3FYCb9",
|
|
183
|
+
message: "Open mdbase connect on one of the owner's devices to finish the switch.",
|
|
184
|
+
context: "Collection storage mode"
|
|
185
|
+
}) : i18n._(
|
|
186
|
+
/** i18n */
|
|
187
|
+
{
|
|
188
|
+
id: "13DGsZ",
|
|
189
|
+
message: "Finishing the switch. Keep the owner device connected.",
|
|
190
|
+
context: "Collection storage mode"
|
|
191
|
+
})
|
|
192
|
+
}), retryAt && Number.isFinite(Date.parse(retryAt)) && _jsx("p", {
|
|
193
|
+
children: _jsx("time", {
|
|
194
|
+
dateTime: retryAt,
|
|
195
|
+
children: i18n._(
|
|
196
|
+
/** i18n */
|
|
197
|
+
{
|
|
198
|
+
id: "ejJPcY",
|
|
199
|
+
message: "Continue after {0}.",
|
|
200
|
+
context: "Collection storage mode",
|
|
201
|
+
values: {
|
|
202
|
+
0: new Date(retryAt).toLocaleString()
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
}), blocked.length > 0 && _jsxs("div", {
|
|
207
|
+
role: "status",
|
|
208
|
+
children: [_jsx("p", {
|
|
209
|
+
children: i18n._(
|
|
210
|
+
/** i18n */
|
|
211
|
+
{
|
|
212
|
+
id: "YQ2gus",
|
|
213
|
+
message: "Update or revoke these devices before continuing:",
|
|
214
|
+
context: "Collection storage mode"
|
|
215
|
+
})
|
|
216
|
+
}), _jsx("ul", {
|
|
217
|
+
children: blocked.map(id => _jsx("li", {
|
|
218
|
+
children: _jsx("code", {
|
|
219
|
+
children: id
|
|
220
|
+
})
|
|
221
|
+
}, id))
|
|
222
|
+
})]
|
|
223
|
+
}), failure && _jsx("p", {
|
|
224
|
+
role: "alert",
|
|
225
|
+
children: i18n._(
|
|
226
|
+
/** i18n */
|
|
227
|
+
{
|
|
228
|
+
id: "Iu3ZPh",
|
|
229
|
+
message: "We couldn\u2019t confirm progress. Refresh, then retry. An interrupted switch may already have started.",
|
|
230
|
+
context: "Collection storage mode"
|
|
231
|
+
})
|
|
232
|
+
}), _jsxs("div", {
|
|
233
|
+
children: [(!pending || native) && _jsx("button", {
|
|
234
|
+
type: "button",
|
|
235
|
+
className: "mdbase-button",
|
|
236
|
+
disabled: busy,
|
|
237
|
+
onClick: () => void commit(target),
|
|
238
|
+
children: busy ? i18n._(
|
|
239
|
+
/** i18n */
|
|
240
|
+
{
|
|
241
|
+
id: "3xE-Bk",
|
|
242
|
+
message: "Working\u2026",
|
|
243
|
+
context: "Collection storage mode"
|
|
244
|
+
}) : pending ? i18n._(
|
|
245
|
+
/** i18n */
|
|
246
|
+
{
|
|
247
|
+
id: "JKLDUc",
|
|
248
|
+
message: "Continue on this device",
|
|
249
|
+
context: "Collection storage mode"
|
|
250
|
+
}) : target === "private" ? i18n._(
|
|
251
|
+
/** i18n */
|
|
252
|
+
{
|
|
253
|
+
id: "rO1a9-",
|
|
254
|
+
message: "Make private",
|
|
255
|
+
context: "Collection storage mode"
|
|
256
|
+
}) : i18n._(
|
|
257
|
+
/** i18n */
|
|
258
|
+
{
|
|
259
|
+
id: "nVYD2-",
|
|
260
|
+
message: "Turn on Cloud copy",
|
|
261
|
+
context: "Collection storage mode"
|
|
262
|
+
})
|
|
263
|
+
}), cancel && pending?.request_id && ["waiting_for_device", "waiting_for_token_expiry"].includes(pending.phase) && _jsx("button", {
|
|
264
|
+
type: "button",
|
|
265
|
+
className: "mdbase-button is-tertiary",
|
|
266
|
+
disabled: busy,
|
|
267
|
+
onClick: () => void cancelPending(),
|
|
268
|
+
children: i18n._(
|
|
269
|
+
/** i18n */
|
|
270
|
+
{
|
|
271
|
+
id: "VON31G",
|
|
272
|
+
message: "Cancel switch",
|
|
273
|
+
context: "Collection storage mode"
|
|
274
|
+
})
|
|
275
|
+
}), _jsx("button", {
|
|
276
|
+
type: "button",
|
|
277
|
+
className: "mdbase-button is-tertiary",
|
|
278
|
+
disabled: busy,
|
|
279
|
+
onClick: () => void refresh(),
|
|
280
|
+
children: i18n._(
|
|
281
|
+
/** i18n */
|
|
282
|
+
{
|
|
283
|
+
id: "tHHSXx",
|
|
284
|
+
message: "Refresh status",
|
|
285
|
+
context: "Collection storage mode"
|
|
286
|
+
})
|
|
287
|
+
})]
|
|
288
|
+
})]
|
|
289
|
+
});
|
|
290
|
+
}
|
|
@@ -1,22 +1,47 @@
|
|
|
1
|
-
import { type JSX, type ReactNode } from "react";
|
|
1
|
+
import { type JSX, type ReactNode, type RefObject } from "react";
|
|
2
2
|
import { type RowDialog } from "./parts.js";
|
|
3
3
|
import { type CollectionUi, type CollectionUiOptions } from "./use-collection-ui.js";
|
|
4
|
-
export
|
|
5
|
-
/** "full": name and role; "compact": name only; "icon": a glyph for toolbars. */
|
|
6
|
-
readonly variant?: "full" | "compact" | "icon";
|
|
4
|
+
export type CollectionSwitcherProps = CollectionUiOptions & {
|
|
7
5
|
readonly className?: string;
|
|
8
6
|
/**
|
|
9
7
|
* Leading content in the trigger: the app's icon, or "mark" for the open
|
|
10
8
|
* collection's initials. In the "icon" variant it replaces the folder glyph.
|
|
11
9
|
*/
|
|
12
10
|
readonly icon?: ReactNode | "mark";
|
|
13
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether the switcher is open. Leave it out and the switcher keeps its own
|
|
13
|
+
* state; pass it and the switcher only asks through `onOpenChange`.
|
|
14
|
+
*/
|
|
15
|
+
readonly open?: boolean;
|
|
16
|
+
/** Called whenever the switcher opens or closes itself, controlled or not. */
|
|
17
|
+
readonly onOpenChange?: (open: boolean) => void;
|
|
18
|
+
} & ({
|
|
19
|
+
/** "full": name and role; "compact": name only; "icon": a glyph for toolbars; "none": no trigger. */
|
|
20
|
+
readonly variant?: "full" | "compact" | "icon";
|
|
21
|
+
readonly anchorRef?: never;
|
|
22
|
+
} | {
|
|
23
|
+
/**
|
|
24
|
+
* No trigger of its own: the app opens the switcher with `open` and
|
|
25
|
+
* `onOpenChange`, for instance from an item in its own menu.
|
|
26
|
+
*/
|
|
27
|
+
readonly variant: "none";
|
|
28
|
+
/**
|
|
29
|
+
* The app's element the switcher belongs to, such as the button of the menu
|
|
30
|
+
* that offered it. The wide-screen popover is placed against it and focus
|
|
31
|
+
* returns to it on close. It must stay mounted while the switcher is open.
|
|
32
|
+
*/
|
|
33
|
+
readonly anchorRef: RefObject<HTMLElement | null>;
|
|
34
|
+
});
|
|
14
35
|
/**
|
|
15
36
|
* The in-app collection switcher: a trigger that opens the approved collections
|
|
16
37
|
* (one click switches, no Connect window), "Add a collection…", "Manage access"
|
|
17
38
|
* and the account. A popover on wide screens, a bottom sheet on narrow ones.
|
|
39
|
+
*
|
|
40
|
+
* Pass `open` and `onOpenChange` to control it. With `variant="none"` it has no
|
|
41
|
+
* trigger, so an app can open it from its own control and pass that control's
|
|
42
|
+
* element (or the menu button that held it) as `anchorRef`.
|
|
18
43
|
*/
|
|
19
|
-
export declare function CollectionSwitcher({ variant, className, icon, ...options }: CollectionSwitcherProps): JSX.Element;
|
|
44
|
+
export declare function CollectionSwitcher({ variant, className, icon, open: openProp, onOpenChange, anchorRef, ...options }: CollectionSwitcherProps): JSX.Element;
|
|
20
45
|
/** The switcher's list and actions, also shown inline by `CollectionPanel`. */
|
|
21
46
|
export declare function SwitcherContent({ ui, onDone, onDialog }: {
|
|
22
47
|
readonly ui: CollectionUi;
|
|
@@ -11,17 +11,32 @@ const NARROW = "(max-width: 639.98px)";
|
|
|
11
11
|
* The in-app collection switcher: a trigger that opens the approved collections
|
|
12
12
|
* (one click switches, no Connect window), "Add a collection…", "Manage access"
|
|
13
13
|
* and the account. A popover on wide screens, a bottom sheet on narrow ones.
|
|
14
|
+
*
|
|
15
|
+
* Pass `open` and `onOpenChange` to control it. With `variant="none"` it has no
|
|
16
|
+
* trigger, so an app can open it from its own control and pass that control's
|
|
17
|
+
* element (or the menu button that held it) as `anchorRef`.
|
|
14
18
|
*/
|
|
15
19
|
export function CollectionSwitcher({
|
|
16
20
|
variant = "full",
|
|
17
21
|
className,
|
|
18
22
|
icon,
|
|
23
|
+
open: openProp,
|
|
24
|
+
onOpenChange,
|
|
25
|
+
anchorRef,
|
|
19
26
|
...options
|
|
20
27
|
}) {
|
|
21
28
|
const ui = useCollectionUi(options);
|
|
22
|
-
const [
|
|
29
|
+
const [ownOpen, setOwnOpen] = useState(false);
|
|
30
|
+
const open = openProp ?? ownOpen;
|
|
31
|
+
const setOpen = next => {
|
|
32
|
+
if (next === open) return;
|
|
33
|
+
if (openProp === undefined) setOwnOpen(next);
|
|
34
|
+
onOpenChange?.(next);
|
|
35
|
+
};
|
|
23
36
|
const [dialog, setDialog] = useState(null);
|
|
24
37
|
const trigger = useRef(null);
|
|
38
|
+
// The element the panel is placed against and hands focus back to.
|
|
39
|
+
const anchor = anchorRef ?? trigger;
|
|
25
40
|
const panelId = useId();
|
|
26
41
|
const narrow = useNarrow();
|
|
27
42
|
const {
|
|
@@ -40,20 +55,20 @@ export function CollectionSwitcher({
|
|
|
40
55
|
}) : _jsx(CollectionsGlyph, {}) : icon;
|
|
41
56
|
const close = refocus => {
|
|
42
57
|
setOpen(false);
|
|
43
|
-
if (refocus)
|
|
58
|
+
if (refocus) anchor.current?.focus();
|
|
44
59
|
};
|
|
45
60
|
const toDialog = next => {
|
|
46
61
|
setOpen(false);
|
|
47
62
|
setDialog(next);
|
|
48
63
|
};
|
|
49
|
-
// The popover that held the row is gone: a closed row dialog hands focus to the
|
|
64
|
+
// The popover that held the row is gone: a closed row dialog hands focus to the anchor.
|
|
50
65
|
const returnFocus = useRef(false);
|
|
51
66
|
useEffect(() => {
|
|
52
67
|
if (dialog || !returnFocus.current) return;
|
|
53
68
|
returnFocus.current = false;
|
|
54
69
|
const active = document.activeElement;
|
|
55
|
-
if (!active || active === document.body)
|
|
56
|
-
}, [dialog]);
|
|
70
|
+
if (!active || active === document.body) anchor.current?.focus();
|
|
71
|
+
}, [dialog, anchor]);
|
|
57
72
|
const content = _jsx(SwitcherContent, {
|
|
58
73
|
ui: ui,
|
|
59
74
|
onDone: () => close(false),
|
|
@@ -62,7 +77,7 @@ export function CollectionSwitcher({
|
|
|
62
77
|
return _jsxs("span", {
|
|
63
78
|
className: ["mdbase-collections", "mdbase-collections-switcher", className].filter(Boolean).join(" "),
|
|
64
79
|
"data-variant": variant,
|
|
65
|
-
children: [_jsxs("button", {
|
|
80
|
+
children: [variant !== "none" && _jsxs("button", {
|
|
66
81
|
ref: trigger,
|
|
67
82
|
type: "button",
|
|
68
83
|
className: "mdbase-collections-trigger",
|
|
@@ -73,7 +88,7 @@ export function CollectionSwitcher({
|
|
|
73
88
|
collection: currentName
|
|
74
89
|
}) : copy.switchCollectionNone,
|
|
75
90
|
title: name,
|
|
76
|
-
onClick: () => setOpen(
|
|
91
|
+
onClick: () => setOpen(!open),
|
|
77
92
|
children: [busy === "select" || busy === "add" || view.kind === "opening" ? _jsx("span", {
|
|
78
93
|
className: "mdbase-collections-spinner",
|
|
79
94
|
"aria-hidden": "true"
|
|
@@ -103,7 +118,7 @@ export function CollectionSwitcher({
|
|
|
103
118
|
}) : _jsx(SwitcherPopover, {
|
|
104
119
|
id: panelId,
|
|
105
120
|
ui: ui,
|
|
106
|
-
|
|
121
|
+
anchorRef: anchor,
|
|
107
122
|
onClose: close,
|
|
108
123
|
children: content
|
|
109
124
|
})), _jsx(RowDialogs, {
|
|
@@ -430,11 +445,15 @@ function CheckGlyph() {
|
|
|
430
445
|
})
|
|
431
446
|
});
|
|
432
447
|
}
|
|
433
|
-
/**
|
|
448
|
+
/**
|
|
449
|
+
* Wide screens: a non-modal dialog anchored to the trigger (or the app's
|
|
450
|
+
* anchor), in the top layer where supported. Pointer presses and focus on the
|
|
451
|
+
* anchor count as inside, so the trigger can toggle it.
|
|
452
|
+
*/
|
|
434
453
|
function SwitcherPopover({
|
|
435
454
|
id,
|
|
436
455
|
ui,
|
|
437
|
-
|
|
456
|
+
anchorRef,
|
|
438
457
|
onClose,
|
|
439
458
|
children
|
|
440
459
|
}) {
|
|
@@ -443,10 +462,10 @@ function SwitcherPopover({
|
|
|
443
462
|
closeRef.current = onClose;
|
|
444
463
|
useLayoutEffect(() => {
|
|
445
464
|
const panel = ref.current;
|
|
446
|
-
const
|
|
447
|
-
if (!panel || !
|
|
465
|
+
const anchor = anchorRef.current;
|
|
466
|
+
if (!panel || !anchor) return undefined;
|
|
448
467
|
if (typeof panel.showPopover === "function") panel.showPopover();
|
|
449
|
-
const box =
|
|
468
|
+
const box = anchor.getBoundingClientRect();
|
|
450
469
|
const width = Math.min(340, window.innerWidth - 16);
|
|
451
470
|
panel.style.width = `${width}px`;
|
|
452
471
|
const place = anchoredPlacement(box, {
|
|
@@ -467,11 +486,11 @@ function SwitcherPopover({
|
|
|
467
486
|
return () => {
|
|
468
487
|
if (typeof panel.hidePopover === "function" && panel.matches(":popover-open")) panel.hidePopover();
|
|
469
488
|
};
|
|
470
|
-
}, [
|
|
489
|
+
}, [anchorRef]);
|
|
471
490
|
useEffect(() => {
|
|
472
491
|
const outside = event => {
|
|
473
492
|
const target = event.target;
|
|
474
|
-
if (target && !ref.current?.contains(target) && !
|
|
493
|
+
if (target && !ref.current?.contains(target) && !anchorRef.current?.contains(target)) closeRef.current(false);
|
|
475
494
|
};
|
|
476
495
|
const resize = () => closeRef.current(false);
|
|
477
496
|
document.addEventListener("pointerdown", outside, true);
|
|
@@ -480,7 +499,7 @@ function SwitcherPopover({
|
|
|
480
499
|
document.removeEventListener("pointerdown", outside, true);
|
|
481
500
|
window.removeEventListener("resize", resize);
|
|
482
501
|
};
|
|
483
|
-
}, [
|
|
502
|
+
}, [anchorRef]);
|
|
484
503
|
return _jsx("div", {
|
|
485
504
|
ref: ref,
|
|
486
505
|
id: id,
|
|
@@ -497,7 +516,7 @@ function SwitcherPopover({
|
|
|
497
516
|
},
|
|
498
517
|
onBlur: event => {
|
|
499
518
|
const next = event.relatedTarget;
|
|
500
|
-
if (next && !ref.current?.contains(next) && !
|
|
519
|
+
if (next && !ref.current?.contains(next) && !anchorRef.current?.contains(next)) onClose(false);
|
|
501
520
|
},
|
|
502
521
|
children: children
|
|
503
522
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { describeError } from "@mdbase-dev/sdk/describe";
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
3
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { actionNotice, problemControl } from "./model.js";
|
|
5
5
|
import { ActionButton, NoticeBlock } from "./parts.js";
|
|
6
6
|
/** Quiet re-plans after a transient refusal (about a minute in all). */
|
|
@@ -21,9 +21,28 @@ export function SetupReview({
|
|
|
21
21
|
});
|
|
22
22
|
const [request, setRequest] = useState({
|
|
23
23
|
options: {},
|
|
24
|
-
notice: null
|
|
24
|
+
notice: null,
|
|
25
|
+
quiet: false
|
|
25
26
|
});
|
|
26
27
|
const [applying, setApplying] = useState(false);
|
|
28
|
+
const applyingNow = useRef(false);
|
|
29
|
+
const shown = useRef(state.status);
|
|
30
|
+
shown.current = state.status;
|
|
31
|
+
// Readiness re-checks whenever the collection changes (in another tab, or in
|
|
32
|
+
// the person's own editor). A review planned before that change can be stale:
|
|
33
|
+
// a restored type may now be upgradable, or a refusal resolved. Plan again,
|
|
34
|
+
// quietly, each time a re-check settles; an apply in flight owns the screen.
|
|
35
|
+
const checking = useSyncExternalStore(mdbase.subscribe, () => mdbase.getSnapshot().readiness.checking);
|
|
36
|
+
const wasChecking = useRef(checking);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const settled = wasChecking.current && !checking;
|
|
39
|
+
wasChecking.current = checking;
|
|
40
|
+
// Only a review on screen is kept: planning or a failure plans again plainly.
|
|
41
|
+
if (settled && !applyingNow.current) setRequest(current => ({
|
|
42
|
+
...current,
|
|
43
|
+
quiet: shown.current === "ready"
|
|
44
|
+
}));
|
|
45
|
+
}, [checking]);
|
|
27
46
|
useEffect(() => {
|
|
28
47
|
const controller = new AbortController();
|
|
29
48
|
let timer = null;
|
|
@@ -34,14 +53,16 @@ export function SetupReview({
|
|
|
34
53
|
...request.options,
|
|
35
54
|
signal: controller.signal
|
|
36
55
|
}).then(plan => {
|
|
37
|
-
if (
|
|
56
|
+
if (controller.signal.aborted) return;
|
|
57
|
+
setState(current => ({
|
|
38
58
|
status: "ready",
|
|
39
59
|
plan,
|
|
40
|
-
notice: request.notice
|
|
41
|
-
});
|
|
60
|
+
notice: request.quiet ? current.notice : request.notice
|
|
61
|
+
}));
|
|
42
62
|
}, cause => {
|
|
43
63
|
if (controller.signal.aborted) return;
|
|
44
64
|
const described = describeError(cause);
|
|
65
|
+
if (request.quiet) return;
|
|
45
66
|
if (described?.transient && n < TRANSIENT_REPLANS) {
|
|
46
67
|
timer = setTimeout(() => attempt(n + 1), described.retryAfterMs ?? Math.min(500 * 2 ** n, 5000));
|
|
47
68
|
return;
|
|
@@ -65,16 +86,25 @@ export function SetupReview({
|
|
|
65
86
|
});
|
|
66
87
|
setRequest({
|
|
67
88
|
options,
|
|
68
|
-
notice
|
|
89
|
+
notice,
|
|
90
|
+
quiet: false
|
|
69
91
|
});
|
|
70
92
|
};
|
|
93
|
+
// Nothing here can change the collection: check it again, then plan again,
|
|
94
|
+
// after the person has fixed it elsewhere. Readiness may open it outright.
|
|
95
|
+
const recheck = () => {
|
|
96
|
+
void ui.actions.track("assess", () => mdbase.assess()).then(() => replan(request.options), () => {});
|
|
97
|
+
};
|
|
71
98
|
const apply = plan => {
|
|
72
99
|
setApplying(true);
|
|
100
|
+
applyingNow.current = true;
|
|
73
101
|
mdbase.setup.apply(plan).then(result => {
|
|
74
102
|
setApplying(false);
|
|
103
|
+
applyingNow.current = false;
|
|
75
104
|
if (result.outcome === "changed") replan(request.options, actionNotice(describeError("setup_changed")));
|
|
76
105
|
}, cause => {
|
|
77
106
|
setApplying(false);
|
|
107
|
+
applyingNow.current = false;
|
|
78
108
|
const notice = actionNotice(describeError(cause));
|
|
79
109
|
setState(current => current.status === "ready" ? {
|
|
80
110
|
...current,
|
|
@@ -122,6 +152,11 @@ export function SetupReview({
|
|
|
122
152
|
} : null;
|
|
123
153
|
const nothing = plan.status === "current" || plan.changes.length === 0 && plan.conflicts.length === 0;
|
|
124
154
|
const adoptable = Object.keys(plan.adoptable).length > 0;
|
|
155
|
+
const applicable = !refusal && plan.canApply;
|
|
156
|
+
// NoticeBlock shows a refusal's own control, except `set_up` (this is setup).
|
|
157
|
+
const refusalActs = !!refusalNotice?.control && refusalNotice.control.kind !== "set_up";
|
|
158
|
+
// Never leave the review without a way forward.
|
|
159
|
+
const stuck = !applicable && !adoptable && !refusalActs;
|
|
125
160
|
return _jsxs("div", {
|
|
126
161
|
className: "mdbase-collections-setup",
|
|
127
162
|
children: [state.notice && _jsx(NoticeBlock, {
|
|
@@ -156,7 +191,7 @@ export function SetupReview({
|
|
|
156
191
|
})]
|
|
157
192
|
}), _jsxs("div", {
|
|
158
193
|
className: "mdbase-collections-actions",
|
|
159
|
-
children: [
|
|
194
|
+
children: [applicable && _jsx(ActionButton, {
|
|
160
195
|
tone: "primary",
|
|
161
196
|
progress: applying ? ui.copy.setupApplying : null,
|
|
162
197
|
onClick: () => apply(plan),
|
|
@@ -169,6 +204,12 @@ export function SetupReview({
|
|
|
169
204
|
adopt: plan.adoptable
|
|
170
205
|
}),
|
|
171
206
|
children: ui.say("setupUseAppVersion")
|
|
207
|
+
}), stuck && _jsx(ActionButton, {
|
|
208
|
+
tone: "primary",
|
|
209
|
+
inert: ui.busy !== null,
|
|
210
|
+
progress: ui.busy === "assess" ? ui.copy.setupChecking : null,
|
|
211
|
+
onClick: recheck,
|
|
212
|
+
children: ui.copy.tryAgain
|
|
172
213
|
})]
|
|
173
214
|
})]
|
|
174
215
|
});
|
|
@@ -4,7 +4,8 @@ export type CollectionUiPart = "panel" | "switcher" | "problem";
|
|
|
4
4
|
export interface MountCollectionUiOptions extends CollectionUiOptions {
|
|
5
5
|
/** "panel": sign-in, choosing and the list (a settings dialog); "switcher": the header trigger; "problem": the current problem with its fix. */
|
|
6
6
|
readonly part: CollectionUiPart;
|
|
7
|
-
|
|
7
|
+
/** "switcher": the trigger's variant. "none" needs an app anchor, so it is not mountable. */
|
|
8
|
+
readonly variant?: Exclude<CollectionSwitcherProps["variant"], "none">;
|
|
8
9
|
/** "switcher": leading content in the trigger ("mark" for the collection's initials). */
|
|
9
10
|
readonly icon?: CollectionSwitcherProps["icon"];
|
|
10
11
|
/** "panel": call `mdbase.start()` when it hasn't started. Default true. */
|