@mdbase-dev/ui 0.1.0-beta.126 → 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 (72) hide show
  1. package/README.md +159 -0
  2. package/css/collections.css +978 -0
  3. package/css/controls.css +26 -1
  4. package/css/screens.css +44 -2
  5. package/dist/AppSwitcher.js +187 -33
  6. package/dist/Checkbox.d.ts +14 -0
  7. package/dist/Checkbox.js +32 -0
  8. package/dist/CommandPalette.d.ts +4 -2
  9. package/dist/CommandPalette.js +275 -103
  10. package/dist/Dialog.js +72 -25
  11. package/dist/Feedback.js +763 -229
  12. package/dist/FeedbackMarkup.d.ts +1 -1
  13. package/dist/FeedbackMarkup.js +326 -125
  14. package/dist/FeedbackVerification.js +115 -68
  15. package/dist/SaveNotice.js +71 -28
  16. package/dist/Select.js +183 -135
  17. package/dist/SelectList.js +59 -14
  18. package/dist/ThemeSelect.js +55 -4
  19. package/dist/apps.d.ts +1 -0
  20. package/dist/apps.js +50 -29
  21. package/dist/brand.js +251 -68
  22. package/dist/codemirror.js +116 -87
  23. package/dist/collections/CollectionGate.d.ts +52 -0
  24. package/dist/collections/CollectionGate.js +456 -0
  25. package/dist/collections/CollectionSaveWarning.d.ts +10 -0
  26. package/dist/collections/CollectionSaveWarning.js +28 -0
  27. package/dist/collections/CollectionSwitcher.d.ts +26 -0
  28. package/dist/collections/CollectionSwitcher.js +579 -0
  29. package/dist/collections/SetupReview.d.ts +9 -0
  30. package/dist/collections/SetupReview.js +175 -0
  31. package/dist/collections/copy.d.ts +100 -0
  32. package/dist/collections/copy.js +937 -0
  33. package/dist/collections/dialogs.d.ts +27 -0
  34. package/dist/collections/dialogs.js +169 -0
  35. package/dist/collections/model.d.ts +146 -0
  36. package/dist/collections/model.js +337 -0
  37. package/dist/collections/mount.d.ts +18 -0
  38. package/dist/collections/mount.js +56 -0
  39. package/dist/collections/parts.d.ts +86 -0
  40. package/dist/collections/parts.js +439 -0
  41. package/dist/collections/testing.d.ts +19 -0
  42. package/dist/collections/testing.js +39 -0
  43. package/dist/collections/use-collection-ui.d.ts +140 -0
  44. package/dist/collections/use-collection-ui.js +236 -0
  45. package/dist/collections.d.ts +19 -0
  46. package/dist/collections.js +13 -0
  47. package/dist/feedback-data.d.ts +13 -12
  48. package/dist/feedback-data.js +365 -168
  49. package/dist/flock.d.ts +63 -0
  50. package/dist/flock.js +140 -0
  51. package/dist/i18n.d.ts +6 -0
  52. package/dist/i18n.js +71 -0
  53. package/dist/locales/de/messages.json +1 -0
  54. package/dist/locales/en/messages.json +1 -0
  55. package/dist/locales/es/messages.json +1 -0
  56. package/dist/locales/fr/messages.json +1 -0
  57. package/dist/locales/it/messages.json +1 -0
  58. package/dist/locales/ja/messages.json +1 -0
  59. package/dist/locales/ko/messages.json +1 -0
  60. package/dist/locales/pt/messages.json +1 -0
  61. package/dist/locales/ru/messages.json +1 -0
  62. package/dist/locales/zh/messages.json +1 -0
  63. package/dist/mark-activity.js +82 -75
  64. package/dist/permission-selection.js +27 -17
  65. package/dist/popover.js +138 -130
  66. package/dist/screens.d.ts +20 -3
  67. package/dist/screens.js +275 -6
  68. package/dist/select-model.js +34 -28
  69. package/dist/text-diff.d.ts +1 -0
  70. package/dist/text-diff.js +106 -74
  71. package/dist/theme.js +34 -40
  72. package/package.json +52 -7
@@ -1,76 +1,123 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { i18n as _i18n } from "@lingui/core";
3
+ import { useLocaleRevision } from "./i18n.js";
2
4
  import { useEffect, useRef, useState } from "react";
