@mdbase-dev/ui 0.1.0-beta.125 → 0.1.0-beta.147

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 (77) hide show
  1. package/README.md +159 -0
  2. package/css/brand.css +10 -10
  3. package/css/collections.css +978 -0
  4. package/css/controls.css +220 -43
  5. package/css/feedback.css +20 -20
  6. package/css/fonts.css +2 -3
  7. package/css/palette.css +10 -10
  8. package/css/screens.css +58 -16
  9. package/css/tokens.css +43 -3
  10. package/dist/AppSwitcher.js +187 -33
  11. package/dist/Checkbox.d.ts +14 -0
  12. package/dist/Checkbox.js +32 -0
  13. package/dist/CommandPalette.d.ts +4 -2
  14. package/dist/CommandPalette.js +275 -103
  15. package/dist/Dialog.js +72 -25
  16. package/dist/Feedback.js +763 -229
  17. package/dist/FeedbackMarkup.d.ts +1 -1
  18. package/dist/FeedbackMarkup.js +326 -125
  19. package/dist/FeedbackVerification.js +115 -68
  20. package/dist/SaveNotice.js +71 -28
  21. package/dist/Select.js +183 -135
  22. package/dist/SelectList.js +59 -14
  23. package/dist/ThemeSelect.js +55 -4
  24. package/dist/apps.d.ts +1 -0
  25. package/dist/apps.js +50 -29
  26. package/dist/brand.js +251 -68
  27. package/dist/codemirror.js +116 -87
  28. package/dist/collections/CollectionGate.d.ts +52 -0
  29. package/dist/collections/CollectionGate.js +456 -0
  30. package/dist/collections/CollectionSaveWarning.d.ts +10 -0
  31. package/dist/collections/CollectionSaveWarning.js +28 -0
  32. package/dist/collections/CollectionSwitcher.d.ts +26 -0
  33. package/dist/collections/CollectionSwitcher.js +579 -0
  34. package/dist/collections/SetupReview.d.ts +9 -0
  35. package/dist/collections/SetupReview.js +175 -0
  36. package/dist/collections/copy.d.ts +100 -0
  37. package/dist/collections/copy.js +937 -0
  38. package/dist/collections/dialogs.d.ts +27 -0
  39. package/dist/collections/dialogs.js +169 -0
  40. package/dist/collections/model.d.ts +146 -0
  41. package/dist/collections/model.js +337 -0
  42. package/dist/collections/mount.d.ts +18 -0
  43. package/dist/collections/mount.js +56 -0
  44. package/dist/collections/parts.d.ts +86 -0
  45. package/dist/collections/parts.js +439 -0
  46. package/dist/collections/testing.d.ts +19 -0
  47. package/dist/collections/testing.js +39 -0
  48. package/dist/collections/use-collection-ui.d.ts +140 -0
  49. package/dist/collections/use-collection-ui.js +236 -0
  50. package/dist/collections.d.ts +19 -0
  51. package/dist/collections.js +13 -0
  52. package/dist/feedback-data.d.ts +13 -12
  53. package/dist/feedback-data.js +365 -168
  54. package/dist/flock.d.ts +63 -0
  55. package/dist/flock.js +140 -0
  56. package/dist/i18n.d.ts +6 -0
  57. package/dist/i18n.js +71 -0
  58. package/dist/locales/de/messages.json +1 -0
  59. package/dist/locales/en/messages.json +1 -0
  60. package/dist/locales/es/messages.json +1 -0
  61. package/dist/locales/fr/messages.json +1 -0
  62. package/dist/locales/it/messages.json +1 -0
  63. package/dist/locales/ja/messages.json +1 -0
  64. package/dist/locales/ko/messages.json +1 -0
  65. package/dist/locales/pt/messages.json +1 -0
  66. package/dist/locales/ru/messages.json +1 -0
  67. package/dist/locales/zh/messages.json +1 -0
  68. package/dist/mark-activity.js +82 -75
  69. package/dist/permission-selection.js +27 -17
  70. package/dist/popover.js +138 -130
  71. package/dist/screens.d.ts +20 -3
  72. package/dist/screens.js +275 -6
  73. package/dist/select-model.js +34 -28
  74. package/dist/text-diff.d.ts +1 -0
  75. package/dist/text-diff.js +106 -74
  76. package/dist/theme.js +34 -40
  77. package/package.json +53 -8
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createRoot } from "react-dom/client";
3
+ import { CollectionPanel, CollectionProblem } from "./CollectionGate.js";
4
+ import { CollectionSwitcher } from "./CollectionSwitcher.js";
5
+ /** Render a shared collection component into an element, for apps that don't render with React. */
6
+ export function mountCollectionUi(element, options) {
7
+ const root = createRoot(element);
8
+ let current = options;
9
+ const render = () => root.render(_jsx(Part, {
10
+ ...current
11
+ }));
12
+ render();
13
+ return {
14
+ update(next) {
15
+ current = {
16
+ ...current,
17
+ ...next
18
+ };
19
+ render();
20
+ },
21
+ unmount() {
22
+ root.unmount();
23
+ }
24
+ };
25
+ }
26
+ function Part({
27
+ part,
28
+ variant,
29
+ icon,
30
+ autoStart,
31
+ ...options
32
+ }) {
33
+ switch (part) {
34
+ case "panel":
35
+ return _jsx(CollectionPanel, {
36
+ ...options,
37
+ ...(autoStart === undefined ? {} : {
38
+ autoStart
39
+ })
40
+ });
41
+ case "switcher":
42
+ return _jsx(CollectionSwitcher, {
43
+ ...options,
44
+ ...(variant ? {
45
+ variant
46
+ } : {}),
47
+ ...(icon === undefined ? {} : {
48
+ icon
49
+ })
50
+ });
51
+ case "problem":
52
+ return _jsx(CollectionProblem, {
53
+ ...options
54
+ });
55
+ }
56
+ }
@@ -0,0 +1,86 @@
1
+ import { type JSX, type ReactNode } from "react";
2
+ import type { CollectionNotice, CollectionRow } from "./model.js";
3
+ import type { CollectionSectionItem, CollectionUi, DeleteCollectionOutcome } from "./use-collection-ui.js";
4
+ /** Which row dialog is open, shared by the gate and the switcher. `onClose` hears a delete's outcome. */
5
+ export type RowDialog = {
6
+ readonly kind: "forget" | "delete";
7
+ readonly row: Pick<CollectionRow, "id" | "name">;
8
+ readonly onClose?: ((outcome: DeleteCollectionOutcome) => void) | undefined;
9
+ } | null;
10
+ export type ButtonTone = "primary" | "secondary" | "quiet" | "danger";
11
+ /**
12
+ * A text button. While another action runs it stays focusable but inert
13
+ * (`aria-disabled`), and the running one shows its progress label.
14
+ */
15
+ export declare function ActionButton({ tone, inert, progress, onClick, children, className, ...rest }: {
16
+ readonly tone?: ButtonTone;
17
+ readonly inert?: boolean;
18
+ /** Shown instead of the label while this button's own action runs. */
19
+ readonly progress?: string | null | undefined;
20
+ readonly onClick: () => void;
21
+ readonly children: ReactNode;
22
+ readonly className?: string;
23
+ readonly "aria-describedby"?: string | undefined;
24
+ }): JSX.Element;
25
+ /** One problem with its own fix: SDK title and message, then the SDK action as a button inside the block. */
26
+ export declare function NoticeBlock({ notice, ui, onSetUp, variant }: {
27
+ readonly notice: CollectionNotice;
28
+ readonly ui: CollectionUi;
29
+ readonly onSetUp?: (() => void) | undefined;
30
+ readonly variant?: "block" | "banner" | "inline";
31
+ }): JSX.Element;
32
+ /** Initials on a hue that stays the same for a collection across sessions. */
33
+ export declare function CollectionMark({ row }: {
34
+ readonly row: Pick<CollectionRow, "id" | "name">;
35
+ }): JSX.Element;
36
+ /** The name (wrapping, never truncated), the app's optional second line, then one line of role and availability. */
37
+ export declare function RowCopy({ row, ui, detailId, current }: {
38
+ readonly row: CollectionRow;
39
+ readonly ui: CollectionUi;
40
+ readonly detailId?: string;
41
+ /** Overrides `row.current` (an app section item is open instead). */
42
+ readonly current?: boolean;
43
+ }): JSX.Element;
44
+ /** Marks a Private collection: a visible label, read with the row's name. */
45
+ export declare function PrivateMark({ ui }: {
46
+ readonly ui: CollectionUi;
47
+ }): JSX.Element;
48
+ /** An app section item: its name and optional second line. */
49
+ export declare function ItemCopy({ item, ui }: {
50
+ readonly item: CollectionSectionItem;
51
+ readonly ui: CollectionUi;
52
+ }): JSX.Element;
53
+ /** "Remove from this list" and, for owners when allowed, "Delete collection…". */
54
+ export declare function RowMore({ row, ui, onDialog, tabbable }: {
55
+ readonly row: CollectionRow;
56
+ readonly ui: CollectionUi;
57
+ readonly onDialog: (dialog: NonNullable<RowDialog>) => void;
58
+ readonly tabbable?: boolean;
59
+ }): JSX.Element;
60
+ /** An app section item's own actions, in the same row menu as an SDK collection's. */
61
+ export declare function ItemMore({ item, ui, onDialog, tabbable }: {
62
+ readonly item: CollectionSectionItem;
63
+ readonly ui: CollectionUi;
64
+ readonly onDialog: (dialog: NonNullable<RowDialog>) => void;
65
+ readonly tabbable?: boolean;
66
+ }): JSX.Element | null;
67
+ /** The app's actions, Choose a different collection, Manage access, the account and Sign out: never under an error. */
68
+ export declare function GateFooter({ ui, chooseAnother, extra }: {
69
+ readonly ui: CollectionUi;
70
+ readonly chooseAnother: boolean;
71
+ readonly extra?: ReactNode;
72
+ }): JSX.Element | null;
73
+ export declare function AccountLine({ ui }: {
74
+ readonly ui: CollectionUi;
75
+ }): JSX.Element;
76
+ /** A native modal <dialog>: the browser traps focus and makes the page inert; Escape cancels. */
77
+ export declare function CollectionDialog({ title, alert, busy, onClose, children, footer }: {
78
+ readonly title: string;
79
+ readonly alert?: boolean;
80
+ readonly busy?: boolean;
81
+ readonly onClose: () => void;
82
+ readonly children: ReactNode;
83
+ readonly footer: ReactNode;
84
+ }): JSX.Element;
85
+ export declare function initials(name: string): string;
86
+ export declare function hueOf(id: string): number;
@@ -0,0 +1,439 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useLayoutEffect, useRef, useState } from "react";
3
+ import { moveMenuFocus, useMenuPopover } from "../popover.js";
4
+ /**
5
+ * A text button. While another action runs it stays focusable but inert
6
+ * (`aria-disabled`), and the running one shows its progress label.
7
+ */
8
+ export function ActionButton({
9
+ tone = "secondary",
10
+ inert = false,
11
+ progress,
12
+ onClick,
13
+ children,
14
+ className,
15
+ ...rest
16
+ }) {
17
+ const label = progress ?? children;
18
+ return _jsx("button", {
19
+ type: "button",
20
+ className: ["mdbase-collections-button", `is-${tone}`, className].filter(Boolean).join(" "),
21
+ "aria-disabled": inert || progress ? true : undefined,
22
+ onClick: () => {
23
+ if (!inert && !progress) onClick();
24
+ },
25
+ ...rest,
26
+ children: label
27
+ });
28
+ }
29
+ /** One problem with its own fix: SDK title and message, then the SDK action as a button inside the block. */
30
+ export function NoticeBlock({
31
+ notice,
32
+ ui,
33
+ onSetUp,
34
+ variant = "block"
35
+ }) {
36
+ const control = notice.control && (notice.control.kind !== "set_up" || onSetUp) ? notice.control : null;
37
+ const urgent = !notice.transient && notice.tone !== "info";
38
+ return _jsxs("div", {
39
+ className: `mdbase-collections-notice is-${variant}`,
40
+ "data-tone": notice.tone,
41
+ role: urgent ? "alert" : "status",
42
+ children: [_jsxs("div", {
43
+ className: "mdbase-collections-notice-copy",
44
+ children: [notice.title && _jsx("p", {
45
+ className: "mdbase-collections-notice-title",
46
+ children: notice.title
47
+ }), _jsx("p", {
48
+ className: "mdbase-collections-notice-message",
49
+ children: notice.message
50
+ })]
51
+ }), control && _jsx("div", {
52
+ className: "mdbase-collections-notice-actions",
53
+ children: _jsx(ControlButton, {
54
+ control: control,
55
+ ui: ui,
56
+ onSetUp: onSetUp
57
+ })
58
+ })]
59
+ });
60
+ }
61
+ function ControlButton({
62
+ control,
63
+ ui,
64
+ onSetUp
65
+ }) {
66
+ if (control.kind === "link") {
67
+ return _jsx("a", {
68
+ className: "mdbase-collections-button is-primary",
69
+ href: control.href,
70
+ target: "_blank",
71
+ rel: "noopener noreferrer",
72
+ children: control.label
73
+ });
74
+ }
75
+ return _jsx(ActionButton, {
76
+ tone: "primary",
77
+ inert: ui.busy !== null,
78
+ onClick: () => ui.actions.perform(control, onSetUp),
79
+ children: control.label
80
+ });
81
+ }
82
+ /** Initials on a hue that stays the same for a collection across sessions. */
83
+ export function CollectionMark({
84
+ row
85
+ }) {
86
+ return _jsx("span", {
87
+ className: "mdbase-collections-mark",
88
+ "aria-hidden": "true",
89
+ style: {
90
+ "--mdbase-collections-hue": hueOf(row.id)
91
+ },
92
+ children: initials(row.name)
93
+ });
94
+ }
95
+ /** The name (wrapping, never truncated), the app's optional second line, then one line of role and availability. */
96
+ export function RowCopy({
97
+ row,
98
+ ui,
99
+ detailId,
100
+ current = row.current
101
+ }) {
102
+ const meta = [current ? ui.copy.openNow : null, row.roleLabel, row.status].filter(Boolean).join(" · ");
103
+ const secondary = ui.options.rowDetail?.(row);
104
+ return _jsxs("span", {
105
+ className: "mdbase-collections-row-copy",
106
+ children: [_jsxs("span", {
107
+ className: "mdbase-collections-row-name",
108
+ children: [row.name, row.disambiguator && _jsxs("span", {
109
+ className: "mdbase-collections-row-hint",
110
+ children: [" ", row.disambiguator]
111
+ }), row.private && _jsxs(_Fragment, {
112
+ children: [" ", _jsx(PrivateMark, {
113
+ ui: ui
114
+ })]
115
+ })]
116
+ }), secondary && _jsx("span", {
117
+ className: "mdbase-collections-row-secondary",
118
+ children: secondary
119
+ }), _jsx("span", {
120
+ className: "mdbase-collections-row-meta",
121
+ children: meta
122
+ }), row.statusDetail && _jsx("span", {
123
+ className: "mdbase-collections-row-detail",
124
+ id: detailId,
125
+ children: row.statusDetail
126
+ })]
127
+ });
128
+ }
129
+ /** Marks a Private collection: a visible label, read with the row's name. */
130
+ export function PrivateMark({
131
+ ui
132
+ }) {
133
+ return _jsxs("span", {
134
+ className: "mdbase-collections-chip mdbase-collections-private",
135
+ title: ui.copy.privateCollectionHint,
136
+ children: [_jsx("svg", {
137
+ viewBox: "0 0 16 16",
138
+ "aria-hidden": "true",
139
+ children: _jsx("path", {
140
+ d: "M4.5 7.5h7v5.5h-7zM6 7.5V5.5a2 2 0 0 1 4 0v2"
141
+ })
142
+ }), ui.copy.privateCollection]
143
+ });
144
+ }
145
+ /** An app section item: its name and optional second line. */
146
+ export function ItemCopy({
147
+ item,
148
+ ui
149
+ }) {
150
+ return _jsxs("span", {
151
+ className: "mdbase-collections-row-copy",
152
+ children: [_jsx("span", {
153
+ className: "mdbase-collections-row-name",
154
+ children: item.name
155
+ }), item.detail && _jsx("span", {
156
+ className: "mdbase-collections-row-secondary",
157
+ children: item.detail
158
+ }), item.current && _jsx("span", {
159
+ className: "mdbase-collections-row-meta",
160
+ children: ui.copy.openNow
161
+ })]
162
+ });
163
+ }
164
+ /** "Remove from this list" and, for owners when allowed, "Delete collection…". */
165
+ export function RowMore({
166
+ row,
167
+ ui,
168
+ onDialog,
169
+ tabbable = true
170
+ }) {
171
+ const canDelete = ui.options.allowDelete === true && row.role === "owner";
172
+ const entries = [{
173
+ id: "forget",
174
+ label: ui.copy.removeFromList,
175
+ onSelect: () => onDialog({
176
+ kind: "forget",
177
+ row
178
+ })
179
+ }];
180
+ if (canDelete) entries.push({
181
+ id: "delete",
182
+ label: ui.copy.deleteCollection,
183
+ danger: true,
184
+ onSelect: () => onDialog({
185
+ kind: "delete",
186
+ row
187
+ })
188
+ });
189
+ return _jsx(RowMenuButton, {
190
+ label: ui.say("moreFor", {
191
+ collection: row.name
192
+ }),
193
+ entries: entries,
194
+ tabbable: tabbable
195
+ });
196
+ }
197
+ /** An app section item's own actions, in the same row menu as an SDK collection's. */
198
+ export function ItemMore({
199
+ item,
200
+ ui,
201
+ onDialog,
202
+ tabbable = true
203
+ }) {
204
+ const actions = item.actions ?? [];
205
+ if (actions.length === 0) return null;
206
+ const context = {
207
+ confirmDelete: onClose => onDialog({
208
+ kind: "delete",
209
+ row: item,
210
+ onClose
211
+ })
212
+ };
213
+ const entries = actions.map(action => ({
214
+ id: action.id,
215
+ label: action.label,
216
+ danger: action.danger,
217
+ disabled: action.disabled,
218
+ onSelect: () => action.onSelect(item, context)
219
+ }));
220
+ return _jsx(RowMenuButton, {
221
+ label: ui.say("moreFor", {
222
+ collection: item.name
223
+ }),
224
+ entries: entries,
225
+ tabbable: tabbable,
226
+ refocus: true
227
+ });
228
+ }
229
+ /**
230
+ * A row's "…" button and its menu. Arrow keys move between enabled entries,
231
+ * Escape closes it and returns focus to the button; with `refocus`, choosing an
232
+ * entry returns focus there too, so a dialog it opens hands focus back to it.
233
+ */
234
+ function RowMenuButton({
235
+ label,
236
+ entries,
237
+ tabbable,
238
+ refocus = false
239
+ }) {
240
+ const [open, setOpen] = useState(false);
241
+ const trigger = useRef(null);
242
+ const menuId = useId();
243
+ const close = focus => {
244
+ setOpen(false);
245
+ if (focus) trigger.current?.focus();
246
+ };
247
+ return _jsxs(_Fragment, {
248
+ children: [_jsx("button", {
249
+ ref: trigger,
250
+ type: "button",
251
+ className: "mdbase-collections-more",
252
+ "aria-label": label,
253
+ "aria-haspopup": "menu",
254
+ "aria-expanded": open,
255
+ "aria-controls": open ? menuId : undefined,
256
+ tabIndex: tabbable ? 0 : -1,
257
+ onClick: () => setOpen(value => !value),
258
+ children: _jsxs("svg", {
259
+ viewBox: "0 0 16 16",
260
+ "aria-hidden": "true",
261
+ children: [_jsx("circle", {
262
+ cx: "3.5",
263
+ cy: "8",
264
+ r: "1.3"
265
+ }), _jsx("circle", {
266
+ cx: "8",
267
+ cy: "8",
268
+ r: "1.3"
269
+ }), _jsx("circle", {
270
+ cx: "12.5",
271
+ cy: "8",
272
+ r: "1.3"
273
+ })]
274
+ })
275
+ }), open && _jsx(RowMenu, {
276
+ id: menuId,
277
+ triggerRef: trigger,
278
+ onClose: close,
279
+ children: entries.map(entry => _jsx("button", {
280
+ type: "button",
281
+ role: "menuitem",
282
+ disabled: entry.disabled,
283
+ className: entry.danger ? "mdbase-collections-menu-item is-danger" : "mdbase-collections-menu-item",
284
+ onClick: () => {
285
+ if (entry.disabled) return;
286
+ close(refocus);
287
+ entry.onSelect();
288
+ },
289
+ children: entry.label
290
+ }, entry.id))
291
+ })]
292
+ });
293
+ }
294
+ function RowMenu({
295
+ id,
296
+ triggerRef,
297
+ onClose,
298
+ children
299
+ }) {
300
+ const menu = useRef(null);
301
+ useMenuPopover(menu, triggerRef, onClose, {
302
+ align: "end",
303
+ focus: '[role="menuitem"]'
304
+ });
305
+ return _jsx("div", {
306
+ ref: menu,
307
+ id: id,
308
+ className: "mdbase-collections-menu",
309
+ popover: "manual",
310
+ role: "menu",
311
+ tabIndex: -1,
312
+ onKeyDown: event => moveMenuFocus(event, menu.current),
313
+ children: children
314
+ });
315
+ }
316
+ /** The app's actions, Choose a different collection, Manage access, the account and Sign out: never under an error. */
317
+ export function GateFooter({
318
+ ui,
319
+ chooseAnother,
320
+ extra
321
+ }) {
322
+ const {
323
+ view,
324
+ copy,
325
+ busy,
326
+ actions
327
+ } = ui;
328
+ const hasExtra = extra !== undefined && extra !== null && extra !== false;
329
+ if (!view.signedIn && !chooseAnother && !hasExtra) return null;
330
+ return _jsxs("footer", {
331
+ className: "mdbase-collections-footer",
332
+ children: [hasExtra && _jsx("span", {
333
+ className: "mdbase-collections-footer-extra",
334
+ children: extra
335
+ }), chooseAnother && _jsx(ActionButton, {
336
+ tone: "quiet",
337
+ inert: busy !== null,
338
+ progress: busy === "choose_another" ? copy.chooseAnother : null,
339
+ onClick: actions.chooseAnother,
340
+ children: copy.chooseAnother
341
+ }), view.signedIn && view.links && _jsx("a", {
342
+ className: "mdbase-collections-link",
343
+ href: view.links.manageAccess,
344
+ target: "_blank",
345
+ rel: "noopener noreferrer",
346
+ children: copy.manageAccess
347
+ }), view.signedIn && _jsx(AccountLine, {
348
+ ui: ui
349
+ })]
350
+ });
351
+ }
352
+ export function AccountLine({
353
+ ui
354
+ }) {
355
+ const {
356
+ view,
357
+ copy,
358
+ busy,
359
+ actions,
360
+ say
361
+ } = ui;
362
+ return _jsxs("span", {
363
+ className: "mdbase-collections-account",
364
+ children: [view.account && _jsx("span", {
365
+ className: "mdbase-collections-account-label",
366
+ children: say("signedInAs", {
367
+ account: view.account
368
+ })
369
+ }), _jsx(ActionButton, {
370
+ tone: "quiet",
371
+ inert: busy !== null,
372
+ progress: busy === "sign_out" ? copy.signingOut : null,
373
+ onClick: actions.signOut,
374
+ children: copy.signOut
375
+ })]
376
+ });
377
+ }
378
+ /** A native modal <dialog>: the browser traps focus and makes the page inert; Escape cancels. */
379
+ export function CollectionDialog({
380
+ title,
381
+ alert = false,
382
+ busy = false,
383
+ onClose,
384
+ children,
385
+ footer
386
+ }) {
387
+ const ref = useRef(null);
388
+ const titleId = useId();
389
+ useLayoutEffect(() => {
390
+ const dialog = ref.current;
391
+ const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
392
+ if (dialog && !dialog.open) {
393
+ if (typeof dialog.showModal === "function") dialog.showModal();else dialog.setAttribute("open", "");
394
+ }
395
+ (dialog?.querySelector("[data-autofocus]") ?? dialog?.querySelector("button"))?.focus();
396
+ return () => {
397
+ if (dialog?.open && typeof dialog.close === "function") dialog.close();
398
+ if (previous?.isConnected) previous.focus();
399
+ };
400
+ }, []);
401
+ return _jsxs("dialog", {
402
+ ref: ref,
403
+ className: "mdbase-collections mdbase-collections-dialog",
404
+ role: alert ? "alertdialog" : undefined,
405
+ "aria-labelledby": titleId,
406
+ onKeyDown: event => {
407
+ event.stopPropagation();
408
+ if (event.key === "Escape") {
409
+ event.preventDefault();
410
+ if (!busy) onClose();
411
+ }
412
+ },
413
+ onCancel: event => {
414
+ event.preventDefault();
415
+ if (!busy) onClose();
416
+ },
417
+ children: [_jsx("h2", {
418
+ id: titleId,
419
+ className: "mdbase-collections-dialog-title",
420
+ children: title
421
+ }), _jsx("div", {
422
+ className: "mdbase-collections-dialog-body",
423
+ children: children
424
+ }), _jsx("div", {
425
+ className: "mdbase-collections-dialog-actions",
426
+ children: footer
427
+ })]
428
+ });
429
+ }
430
+ export function initials(name) {
431
+ const words = name.replace(/[^\p{L}\p{N}\s]/gu, " ").trim().split(/\s+/u).filter(Boolean);
432
+ const letters = words.slice(0, 2).map(word => String.fromCodePoint(word.codePointAt(0) ?? 63));
433
+ return (letters.join("") || "?").toLocaleUpperCase();
434
+ }
435
+ export function hueOf(id) {
436
+ let hash = 0;
437
+ for (const code of id) hash = (hash * 31 + (code.codePointAt(0) ?? 0)) % 360;
438
+ return hash;
439
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@mdbase-dev/ui/collections/testing`: for Node test runners that render the
3
+ * collection components without a DOM environment per file.
4
+ *
5
+ * The components' focus and popover code reads the global `window` and
6
+ * `document` (not the rendered element's `ownerDocument`), so the test's
7
+ * window must also be the global one.
8
+ */
9
+ /**
10
+ * Make `window` (a jsdom or happy-dom window) the global window and document
11
+ * for the collection components. Returns a function that restores the
12
+ * previous globals.
13
+ *
14
+ * ```ts
15
+ * const restore = shareTestDocument(new JSDOM("<!doctype html><body></body>").window);
16
+ * afterAll(restore);
17
+ * ```
18
+ */
19
+ export declare function shareTestDocument(window: object): () => void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `@mdbase-dev/ui/collections/testing`: for Node test runners that render the
3
+ * collection components without a DOM environment per file.
4
+ *
5
+ * The components' focus and popover code reads the global `window` and
6
+ * `document` (not the rendered element's `ownerDocument`), so the test's
7
+ * window must also be the global one.
8
+ */
9
+ const shared = ["window", "document", "navigator", "Node", "Element", "HTMLElement", "HTMLButtonElement", "HTMLDialogElement", "HTMLInputElement", "Event", "FocusEvent", "KeyboardEvent", "MouseEvent", "PointerEvent", "getComputedStyle", "matchMedia", "requestAnimationFrame", "cancelAnimationFrame"];
10
+ /**
11
+ * Make `window` (a jsdom or happy-dom window) the global window and document
12
+ * for the collection components. Returns a function that restores the
13
+ * previous globals.
14
+ *
15
+ * ```ts
16
+ * const restore = shareTestDocument(new JSDOM("<!doctype html><body></body>").window);
17
+ * afterAll(restore);
18
+ * ```
19
+ */
20
+ export function shareTestDocument(window) {
21
+ const global = globalThis;
22
+ const source = window;
23
+ const previous = new Map();
24
+ for (const name of shared) {
25
+ const value = name === "window" ? window : source[name];
26
+ if (value === undefined) continue;
27
+ previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
28
+ Object.defineProperty(globalThis, name, {
29
+ configurable: true,
30
+ writable: true,
31
+ value: typeof value === "function" && /^[a-z]/.test(name) ? value.bind(window) : value
32
+ });
33
+ }
34
+ return () => {
35
+ for (const [name, descriptor] of previous) {
36
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor);else delete global[name];
37
+ }
38
+ };
39
+ }