@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
@@ -1,9 +1,16 @@
1
1
  import { useEffect, useRef, useSyncExternalStore } from "react";
2
2
  /** Matches the signal animations in `motion.css`. */
3
- const SIGNAL_MS = { saved: 900, error: 1500 };
3
+ const SIGNAL_MS = {
4
+ saved: 900,
5
+ error: 1500
6
+ };
4
7
  /** Several notices often report the same save; one reaction is enough. */
5
8
  const REPEAT_MS = 400;
6
- const IDLE = { signal: null, progress: null, busy: null };
9
+ const IDLE = {
10
+ signal: null,
11
+ progress: null,
12
+ busy: null
13
+ };
7
14
  let snapshot = IDLE;
8
15
  let signal = null;
9
16
  let signalAt = 0;
@@ -13,109 +20,109 @@ const tasks = new Map();
13
20
  const holds = [];
14
21
  const listeners = new Set();
15
22
  function publish() {
16
- const progress = tasks.size
17
- ? [...tasks.values()].reduce((sum, fraction) => sum + fraction, 0) / tasks.size
18
- : null;
19
- snapshot = { signal, progress, busy: holds.at(-1)?.loop ?? null };
20
- for (const listener of listeners)
21
- listener();
23
+ const progress = tasks.size ? [...tasks.values()].reduce((sum, fraction) => sum + fraction, 0) / tasks.size : null;
24
+ snapshot = {
25
+ signal,
26
+ progress,
27
+ busy: holds.at(-1)?.loop ?? null
28
+ };
29
+ for (const listener of listeners) listener();
22
30
  }
23
31
  function clamp(fraction) {
24
- return Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
32
+ return Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
25
33
  }
26
34
  /** Plays a one-shot reaction on the mark. A fresh error is never covered by a save. */
27
35
  export function signalMdbaseMark(kind) {
28
- const now = Date.now();
29
- if (signal && now - signalAt < REPEAT_MS && (signal.kind === kind || signal.kind === "error"))
30
- return;
31
- signal = { kind, id: nextId++ };
32
- signalAt = now;
33
- clearTimeout(signalTimer);
34
- signalTimer = setTimeout(() => {
35
- signal = null;
36
- publish();
37
- }, SIGNAL_MS[kind]);
36
+ const now = Date.now();
37
+ if (signal && now - signalAt < REPEAT_MS && (signal.kind === kind || signal.kind === "error")) return;
38
+ signal = {
39
+ kind,
40
+ id: nextId++
41
+ };
42
+ signalAt = now;
43
+ clearTimeout(signalTimer);
44
+ signalTimer = setTimeout(() => {
45
+ signal = null;
38
46
  publish();
47
+ }, SIGNAL_MS[kind]);
48
+ publish();
39
49
  }
40
50
  /** Shows an operation's known progress on the mark until it finishes, fails or is cancelled. */
41
51
  export function trackMdbaseMarkProgress(initial = 0) {
42
- const id = nextId++;
43
- tasks.set(id, clamp(initial));
52
+ const id = nextId++;
53
+ tasks.set(id, clamp(initial));
54
+ publish();
55
+ const end = kind => {
56
+ if (!tasks.delete(id)) return;
44
57
  publish();
45
- const end = (kind) => {
46
- if (!tasks.delete(id))
47
- return;
48
- publish();
49
- if (kind)
50
- signalMdbaseMark(kind);
51
- };
52
- return {
53
- update(fraction) {
54
- if (!tasks.has(id))
55
- return;
56
- tasks.set(id, clamp(fraction));
57
- publish();
58
- },
59
- finish: () => end("saved"),
60
- fail: () => end("error"),
61
- cancel: () => end()
62
- };
58
+ if (kind) signalMdbaseMark(kind);
59
+ };
60
+ return {
61
+ update(fraction) {
62
+ if (!tasks.has(id)) return;
63
+ tasks.set(id, clamp(fraction));
64
+ publish();
65
+ },
66
+ finish: () => end("saved"),
67
+ fail: () => end("error"),
68
+ cancel: () => end()
69
+ };
63
70
  }
64
71
  /** Loops the mark until the returned release is called. Releasing twice is harmless. */
