@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.
- package/README.md +159 -0
- package/css/brand.css +10 -10
- package/css/collections.css +978 -0
- package/css/controls.css +220 -43
- package/css/feedback.css +20 -20
- package/css/fonts.css +2 -3
- package/css/palette.css +10 -10
- package/css/screens.css +58 -16
- package/css/tokens.css +43 -3
- package/dist/AppSwitcher.js +187 -33
- package/dist/Checkbox.d.ts +14 -0
- package/dist/Checkbox.js +32 -0
- package/dist/CommandPalette.d.ts +4 -2
- package/dist/CommandPalette.js +275 -103
- package/dist/Dialog.js +72 -25
- package/dist/Feedback.js +763 -229
- package/dist/FeedbackMarkup.d.ts +1 -1
- package/dist/FeedbackMarkup.js +326 -125
- package/dist/FeedbackVerification.js +115 -68
- package/dist/SaveNotice.js +71 -28
- package/dist/Select.js +183 -135
- package/dist/SelectList.js +59 -14
- package/dist/ThemeSelect.js +55 -4
- package/dist/apps.d.ts +1 -0
- package/dist/apps.js +50 -29
- package/dist/brand.js +251 -68
- package/dist/codemirror.js +116 -87
- package/dist/collections/CollectionGate.d.ts +52 -0
- package/dist/collections/CollectionGate.js +456 -0
- package/dist/collections/CollectionSaveWarning.d.ts +10 -0
- package/dist/collections/CollectionSaveWarning.js +28 -0
- package/dist/collections/CollectionSwitcher.d.ts +26 -0
- package/dist/collections/CollectionSwitcher.js +579 -0
- package/dist/collections/SetupReview.d.ts +9 -0
- package/dist/collections/SetupReview.js +175 -0
- package/dist/collections/copy.d.ts +100 -0
- package/dist/collections/copy.js +937 -0
- package/dist/collections/dialogs.d.ts +27 -0
- package/dist/collections/dialogs.js +169 -0
- package/dist/collections/model.d.ts +146 -0
- package/dist/collections/model.js +337 -0
- package/dist/collections/mount.d.ts +18 -0
- package/dist/collections/mount.js +56 -0
- package/dist/collections/parts.d.ts +86 -0
- package/dist/collections/parts.js +439 -0
- package/dist/collections/testing.d.ts +19 -0
- package/dist/collections/testing.js +39 -0
- package/dist/collections/use-collection-ui.d.ts +140 -0
- package/dist/collections/use-collection-ui.js +236 -0
- package/dist/collections.d.ts +19 -0
- package/dist/collections.js +13 -0
- package/dist/feedback-data.d.ts +13 -12
- package/dist/feedback-data.js +365 -168
- package/dist/flock.d.ts +63 -0
- package/dist/flock.js +140 -0
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +71 -0
- package/dist/locales/de/messages.json +1 -0
- package/dist/locales/en/messages.json +1 -0
- package/dist/locales/es/messages.json +1 -0
- package/dist/locales/fr/messages.json +1 -0
- package/dist/locales/it/messages.json +1 -0
- package/dist/locales/ja/messages.json +1 -0
- package/dist/locales/ko/messages.json +1 -0
- package/dist/locales/pt/messages.json +1 -0
- package/dist/locales/ru/messages.json +1 -0
- package/dist/locales/zh/messages.json +1 -0
- package/dist/mark-activity.js +82 -75
- package/dist/permission-selection.js +27 -17
- package/dist/popover.js +138 -130
- package/dist/screens.d.ts +20 -3
- package/dist/screens.js +275 -6
- package/dist/select-model.js +34 -28
- package/dist/text-diff.d.ts +1 -0
- package/dist/text-diff.js +106 -74
- package/dist/theme.js +34 -40
- package/package.json +53 -8
package/dist/mark-activity.js
CHANGED
|
@@ -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 = {
|
|
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 = {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
87
|
+
listeners.add(listener);
|
|
88
|
+
return () => listeners.delete(listener);
|
|
80
89
|
}
|
|
81
90
|
export function getMdbaseMarkActivity() {
|
|
82
|
-
|
|
91
|
+
return snapshot;
|
|
83
92
|
}
|
|
84
93
|
export function useMdbaseMarkActivity() {
|
|
85
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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, {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
popover.
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
element.showPopover();
|
|
41
|
+
if (typeof element.showPopover === "function") element.showPopover();
|
|
34
42
|
}
|
|
35
43
|
function hideFromTopLayer(element) {
|
|
36
|
-
|
|
37
|
-
element.hidePopover();
|
|
44
|
+
if (typeof element.hidePopover === "function" && element.matches(":popover-open")) element.hidePopover();
|
|
38
45
|
}
|
|
39
46
|
function onScreen(element) {
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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, {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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(
|
|
9
|
-
|
|
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;
|