3
5
  let turnstileScript;
4
- export function FeedbackVerification({ siteKey, onToken }) {
5
- const container = useRef(null);
6
- const callback = useRef(onToken);
7
- const [error, setError] = useState("");
8
- const [attempt, setAttempt] = useState(0);
9
- callback.current = onToken;
10
- useEffect(() => {
11
- callback.current("");
12
- setError("");
13
- let active = true;
14
- let widgetId;
15
- void loadTurnstile().then(() => {
16
- if (!active || !container.current || !window.turnstile)
17
- return;
18
- widgetId = window.turnstile.render(container.current, {
19
- sitekey: siteKey,
20
- callback: (token) => { if (active) {
21
- setError("");
22
- callback.current(token);
23
- } },
24
- "expired-callback": () => { if (active)
25
- callback.current(""); },
26
- "error-callback": () => { if (active) {
27
- callback.current("");
28
- setError("Verification could not be completed. Try again.");
29
- } },
30
- action: "feedback",
31
- theme: "auto"
32
- });
33
- }).catch(() => {
34
- if (active)
35
- setError("Verification could not be loaded. Check your connection and try again.");
36
- });
37
- return () => {
38
- active = false;
39
- if (widgetId && window.turnstile)
40
- window.turnstile.remove(widgetId);
41
- };
42
- }, [siteKey, attempt]);
43
- return _jsxs("div", { className: "mdbase-feedback-verification", children: [_jsx("div", { ref: container }), error && _jsxs(_Fragment, { children: [_jsx("p", { className: "mdbase-feedback-error", role: "alert", children: error }), _jsx("button", { type: "button", onClick: () => setAttempt((value) => value + 1), children: "Retry verification" })] })] });
6
+ export function FeedbackVerification({
7
+ siteKey,
8
+ onToken
9
+ }) {
10
+ useLocaleRevision();
11
+ const container = useRef(null);
12
+ const callback = useRef(onToken);
13
+ const [error, setError] = useState("");
14
+ const [attempt, setAttempt] = useState(0);
15
+ callback.current = onToken;
16
+ useEffect(() => {
17
+ callback.current("");
18
+ setError("");
19
+ let active = true;
20
+ let widgetId;
21
+ void loadTurnstile().then(() => {
22
+ if (!active || !container.current || !window.turnstile) return;
23
+ widgetId = window.turnstile.render(container.current, {
24
+ sitekey: siteKey,
25
+ callback: token => {
26
+ if (active) {
27
+ setError("");
28
+ callback.current(token);
29
+ }
30
+ },
31
+ "expired-callback": () => {
32
+ if (active) callback.current("");
33
+ },
34
+ "error-callback": () => {
35
+ if (active) {
36
+ callback.current("");
37
+ setError(_i18n._(
38
+ /** i18n */
39
+ {
40
+ id: "kC9O29",
41
+ message: "Verification could not be completed. Try again."
42
+ }));
43
+ }
44
+ },
45
+ action: "feedback",
46
+ theme: "auto"
47
+ });
48
+ }).catch(() => {
49
+ if (active) setError(_i18n._(
50
+ /** i18n */
51
+ {
52
+ id: "gvuZjZ",
53
+ message: "Verification could not be loaded. Check your connection and try again."
54
+ }));
55
+ });
56
+ return () => {
57
+ active = false;
58
+ if (widgetId && window.turnstile) window.turnstile.remove(widgetId);
59
+ };
60
+ }, [siteKey, attempt]);
61
+ return _jsxs("div", {
62
+ className: "mdbase-feedback-verification",
63
+ children: [_jsx("div", {
64
+ ref: container
65
+ }), error && _jsxs(_Fragment, {
66
+ children: [_jsx("p", {
67
+ className: "mdbase-feedback-error",
68
+ role: "alert",
69
+ children: error
70
+ }), _jsx("button", {
71
+ type: "button",
72
+ onClick: () => setAttempt(value => value + 1),
73
+ children: _i18n._(
74
+ /** i18n */
75
+ {
76
+ id: "ak4q13",
77
+ message: "Retry verification"
78
+ })
79
+ })]
80
+ })]
81
+ });
44
82
  }
45
83
  function loadTurnstile() {
46
- if (window.turnstile)
47
- return Promise.resolve();
48
- if (turnstileScript)
49
- return turnstileScript;
50
- turnstileScript = new Promise((resolve, reject) => {
51
- const existing = document.querySelector('script[data-mdbase-turnstile="true"]');
52
- const script = existing ?? document.createElement("script");
53
- const cleanup = () => { clearTimeout(timer); script.removeEventListener("load", loaded); script.removeEventListener("error", failed); };
54
- const loaded = () => { cleanup(); if (window.turnstile)
55
- resolve();
56
- else
57
- reject(new Error("Turnstile API unavailable")); };
58
- const failed = () => { cleanup(); reject(new Error("Turnstile failed to load")); };
59
- const timer = setTimeout(failed, 15_000);
60
- script.addEventListener("load", loaded, { once: true });
61
- script.addEventListener("error", failed, { once: true });
62
- if (!existing) {
63
- script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
64
- script.async = true;
65
- script.defer = true;
66
- script.dataset.mdbaseTurnstile = "true";
67
- document.head.append(script);
68
- }
84
+ if (window.turnstile) return Promise.resolve();
85
+ if (turnstileScript) return turnstileScript;
86
+ turnstileScript = new Promise((resolve, reject) => {
87
+ const existing = document.querySelector('script[data-mdbase-turnstile="true"]');
88
+ const script = existing ?? document.createElement("script");
89
+ const cleanup = () => {
90
+ clearTimeout(timer);
91
+ script.removeEventListener("load", loaded);
92
+ script.removeEventListener("error", failed);
93
+ };
94
+ const loaded = () => {
95
+ cleanup();
96
+ if (window.turnstile) resolve();else reject(new Error("Turnstile API unavailable"));
97
+ };
98
+ const failed = () => {
99
+ cleanup();
100
+ reject(new Error("Turnstile failed to load"));
101
+ };
102
+ const timer = setTimeout(failed, 15_000);
103
+ script.addEventListener("load", loaded, {
104
+ once: true
69
105
  });
70
- turnstileScript = turnstileScript.catch((error) => {
71
- document.querySelector('script[data-mdbase-turnstile="true"]')?.remove();
72
- turnstileScript = undefined;
73
- throw error;
106
+ script.addEventListener("error", failed, {
107
+ once: true
74
108
  });
75
- return turnstileScript;
109
+ if (!existing) {
110
+ script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
111
+ script.async = true;
112
+ script.defer = true;
113
+ script.dataset.mdbaseTurnstile = "true";
114
+ document.head.append(script);
115
+ }
116
+ });
117
+ turnstileScript = turnstileScript.catch(error => {
118
+ document.querySelector('script[data-mdbase-turnstile="true"]')?.remove();
119
+ turnstileScript = undefined;
120
+ throw error;
121
+ });
122
+ return turnstileScript;
76
123
  }
@@ -1,21 +1,49 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { i18n as _i18n } from "@lingui/core";
3
+ import { useLocaleRevision } from "./i18n.js";
2
4
  import { useEffect, useRef } from "react";
3
5
  import { holdMdbaseMarkBusy, signalMdbaseMark } from "./mark-activity.js";
4
6
  /** A save shorter than this never sets the mark moving. */
5
7
  const SLOW_SAVE_MS = 600;
6
8
  /** The mark's reaction when a notice changes from `before` to `tone`; none when it first appears. */
7
9
  export function saveToneSignal(before, tone) {
8
- if (before === null || before === tone)
9
- return null;
10
- if (tone === "attention")
11
- return "error";
12
- return tone === "saved" && (before === "saving" || before === "pending") ? "saved" : null;
10
+ if (before === null || before === tone) return null;
11
+ if (tone === "attention") return "error";
12
+ return tone === "saved" && (before === "saving" || before === "pending") ? "saved" : null;
13
13
  }
14
14
  const defaultLabels = {
15
- saved: "Saved",
16
- saving: "Saving",
17
- pending: "Unsaved",
18
- attention: "Needs attention"
15
+ get saved() {
16
+ return _i18n._(
17
+ /** i18n */
18
+ {
19
+ id: "idD8Ev",
20
+ message: "Saved"
21
+ });
22
+ },
23
+ get saving() {
24
+ return _i18n._(
25
+ /** i18n */
26
+ {
27
+ id: "uF9ruK",
28
+ message: "Saving"
29
+ });
30
+ },
31
+ get pending() {
32
+ return _i18n._(
33
+ /** i18n */
34
+ {
35
+ id: "P74TOi",
36
+ message: "Unsaved"
37
+ });
38
+ },
39
+ get attention() {
40
+ return _i18n._(
41
+ /** i18n */
42
+ {
43
+ id: "fAUSZy",
44
+ message: "Needs attention"
45
+ });
46
+ }
19
47
  };
20
48
  /**
21
49
  * The minimal save notice every mdbase app shows beside what it edits. Put it inside an
@@ -25,23 +53,38 @@ const defaultLabels = {
25
53
  * The notice also drives the app's mark: a save that completes plays `saved`, a newly
26
54
  * raised problem plays `error`, and a slow save bounces the mark until it ends.
27
55
  */
28
- export function SaveNotice({ tone, label = defaultLabels[tone], className }) {
29
- const previous = useRef(null);
30
- useEffect(() => {
31
- const kind = saveToneSignal(previous.current, tone);
32
- previous.current = tone;
33
- if (kind)
34
- signalMdbaseMark(kind);
35
- }, [tone]);
36
- useEffect(() => {
37
- if (tone !== "saving")
38
- return;
39
- let release;
40
- const timer = setTimeout(() => { release = holdMdbaseMarkBusy("bounce"); }, SLOW_SAVE_MS);
41
- return () => {
42
- clearTimeout(timer);
43
- release?.();
44
- };
45
- }, [tone]);
46
- return _jsxs("span", { className: ["mdbase-save-notice", `is-${tone}`, tone === "saved" && "mdbase-settle", className].filter(Boolean).join(" "), "aria-live": "polite", children: [tone === "saved" && _jsx("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", children: _jsx("path", { d: "m3.5 8.5 3 3 6-7" }) }), label] });
56
+ export function SaveNotice({
57
+ tone,
58
+ label = defaultLabels[tone],
59
+ className
60
+ }) {
61
+ useLocaleRevision();
62
+ const previous = useRef(null);
63
+ useEffect(() => {
64
+ const kind = saveToneSignal(previous.current, tone);
65
+ previous.current = tone;
66
+ if (kind) signalMdbaseMark(kind);
67
+ }, [tone]);
68
+ useEffect(() => {
69
+ if (tone !== "saving") return;
70
+ let release;
71
+ const timer = setTimeout(() => {
72
+ release = holdMdbaseMarkBusy("bounce");
73
+ }, SLOW_SAVE_MS);
74
+ return () => {
75
+ clearTimeout(timer);
76
+ release?.();
77
+ };
78
+ }, [tone]);
79
+ return _jsxs("span", {
80
+ className: ["mdbase-save-notice", `is-${tone}`, tone === "saved" && "mdbase-settle", className].filter(Boolean).join(" "),
81
+ "aria-live": "polite",
82
+ children: [tone === "saved" && _jsx("svg", {
83
+ viewBox: "0 0 16 16",
84
+ "aria-hidden": "true",
85
+ children: _jsx("path", {
86
+ d: "m3.5 8.5 3 3 6-7"
87
+ })
88
+ }), label]
89
+ });
47
90
  }
package/dist/Select.js CHANGED
@@ -1,6 +1,6 @@
1
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 { edgeOption, extendTypeahead, flattenOptions, stepOption, typeaheadOption, } from "./select-model.js";
2
+ import { useId, useLayoutEffect, useRef, useState } from "react";
3
+ import { edgeOption, extendTypeahead, flattenOptions, stepOption, typeaheadOption } from "./select-model.js";
4
4
  import { SelectList } from "./SelectList.js";
5
5
  import { useSelectPopover } from "./popover.js";
6
6
  /**
@@ -9,150 +9,198 @@ import { useSelectPopover } from "./popover.js";
9
9
  * panels, menus) never cut it off, while it stays in the DOM beside its trigger: a click in
10
10
  * the list still counts as inside any menu that contains the select.
11
11
  */
12
- export function Select({ value, options: items, onChange, placeholder, className, disabled, onKeyDown, onClick, ref, ...trigger }) {
13
- const id = useId();
14
- const triggerRef = useRef(null);
15
- const listRef = useRef(null);
16
- const options = flattenOptions(items);
17
- const selected = options.findIndex((option) => option.value === value);
18
- const state = useSelectState(options, selected, value, onChange, disabled, triggerRef);
19
- useSelectPopover(state.open, triggerRef, listRef, () => state.close(false));
20
- useLayoutEffect(() => {
21
- if (state.open && listRef.current) {
22
- revealOption(listRef.current, state.active);
12
+ export function Select({
13
+ value,
14
+ options: items,
15
+ onChange,
16
+ placeholder,
17
+ className,
18
+ disabled,
19
+ onKeyDown,
20
+ onClick,
21
+ ref,
22
+ ...trigger
23
+ }) {
24
+ const id = useId();
25
+ const {
26
+ "aria-label": ariaLabel,
27
+ "aria-labelledby": ariaLabelledBy
28
+ } = trigger;
29
+ const triggerRef = useRef(null);
30
+ const listRef = useRef(null);
31
+ const options = flattenOptions(items);
32
+ const selected = options.findIndex(option => option.value === value);
33
+ const state = useSelectState(options, selected, value, onChange, disabled, triggerRef);
34
+ useSelectPopover(state.open, triggerRef, listRef, () => state.close(false));
35
+ useLayoutEffect(() => {
36
+ if (state.open && listRef.current) {
37
+ revealOption(listRef.current, state.active);
38
+ }
39
+ }, [state.active, state.open]);
40
+ const label = selected >= 0 ? options[selected]?.label : placeholder;
41
+ return _jsxs(_Fragment, {
42
+ children: [_jsxs("button", {
43
+ ...trigger,
44
+ ref: node => {
45
+ triggerRef.current = node;
46
+ if (typeof ref === "function") ref(node);else if (ref) ref.current = node;
47
+ },
48
+ type: "button",
49
+ role: "combobox",
50
+ className: ["mdbase-select", className].filter(Boolean).join(" "),
51
+ "data-value": value,
52
+ disabled: disabled,
53
+ "aria-haspopup": "listbox",
54
+ "aria-expanded": state.open,
55
+ "aria-controls": `${id}-list`,
56
+ onClick: event => {
57
+ onClick?.(event);
58
+ if (!event.defaultPrevented) {
59
+ state.toggle();
60
+ }
61
+ },
62
+ onKeyDown: event => {
63
+ onKeyDown?.(event);
64
+ if (!event.defaultPrevented) {
65
+ state.onTriggerKey(event);
23
66
  }
24
- }, [state.active, state.open]);
25
- const label = selected >= 0 ? options[selected]?.label : placeholder;
26
- return (_jsxs(_Fragment, { children: [_jsxs("button", { ...trigger, ref: (node) => {
27
- triggerRef.current = node;
28
- if (typeof ref === "function")
29
- ref(node);
30
- else if (ref)
31
- ref.current = node;
32
- }, type: "button", role: "combobox", className: ["mdbase-select", className].filter(Boolean).join(" "), "data-value": value, disabled: disabled, "aria-haspopup": "listbox", "aria-expanded": state.open, "aria-controls": `${id}-list`, onClick: (event) => {
33
- onClick?.(event);
34
- if (!event.defaultPrevented) {
35
- state.toggle();
36
- }
37
- }, onKeyDown: (event) => {
38
- onKeyDown?.(event);
39
- if (!event.defaultPrevented) {
40
- state.onTriggerKey(event);
41
- }
42
- }, children: [_jsx("span", { className: selected >= 0 ? "mdbase-select-value" : "mdbase-select-value is-empty", children: label ?? "" }), _jsx("svg", { className: "mdbase-select-chevron", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "m7 10 5 5 5-5" }) })] }), _jsx(SelectList, { id: id, listRef: listRef, items: items, selected: selected, active: state.active, open: state.open, label: trigger["aria-label"], labelledBy: trigger["aria-labelledby"], onActivate: state.setActive, onChoose: state.choose, onKeyDown: state.onListKey })] }));
67
+ },
68
+ children: [_jsx("span", {
69
+ className: selected >= 0 ? "mdbase-select-value" : "mdbase-select-value is-empty",
70
+ children: label ?? ""
71
+ }), _jsx("svg", {
72
+ className: "mdbase-select-chevron",
73
+ viewBox: "0 0 24 24",
74
+ "aria-hidden": "true",
75
+ children: _jsx("path", {
76
+ d: "m7 10 5 5 5-5"
77
+ })
78
+ })]
79
+ }), _jsx(SelectList, {
80
+ id: id,
81
+ listRef: listRef,
82
+ items: items,
83
+ selected: selected,
84
+ active: state.active,
85
+ open: state.open,
86
+ label: ariaLabel,
87
+ labelledBy: ariaLabelledBy,
88
+ onActivate: state.setActive,
89
+ onChoose: state.choose,
90
+ onKeyDown: state.onListKey
91
+ })]
92
+ });
43
93
  }
44
94
  function useSelectState(options, selected, value, onChange, disabled, triggerRef) {
45
- const [open, setOpen] = useState(false);
46
- const [active, setActive] = useState(-1);
47
- const typed = useRef({ text: "", at: 0 });
48
- const typeahead = (event, from) => {
49
- typed.current = extendTypeahead(typed.current, event.key, event.timeStamp);
50
- return typeaheadOption(options, typed.current.text, from);
51
- };
52
- const show = (index) => {
53
- if (!disabled && options.length) {
54
- setActive(index);
55
- setOpen(true);
56
- }
57
- };
58
- const close = (refocus) => {
59
- setOpen(false);
60
- if (refocus) {
61
- triggerRef.current?.focus();
62
- }
63
- };
64
- const commit = (index) => {
65
- const option = options[index];
66
- if (option && !option.disabled && option.value !== value) {
67
- onChange(option.value);
68
- }
69
- };
70
- const choose = (index) => {
71
- if (options[index] && !options[index].disabled) {
72
- close(true);
73
- commit(index);
74
- }
75
- };
76
- const initial = (key) => selected >= 0 ? selected : edgeOption(options, key === "ArrowUp" ? "last" : "first");
77
- const onTriggerKey = (event) => {
78
- if (disabled) {
79
- return;
80
- }
81
- if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
82
- event.preventDefault();
83
- show(initial(event.key));
84
- }
85
- else if (isPrintable(event)) {
86
- // Like a native select, typing on the closed control changes the value directly.
87
- commit(typeahead(event, selected));
88
- }
89
- };
90
- const moves = {
91
- ArrowDown: () => stepOption(options, active, 1),
92
- ArrowUp: () => stepOption(options, active, -1),
93
- Home: () => edgeOption(options, "first"),
94
- End: () => edgeOption(options, "last"),
95
- PageDown: () => stepPage(options, active, 1),
96
- PageUp: () => stepPage(options, active, -1),
97
- };
98
- const onListKey = (event) => {
99
- event.stopPropagation();
100
- const move = moves[event.key];
101
- if (move) {
102
- event.preventDefault();
103
- setActive(move());
104
- }
105
- else if (event.key === "Enter" || event.key === " ") {
106
- event.preventDefault();
107
- choose(active);
108
- }
109
- else if (event.key === "Escape" || event.key === "Tab") {
110
- if (event.key === "Escape") {
111
- event.preventDefault();
112
- }
113
- close(event.key === "Escape");
114
- }
115
- else if (isPrintable(event)) {
116
- const match = typeahead(event, active);
117
- setActive(match >= 0 ? match : active);
118
- }
119
- };
120
- return {
121
- open,
122
- active,
123
- setActive,
124
- close,
125
- choose,
126
- toggle: () => (open ? close(false) : show(initial("ArrowDown"))),
127
- onTriggerKey,
128
- onListKey,
129
- };
95
+ const [open, setOpen] = useState(false);
96
+ const [active, setActive] = useState(-1);
97
+ const typed = useRef({
98
+ text: "",
99
+ at: 0
100
+ });
101
+ const typeahead = (event, from) => {
102
+ typed.current = extendTypeahead(typed.current, event.key, event.timeStamp);
103
+ return typeaheadOption(options, typed.current.text, from);
104
+ };
105
+ const show = index => {
106
+ if (!disabled && options.length) {
107
+ setActive(index);
108
+ setOpen(true);
109
+ }
110
+ };
111
+ const close = refocus => {
112
+ setOpen(false);
113
+ if (refocus) {
114
+ triggerRef.current?.focus();
115
+ }
116
+ };
117
+ const commit = index => {
118
+ const option = options[index];
119
+ if (option && !option.disabled && option.value !== value) {
120
+ onChange(option.value);
121
+ }
122
+ };
123
+ const choose = index => {
124
+ if (options[index] && !options[index].disabled) {
125
+ close(true);
126
+ commit(index);
127
+ }
128
+ };
129
+ const initial = key => selected >= 0 ? selected : edgeOption(options, key === "ArrowUp" ? "last" : "first");
130
+ const onTriggerKey = event => {
131
+ if (disabled) {
132
+ return;
133
+ }
134
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
135
+ event.preventDefault();
136
+ show(initial(event.key));
137
+ } else if (isPrintable(event)) {
138
+ // Like a native select, typing on the closed control changes the value directly.
139
+ commit(typeahead(event, selected));
140
+ }
141
+ };
142
+ const moves = {
143
+ ArrowDown: () => stepOption(options, active, 1),
144
+ ArrowUp: () => stepOption(options, active, -1),
145
+ Home: () => edgeOption(options, "first"),
146
+ End: () => edgeOption(options, "last"),
147
+ PageDown: () => stepPage(options, active, 1),
148
+ PageUp: () => stepPage(options, active, -1)
149
+ };
150
+ const onListKey = event => {
151
+ event.stopPropagation();
152
+ const move = moves[event.key];
153
+ if (move) {
154
+ event.preventDefault();
155
+ setActive(move());
156
+ } else if (event.key === "Enter" || event.key === " ") {
157
+ event.preventDefault();
158
+ choose(active);
159
+ } else if (event.key === "Escape" || event.key === "Tab") {
160
+ if (event.key === "Escape") {
161
+ event.preventDefault();
162
+ }
163
+ close(event.key === "Escape");
164
+ } else if (isPrintable(event)) {
165
+ const match = typeahead(event, active);
166
+ setActive(match >= 0 ? match : active);
167
+ }
168
+ };
169
+ return {
170
+ open,
171
+ active,
172
+ setActive,
173
+ close,
174
+ choose,
175
+ toggle: () => open ? close(false) : show(initial("ArrowDown")),
176
+ onTriggerKey,
177
+ onListKey
178
+ };
130
179
  }
131
180
  function stepPage(options, from, direction) {
132
- let index = from;
133
- for (let step = 0; step < 8; step += 1) {
134
- index = stepOption(options, index, direction);
135
- }
136
- return index;
181
+ let index = from;
182
+ for (let step = 0; step < 8; step += 1) {
183
+ index = stepOption(options, index, direction);
184
+ }
185
+ return index;
137
186
  }
138
187
  /**
139
188
  * Scrolls only the list to show an option. scrollIntoView would also scroll the panels the
140
189
  * list sits inside in the DOM, which the open list treats as the page moving and closes.
141
190
  */
142
191
  function revealOption(list, index) {
143
- const option = list.querySelector(`[data-index="${String(index)}"]`);
144
- if (!option) {
145
- return;
146
- }
147
- const top = option.offsetTop;
148
- const bottom = top + option.offsetHeight;
149
- if (top < list.scrollTop) {
150
- list.scrollTop = top;
151
- }
152
- else if (bottom > list.scrollTop + list.clientHeight) {
153
- list.scrollTop = bottom - list.clientHeight;
154
- }
192
+ const option = list.querySelector(`[data-index="${String(index)}"]`);
193
+ if (!option) {
194
+ return;
195
+ }
196
+ const top = option.offsetTop;
197
+ const bottom = top + option.offsetHeight;
198
+ if (top < list.scrollTop) {
199
+ list.scrollTop = top;
200
+ } else if (bottom > list.scrollTop + list.clientHeight) {
201
+ list.scrollTop = bottom - list.clientHeight;
202
+ }
155
203
  }
156
204
  function isPrintable(event) {
157
- return (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey);
205
+ return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey;
158
206
  }