65
72
  export function holdMdbaseMarkBusy(loop) {
66
- const id = nextId++;
67
- holds.push({ id, loop });
73
+ const id = nextId++;
74
+ holds.push({
75
+ id,
76
+ loop
77
+ });
78
+ publish();
79
+ return () => {
80
+ const index = holds.findIndex(hold => hold.id === id);
81
+ if (index < 0) return;
82
+ holds.splice(index, 1);
68
83
  publish();
69
- return () => {
70
- const index = holds.findIndex((hold) => hold.id === id);
71
- if (index < 0)
72
- return;
73
- holds.splice(index, 1);
74
- publish();
75
- };
84
+ };
76
85
  }
77
86
  function subscribe(listener) {
78
- listeners.add(listener);
79
- return () => listeners.delete(listener);
87
+ listeners.add(listener);
88
+ return () => listeners.delete(listener);
80
89
  }
81
90
  export function getMdbaseMarkActivity() {
82
- return snapshot;
91
+ return snapshot;
83
92
  }
84
93
  export function useMdbaseMarkActivity() {
85
- return useSyncExternalStore(subscribe, getMdbaseMarkActivity, () => IDLE);
94
+ return useSyncExternalStore(subscribe, getMdbaseMarkActivity, () => IDLE);
86
95
  }
87
96
  /**
88
97
  * Shows `fraction` on the mark while it is a number. Becoming null or unmounting ends the
89
98
  * operation quietly; call `signalMdbaseMark` separately if the end deserves a reaction.
90
99
  */
91
100
  export function useMdbaseMarkProgress(fraction) {
92
- const handle = useRef(null);
93
- const tracking = typeof fraction === "number";
94
- useEffect(() => {
95
- if (!tracking)
96
- return;
97
- const progress = trackMdbaseMarkProgress();
98
- handle.current = progress;
99
- return () => {
100
- progress.cancel();
101
- handle.current = null;
102
- };
103
- }, [tracking]);
104
- useEffect(() => {
105
- if (typeof fraction === "number")
106
- handle.current?.update(fraction);
107
- }, [fraction]);
101
+ const handle = useRef(null);
102
+ const tracking = typeof fraction === "number";
103
+ useEffect(() => {
104
+ if (!tracking) return;
105
+ const progress = trackMdbaseMarkProgress();
106
+ handle.current = progress;
107
+ return () => {
108
+ progress.cancel();
109
+ handle.current = null;
110
+ };
111
+ }, [tracking]);
112
+ useEffect(() => {
113
+ if (typeof fraction === "number") handle.current?.update(fraction);
114
+ }, [fraction]);
108
115
  }
109
116
  /** Loops the mark while `loop` is set. */
110
117
  export function useMdbaseMarkBusy(loop) {
111
- useEffect(() => (loop ? holdMdbaseMarkBusy(loop) : undefined), [loop]);
118
+ useEffect(() => loop ? holdMdbaseMarkBusy(loop) : undefined, [loop]);
112
119
  }
113
120
  /** For tests: forget every signal, operation and hold. */
114
121
  export function resetMdbaseMarkActivity() {
115
- clearTimeout(signalTimer);
116
- signal = null;
117
- signalAt = 0;
118
- tasks.clear();
119
- holds.length = 0;
120
- publish();
122
+ clearTimeout(signalTimer);
123
+ signal = null;
124
+ signalAt = 0;
125
+ tasks.clear();
126
+ holds.length = 0;
127
+ publish();
121
128
  }
@@ -1,21 +1,31 @@
1
1
  import { useEffect, useState } from "react";
2
2
  /** A permission review survives polling, but never silently rebases onto changed access. */
