@orz/ui 0.0.0-stage → 0.1.0
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/LICENSE +29 -0
- package/README.md +541 -2
- package/dist/components/avatar.d.ts +14 -0
- package/dist/components/avatar.d.ts.map +1 -0
- package/dist/components/avatar.js +23 -0
- package/dist/components/avatar.js.map +1 -0
- package/dist/components/badge.d.ts +10 -0
- package/dist/components/badge.d.ts.map +1 -0
- package/dist/components/badge.js +26 -0
- package/dist/components/badge.js.map +1 -0
- package/dist/components/button.d.ts +11 -0
- package/dist/components/button.d.ts.map +1 -0
- package/dist/components/button.js +40 -0
- package/dist/components/button.js.map +1 -0
- package/dist/components/card.d.ts +12 -0
- package/dist/components/card.d.ts.map +1 -0
- package/dist/components/card.js +25 -0
- package/dist/components/card.js.map +1 -0
- package/dist/components/color-picker.d.ts +58 -0
- package/dist/components/color-picker.d.ts.map +1 -0
- package/dist/components/color-picker.js +950 -0
- package/dist/components/color-picker.js.map +1 -0
- package/dist/components/confirm-dialog.d.ts +26 -0
- package/dist/components/confirm-dialog.d.ts.map +1 -0
- package/dist/components/confirm-dialog.js +18 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/dialog.d.ts +26 -0
- package/dist/components/dialog.d.ts.map +1 -0
- package/dist/components/dialog.js +149 -0
- package/dist/components/dialog.js.map +1 -0
- package/dist/components/dropdown-menu.d.ts +30 -0
- package/dist/components/dropdown-menu.d.ts.map +1 -0
- package/dist/components/dropdown-menu.js +100 -0
- package/dist/components/dropdown-menu.js.map +1 -0
- package/dist/components/filter-menu.d.ts +98 -0
- package/dist/components/filter-menu.d.ts.map +1 -0
- package/dist/components/filter-menu.js +745 -0
- package/dist/components/filter-menu.js.map +1 -0
- package/dist/components/highlighted-input.d.ts +40 -0
- package/dist/components/highlighted-input.d.ts.map +1 -0
- package/dist/components/highlighted-input.js +95 -0
- package/dist/components/highlighted-input.js.map +1 -0
- package/dist/components/hover-card.d.ts +7 -0
- package/dist/components/hover-card.d.ts.map +1 -0
- package/dist/components/hover-card.js +22 -0
- package/dist/components/hover-card.js.map +1 -0
- package/dist/components/input.d.ts +4 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.js +7 -0
- package/dist/components/input.js.map +1 -0
- package/dist/components/label.d.ts +5 -0
- package/dist/components/label.d.ts.map +1 -0
- package/dist/components/label.js +8 -0
- package/dist/components/label.js.map +1 -0
- package/dist/components/link-chip.d.ts +67 -0
- package/dist/components/link-chip.d.ts.map +1 -0
- package/dist/components/link-chip.js +71 -0
- package/dist/components/link-chip.js.map +1 -0
- package/dist/components/load-failure.d.ts +24 -0
- package/dist/components/load-failure.d.ts.map +1 -0
- package/dist/components/load-failure.js +27 -0
- package/dist/components/load-failure.js.map +1 -0
- package/dist/components/load-more.d.ts +15 -0
- package/dist/components/load-more.d.ts.map +1 -0
- package/dist/components/load-more.js +25 -0
- package/dist/components/load-more.js.map +1 -0
- package/dist/components/media-controls.d.ts +50 -0
- package/dist/components/media-controls.d.ts.map +1 -0
- package/dist/components/media-controls.js +71 -0
- package/dist/components/media-controls.js.map +1 -0
- package/dist/components/popover.d.ts +9 -0
- package/dist/components/popover.d.ts.map +1 -0
- package/dist/components/popover.js +82 -0
- package/dist/components/popover.js.map +1 -0
- package/dist/components/query-box.d.ts +78 -0
- package/dist/components/query-box.d.ts.map +1 -0
- package/dist/components/query-box.js +332 -0
- package/dist/components/query-box.js.map +1 -0
- package/dist/components/query-highlight.d.ts +14 -0
- package/dist/components/query-highlight.d.ts.map +1 -0
- package/dist/components/query-highlight.js +132 -0
- package/dist/components/query-highlight.js.map +1 -0
- package/dist/components/select.d.ts +19 -0
- package/dist/components/select.d.ts.map +1 -0
- package/dist/components/select.js +38 -0
- package/dist/components/select.js.map +1 -0
- package/dist/components/separator.d.ts +5 -0
- package/dist/components/separator.d.ts.map +1 -0
- package/dist/components/separator.js +9 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/sheet.d.ts +16 -0
- package/dist/components/sheet.d.ts.map +1 -0
- package/dist/components/sheet.js +41 -0
- package/dist/components/sheet.js.map +1 -0
- package/dist/components/skeleton.d.ts +3 -0
- package/dist/components/skeleton.d.ts.map +1 -0
- package/dist/components/skeleton.js +7 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/slider.d.ts +8 -0
- package/dist/components/slider.d.ts.map +1 -0
- package/dist/components/slider.js +14 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sonner.d.ts +4 -0
- package/dist/components/sonner.d.ts.map +1 -0
- package/dist/components/sonner.js +26 -0
- package/dist/components/sonner.js.map +1 -0
- package/dist/components/switch.d.ts +5 -0
- package/dist/components/switch.d.ts.map +1 -0
- package/dist/components/switch.js +8 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/table.d.ts +11 -0
- package/dist/components/table.d.ts.map +1 -0
- package/dist/components/table.js +28 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/tabs.d.ts +8 -0
- package/dist/components/tabs.d.ts.map +1 -0
- package/dist/components/tabs.js +18 -0
- package/dist/components/tabs.js.map +1 -0
- package/dist/components/textarea.d.ts +4 -0
- package/dist/components/textarea.d.ts.map +1 -0
- package/dist/components/textarea.js +7 -0
- package/dist/components/textarea.js.map +1 -0
- package/dist/components/theme-editor/draft.d.ts +235 -0
- package/dist/components/theme-editor/draft.d.ts.map +1 -0
- package/dist/components/theme-editor/draft.js +638 -0
- package/dist/components/theme-editor/draft.js.map +1 -0
- package/dist/components/theme-editor/export-panel.d.ts +14 -0
- package/dist/components/theme-editor/export-panel.d.ts.map +1 -0
- package/dist/components/theme-editor/export-panel.js +39 -0
- package/dist/components/theme-editor/export-panel.js.map +1 -0
- package/dist/components/theme-editor/form.d.ts +69 -0
- package/dist/components/theme-editor/form.d.ts.map +1 -0
- package/dist/components/theme-editor/form.js +350 -0
- package/dist/components/theme-editor/form.js.map +1 -0
- package/dist/components/theme-editor/import-panel.d.ts +21 -0
- package/dist/components/theme-editor/import-panel.d.ts.map +1 -0
- package/dist/components/theme-editor/import-panel.js +117 -0
- package/dist/components/theme-editor/import-panel.js.map +1 -0
- package/dist/components/theme-editor/theme-list.d.ts +23 -0
- package/dist/components/theme-editor/theme-list.d.ts.map +1 -0
- package/dist/components/theme-editor/theme-list.js +42 -0
- package/dist/components/theme-editor/theme-list.js.map +1 -0
- package/dist/components/theme-editor/token-list.d.ts +30 -0
- package/dist/components/theme-editor/token-list.d.ts.map +1 -0
- package/dist/components/theme-editor/token-list.js +124 -0
- package/dist/components/theme-editor/token-list.js.map +1 -0
- package/dist/components/theme-editor/tokens.d.ts +37 -0
- package/dist/components/theme-editor/tokens.d.ts.map +1 -0
- package/dist/components/theme-editor/tokens.js +155 -0
- package/dist/components/theme-editor/tokens.js.map +1 -0
- package/dist/components/theme-editor.d.ts +26 -0
- package/dist/components/theme-editor.d.ts.map +1 -0
- package/dist/components/theme-editor.js +467 -0
- package/dist/components/theme-editor.js.map +1 -0
- package/dist/components/theme-menu.d.ts +28 -0
- package/dist/components/theme-menu.d.ts.map +1 -0
- package/dist/components/theme-menu.js +308 -0
- package/dist/components/theme-menu.js.map +1 -0
- package/dist/components/tooltip.d.ts +11 -0
- package/dist/components/tooltip.d.ts.map +1 -0
- package/dist/components/tooltip.js +18 -0
- package/dist/components/tooltip.js.map +1 -0
- package/dist/components/unsaved-changes-dialog.d.ts +39 -0
- package/dist/components/unsaved-changes-dialog.d.ts.map +1 -0
- package/dist/components/unsaved-changes-dialog.js +46 -0
- package/dist/components/unsaved-changes-dialog.js.map +1 -0
- package/dist/hooks/scroll-insets.d.ts +89 -0
- package/dist/hooks/scroll-insets.d.ts.map +1 -0
- package/dist/hooks/scroll-insets.js +265 -0
- package/dist/hooks/scroll-insets.js.map +1 -0
- package/dist/hooks/use-element-height.d.ts +11 -0
- package/dist/hooks/use-element-height.d.ts.map +1 -0
- package/dist/hooks/use-element-height.js +33 -0
- package/dist/hooks/use-element-height.js.map +1 -0
- package/dist/hooks/use-media-query.d.ts +22 -0
- package/dist/hooks/use-media-query.d.ts.map +1 -0
- package/dist/hooks/use-media-query.js +38 -0
- package/dist/hooks/use-media-query.js.map +1 -0
- package/dist/hooks/use-overlay-scrollbars.d.ts +31 -0
- package/dist/hooks/use-overlay-scrollbars.d.ts.map +1 -0
- package/dist/hooks/use-overlay-scrollbars.js +80 -0
- package/dist/hooks/use-overlay-scrollbars.js.map +1 -0
- package/dist/hooks/use-paged-append.d.ts +7 -0
- package/dist/hooks/use-paged-append.d.ts.map +1 -0
- package/dist/hooks/use-paged-append.js +53 -0
- package/dist/hooks/use-paged-append.js.map +1 -0
- package/dist/hooks/use-query-completion.d.ts +27 -0
- package/dist/hooks/use-query-completion.d.ts.map +1 -0
- package/dist/hooks/use-query-completion.js +100 -0
- package/dist/hooks/use-query-completion.js.map +1 -0
- package/dist/hooks/use-slash-shortcut.d.ts +9 -0
- package/dist/hooks/use-slash-shortcut.d.ts.map +1 -0
- package/dist/hooks/use-slash-shortcut.js +38 -0
- package/dist/hooks/use-slash-shortcut.js.map +1 -0
- package/dist/hooks/viewport-chrome.d.ts +50 -0
- package/dist/hooks/viewport-chrome.d.ts.map +1 -0
- package/dist/hooks/viewport-chrome.js +170 -0
- package/dist/hooks/viewport-chrome.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cell-width-table.d.ts +3 -0
- package/dist/lib/cell-width-table.d.ts.map +1 -0
- package/dist/lib/cell-width-table.js +6 -0
- package/dist/lib/cell-width-table.js.map +1 -0
- package/dist/lib/cell-width.d.ts +7 -0
- package/dist/lib/cell-width.d.ts.map +1 -0
- package/dist/lib/cell-width.js +50 -0
- package/dist/lib/cell-width.js.map +1 -0
- package/dist/lib/clipboard.d.ts +8 -0
- package/dist/lib/clipboard.d.ts.map +1 -0
- package/dist/lib/clipboard.js +19 -0
- package/dist/lib/clipboard.js.map +1 -0
- package/dist/lib/color.d.ts +40 -0
- package/dist/lib/color.d.ts.map +1 -0
- package/dist/lib/color.js +177 -0
- package/dist/lib/color.js.map +1 -0
- package/dist/lib/scroll-map.d.ts +20 -0
- package/dist/lib/scroll-map.d.ts.map +1 -0
- package/dist/lib/scroll-map.js +46 -0
- package/dist/lib/scroll-map.js.map +1 -0
- package/dist/lib/text-metrics.d.ts +33 -0
- package/dist/lib/text-metrics.d.ts.map +1 -0
- package/dist/lib/text-metrics.js +89 -0
- package/dist/lib/text-metrics.js.map +1 -0
- package/dist/lib/theme-file.d.ts +51 -0
- package/dist/lib/theme-file.d.ts.map +1 -0
- package/dist/lib/theme-file.js +511 -0
- package/dist/lib/theme-file.js.map +1 -0
- package/dist/lib/unsaved-guard.d.ts +33 -0
- package/dist/lib/unsaved-guard.d.ts.map +1 -0
- package/dist/lib/unsaved-guard.js +37 -0
- package/dist/lib/unsaved-guard.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/theme/custom.d.ts +8 -0
- package/dist/theme/custom.d.ts.map +1 -0
- package/dist/theme/custom.js +56 -0
- package/dist/theme/custom.js.map +1 -0
- package/dist/theme/index.d.ts +6 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +5 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/paint-guard.d.ts +20 -0
- package/dist/theme/paint-guard.d.ts.map +1 -0
- package/dist/theme/paint-guard.js +120 -0
- package/dist/theme/paint-guard.js.map +1 -0
- package/dist/theme/picks.d.ts +22 -0
- package/dist/theme/picks.d.ts.map +1 -0
- package/dist/theme/picks.js +43 -0
- package/dist/theme/picks.js.map +1 -0
- package/dist/theme/pref.d.ts +39 -0
- package/dist/theme/pref.d.ts.map +1 -0
- package/dist/theme/pref.js +129 -0
- package/dist/theme/pref.js.map +1 -0
- package/dist/theme/provider.d.ts +41 -0
- package/dist/theme/provider.d.ts.map +1 -0
- package/dist/theme/provider.js +103 -0
- package/dist/theme/provider.js.map +1 -0
- package/dist/theme/store.d.ts +104 -0
- package/dist/theme/store.d.ts.map +1 -0
- package/dist/theme/store.js +509 -0
- package/dist/theme/store.js.map +1 -0
- package/package.json +94 -3
- package/styles.css +79 -0
- package/theme.css +186 -0
- package/vendor/shadcn-tailwind.css +664 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { CheckIcon, ChevronRightIcon, SearchIcon } from "lucide-react";
|
|
3
|
+
import { Fragment, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { cn } from "../lib/utils.js";
|
|
5
|
+
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger, } from "./popover.js";
|
|
6
|
+
/** Case-folded, with diacritics dropped, so `frappe` finds `Frappé`. */
|
|
7
|
+
function fold(text) {
|
|
8
|
+
// A final sigma lowers to ς only at a word's end, which a single character
|
|
9
|
+
// folded on its own never is; σ for both keeps a text matching itself.
|
|
10
|
+
return text
|
|
11
|
+
.normalize("NFD")
|
|
12
|
+
.replace(/\p{M}/gu, "")
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/ς/g, "σ");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Where `query` occurs in `text`, folded as above, as a range of `text`'s own
|
|
18
|
+
* indices; `null` when it does not. An empty query matches at 0 with nothing
|
|
19
|
+
* to mark.
|
|
20
|
+
*/
|
|
21
|
+
export function matchText(text, query) {
|
|
22
|
+
const needle = fold(query);
|
|
23
|
+
if (needle === "")
|
|
24
|
+
return { start: 0, end: 0 };
|
|
25
|
+
// Folding changes lengths (é is one unit, then two, then one), so each
|
|
26
|
+
// folded unit remembers the source character it came from.
|
|
27
|
+
let folded = "";
|
|
28
|
+
const starts = [];
|
|
29
|
+
const ends = [];
|
|
30
|
+
let at = 0;
|
|
31
|
+
for (const char of text) {
|
|
32
|
+
const part = fold(char);
|
|
33
|
+
if (part === "" && ends.length > 0) {
|
|
34
|
+
// A combining mark already in the source joins its base letter's range.
|
|
35
|
+
ends[ends.length - 1] = at + char.length;
|
|
36
|
+
}
|
|
37
|
+
for (let i = 0; i < part.length; i++) {
|
|
38
|
+
starts.push(at);
|
|
39
|
+
ends.push(at + char.length);
|
|
40
|
+
}
|
|
41
|
+
folded += part;
|
|
42
|
+
at += char.length;
|
|
43
|
+
}
|
|
44
|
+
const hit = folded.indexOf(needle);
|
|
45
|
+
if (hit < 0)
|
|
46
|
+
return null;
|
|
47
|
+
return {
|
|
48
|
+
start: starts[hit],
|
|
49
|
+
end: ends[hit + needle.length - 1],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `text`, with the part `query` matched in a `<mark>`. One element, so a
|
|
54
|
+
* flex row lays it out as one item rather than three.
|
|
55
|
+
*/
|
|
56
|
+
export function MatchedText({ text, query }) {
|
|
57
|
+
const range = matchText(text, query);
|
|
58
|
+
if (!range || range.start === range.end)
|
|
59
|
+
return _jsx("span", { children: text });
|
|
60
|
+
return (_jsxs("span", { children: [text.slice(0, range.start), _jsx("mark", { className: "bg-transparent font-semibold text-inherit underline decoration-primary/60 underline-offset-2", children: text.slice(range.start, range.end) }), text.slice(range.end)] }));
|
|
61
|
+
}
|
|
62
|
+
const FOCUSABLE = 'input, textarea, select, button, a[href], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
|
|
63
|
+
const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
|
|
64
|
+
/** Focuses the first element after (or before) `from` in tab order that takes it. */
|
|
65
|
+
function focusBeside(from, backwards, skip) {
|
|
66
|
+
const all = [...document.querySelectorAll(TABBABLE)].filter((el) => el === from || (!skip?.contains(el) && el.tabIndex >= 0));
|
|
67
|
+
const index = all.indexOf(from);
|
|
68
|
+
const candidates = backwards
|
|
69
|
+
? all.slice(0, Math.max(index, 0)).reverse()
|
|
70
|
+
: all.slice(index + 1);
|
|
71
|
+
for (const el of candidates) {
|
|
72
|
+
el.focus({ preventScroll: false });
|
|
73
|
+
if (document.activeElement === el)
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Nothing beyond the trigger takes focus; keep it somewhere useful.
|
|
77
|
+
from.focus({ preventScroll: true });
|
|
78
|
+
}
|
|
79
|
+
/** Ray casting, the test Radix Menu's submenu grace area uses. */
|
|
80
|
+
function insidePolygon(point, polygon) {
|
|
81
|
+
let inside = false;
|
|
82
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
83
|
+
const a = polygon[i];
|
|
84
|
+
const b = polygon[j];
|
|
85
|
+
if (a.y > point.y !== b.y > point.y &&
|
|
86
|
+
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x) {
|
|
87
|
+
inside = !inside;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return inside;
|
|
91
|
+
}
|
|
92
|
+
/** How long the pointer may take crossing other rows toward an open submenu. */
|
|
93
|
+
const GRACE_MS = 300;
|
|
94
|
+
const ROW_CLASS = "group/option relative flex w-full cursor-default items-center rounded-md text-sm select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
|
|
95
|
+
const CELL_CLASS = "gap-1.5 py-1 pl-1.5";
|
|
96
|
+
const OPTION_CLASS = cn(ROW_CLASS, CELL_CLASS, "data-highlighted:bg-accent data-highlighted:text-accent-foreground");
|
|
97
|
+
/**
|
|
98
|
+
* A split row's left half, the part that picks the row. Only it lights up:
|
|
99
|
+
* the chevron's half opens the submenu, which hovering the row already did.
|
|
100
|
+
*/
|
|
101
|
+
const ACTION_CLASS = cn("relative flex min-w-0 flex-1 items-center self-stretch rounded-md pr-6.5", CELL_CLASS, "group-data-highlighted/option:bg-accent group-data-highlighted/option:text-accent-foreground");
|
|
102
|
+
function step(from, at, by) {
|
|
103
|
+
for (let i = at + by; i >= 0 && i < from.length; i += by) {
|
|
104
|
+
const row = from[i];
|
|
105
|
+
if (!row.disabled)
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
function FilterBox({ expanded, ...props }) {
|
|
111
|
+
return (_jsxs("div", { className: "flex items-center gap-2 border-b px-2.5", children: [_jsx(SearchIcon, { "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground" }), _jsx("input", { type: "text", role: "combobox", "aria-expanded": expanded, "aria-autocomplete": "list", autoComplete: "off", autoCorrect: "off", spellCheck: false, className: "h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground", ...props })] }));
|
|
112
|
+
}
|
|
113
|
+
function Mark({ className, children, }) {
|
|
114
|
+
return (_jsx("span", { className: cn("pointer-events-none absolute flex size-4 items-center justify-center", className), children: children }));
|
|
115
|
+
}
|
|
116
|
+
const isEscapeOfComposition = (event) => event.isComposing || event.keyCode === 229;
|
|
117
|
+
/**
|
|
118
|
+
* A popover with a filter box over a list, the combobox pattern: focus stays
|
|
119
|
+
* in the box, so typing always filters, and the arrow keys walk a highlight
|
|
120
|
+
* that `aria-activedescendant` follows. The caller filters; this component
|
|
121
|
+
* owns the surface, the highlight and the keys.
|
|
122
|
+
*/
|
|
123
|
+
export function FilterMenu({ ref, children, open: openProp, defaultOpen = false, onOpenChange, query, onQueryChange, sections, renderRow, selectedId, onHighlight, onSelect, getSubmenu, submenuFilter, selectedSubmenuId, submenuLabel, header, footer, placeholder = "Filter…", inputLabel = "Filter", listLabel, empty = (q) => (q === "" ? "Nothing to pick" : `No matches for "${q}"`), align = "start", side, sideOffset, className, }) {
|
|
124
|
+
const [openState, setOpenState] = useState(defaultOpen);
|
|
125
|
+
const open = openProp ?? openState;
|
|
126
|
+
const [highlight, setHighlightId] = useState(null);
|
|
127
|
+
// Non-null while the highlight is inside the highlighted row's submenu.
|
|
128
|
+
const [subHighlight, setSubHighlight] = useState(null);
|
|
129
|
+
// A submenu opens for a highlight the user moved, not for one resting on
|
|
130
|
+
// the committed row or the query's first hit.
|
|
131
|
+
const [moved, setMoved] = useState(false);
|
|
132
|
+
// Kept by parent, so passing back over the parent row on the way into its
|
|
133
|
+
// submenu does not wipe what was typed there.
|
|
134
|
+
const [subQuery, setSubQuery] = useState(null);
|
|
135
|
+
// The submenu's filter box has focus, and with it the keys.
|
|
136
|
+
const [subKeys, setSubKeys] = useState(false);
|
|
137
|
+
// Like `mode`: typing in the submenu's box puts its highlight on the first
|
|
138
|
+
// hit until the user moves it.
|
|
139
|
+
const subMode = useRef("user");
|
|
140
|
+
const reported = useRef(null);
|
|
141
|
+
const input = useRef(null);
|
|
142
|
+
const subInput = useRef(null);
|
|
143
|
+
const trigger = useRef(null);
|
|
144
|
+
const content = useRef(null);
|
|
145
|
+
const list = useRef(null);
|
|
146
|
+
const submenu = useRef(null);
|
|
147
|
+
const anchor = useRef(null);
|
|
148
|
+
const tabbing = useRef(null);
|
|
149
|
+
const grace = useRef(null);
|
|
150
|
+
const graceTimer = useRef(null);
|
|
151
|
+
const pointerIn = useRef(null);
|
|
152
|
+
// A row's leave fires at the same point as the move that crossed out of it,
|
|
153
|
+
// so the direction it left in is only known from the sample before.
|
|
154
|
+
const lastX = useRef(null);
|
|
155
|
+
/**
|
|
156
|
+
* Who placed the highlight: resting on the committed row, on the query's
|
|
157
|
+
* first hit, or by the user. The first two follow the list as it changes.
|
|
158
|
+
*/
|
|
159
|
+
const mode = useRef("rest");
|
|
160
|
+
const base = useId();
|
|
161
|
+
const listId = `${base}-list`;
|
|
162
|
+
const submenuId = `${base}-submenu`;
|
|
163
|
+
const visible = useMemo(() => sections.filter((section) => section.rows.length > 0), [sections]);
|
|
164
|
+
const rows = useMemo(() => visible.flatMap((s) => s.rows), [visible]);
|
|
165
|
+
const indexOf = useMemo(() => new Map(rows.map((row, i) => [row.id, i])), [rows]);
|
|
166
|
+
const optionId = (index) => `${base}-option-${index}`;
|
|
167
|
+
const subOptionId = (index) => `${base}-suboption-${index}`;
|
|
168
|
+
const onHighlightRef = useRef(onHighlight);
|
|
169
|
+
onHighlightRef.current = onHighlight;
|
|
170
|
+
const report = useCallback((row) => {
|
|
171
|
+
const id = row?.id ?? null;
|
|
172
|
+
if (reported.current === id)
|
|
173
|
+
return;
|
|
174
|
+
reported.current = id;
|
|
175
|
+
onHighlightRef.current?.(row);
|
|
176
|
+
}, []);
|
|
177
|
+
// Moving the highlight in the list always leaves the submenu.
|
|
178
|
+
const setHighlightState = useCallback((id, byUser = false) => {
|
|
179
|
+
setHighlightId(id);
|
|
180
|
+
setSubHighlight(null);
|
|
181
|
+
setSubKeys(false);
|
|
182
|
+
subMode.current = "user";
|
|
183
|
+
setMoved(byUser);
|
|
184
|
+
}, []);
|
|
185
|
+
/** A highlight the user moved, reported as such. */
|
|
186
|
+
const move = (row) => {
|
|
187
|
+
mode.current = "user";
|
|
188
|
+
setHighlightState(row?.id ?? null, true);
|
|
189
|
+
report(row);
|
|
190
|
+
};
|
|
191
|
+
const committed = selectedId !== undefined && indexOf.has(selectedId) ? selectedId : null;
|
|
192
|
+
/** The highlight back on the committed row, which shows no preview. */
|
|
193
|
+
const rest = useCallback(() => {
|
|
194
|
+
mode.current = "rest";
|
|
195
|
+
setHighlightState(committed);
|
|
196
|
+
report(null);
|
|
197
|
+
}, [committed, report, setHighlightState]);
|
|
198
|
+
useImperativeHandle(ref, () => ({ resetHighlight: rest }), [rest]);
|
|
199
|
+
const setOpen = (next) => {
|
|
200
|
+
if (next === open)
|
|
201
|
+
return;
|
|
202
|
+
// Cleared here as well as below, so the opening render is already
|
|
203
|
+
// unfiltered rather than one pass later.
|
|
204
|
+
if (next)
|
|
205
|
+
onQueryChange("");
|
|
206
|
+
setOpenState(next);
|
|
207
|
+
onOpenChange?.(next);
|
|
208
|
+
};
|
|
209
|
+
// In an effect rather than setOpen, so a controlled `open` that the parent
|
|
210
|
+
// flips gets the same reset and the same closing null.
|
|
211
|
+
const wasOpen = useRef(false);
|
|
212
|
+
const onQueryChangeRef = useRef(onQueryChange);
|
|
213
|
+
onQueryChangeRef.current = onQueryChange;
|
|
214
|
+
useLayoutEffect(() => {
|
|
215
|
+
if (open === wasOpen.current)
|
|
216
|
+
return;
|
|
217
|
+
wasOpen.current = open;
|
|
218
|
+
if (!open)
|
|
219
|
+
report(null);
|
|
220
|
+
else {
|
|
221
|
+
setSubQuery(null);
|
|
222
|
+
if (query !== "")
|
|
223
|
+
onQueryChangeRef.current("");
|
|
224
|
+
}
|
|
225
|
+
}, [open, query, report]);
|
|
226
|
+
const highlightIndex = highlight === null ? -1 : (indexOf.get(highlight) ?? -1);
|
|
227
|
+
const parent = highlightIndex < 0 ? undefined : rows[highlightIndex];
|
|
228
|
+
const hasSubmenu = (row) => (getSubmenu?.(row, "")?.length ?? 0) > 0;
|
|
229
|
+
const subFilter = parent && hasSubmenu(parent) ? submenuFilter?.(parent) : undefined;
|
|
230
|
+
const ownQuery = subFilter && subQuery && subQuery.parent === parent?.id ? subQuery : null;
|
|
231
|
+
const subText = ownQuery?.text ?? "";
|
|
232
|
+
const subRows = useMemo(() => (parent && getSubmenu?.(parent, subText)) || [], [parent, getSubmenu, subText]);
|
|
233
|
+
const submenuOpen = open && moved && parent !== undefined && hasSubmenu(parent);
|
|
234
|
+
const subBox = submenuOpen && subFilter !== undefined;
|
|
235
|
+
const inSubBox = subBox && subKeys;
|
|
236
|
+
const subIndex = subHighlight === null
|
|
237
|
+
? -1
|
|
238
|
+
: subRows.findIndex((row) => row.id === subHighlight);
|
|
239
|
+
// Radix only flips a side submenu to the other side and never shifts it
|
|
240
|
+
// sideways, so where neither side has room it opens below the row instead.
|
|
241
|
+
const [subBelow, setSubBelow] = useState(false);
|
|
242
|
+
const placeSubmenu = useCallback(() => {
|
|
243
|
+
const surface = content.current?.getBoundingClientRect();
|
|
244
|
+
const width = submenu.current?.offsetWidth;
|
|
245
|
+
if (!surface || !width)
|
|
246
|
+
return;
|
|
247
|
+
const room = document.documentElement.clientWidth;
|
|
248
|
+
setSubBelow(surface.right + width > room && surface.left - width < 0);
|
|
249
|
+
}, []);
|
|
250
|
+
// Not an effect on opening: the portal mounts the submenu a commit later.
|
|
251
|
+
const submenuRef = useCallback((el) => {
|
|
252
|
+
submenu.current = el;
|
|
253
|
+
if (el)
|
|
254
|
+
placeSubmenu();
|
|
255
|
+
}, [placeSubmenu]);
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!submenuOpen)
|
|
258
|
+
return;
|
|
259
|
+
window.addEventListener("resize", placeSubmenu);
|
|
260
|
+
return () => window.removeEventListener("resize", placeSubmenu);
|
|
261
|
+
}, [submenuOpen, placeSubmenu]);
|
|
262
|
+
// Typing puts the highlight on the best hit, so Enter takes it; clearing the
|
|
263
|
+
// query puts it back on the committed row. Until the user moves it, it keeps
|
|
264
|
+
// following those as the list changes (a late load, a filter that lags the
|
|
265
|
+
// query); a row of theirs that leaves the list takes the highlight with it.
|
|
266
|
+
const lastQuery = useRef(null);
|
|
267
|
+
useLayoutEffect(() => {
|
|
268
|
+
if (!open) {
|
|
269
|
+
lastQuery.current = null;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (lastQuery.current !== query) {
|
|
273
|
+
const opening = lastQuery.current === null;
|
|
274
|
+
lastQuery.current = query;
|
|
275
|
+
mode.current = query === "" || opening ? "rest" : "hit";
|
|
276
|
+
}
|
|
277
|
+
if (mode.current === "rest") {
|
|
278
|
+
setHighlightState(committed);
|
|
279
|
+
report(null);
|
|
280
|
+
}
|
|
281
|
+
else if (mode.current === "hit") {
|
|
282
|
+
const first = rows.find((row) => !row.disabled) ?? null;
|
|
283
|
+
setHighlightState(first?.id ?? null);
|
|
284
|
+
report(first);
|
|
285
|
+
}
|
|
286
|
+
else if (highlight !== null && !indexOf.has(highlight)) {
|
|
287
|
+
setHighlightState(null);
|
|
288
|
+
report(null);
|
|
289
|
+
}
|
|
290
|
+
else if (inSubBox && subMode.current === "hit") {
|
|
291
|
+
const first = step(subRows, -1, 1) ?? null;
|
|
292
|
+
if ((first?.id ?? null) !== subHighlight) {
|
|
293
|
+
setSubHighlight(first?.id ?? null);
|
|
294
|
+
report(first ?? parent ?? null);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (subHighlight !== null && subIndex < 0) {
|
|
298
|
+
// The submenu row is gone; the parent row still stands.
|
|
299
|
+
setSubHighlight(null);
|
|
300
|
+
report(parent ?? null);
|
|
301
|
+
}
|
|
302
|
+
}, [
|
|
303
|
+
open,
|
|
304
|
+
query,
|
|
305
|
+
rows,
|
|
306
|
+
indexOf,
|
|
307
|
+
highlight,
|
|
308
|
+
committed,
|
|
309
|
+
report,
|
|
310
|
+
setHighlightState,
|
|
311
|
+
subHighlight,
|
|
312
|
+
subIndex,
|
|
313
|
+
subRows,
|
|
314
|
+
inSubBox,
|
|
315
|
+
parent,
|
|
316
|
+
]);
|
|
317
|
+
// Focus follows whichever box has the keys. Only between the two boxes, or
|
|
318
|
+
// back from nowhere when the submenu took the focused box away with it.
|
|
319
|
+
useLayoutEffect(() => {
|
|
320
|
+
if (!open)
|
|
321
|
+
return;
|
|
322
|
+
const target = inSubBox ? subInput.current : input.current;
|
|
323
|
+
const active = document.activeElement;
|
|
324
|
+
if (!target || active === target)
|
|
325
|
+
return;
|
|
326
|
+
if (active === null ||
|
|
327
|
+
active === document.body ||
|
|
328
|
+
active === input.current ||
|
|
329
|
+
active === subInput.current) {
|
|
330
|
+
target.focus({ preventScroll: true });
|
|
331
|
+
}
|
|
332
|
+
}, [open, inSubBox]);
|
|
333
|
+
const inSubBoxRef = useRef(inSubBox);
|
|
334
|
+
inSubBoxRef.current = inSubBox;
|
|
335
|
+
// The submenu mounts a render after it opens, so a box entered by → is not
|
|
336
|
+
// there yet when the effect above runs.
|
|
337
|
+
const subInputRef = useCallback((el) => {
|
|
338
|
+
subInput.current = el;
|
|
339
|
+
if (el && inSubBoxRef.current)
|
|
340
|
+
el.focus({ preventScroll: true });
|
|
341
|
+
}, []);
|
|
342
|
+
const revealHighlight = useRef(() => { });
|
|
343
|
+
revealHighlight.current = () => {
|
|
344
|
+
if (!open)
|
|
345
|
+
return;
|
|
346
|
+
const id = subIndex >= 0
|
|
347
|
+
? subOptionId(subIndex)
|
|
348
|
+
: highlightIndex >= 0
|
|
349
|
+
? optionId(highlightIndex)
|
|
350
|
+
: null;
|
|
351
|
+
if (id)
|
|
352
|
+
document.getElementById(id)?.scrollIntoView?.({ block: "nearest" });
|
|
353
|
+
};
|
|
354
|
+
useLayoutEffect(() => revealHighlight.current());
|
|
355
|
+
// The effect above runs before the portal has mounted the list on open, and
|
|
356
|
+
// the list only gets its height once the surface is placed, so the
|
|
357
|
+
// committed row is revealed again as the list takes a size. Only until the
|
|
358
|
+
// user scrolls it: a page scroll or a window resize resizes it as well.
|
|
359
|
+
const revealOnResize = useRef(false);
|
|
360
|
+
const stopRevealOnResize = () => {
|
|
361
|
+
revealOnResize.current = false;
|
|
362
|
+
};
|
|
363
|
+
const listRef = useCallback((el) => {
|
|
364
|
+
list.current = el;
|
|
365
|
+
if (el === null || typeof ResizeObserver === "undefined")
|
|
366
|
+
return;
|
|
367
|
+
revealOnResize.current = true;
|
|
368
|
+
const observer = new ResizeObserver(() => {
|
|
369
|
+
if (revealOnResize.current)
|
|
370
|
+
revealHighlight.current();
|
|
371
|
+
});
|
|
372
|
+
observer.observe(el);
|
|
373
|
+
// With a cleanup returned, React no longer calls the ref with null.
|
|
374
|
+
return () => {
|
|
375
|
+
observer.disconnect();
|
|
376
|
+
list.current = null;
|
|
377
|
+
};
|
|
378
|
+
}, []);
|
|
379
|
+
const clearGrace = useCallback(() => {
|
|
380
|
+
grace.current = null;
|
|
381
|
+
if (graceTimer.current !== null)
|
|
382
|
+
clearTimeout(graceTimer.current);
|
|
383
|
+
graceTimer.current = null;
|
|
384
|
+
}, []);
|
|
385
|
+
useEffect(() => clearGrace, [clearGrace]);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!open)
|
|
388
|
+
clearGrace();
|
|
389
|
+
}, [open, clearGrace]);
|
|
390
|
+
/** `heading: false` for the list's own leave, which fires where the row's did. */
|
|
391
|
+
const inGrace = (event, heading = true) => {
|
|
392
|
+
const area = grace.current;
|
|
393
|
+
if (area === null || Date.now() >= area.until)
|
|
394
|
+
return false;
|
|
395
|
+
// As Radix does, only while heading for the submenu's side: a pointer
|
|
396
|
+
// moving straight down the list grazes the triangle's thin edge. A sample
|
|
397
|
+
// with no sideways movement keeps the last direction.
|
|
398
|
+
if (event.clientX !== area.x) {
|
|
399
|
+
area.towards = area.right === event.clientX > area.x;
|
|
400
|
+
area.x = event.clientX;
|
|
401
|
+
}
|
|
402
|
+
return ((area.towards || !heading) &&
|
|
403
|
+
insidePolygon({ x: event.clientX, y: event.clientY }, area.polygon));
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* On the menu's own border where a submenu sits flush against it: the one
|
|
407
|
+
* pixel between the list and the submenu, crossed either way.
|
|
408
|
+
*/
|
|
409
|
+
const onSeam = (event) => {
|
|
410
|
+
const rect = submenu.current?.getBoundingClientRect();
|
|
411
|
+
if (!rect || event.relatedTarget !== content.current)
|
|
412
|
+
return false;
|
|
413
|
+
const { clientX: x, clientY: y } = event;
|
|
414
|
+
return (y >= rect.top &&
|
|
415
|
+
y <= rect.bottom &&
|
|
416
|
+
Math.min(Math.abs(x - rect.left), Math.abs(x - rect.right)) <= 2);
|
|
417
|
+
};
|
|
418
|
+
/** The highlight goes after `ms` unless the pointer is back in the list or the submenu. */
|
|
419
|
+
const closeUnlessBack = (ms) => {
|
|
420
|
+
if (graceTimer.current !== null)
|
|
421
|
+
clearTimeout(graceTimer.current);
|
|
422
|
+
graceTimer.current = setTimeout(() => {
|
|
423
|
+
graceTimer.current = null;
|
|
424
|
+
if (pointerIn.current === null)
|
|
425
|
+
move(null);
|
|
426
|
+
}, ms);
|
|
427
|
+
};
|
|
428
|
+
const choose = (row) => {
|
|
429
|
+
if (row.disabled)
|
|
430
|
+
return;
|
|
431
|
+
onSelect(row);
|
|
432
|
+
setOpen(false);
|
|
433
|
+
};
|
|
434
|
+
const moveIntoSubmenu = (row) => {
|
|
435
|
+
mode.current = "user";
|
|
436
|
+
subMode.current = "user";
|
|
437
|
+
clearGrace();
|
|
438
|
+
setMoved(true);
|
|
439
|
+
setSubHighlight(row.id);
|
|
440
|
+
report(row);
|
|
441
|
+
};
|
|
442
|
+
/** The submenu's filter box takes the keys; its highlight stays put. */
|
|
443
|
+
const enterSubBox = () => {
|
|
444
|
+
mode.current = "user";
|
|
445
|
+
clearGrace();
|
|
446
|
+
setMoved(true);
|
|
447
|
+
setSubKeys(true);
|
|
448
|
+
};
|
|
449
|
+
const backToParent = () => {
|
|
450
|
+
setSubHighlight(null);
|
|
451
|
+
setSubKeys(false);
|
|
452
|
+
subMode.current = "user";
|
|
453
|
+
report(parent ?? null);
|
|
454
|
+
};
|
|
455
|
+
/** Both boxes' keys; `box` says which one they were pressed in. */
|
|
456
|
+
const onKeyDown = (event, box) => {
|
|
457
|
+
// Safari ends a composition with an Enter whose isComposing is already
|
|
458
|
+
// false; keyCode 229 still marks it as the IME's.
|
|
459
|
+
if (event.nativeEvent.isComposing || event.keyCode === 229)
|
|
460
|
+
return;
|
|
461
|
+
const inSubmenu = box === "submenu" || subIndex >= 0;
|
|
462
|
+
const from = inSubmenu ? subRows : rows;
|
|
463
|
+
const at = inSubmenu ? subIndex : highlightIndex;
|
|
464
|
+
const field = event.currentTarget;
|
|
465
|
+
let next;
|
|
466
|
+
switch (event.key) {
|
|
467
|
+
case "ArrowDown":
|
|
468
|
+
next = step(from, at < 0 ? -1 : at, 1);
|
|
469
|
+
break;
|
|
470
|
+
case "ArrowUp":
|
|
471
|
+
next = step(from, at < 0 ? from.length : at, -1);
|
|
472
|
+
break;
|
|
473
|
+
case "Home":
|
|
474
|
+
next = step(from, -1, 1);
|
|
475
|
+
break;
|
|
476
|
+
case "End":
|
|
477
|
+
next = step(from, from.length, -1);
|
|
478
|
+
break;
|
|
479
|
+
case "ArrowRight": {
|
|
480
|
+
if (inSubmenu || field.selectionStart !== field.value.length)
|
|
481
|
+
return;
|
|
482
|
+
if (!parent || !hasSubmenu(parent))
|
|
483
|
+
return;
|
|
484
|
+
const first = step(subRows, -1, 1);
|
|
485
|
+
// A filter box is somewhere to go even with every row filtered out.
|
|
486
|
+
if (!first && !subFilter)
|
|
487
|
+
return;
|
|
488
|
+
event.preventDefault();
|
|
489
|
+
if (subFilter)
|
|
490
|
+
enterSubBox();
|
|
491
|
+
if (first)
|
|
492
|
+
moveIntoSubmenu(first);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
case "ArrowLeft":
|
|
496
|
+
if (!inSubmenu)
|
|
497
|
+
return;
|
|
498
|
+
// The submenu's own box moves its caret first, as → does in the menu's.
|
|
499
|
+
if (box === "submenu" &&
|
|
500
|
+
(field.selectionStart !== 0 || field.selectionEnd !== 0)) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
event.preventDefault();
|
|
504
|
+
backToParent();
|
|
505
|
+
return;
|
|
506
|
+
case "Enter": {
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
const row = at < 0 ? undefined : from[at];
|
|
509
|
+
if (row)
|
|
510
|
+
choose(row);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
case "Tab":
|
|
514
|
+
event.preventDefault();
|
|
515
|
+
tabbing.current = event.shiftKey ? "backward" : "forward";
|
|
516
|
+
setOpen(false);
|
|
517
|
+
return;
|
|
518
|
+
default:
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
event.preventDefault();
|
|
522
|
+
if (!next)
|
|
523
|
+
return;
|
|
524
|
+
if (inSubmenu)
|
|
525
|
+
moveIntoSubmenu(next);
|
|
526
|
+
else
|
|
527
|
+
move(next);
|
|
528
|
+
};
|
|
529
|
+
// The row's height and the surface's sides: the submenu sits against the
|
|
530
|
+
// surface's border whatever the list's padding and scrollbar take.
|
|
531
|
+
const anchorRow = useCallback((row) => {
|
|
532
|
+
anchor.current = row && {
|
|
533
|
+
// Through it, floating-ui follows the row's scrolling and resizing.
|
|
534
|
+
contextElement: row,
|
|
535
|
+
getBoundingClientRect: () => {
|
|
536
|
+
const own = row.getBoundingClientRect();
|
|
537
|
+
const surface = content.current?.getBoundingClientRect() ?? own;
|
|
538
|
+
return DOMRect.fromRect({
|
|
539
|
+
x: surface.x,
|
|
540
|
+
y: own.y,
|
|
541
|
+
width: surface.width,
|
|
542
|
+
height: own.height,
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}, []);
|
|
547
|
+
/** Leaving the parent row: the triangle toward the submenu's near edge. */
|
|
548
|
+
const onParentLeave = (event) => {
|
|
549
|
+
const rect = submenu.current?.getBoundingClientRect();
|
|
550
|
+
if (!rect)
|
|
551
|
+
return;
|
|
552
|
+
const right = rect.left >= event.clientX;
|
|
553
|
+
const near = right ? rect.left : rect.right;
|
|
554
|
+
const from = lastX.current ?? event.clientX;
|
|
555
|
+
grace.current = {
|
|
556
|
+
polygon: [
|
|
557
|
+
{ x: event.clientX + (right ? -5 : 5), y: event.clientY },
|
|
558
|
+
{ x: near, y: rect.top },
|
|
559
|
+
{ x: near, y: rect.bottom },
|
|
560
|
+
],
|
|
561
|
+
until: Date.now() + GRACE_MS,
|
|
562
|
+
right,
|
|
563
|
+
x: event.clientX,
|
|
564
|
+
towards: event.clientX !== from && right === event.clientX > from,
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
let flatIndex = 0;
|
|
568
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, ref: trigger,
|
|
569
|
+
// What a select's trigger does; a popover's opens on click alone.
|
|
570
|
+
onKeyDown: (event) => {
|
|
571
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp")
|
|
572
|
+
return;
|
|
573
|
+
event.preventDefault();
|
|
574
|
+
setOpen(true);
|
|
575
|
+
}, children: children }), _jsxs(PopoverContent, { ref: content, align: align, side: side, sideOffset: sideOffset, className: cn("flex max-h-[min(24rem,var(--radix-popover-content-available-height))] flex-col overflow-hidden p-0", className),
|
|
576
|
+
// Pressing anything but a control (a row, the search icon, padding)
|
|
577
|
+
// would move focus onto the surface, out of the box and its keys.
|
|
578
|
+
onMouseDown: (event) => {
|
|
579
|
+
if (!event.target.closest(FOCUSABLE)) {
|
|
580
|
+
event.preventDefault();
|
|
581
|
+
}
|
|
582
|
+
}, onEscapeKeyDown: (event) => {
|
|
583
|
+
// That Escape cancels the composition, not the menu.
|
|
584
|
+
if (isEscapeOfComposition(event))
|
|
585
|
+
event.preventDefault();
|
|
586
|
+
}, onOpenAutoFocus: (event) => {
|
|
587
|
+
// The box, not the first tabbable, which may be in `header`.
|
|
588
|
+
event.preventDefault();
|
|
589
|
+
input.current?.focus({ preventScroll: true });
|
|
590
|
+
}, onCloseAutoFocus: (event) => {
|
|
591
|
+
const direction = tabbing.current;
|
|
592
|
+
tabbing.current = null;
|
|
593
|
+
if (direction === null || !trigger.current)
|
|
594
|
+
return;
|
|
595
|
+
event.preventDefault();
|
|
596
|
+
// The surface may already be gone by now, its ref with it.
|
|
597
|
+
focusBeside(trigger.current, direction === "backward", content.current);
|
|
598
|
+
}, children: [_jsx(FilterBox, { ref: input, "aria-label": inputLabel, expanded: open, "aria-controls": listId, "aria-activedescendant": subIndex >= 0
|
|
599
|
+
? subOptionId(subIndex)
|
|
600
|
+
: highlightIndex < 0
|
|
601
|
+
? undefined
|
|
602
|
+
: optionId(highlightIndex), placeholder: placeholder, value: query, onChange: (event) => onQueryChange(event.target.value), onKeyDown: (event) => onKeyDown(event, "menu"),
|
|
603
|
+
// Pressed back into while the submenu's box had the keys.
|
|
604
|
+
onFocus: () => {
|
|
605
|
+
if (subKeys)
|
|
606
|
+
backToParent();
|
|
607
|
+
} }), header, _jsxs("div", { ref: listRef, id: listId, role: "listbox", "aria-label": listLabel, className: "min-h-0 flex-1 overflow-y-auto p-1", onWheel: stopRevealOnResize, onTouchMove: stopRevealOnResize,
|
|
608
|
+
// Where a drag of the scrollbar starts, too.
|
|
609
|
+
onPointerDown: stopRevealOnResize, onPointerEnter: (event) => {
|
|
610
|
+
pointerIn.current = "list";
|
|
611
|
+
lastX.current = event.clientX;
|
|
612
|
+
}, onPointerMove: (event) => {
|
|
613
|
+
lastX.current = event.clientX;
|
|
614
|
+
}, onPointerLeave: (event) => {
|
|
615
|
+
pointerIn.current = null;
|
|
616
|
+
const to = event.relatedTarget;
|
|
617
|
+
if (to instanceof Node && submenu.current?.contains(to))
|
|
618
|
+
return;
|
|
619
|
+
if (onSeam(event)) {
|
|
620
|
+
// However long the pointer lingered on the scrollbar on its way.
|
|
621
|
+
closeUnlessBack(GRACE_MS);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (inGrace(event, false)) {
|
|
625
|
+
// Crossing the gap toward the submenu; if the pointer never
|
|
626
|
+
// arrives, the highlight still goes when the grace runs out.
|
|
627
|
+
closeUnlessBack((grace.current?.until ?? 0) - Date.now());
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (highlight !== null)
|
|
631
|
+
move(null);
|
|
632
|
+
}, children: [rows.length === 0 && (_jsx("div", { role: "presentation", className: "px-2 py-4 text-center text-muted-foreground text-sm", children: empty(query) })), visible.map((section, sectionIndex) => {
|
|
633
|
+
// By position: a section id may hold spaces, which would split
|
|
634
|
+
// aria-labelledby's id list.
|
|
635
|
+
const labelId = `${base}-section-${sectionIndex}`;
|
|
636
|
+
const options = section.rows.map((row) => {
|
|
637
|
+
const index = flatIndex++;
|
|
638
|
+
const highlighted = index === highlightIndex;
|
|
639
|
+
const selected = row.id === selectedId;
|
|
640
|
+
const split = hasSubmenu(row);
|
|
641
|
+
// One gutter for every row of a menu with submenus, so what
|
|
642
|
+
// renderRow puts at the right edge lines up.
|
|
643
|
+
const gutter = getSubmenu !== undefined;
|
|
644
|
+
const cell = (_jsxs(_Fragment, { children: [renderRow(row, { highlighted, selected, query }), selected && (_jsx(Mark, { className: split ? "right-0.5" : gutter ? "right-7" : "right-2", children: _jsx(CheckIcon, { "aria-hidden": true }) }))] }));
|
|
645
|
+
return (_jsxs("div", { ref: highlighted ? anchorRow : undefined, id: optionId(index), role: "option", tabIndex: -1, "aria-selected": highlighted && subIndex < 0, "aria-disabled": row.disabled || undefined, "aria-haspopup": split ? "listbox" : undefined, "aria-expanded": split ? highlighted && submenuOpen : undefined, "data-highlighted": highlighted ? "" : undefined, "data-selected": selected ? "" : undefined, "data-disabled": row.disabled ? "" : undefined, className: split
|
|
646
|
+
? ROW_CLASS
|
|
647
|
+
: cn(OPTION_CLASS, gutter ? "pr-13" : "pr-8"), onPointerMove: (event) => {
|
|
648
|
+
if (row.disabled)
|
|
649
|
+
return;
|
|
650
|
+
// A highlight resting here (the committed row, the first
|
|
651
|
+
// hit) still takes the hover, so its submenu opens; and
|
|
652
|
+
// back on the parent from its submenu's box, the keys
|
|
653
|
+
// come back too.
|
|
654
|
+
if (highlighted && moved && subIndex < 0 && !subKeys) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (inGrace(event))
|
|
658
|
+
return;
|
|
659
|
+
clearGrace();
|
|
660
|
+
move(row);
|
|
661
|
+
}, onPointerLeave: highlighted && split ? onParentLeave : undefined, onClick: () => choose(row), children: [split ? (_jsx("span", { "data-slot": "submenu-action", className: ACTION_CLASS, children: cell })) : (cell), split && (_jsxs("span", { "aria-hidden": true, "data-slot": "submenu-trigger", className: "relative flex w-6.5 shrink-0 items-center justify-end self-stretch pr-2 text-muted-foreground group-aria-expanded/option:text-foreground",
|
|
662
|
+
// A split button's other half: it opens the submenu,
|
|
663
|
+
// where the rest of the row picks the row. A touch
|
|
664
|
+
// has no hover to open it any other way.
|
|
665
|
+
onClick: (event) => {
|
|
666
|
+
event.stopPropagation();
|
|
667
|
+
if (row.disabled || (highlighted && submenuOpen))
|
|
668
|
+
return;
|
|
669
|
+
clearGrace();
|
|
670
|
+
move(row);
|
|
671
|
+
}, children: [_jsx("span", { "data-slot": "submenu-divider", className: "absolute inset-y-1.5 left-0 w-px bg-current opacity-20" }), _jsx(ChevronRightIcon, { "aria-hidden": true })] }))] }, row.id));
|
|
672
|
+
});
|
|
673
|
+
return section.label === undefined ? (_jsx(Fragment, { children: options }, section.id)) : (_jsxs("div", { role: "group", "aria-labelledby": labelId, children: [_jsx("div", { id: labelId, className: "px-1.5 py-1 text-muted-foreground text-xs", children: section.label }), options] }, section.id));
|
|
674
|
+
})] }), footer, _jsxs(Popover, { open: submenuOpen, children: [_jsx(PopoverAnchor, { virtualRef: anchor }), _jsxs(PopoverContent, { ref: submenuRef, side: subBelow ? "bottom" : "right", align: "start", sideOffset: 0, className: cn("flex max-h-[min(24rem,var(--radix-popover-content-available-height))] w-60 flex-col overflow-hidden p-0",
|
|
675
|
+
// placeSubmenu measures this width to decide whether it fits
|
|
676
|
+
// beside the menu. Capped to the room beside it, it would
|
|
677
|
+
// always fit, and stay there ever narrower instead of dropping.
|
|
678
|
+
!subBelow && "max-w-none"), style: ownQuery
|
|
679
|
+
? {
|
|
680
|
+
minHeight: `min(${ownQuery.height}px, var(--radix-popover-content-available-height))`,
|
|
681
|
+
}
|
|
682
|
+
: undefined, onOpenAutoFocus: (event) => event.preventDefault(), onCloseAutoFocus: (event) => event.preventDefault(), onInteractOutside: (event) => {
|
|
683
|
+
const target = event.target;
|
|
684
|
+
if (target instanceof Node && content.current?.contains(target))
|
|
685
|
+
event.preventDefault();
|
|
686
|
+
},
|
|
687
|
+
// The topmost layer gets Escape, so the menu's own keys live here
|
|
688
|
+
// while the submenu is open.
|
|
689
|
+
onEscapeKeyDown: (event) => {
|
|
690
|
+
event.preventDefault();
|
|
691
|
+
if (isEscapeOfComposition(event))
|
|
692
|
+
return;
|
|
693
|
+
if (subIndex >= 0 || inSubBox)
|
|
694
|
+
backToParent();
|
|
695
|
+
else
|
|
696
|
+
setOpen(false);
|
|
697
|
+
}, onMouseDown: (event) => {
|
|
698
|
+
if (!event.target.closest(FOCUSABLE)) {
|
|
699
|
+
event.preventDefault();
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
// Hover only: a tap's leave comes with the pointer lifting, not
|
|
703
|
+
// moving off, and would close the submenu under the tap.
|
|
704
|
+
onPointerEnter: (event) => {
|
|
705
|
+
if (event.pointerType === "touch")
|
|
706
|
+
return;
|
|
707
|
+
pointerIn.current = "submenu";
|
|
708
|
+
clearGrace();
|
|
709
|
+
// Typing with the pointer over a submenu filters the submenu.
|
|
710
|
+
if (subBox && !subKeys)
|
|
711
|
+
enterSubBox();
|
|
712
|
+
}, onPointerLeave: (event) => {
|
|
713
|
+
if (event.pointerType === "touch")
|
|
714
|
+
return;
|
|
715
|
+
pointerIn.current = null;
|
|
716
|
+
const to = event.relatedTarget;
|
|
717
|
+
if (to instanceof Node && list.current?.contains(to))
|
|
718
|
+
return;
|
|
719
|
+
if (onSeam(event))
|
|
720
|
+
closeUnlessBack(GRACE_MS);
|
|
721
|
+
else
|
|
722
|
+
move(null);
|
|
723
|
+
}, children: [subBox && parent && (_jsx(FilterBox, { ref: subInputRef, "aria-label": subFilter.label ??
|
|
724
|
+
(submenuLabel ? `Filter ${submenuLabel(parent)}` : "Filter"), expanded: true, "aria-controls": submenuId, "aria-activedescendant": subIndex < 0 ? undefined : subOptionId(subIndex), placeholder: subFilter.placeholder ?? "Filter…", value: subText, onChange: (event) => {
|
|
725
|
+
subMode.current = "hit";
|
|
726
|
+
setSubQuery({
|
|
727
|
+
parent: parent.id,
|
|
728
|
+
text: event.target.value,
|
|
729
|
+
height: ownQuery?.height ??
|
|
730
|
+
submenu.current?.getBoundingClientRect().height ??
|
|
731
|
+
0,
|
|
732
|
+
});
|
|
733
|
+
}, onKeyDown: (event) => onKeyDown(event, "submenu"), onFocus: () => {
|
|
734
|
+
if (!subKeys)
|
|
735
|
+
enterSubBox();
|
|
736
|
+
} })), _jsxs("div", { id: submenuId, role: "listbox", "aria-label": parent ? submenuLabel?.(parent) : undefined, className: "min-h-0 flex-1 overflow-y-auto p-1", children: [submenuOpen && subRows.length === 0 && (_jsx("div", { role: "presentation", className: "px-2 py-4 text-center text-muted-foreground text-sm", children: empty(subText) })), subRows.map((row, index) => {
|
|
737
|
+
const highlighted = index === subIndex;
|
|
738
|
+
const selected = row.id === selectedSubmenuId;
|
|
739
|
+
return (_jsxs("div", { id: subOptionId(index), role: "option", tabIndex: -1, "aria-selected": highlighted, "aria-disabled": row.disabled || undefined, "data-highlighted": highlighted ? "" : undefined, "data-selected": selected ? "" : undefined, "data-disabled": row.disabled ? "" : undefined, className: cn(OPTION_CLASS, "pr-8"), onPointerMove: () => {
|
|
740
|
+
if (!row.disabled && !highlighted)
|
|
741
|
+
moveIntoSubmenu(row);
|
|
742
|
+
}, onClick: () => choose(row), children: [renderRow(row, { highlighted, selected, query: subText }), selected && (_jsx(Mark, { className: "right-2", children: _jsx(CheckIcon, { "aria-hidden": true }) }))] }, row.id));
|
|
743
|
+
})] })] })] })] })] }));
|
|
744
|
+
}
|
|
745
|
+
//# sourceMappingURL=filter-menu.js.map
|