3
3
  export function usePermissionSelection(id, allowed) {
4
- const key = JSON.stringify([id, [...allowed].sort()]);
5
- const [review, setReview] = useState(() => ({ key, selected: [...allowed], needsReview: false }));
6
- const changedExternally = review.key !== key;
7
- const needsReview = changedExternally && JSON.stringify([id, [...review.selected].sort()]) !== key;
8
- useEffect(() => {
9
- if (changedExternally)
10
- setReview({ key, selected: [...allowed], needsReview });
11
- }, [key, changedExternally, needsReview]);
12
- return {
13
- selected: changedExternally ? [...allowed] : review.selected,
14
- needsReview: changedExternally ? needsReview : review.needsReview,
15
- setSelected: (selected) => setReview((current) => ({
16
- ...current,
17
- selected: typeof selected === "function" ? selected(current.selected) : selected
18
- })),
19
- acknowledge: () => setReview((current) => ({ ...current, needsReview: false }))
20
- };
4
+ const key = JSON.stringify([id, [...allowed].sort()]);
5
+ const [review, setReview] = useState(() => ({
6
+ key,
7
+ selected: [...allowed],
8
+ needsReview: false
9
+ }));
10
+ const changedExternally = review.key !== key;
11
+ const needsReview = changedExternally && JSON.stringify([id, [...review.selected].sort()]) !== key;
12
+ useEffect(() => {
13
+ if (changedExternally) setReview({
14
+ key,
15
+ selected: [...allowed],
16
+ needsReview
17
+ });
18
+ }, [key, changedExternally, needsReview]);
19
+ return {
20
+ selected: changedExternally ? [...allowed] : review.selected,
21
+ needsReview: changedExternally ? needsReview : review.needsReview,
22
+ setSelected: selected => setReview(current => ({
23
+ ...current,
24
+ selected: typeof selected === "function" ? selected(current.selected) : selected
25
+ })),
26
+ acknowledge: () => setReview(current => ({
27
+ ...current,
28
+ needsReview: false
29
+ }))
30
+ };
21
31
  }
package/dist/popover.js CHANGED
@@ -3,160 +3,168 @@ import { useEffect, useEffectEvent, useLayoutEffect } from "react";
3
3
  * Where a popover opens beside its trigger: below when it fits, otherwise on whichever side
4
4
  * has more room, lined up with the trigger's start (or end) edge and kept inside the viewport.
5
5
  */
6
- export function anchoredPlacement(trigger, popover, viewport, { gap = 4, margin = 8, align = "start" } = {}) {
7
- const below = viewport.height - trigger.bottom - gap - margin;
8
- const above = trigger.top - gap - margin;
9
- const opensUp = popover.height > below && above > below;
10
- const room = Math.max(120, opensUp ? above : below);
11
- const height = Math.min(popover.height, room);
12
- const width = Math.max(popover.width, trigger.width);
13
- const start = align === "end" ? trigger.left + trigger.width - width : trigger.left;
14
- return {
15
- top: opensUp ? trigger.top - gap - height : trigger.bottom + gap,
16
- left: Math.max(margin, Math.min(start, viewport.width - width - margin)),
17
- maxHeight: room
18
- };
6
+ export function anchoredPlacement(trigger, popover, viewport, {
7
+ gap = 4,
8
+ margin = 8,
9
+ align = "start"
10
+ } = {}) {
11
+ const below = viewport.height - trigger.bottom - gap - margin;
12
+ const above = trigger.top - gap - margin;
13
+ const opensUp = popover.height > below && above > below;
14
+ const room = Math.max(120, opensUp ? above : below);
15
+ const height = Math.min(popover.height, room);
16
+ const width = Math.max(popover.width, trigger.width);
17
+ const start = align === "end" ? trigger.left + trigger.width - width : trigger.left;
18
+ return {
19
+ top: opensUp ? trigger.top - gap - height : trigger.bottom + gap,
20
+ left: Math.max(margin, Math.min(start, viewport.width - width - margin)),
21
+ maxHeight: room
22
+ };
19
23
  }
20
24
  function place(trigger, popover, options) {
21
- const box = trigger.getBoundingClientRect();
22
- if (options.matchWidth)
23
- popover.style.minWidth = `${box.width}px`;
24
- popover.style.maxHeight = "";
25
- const placement = anchoredPlacement(box, { width: popover.offsetWidth, height: popover.scrollHeight }, { width: window.innerWidth, height: window.innerHeight }, options);
26
- popover.style.top = `${placement.top}px`;
27
- popover.style.left = `${placement.left}px`;
28
- popover.style.maxHeight = `${placement.maxHeight}px`;
25
+ const box = trigger.getBoundingClientRect();
26
+ if (options.matchWidth) popover.style.minWidth = `${box.width}px`;
27
+ popover.style.maxHeight = "";
28
+ const placement = anchoredPlacement(box, {
29
+ width: popover.offsetWidth,
30
+ height: popover.scrollHeight
31
+ }, {
32
+ width: window.innerWidth,
33
+ height: window.innerHeight
34
+ }, options);
35
+ popover.style.top = `${placement.top}px`;
36
+ popover.style.left = `${placement.left}px`;
37
+ popover.style.maxHeight = `${placement.maxHeight}px`;
29
38
  }
30
39
  // Browsers without the Popover API (and jsdom) keep the element in place instead.
31
40
  function showInTopLayer(element) {
32
- if (typeof element.showPopover === "function")
33
- element.showPopover();
41
+ if (typeof element.showPopover === "function") element.showPopover();
34
42
  }
35
43
  function hideFromTopLayer(element) {
36
- if (typeof element.hidePopover === "function" && element.matches(":popover-open"))
37
- element.hidePopover();
44
+ if (typeof element.hidePopover === "function" && element.matches(":popover-open")) element.hidePopover();
38
45
  }
39
46
  function onScreen(element) {
40
- const box = element.getBoundingClientRect();
41
- return box.width > 0 && box.bottom > 0 && box.top < window.innerHeight
42
- && box.right > 0 && box.left < window.innerWidth;
47
+ const box = element.getBoundingClientRect();
48
+ return box.width > 0 && box.bottom > 0 && box.top < window.innerHeight && box.right > 0 && box.left < window.innerWidth;
43
49
  }
44
50
  /**
45
51
  * Shows a select's list in the top layer beside its trigger, focuses it, and dismisses it when
46
52
  * the pointer lands elsewhere or the page scrolls or resizes underneath it.
47
53
  */
48
54
  export function useSelectPopover(open, triggerRef, listRef, onDismiss) {
49
- const dismiss = useEffectEvent(onDismiss);
50
- useLayoutEffect(() => {
51
- const list = listRef.current;
52
- const trigger = triggerRef.current;
53
- if (!open || !list || !trigger)
54
- return undefined;
55
- showInTopLayer(list);
56
- place(trigger, list, { matchWidth: true });
57
- list.focus({ preventScroll: true });
58
- return () => {
59
- hideFromTopLayer(list);
60
- };
61
- }, [open, triggerRef, listRef]);
62
- useEffect(() => {
63
- const list = listRef.current;
64
- const trigger = triggerRef.current;
65
- if (!open || !list || !trigger)
66
- return undefined;
67
- const outside = (event) => {
68
- const target = event.target;
69
- if (target && !list.contains(target) && !trigger.contains(target))
70
- dismiss();
71
- };
72
- // Content under the list can move (a panel scrolls, a list re-renders): the list follows
73
- // its trigger, and closes only once the trigger has left the screen.
74
- const follow = (event) => {
75
- if (list.contains(event.target))
76
- return;
77
- if (onScreen(trigger))
78
- place(trigger, list, { matchWidth: true });
79
- else
80
- dismiss();
81
- };
82
- const onResize = () => dismiss();
83
- document.addEventListener("pointerdown", outside, true);
84
- document.addEventListener("scroll", follow, true);
85
- window.addEventListener("resize", onResize);
86
- return () => {
87
- document.removeEventListener("pointerdown", outside, true);
88
- document.removeEventListener("scroll", follow, true);
89
- window.removeEventListener("resize", onResize);
90
- };
91
- }, [open, triggerRef, listRef]);
55
+ const dismiss = useEffectEvent(onDismiss);
56
+ useLayoutEffect(() => {
57
+ const list = listRef.current;
58
+ const trigger = triggerRef.current;
59
+ if (!open || !list || !trigger) return undefined;
60
+ showInTopLayer(list);
61
+ place(trigger, list, {
62
+ matchWidth: true
63
+ });
64
+ list.focus({
65
+ preventScroll: true
66
+ });
67
+ return () => {
68
+ hideFromTopLayer(list);
69
+ };
70
+ }, [open, triggerRef, listRef]);
71
+ useEffect(() => {
72
+ const list = listRef.current;
73
+ const trigger = triggerRef.current;
74
+ if (!open || !list || !trigger) return undefined;
75
+ const outside = event => {
76
+ const target = event.target;
77
+ if (target && !list.contains(target) && !trigger.contains(target)) dismiss();
78
+ };
79
+ // Content under the list can move (a panel scrolls, a list re-renders): the list follows
80
+ // its trigger, and closes only once the trigger has left the screen.
81
+ const follow = event => {
82
+ if (list.contains(event.target)) return;
83
+ if (onScreen(trigger)) place(trigger, list, {
84
+ matchWidth: true
85
+ });else dismiss();
86
+ };
87
+ const onResize = () => dismiss();
88
+ document.addEventListener("pointerdown", outside, true);
89
+ document.addEventListener("scroll", follow, true);
90
+ window.addEventListener("resize", onResize);
91
+ return () => {
92
+ document.removeEventListener("pointerdown", outside, true);
93
+ document.removeEventListener("scroll", follow, true);
94
+ window.removeEventListener("resize", onResize);
95
+ };
96
+ }, [open, triggerRef, listRef]);
92
97
  }
93
98
  /**
94
99
  * Keeps a mounted menu in the top layer beside its trigger. It closes on an outside press,
95
100
  * Escape (returning focus to the trigger), Tab or a resize.
96
101
  */
97
- export function useMenuPopover(menuRef, triggerRef, onClose, { width, align = "start", focus = '[aria-checked="true"], [role^="menuitem"]', busy = false }) {
98
- const close = useEffectEvent(onClose);
99
- const locked = useEffectEvent(() => busy);
100
- useLayoutEffect(() => {
101
- const menu = menuRef.current;
102
- const trigger = triggerRef.current;
103
- if (!menu || !trigger)
104
- return undefined;
105
- showInTopLayer(menu);
106
- if (width !== undefined)
107
- menu.style.width = `${Math.min(width, window.innerWidth - 16)}px`;
108
- place(trigger, menu, { gap: 6, align, matchWidth: false });
109
- (menu.querySelector(focus) ?? menu).focus({ preventScroll: true });
110
- return () => {
111
- hideFromTopLayer(menu);
112
- };
113
- }, [menuRef, triggerRef, width, align, focus]);
114
- useEffect(() => {
115
- const onPointerDown = (event) => {
116
- const target = event.target;
117
- if (target && !locked() && !menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
118
- close(false);
119
- }
120
- };
121
- const onKeyDown = (event) => {
122
- if ((event.key === "Escape" || event.key === "Tab") && !locked()) {
123
- if (event.key === "Escape") {
124
- event.preventDefault();
125
- event.stopPropagation();
126
- }
127
- close(event.key === "Escape");
128
- }
129
- };
130
- const onResize = () => close(false);
131
- document.addEventListener("pointerdown", onPointerDown, true);
132
- document.addEventListener("keydown", onKeyDown, true);
133
- window.addEventListener("resize", onResize);
134
- return () => {
135
- document.removeEventListener("pointerdown", onPointerDown, true);
136
- document.removeEventListener("keydown", onKeyDown, true);
137
- window.removeEventListener("resize", onResize);
138
- };
139
- }, [menuRef, triggerRef]);
102
+ export function useMenuPopover(menuRef, triggerRef, onClose, {
103
+ width,
104
+ align = "start",
105
+ focus = '[aria-checked="true"], [role^="menuitem"]',
106
+ busy = false
107
+ }) {
108
+ const close = useEffectEvent(onClose);
109
+ const locked = useEffectEvent(() => busy);
110
+ useLayoutEffect(() => {
111
+ const menu = menuRef.current;
112
+ const trigger = triggerRef.current;
113
+ if (!menu || !trigger) return undefined;
114
+ showInTopLayer(menu);
115
+ if (width !== undefined) menu.style.width = `${Math.min(width, window.innerWidth - 16)}px`;
116
+ place(trigger, menu, {
117
+ gap: 6,
118
+ align,
119
+ matchWidth: false
120
+ });
121
+ (menu.querySelector(focus) ?? menu).focus({
122
+ preventScroll: true
123
+ });
124
+ return () => {
125
+ hideFromTopLayer(menu);
126
+ };
127
+ }, [menuRef, triggerRef, width, align, focus]);
128
+ useEffect(() => {
129
+ const onPointerDown = event => {
130
+ const target = event.target;
131
+ if (target && !locked() && !menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
132
+ close(false);
133
+ }
134
+ };
135
+ const onKeyDown = event => {
136
+ if ((event.key === "Escape" || event.key === "Tab") && !locked()) {
137
+ if (event.key === "Escape") {
138
+ event.preventDefault();
139
+ event.stopPropagation();
140
+ }
141
+ close(event.key === "Escape");
142
+ }
143
+ };
144
+ const onResize = () => close(false);
145
+ document.addEventListener("pointerdown", onPointerDown, true);
146
+ document.addEventListener("keydown", onKeyDown, true);
147
+ window.addEventListener("resize", onResize);
148
+ return () => {
149
+ document.removeEventListener("pointerdown", onPointerDown, true);
150
+ document.removeEventListener("keydown", onKeyDown, true);
151
+ window.removeEventListener("resize", onResize);
152
+ };
153
+ }, [menuRef, triggerRef]);
140
154
  }
141
155
  /**
142
156
  * Arrow keys, Home and End move between a menu's enabled items. From a text field inside the
143
157
  * menu (a filter), Home and End stay in the field and the arrows enter the list.
144
158
  */
145
159
  export function moveMenuFocus(event, menu) {
146
- if (!menu || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key))
147
- return;
148
- const items = [...menu.querySelectorAll('[role^="menuitem"]:not(:disabled)')];
149
- const inField = document.activeElement instanceof HTMLInputElement && menu.contains(document.activeElement);
150
- if (inField && (event.key === "Home" || event.key === "End"))
151
- return;
152
- event.preventDefault();
153
- const current = items.indexOf(document.activeElement);
154
- const next = event.key === "Home"
155
- ? 0
156
- : event.key === "End"
157
- ? items.length - 1
158
- : inField
159
- ? 0
160
- : (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
161
- items[next]?.focus({ preventScroll: true });
160
+ if (!menu || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
161
+ const items = [...menu.querySelectorAll('[role^="menuitem"]:not(:disabled)')];
162
+ const inField = document.activeElement instanceof HTMLInputElement && menu.contains(document.activeElement);
163
+ if (inField && (event.key === "Home" || event.key === "End")) return;
164
+ event.preventDefault();
165
+ const current = items.indexOf(document.activeElement);
166
+ const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : inField ? 0 : (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
167
+ items[next]?.focus({
168
+ preventScroll: true
169
+ });
162
170
  }
package/dist/screens.d.ts CHANGED
@@ -1,16 +1,27 @@
1
- import type { JSX, ReactNode } from "react";
1
+ import { type JSX, type ReactNode } from "react";
2
2
  import type { MdbaseAppId } from "./apps.js";
3
3
  /**
4
4
  * What an app shows while it opens a collection: its wordmark, scanning, and a quiet line
5
5
  * saying what it is waiting for. A failure keeps the same place, shakes the mark once and
6
6
  * offers a retry.
7
+ *
8
+ * With `entrance="flock"` it is the launch screen: a 42px mark that the flock flies onto
9
+ * from the edges of the screen, once per page, before it starts scanning. Pair it with
10
+ * `useLaunchEntrancePlaying()` so a fast open does not cut the entrance off.
7
11
  */
8
- export declare function OpeningScreen({ app, title, detail, error, onRetry }: {
9
- readonly app: MdbaseAppId;
12
+ export declare function OpeningScreen(props: {
13
+ /** A built-in app wordmark, or the name of an independent collection app. */
14
+ readonly app: MdbaseAppId | {
15
+ readonly name: string;
16
+ readonly mark?: ReactNode;
17
+ };
18
+ /** Embedded gates must not add a second main landmark. */
19
+ readonly layout?: "page" | "panel" | undefined;
10
20
  readonly title: string;
11
21
  readonly detail?: string | undefined;
12
22
  readonly error?: string | null | undefined;
13
23
  readonly onRetry?: (() => void) | undefined;
24
+ readonly entrance?: "flock" | undefined;
14
25
  }): JSX.Element;
15
26
  /**
16
27
  * The screen before a collection is open: the app's wordmark, a headline, what the app will
@@ -28,3 +39,9 @@ export declare function ConnectLayout({ app, title, lead, status, error, detail,
28
39
  readonly footnote?: ReactNode | undefined;
29
40
  readonly children?: ReactNode | undefined;
30
41
  }): JSX.Element;
42
+ /**
43
+ * True while the launch entrance is flying. An app that opens quickly keeps its
44
+ * opening screen until this is false, so the entrance is never cut off mid-flight;
45
+ * it lasts at most two seconds.
46
+ */
47
+ export declare function useLaunchEntrancePlaying(): boolean;