@mendylanda/ui 0.1.0 → 0.1.1
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/dist/class-names.js +1 -1
- package/dist/filters/filter-bar.js +114 -52
- package/dist/filters/filter-collection.d.ts +28 -0
- package/dist/filters/filter-collection.js +191 -0
- package/dist/filters/filter-date-editor.d.ts +3 -1
- package/dist/filters/filter-date-editor.js +5 -5
- package/dist/filters/filter-menu-focus.d.ts +8 -0
- package/dist/filters/filter-menu-focus.js +69 -0
- package/dist/filters/filter-menu-panel.d.ts +2 -1
- package/dist/filters/filter-menu-panel.js +149 -66
- package/dist/filters/filters.js +2 -2
- package/dist/filters/use-filter-options.js +7 -5
- package/dist/filters/use-menu-pointer.d.ts +10 -0
- package/dist/filters/use-menu-pointer.js +64 -0
- package/dist/filters/use-value-draft.d.ts +9 -2
- package/dist/filters/use-value-draft.js +20 -5
- package/dist/primitives/calendar.js +2 -2
- package/dist/primitives/dropdown-menu.js +2 -2
- package/dist/styles.css +217 -63
- package/package.json +2 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
|
|
5
|
+
import { cn } from "../utils.js";
|
|
6
|
+
/** A single keyboard model for both ordinary and measured, virtualized collections. */
|
|
7
|
+
export function FilterCollection({ items, children, className, role, label, collectionRef, initialKey, }) {
|
|
8
|
+
const scroll = useRef(null);
|
|
9
|
+
const nodes = useRef(new Map());
|
|
10
|
+
const pending = useRef(null);
|
|
11
|
+
const ownsFocus = useRef(false);
|
|
12
|
+
const previousIndex = useRef(0);
|
|
13
|
+
const [focused, setFocused] = useState(initialKey ?? items.find((item) => !item.disabled)?.key);
|
|
14
|
+
const typeahead = useRef({ text: "", at: 0 });
|
|
15
|
+
const virtual = items.length > 100;
|
|
16
|
+
const keys = useMemo(() => new Map(items.map((item, index) => [item.key, index])), [items]);
|
|
17
|
+
const focusedIndex = focused === undefined ? undefined : keys.get(focused);
|
|
18
|
+
const rowEstimate = (() => {
|
|
19
|
+
const rem = typeof document === "undefined"
|
|
20
|
+
? 16
|
|
21
|
+
: parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
22
|
+
return (rem *
|
|
23
|
+
(typeof matchMedia !== "undefined" &&
|
|
24
|
+
matchMedia("(pointer: fine) and (min-width: 640px)").matches
|
|
25
|
+
? 2
|
|
26
|
+
: 2.5));
|
|
27
|
+
})();
|
|
28
|
+
const virtualizer = useVirtualizer({
|
|
29
|
+
count: items.length,
|
|
30
|
+
enabled: virtual,
|
|
31
|
+
getScrollElement: () => scroll.current,
|
|
32
|
+
estimateSize: () => rowEstimate,
|
|
33
|
+
getItemKey: useCallback((index) => items[index].key, [items]),
|
|
34
|
+
overscan: 5,
|
|
35
|
+
initialOffset: () => (initialKey === undefined ? 0 : (keys.get(initialKey) ?? 0) * rowEstimate),
|
|
36
|
+
rangeExtractor: useCallback((range) => {
|
|
37
|
+
const visible = defaultRangeExtractor(range);
|
|
38
|
+
// Keep DOM focus alive when a wheel or touch scroll moves it outside the viewport.
|
|
39
|
+
return focusedIndex === undefined
|
|
40
|
+
? visible
|
|
41
|
+
: [...new Set([...visible, focusedIndex])].sort((a, b) => a - b);
|
|
42
|
+
}, [focusedIndex]),
|
|
43
|
+
});
|
|
44
|
+
function focus(key) {
|
|
45
|
+
const index = keys.get(key);
|
|
46
|
+
if (index === undefined || items[index]?.disabled)
|
|
47
|
+
return;
|
|
48
|
+
pending.current = key;
|
|
49
|
+
setFocused(key);
|
|
50
|
+
if (virtual)
|
|
51
|
+
virtualizer.scrollToIndex(index, { align: "auto" });
|
|
52
|
+
const node = nodes.current.get(key);
|
|
53
|
+
if (node) {
|
|
54
|
+
node.focus({ preventScroll: virtual });
|
|
55
|
+
pending.current = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
useImperativeHandle(collectionRef, () => ({
|
|
59
|
+
focus,
|
|
60
|
+
focusFirst() {
|
|
61
|
+
const first = items.find((item) => !item.disabled);
|
|
62
|
+
if (first)
|
|
63
|
+
focus(first.key);
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
if (pending.current !== null && !keys.has(pending.current))
|
|
68
|
+
pending.current = null;
|
|
69
|
+
if (focusedIndex === undefined || items[focusedIndex]?.disabled) {
|
|
70
|
+
const replacement = items.slice(previousIndex.current).find((item) => !item.disabled) ??
|
|
71
|
+
items
|
|
72
|
+
.slice(0, previousIndex.current)
|
|
73
|
+
.reverse()
|
|
74
|
+
.find((item) => !item.disabled);
|
|
75
|
+
setFocused(replacement?.key);
|
|
76
|
+
if (!replacement)
|
|
77
|
+
previousIndex.current = 0;
|
|
78
|
+
const active = document.activeElement;
|
|
79
|
+
if (ownsFocus.current &&
|
|
80
|
+
(active === document.body || (active && scroll.current?.contains(active)))) {
|
|
81
|
+
if (replacement)
|
|
82
|
+
focus(replacement.key);
|
|
83
|
+
else
|
|
84
|
+
scroll.current?.focus({ preventScroll: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
previousIndex.current = focusedIndex;
|
|
89
|
+
// Enabling virtualization can briefly have no measured range. Restore a
|
|
90
|
+
// still-valid focused item as soon as its row mounts again.
|
|
91
|
+
if (ownsFocus.current && document.activeElement === document.body)
|
|
92
|
+
focus(items[focusedIndex].key);
|
|
93
|
+
}
|
|
94
|
+
const key = pending.current;
|
|
95
|
+
const node = key === null ? undefined : nodes.current.get(key);
|
|
96
|
+
if (!node)
|
|
97
|
+
return;
|
|
98
|
+
node.focus({ preventScroll: virtual });
|
|
99
|
+
const index = keys.get(key);
|
|
100
|
+
if (virtual && index !== undefined)
|
|
101
|
+
virtualizer.scrollToIndex(index, { align: "auto" });
|
|
102
|
+
pending.current = null;
|
|
103
|
+
});
|
|
104
|
+
function render(index, start) {
|
|
105
|
+
const item = items[index];
|
|
106
|
+
return children(item, index, {
|
|
107
|
+
"data-index": index,
|
|
108
|
+
tabIndex: item.key === focused && !item.disabled ? 0 : -1,
|
|
109
|
+
"data-collection-key": item.key,
|
|
110
|
+
ref(node) {
|
|
111
|
+
if (node) {
|
|
112
|
+
nodes.current.set(item.key, node);
|
|
113
|
+
if (virtual)
|
|
114
|
+
virtualizer.measureElement(node);
|
|
115
|
+
}
|
|
116
|
+
else
|
|
117
|
+
nodes.current.delete(item.key);
|
|
118
|
+
},
|
|
119
|
+
style: virtual
|
|
120
|
+
? {
|
|
121
|
+
position: "absolute",
|
|
122
|
+
top: 0,
|
|
123
|
+
left: 0,
|
|
124
|
+
width: "100%",
|
|
125
|
+
transform: `translateY(${start}px)`,
|
|
126
|
+
}
|
|
127
|
+
: undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return (_jsx("div", { ref: scroll, tabIndex: -1, role: role, "aria-label": label, "data-filter-collection": "", "data-virtual": virtual, className: cn("mui-410da8dfa8ac mui-2368e909f3a5 mui-998780cdef87 mui-1dee6e3ec67d", className), onBlurCapture: (event) => {
|
|
131
|
+
if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget))
|
|
132
|
+
ownsFocus.current = false;
|
|
133
|
+
}, onFocusCapture: (event) => {
|
|
134
|
+
ownsFocus.current = true;
|
|
135
|
+
const key = event.target.closest("[data-collection-key]")
|
|
136
|
+
?.dataset.collectionKey;
|
|
137
|
+
if (key !== undefined && keys.has(key))
|
|
138
|
+
setFocused(key);
|
|
139
|
+
}, onKeyDownCapture: (event) => {
|
|
140
|
+
if (event.nativeEvent.isComposing || event.ctrlKey || event.metaKey || event.altKey)
|
|
141
|
+
return;
|
|
142
|
+
if (event.target.closest("input, textarea, select, [contenteditable=true]"))
|
|
143
|
+
return;
|
|
144
|
+
const key = event.target.closest("[data-collection-key]")
|
|
145
|
+
?.dataset.collectionKey;
|
|
146
|
+
if (key === undefined || !keys.has(key))
|
|
147
|
+
return;
|
|
148
|
+
const enabled = items.filter((item) => !item.disabled);
|
|
149
|
+
const index = enabled.findIndex((item) => item.key === key);
|
|
150
|
+
let next;
|
|
151
|
+
const page = Math.max(1, Math.floor((scroll.current?.clientHeight ?? 320) / rowEstimate) - 1);
|
|
152
|
+
switch (event.key) {
|
|
153
|
+
case "Home":
|
|
154
|
+
next = enabled[0];
|
|
155
|
+
break;
|
|
156
|
+
case "End":
|
|
157
|
+
next = enabled.at(-1);
|
|
158
|
+
break;
|
|
159
|
+
case "ArrowDown":
|
|
160
|
+
next = enabled[(index + 1) % enabled.length];
|
|
161
|
+
break;
|
|
162
|
+
case "ArrowUp":
|
|
163
|
+
next = enabled[(index - 1 + enabled.length) % enabled.length];
|
|
164
|
+
break;
|
|
165
|
+
case "PageDown":
|
|
166
|
+
next = enabled[Math.min(index + page, enabled.length - 1)];
|
|
167
|
+
break;
|
|
168
|
+
case "PageUp":
|
|
169
|
+
next = enabled[Math.max(index - page, 0)];
|
|
170
|
+
break;
|
|
171
|
+
default: {
|
|
172
|
+
if (event.key.length !== 1 || event.key === " ")
|
|
173
|
+
return;
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const text = now - typeahead.current.at < 600 ? typeahead.current.text + event.key : event.key;
|
|
176
|
+
typeahead.current = { text, at: now };
|
|
177
|
+
const term = [...text].every((char) => char === text[0]) ? text[0] : text;
|
|
178
|
+
const order = [...enabled.slice(index + 1), ...enabled.slice(0, index + 1)];
|
|
179
|
+
next = order.find((item) => item.label.toLocaleLowerCase().startsWith(term.toLocaleLowerCase()));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
if (next)
|
|
185
|
+
focus(next.key);
|
|
186
|
+
}, children: _jsx("div", { role: "presentation", style: virtual
|
|
187
|
+
? { height: virtualizer.getTotalSize(), position: "relative", width: "100%" }
|
|
188
|
+
: undefined, children: virtual
|
|
189
|
+
? virtualizer.getVirtualItems().map((row) => render(row.index, row.start))
|
|
190
|
+
: items.map((_, index) => render(index)) }) }));
|
|
191
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { RuntimeField } from "./filter-definition.js";
|
|
2
|
-
export declare function FilterDateEditor({ field, value, disabled, apply, error, autoFocus, showLabel, }: {
|
|
2
|
+
export declare function FilterDateEditor({ field, value, disabled, apply, error, draftKey, autoFocus, showLabel, showClear, }: {
|
|
3
|
+
draftKey?: string;
|
|
3
4
|
autoFocus?: boolean;
|
|
4
5
|
showLabel?: boolean;
|
|
6
|
+
showClear?: boolean;
|
|
5
7
|
field: RuntimeField;
|
|
6
8
|
value: unknown;
|
|
7
9
|
disabled?: boolean;
|
|
@@ -12,20 +12,20 @@ function dateString(value) {
|
|
|
12
12
|
return null;
|
|
13
13
|
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
|
14
14
|
}
|
|
15
|
-
export function FilterDateEditor({ field, value, disabled, apply, error, autoFocus = true, showLabel = true, }) {
|
|
16
|
-
const [range, setRange] = useValueDraft(value, (current) => current ? { from: calendarDate(current.from), to: calendarDate(current.to) } : undefined);
|
|
15
|
+
export function FilterDateEditor({ field, value, disabled, apply, error, draftKey, autoFocus = true, showLabel = true, showClear = true, }) {
|
|
16
|
+
const [range, setRange] = useValueDraft(value, (current) => current ? { from: calendarDate(current.from), to: calendarDate(current.to) } : undefined, draftKey);
|
|
17
17
|
return (_jsxs("div", { "data-mendy-ui": "", onKeyDown: (event) => {
|
|
18
18
|
// Calendar arrows navigate days. Escape still dismisses the surrounding editor.
|
|
19
19
|
if (event.key !== "Escape")
|
|
20
20
|
event.stopPropagation();
|
|
21
|
-
}, children: [showLabel && _jsx("p", { className: "mui-0f9e6672913a mui-0a702d97ed7d mui-c74ab393b96d mui-daaac3fbf55e", children: field.label }), _jsx(Calendar, { mode: "range", autoFocus: autoFocus, selected: range, onSelect: (next) => {
|
|
21
|
+
}, children: [showLabel && _jsx("p", { className: "mui-0f9e6672913a mui-0a702d97ed7d mui-c74ab393b96d mui-daaac3fbf55e", children: field.label }), _jsx(Calendar, { className: "mui-1752f11474a6 mui-82c2977beb8e mui-d78a8526410d", mode: "range", autoFocus: autoFocus, selected: range, onSelect: (next) => {
|
|
22
22
|
const selected = next ? { from: next.from, to: next.to ?? next.from } : undefined;
|
|
23
23
|
setRange(selected);
|
|
24
24
|
apply(selected
|
|
25
25
|
? { from: dateString(selected.from), to: dateString(selected.to) }
|
|
26
26
|
: field.clearValue, false);
|
|
27
|
-
}, defaultMonth: range?.from ?? range?.to, disabled: disabled }), _jsxs("div", { "data-mendy-ui": "", className: "mui-267171770524 mui-894c3815a57f mui-094f5333853b", children: [_jsx("p", { className: "mui-3992de70b033 mui-35f35c41d134", children: "Pick a day, or two dates for a range." }), error && (_jsx("p", { role: "alert", className: "mui-c74ab393b96d mui-887b9502d5c7", children: error })), _jsx("div", { "data-mendy-ui": "", className: "mui-222f930b8752 mui-074569488cca", children: _jsx(Button, { type: "button", size: "sm", variant: "ghost", disabled: disabled, onClick: () => {
|
|
27
|
+
}, defaultMonth: range?.from ?? range?.to, disabled: disabled }), _jsxs("div", { "data-mendy-ui": "", className: "mui-267171770524 mui-894c3815a57f mui-094f5333853b", children: [_jsx("p", { className: "mui-3992de70b033 mui-35f35c41d134", children: "Pick a day, or two dates for a range." }), error && (_jsx("p", { role: "alert", className: "mui-c74ab393b96d mui-887b9502d5c7", children: error })), showClear && (_jsx("div", { "data-mendy-ui": "", className: "mui-222f930b8752 mui-074569488cca", children: _jsx(Button, { type: "button", size: "sm", variant: "ghost", className: "mui-958e2cfcf5b6 mui-e7e01cc7f4df", disabled: disabled || !range, onClick: () => {
|
|
28
28
|
setRange(undefined);
|
|
29
29
|
apply(field.clearValue);
|
|
30
|
-
}, children: "Clear date" }) })] })] }));
|
|
30
|
+
}, children: "Clear date" }) }))] })] }));
|
|
31
31
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
2
|
+
export declare function focusMenuEditor(root: HTMLDivElement | null): void;
|
|
3
|
+
export declare function handleMenuTab(event: KeyboardEvent<HTMLDivElement>, { trigger, editor, onClose, hasSelection, }: {
|
|
4
|
+
trigger: HTMLButtonElement | null;
|
|
5
|
+
editor: HTMLDivElement | null;
|
|
6
|
+
onClose(): void;
|
|
7
|
+
hasSelection: boolean;
|
|
8
|
+
}): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function visible(element) {
|
|
2
|
+
return (!element.matches(":disabled, [data-disabled], [aria-disabled=true]") &&
|
|
3
|
+
!element.closest("[hidden], [inert]") &&
|
|
4
|
+
element.getClientRects().length > 0 &&
|
|
5
|
+
getComputedStyle(element).visibility !== "hidden");
|
|
6
|
+
}
|
|
7
|
+
export function focusMenuEditor(root) {
|
|
8
|
+
if (!root)
|
|
9
|
+
return;
|
|
10
|
+
const priorities = [
|
|
11
|
+
"input, textarea",
|
|
12
|
+
'[role="grid"] button[tabindex="0"]',
|
|
13
|
+
'[role^="menuitem"][aria-checked="true"]',
|
|
14
|
+
'[role^="menuitem"]',
|
|
15
|
+
'button, [tabindex="0"]',
|
|
16
|
+
];
|
|
17
|
+
for (const selector of priorities) {
|
|
18
|
+
const target = [...root.querySelectorAll(selector)].find((element) => !element.matches(':disabled, [data-disabled], [aria-disabled="true"]') &&
|
|
19
|
+
element.getClientRects().length > 0);
|
|
20
|
+
if (target) {
|
|
21
|
+
target.focus();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
root.focus();
|
|
26
|
+
}
|
|
27
|
+
export function handleMenuTab(event, { trigger, editor, onClose, hasSelection, }) {
|
|
28
|
+
if (event.key !== "Tab" ||
|
|
29
|
+
event.altKey ||
|
|
30
|
+
event.ctrlKey ||
|
|
31
|
+
event.metaKey ||
|
|
32
|
+
!event.currentTarget.contains(event.target))
|
|
33
|
+
return;
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
event.stopPropagation();
|
|
36
|
+
const root = event.currentTarget;
|
|
37
|
+
const target = event.target;
|
|
38
|
+
const selector = "button, input, textarea, select, a[href], [tabindex]";
|
|
39
|
+
const menuStops = new Set();
|
|
40
|
+
root.querySelectorAll('[role="menu"]').forEach((menu) => {
|
|
41
|
+
const choices = [...menu.querySelectorAll('[role^="menuitem"]')].filter(visible);
|
|
42
|
+
const current = choices.find((item) => item === document.activeElement) ??
|
|
43
|
+
choices.find((item) => item.getAttribute("aria-checked") === "true") ??
|
|
44
|
+
choices[0];
|
|
45
|
+
if (current)
|
|
46
|
+
menuStops.add(current);
|
|
47
|
+
});
|
|
48
|
+
const stops = [...root.querySelectorAll(selector)].filter((element) => visible(element) &&
|
|
49
|
+
(element.matches('[role^="menuitem"]') ? menuStops.has(element) : element.tabIndex >= 0));
|
|
50
|
+
const index = stops.indexOf(target);
|
|
51
|
+
const next = index < 0 ? undefined : stops[index + (event.shiftKey ? -1 : 1)];
|
|
52
|
+
if (target.closest('[aria-label="Filter types"]') &&
|
|
53
|
+
!event.shiftKey &&
|
|
54
|
+
hasSelection &&
|
|
55
|
+
(!next || editor?.contains(next)))
|
|
56
|
+
focusMenuEditor(editor);
|
|
57
|
+
else if (next)
|
|
58
|
+
next.focus();
|
|
59
|
+
else {
|
|
60
|
+
const button = trigger;
|
|
61
|
+
const outside = [...document.querySelectorAll(selector)].filter((element) => visible(element) &&
|
|
62
|
+
element.tabIndex >= 0 &&
|
|
63
|
+
!root.contains(element) &&
|
|
64
|
+
!element.hasAttribute("data-radix-focus-guard"));
|
|
65
|
+
const destination = event.shiftKey ? button : (outside[outside.indexOf(button) + 1] ?? button);
|
|
66
|
+
destination?.focus();
|
|
67
|
+
onClose();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -13,9 +13,10 @@ interface FilterMenuPanelProps {
|
|
|
13
13
|
sections: FilterMenuSection[];
|
|
14
14
|
selectedId: string | null;
|
|
15
15
|
onSelect(id: string | null): void;
|
|
16
|
+
onClose(): void;
|
|
16
17
|
anchor?: RefObject<HTMLDivElement | null>;
|
|
17
18
|
trigger: RefObject<HTMLButtonElement | null>;
|
|
18
19
|
}
|
|
19
20
|
/** One dialog contains the filter list and its editor, with a single-panel layout on phones. */
|
|
20
|
-
export declare function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigger, }: FilterMenuPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export declare function FilterMenuPanel({ sections, selectedId, onSelect, onClose, anchor, trigger, }: FilterMenuPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
21
22
|
export {};
|
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { ArrowLeft, ChevronRight } from "lucide-react";
|
|
5
|
-
import { Button } from "../customization.js";
|
|
5
|
+
import { Button, Input } from "../customization.js";
|
|
6
|
+
import { FilterCollection } from "./filter-collection.js";
|
|
7
|
+
import { useValueDraft } from "./use-value-draft.js";
|
|
6
8
|
import { DropdownMenuContent } from "../primitives/dropdown-menu.js";
|
|
7
9
|
import { useMendyUI } from "../customization.js";
|
|
10
|
+
import { focusMenuEditor, handleMenuTab } from "./filter-menu-focus.js";
|
|
11
|
+
import { useMenuPointer } from "./use-menu-pointer.js";
|
|
8
12
|
import { cn } from "../utils.js";
|
|
9
13
|
const desktopQuery = "(min-width: 640px)";
|
|
10
14
|
function subscribeViewport(listener) {
|
|
11
15
|
const media = window.matchMedia(desktopQuery);
|
|
12
16
|
media.addEventListener("change", listener);
|
|
13
|
-
|
|
17
|
+
const observer = new ResizeObserver(listener);
|
|
18
|
+
observer.observe(document.documentElement);
|
|
19
|
+
window.addEventListener("resize", listener);
|
|
20
|
+
return () => {
|
|
21
|
+
media.removeEventListener("change", listener);
|
|
22
|
+
window.removeEventListener("resize", listener);
|
|
23
|
+
observer.disconnect();
|
|
24
|
+
};
|
|
14
25
|
}
|
|
15
|
-
const isDesktop = () => window.
|
|
26
|
+
const isDesktop = () => window.innerWidth >= 40 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
16
27
|
const serverDesktop = () => false;
|
|
17
28
|
/** One dialog contains the filter list and its editor, with a single-panel layout on phones. */
|
|
18
|
-
export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigger, }) {
|
|
29
|
+
export function FilterMenuPanel({ sections, selectedId, onSelect, onClose, anchor, trigger, }) {
|
|
19
30
|
const { classNames } = useMendyUI();
|
|
20
31
|
const desktop = useSyncExternalStore(subscribeViewport, isDesktop, serverDesktop);
|
|
21
32
|
const selected = sections.find((section) => section.id === selectedId && !section.disabled) ??
|
|
@@ -25,7 +36,19 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigge
|
|
|
25
36
|
const rows = useRef(new Map());
|
|
26
37
|
const pendingEditorFocus = useRef(false);
|
|
27
38
|
const [alignOffset, setAlignOffset] = useState(0);
|
|
39
|
+
const [side, setSide] = useState("bottom");
|
|
28
40
|
const panelId = useId();
|
|
41
|
+
const initialSelection = useRef(selected?.id);
|
|
42
|
+
const lastSelection = useRef(selected?.id);
|
|
43
|
+
const [keyboardNavigation, setKeyboardNavigation] = useState(() => trigger.current?.matches(":focus-visible") ?? false);
|
|
44
|
+
const pointer = useMenuPointer({
|
|
45
|
+
editor,
|
|
46
|
+
selectedId: selected?.id,
|
|
47
|
+
onChoose(id) {
|
|
48
|
+
choose(id);
|
|
49
|
+
rows.current.get(id)?.focus({ preventScroll: true });
|
|
50
|
+
},
|
|
51
|
+
});
|
|
29
52
|
useLayoutEffect(() => {
|
|
30
53
|
const button = trigger.current;
|
|
31
54
|
if (!button)
|
|
@@ -33,33 +56,47 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigge
|
|
|
33
56
|
const update = () => {
|
|
34
57
|
const bounds = button.getBoundingClientRect();
|
|
35
58
|
const target = anchor?.current?.getBoundingClientRect();
|
|
59
|
+
const rtl = getComputedStyle(button).direction === "rtl";
|
|
60
|
+
const viewport = window.visualViewport;
|
|
61
|
+
const viewportTop = viewport?.offsetTop ?? 0;
|
|
62
|
+
const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight);
|
|
63
|
+
const below = viewportBottom - bounds.bottom - 23;
|
|
64
|
+
const above = bounds.top - viewportTop - 23;
|
|
65
|
+
const preferredHeight = 30 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
66
|
+
// A short list can fit below the trigger while its editor cannot. Choose room
|
|
67
|
+
// for the editor before Radix constrains its height to the current side.
|
|
68
|
+
setSide(below < preferredHeight && above > below ? "top" : "bottom");
|
|
36
69
|
// Desktop starts at the search field's edge; mobile stays close to the icon.
|
|
37
|
-
setAlignOffset(desktop
|
|
70
|
+
setAlignOffset(desktop
|
|
71
|
+
? rtl
|
|
72
|
+
? bounds.right - (target?.right ?? bounds.right)
|
|
73
|
+
: (target?.left ?? bounds.left) - bounds.left
|
|
74
|
+
: 0);
|
|
38
75
|
};
|
|
39
76
|
update();
|
|
40
77
|
const observer = new ResizeObserver(update);
|
|
41
78
|
observer.observe(anchor?.current ?? button);
|
|
42
79
|
window.addEventListener("resize", update);
|
|
80
|
+
window.addEventListener("scroll", update, true);
|
|
81
|
+
window.visualViewport?.addEventListener("resize", update);
|
|
82
|
+
window.visualViewport?.addEventListener("scroll", update);
|
|
43
83
|
return () => {
|
|
44
84
|
observer.disconnect();
|
|
45
85
|
window.removeEventListener("resize", update);
|
|
86
|
+
window.removeEventListener("scroll", update, true);
|
|
87
|
+
window.visualViewport?.removeEventListener("resize", update);
|
|
88
|
+
window.visualViewport?.removeEventListener("scroll", update);
|
|
46
89
|
};
|
|
47
90
|
}, [anchor, desktop, trigger]);
|
|
48
91
|
useEffect(() => {
|
|
49
92
|
const frame = requestAnimationFrame(() => {
|
|
50
|
-
const first =
|
|
93
|
+
const first = rows.current.get(initialSelection.current ?? "") ??
|
|
94
|
+
[...rows.current.values()].find((button) => !button.disabled);
|
|
51
95
|
(first ?? content.current)?.focus();
|
|
52
96
|
});
|
|
53
97
|
return () => cancelAnimationFrame(frame);
|
|
54
98
|
}, []);
|
|
55
|
-
|
|
56
|
-
const root = editor.current;
|
|
57
|
-
if (!root)
|
|
58
|
-
return;
|
|
59
|
-
// Prefer the current calendar day to month-navigation buttons.
|
|
60
|
-
const target = root.querySelector('input:not([disabled]), textarea:not([disabled]), [role="grid"] button[tabindex="0"], [role^="menuitem"]:not([data-disabled])') ?? root.querySelector('button:not([disabled]), [tabindex="0"]');
|
|
61
|
-
(target ?? root).focus();
|
|
62
|
-
}
|
|
99
|
+
const focusEditor = () => focusMenuEditor(editor.current);
|
|
63
100
|
useLayoutEffect(() => {
|
|
64
101
|
if (!pendingEditorFocus.current)
|
|
65
102
|
return;
|
|
@@ -67,6 +104,7 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigge
|
|
|
67
104
|
focusEditor();
|
|
68
105
|
});
|
|
69
106
|
function choose(id, enter = false) {
|
|
107
|
+
pointer.cancel();
|
|
70
108
|
if (enter && selected?.id === id && editor.current)
|
|
71
109
|
focusEditor();
|
|
72
110
|
else
|
|
@@ -75,11 +113,13 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigge
|
|
|
75
113
|
}
|
|
76
114
|
function back() {
|
|
77
115
|
const previous = selected?.id;
|
|
116
|
+
lastSelection.current = previous;
|
|
117
|
+
pointer.cancel();
|
|
78
118
|
onSelect(null);
|
|
79
119
|
requestAnimationFrame(() => previous && rows.current.get(previous)?.focus());
|
|
80
120
|
}
|
|
81
121
|
const showList = desktop || !selected;
|
|
82
|
-
return (_jsx(DropdownMenuContent, { ref: content, "data-mendy-ui": "", "data-slot": "filter-menu-panel", role: "dialog", "aria-label": "Filters", "aria-labelledby": undefined, "aria-orientation": undefined, align: desktop ? "start" : "end", alignOffset: alignOffset, sideOffset: 7, collisionPadding:
|
|
122
|
+
return (_jsx(DropdownMenuContent, { ref: content, "data-mendy-ui": "", "data-slot": "filter-menu-panel", role: "dialog", "aria-label": "Filters", "aria-labelledby": undefined, "aria-orientation": undefined, align: desktop ? "start" : "end", alignOffset: alignOffset, side: side, sideOffset: 7, collisionPadding: 16, onEscapeKeyDown: (event) => {
|
|
83
123
|
if (selected && !desktop) {
|
|
84
124
|
event.preventDefault();
|
|
85
125
|
back();
|
|
@@ -91,15 +131,28 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigge
|
|
|
91
131
|
focused !== document.body &&
|
|
92
132
|
!event.target.contains(focused))
|
|
93
133
|
event.preventDefault();
|
|
94
|
-
},
|
|
95
|
-
|
|
134
|
+
}, onPointerDownCapture: () => {
|
|
135
|
+
setKeyboardNavigation(false);
|
|
136
|
+
pointer.cancel();
|
|
137
|
+
}, onPointerLeave: pointer.cancel, onKeyDownCapture: (event) => {
|
|
138
|
+
if (!event.nativeEvent.isComposing &&
|
|
139
|
+
!["Shift", "Control", "Alt", "Meta"].includes(event.key))
|
|
140
|
+
setKeyboardNavigation(true);
|
|
96
141
|
if (event.key === "Tab")
|
|
97
|
-
|
|
142
|
+
pointer.cancel();
|
|
143
|
+
handleMenuTab(event, {
|
|
144
|
+
trigger: trigger.current,
|
|
145
|
+
editor: editor.current,
|
|
146
|
+
onClose,
|
|
147
|
+
hasSelection: Boolean(selected),
|
|
148
|
+
});
|
|
149
|
+
}, onKeyDown: (event) => {
|
|
98
150
|
const target = event.target;
|
|
99
151
|
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
|
|
100
152
|
if (event.key === (rtl ? "ArrowRight" : "ArrowLeft") &&
|
|
101
153
|
target instanceof HTMLElement &&
|
|
102
|
-
!target.
|
|
154
|
+
!target.isContentEditable &&
|
|
155
|
+
!target.closest('input, textarea, select, [role="grid"], [role="slider"], [role="spinbutton"], [role="combobox"], [role="tablist"], [role="tree"], [role="listbox"]')) {
|
|
103
156
|
event.preventDefault();
|
|
104
157
|
event.stopPropagation();
|
|
105
158
|
if (desktop)
|
|
@@ -107,58 +160,88 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigge
|
|
|
107
160
|
else
|
|
108
161
|
back();
|
|
109
162
|
}
|
|
110
|
-
}, className: cn("mui-
|
|
111
|
-
? "mui-0f2a693e93e2 mui-
|
|
163
|
+
}, className: cn("mui-5662a94d9dba mui-571ea69568d3 mui-34055e6f8c1b mui-d5111d0e9f48 mui-04760bcd507f mui-94ea94fde25f mui-393df0d154e0 mui-548a450e8e53", desktop && selected ? "mui-8dd3eece40db" : "mui-919182384559", classNames?.menu), children: _jsxs("div", { className: cn("mui-80baa5d03af7", desktop && selected
|
|
164
|
+
? "mui-0f2a693e93e2 mui-b5985369ee35 mui-da607d0a2538" : "mui-222f930b8752 mui-302c0d124a94"), children: [showList && (_jsx(FilterMenuList, { sections: sections, selectedId: selected?.id, initialKey: selected?.id ?? lastSelection.current, panelId: panelId, desktop: desktop, rows: rows, choose: choose, keyboardNavigation: keyboardNavigation, onPointerMove: (id, event) => {
|
|
165
|
+
if (desktop && event.pointerType === "mouse") {
|
|
166
|
+
setKeyboardNavigation(false);
|
|
167
|
+
const focused = document.activeElement;
|
|
168
|
+
if (focused instanceof HTMLElement &&
|
|
169
|
+
editor.current?.contains(focused) &&
|
|
170
|
+
(focused.matches("input, textarea") || focused.isContentEditable)) {
|
|
171
|
+
pointer.cancel();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
pointer.move(id, event);
|
|
175
|
+
}
|
|
176
|
+
} })), selected && (_jsxs("div", { onPointerEnter: pointer.cancel, className: "mui-222f930b8752 mui-410da8dfa8ac mui-184ddc11e5f9 mui-302c0d124a94", children: [_jsxs("div", { className: cn("mui-222f930b8752 mui-8423dc94ee06 mui-27ead27a81df mui-71556df3b421 mui-074569488cca mui-bbe39cfb5cc6 mui-0f9e6672913a mui-b5edc3ea7c91 mui-3992de70b033 mui-daaac3fbf55e mui-aa0e3c036805 mui-0288c4ea71ea", classNames?.menuHeader), children: [!desktop && (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: back, className: "mui-958e2cfcf5b6 mui-70007b876865 mui-e7e01cc7f4df mui-3992de70b033 mui-52101fc7d8bb", children: [_jsx(ArrowLeft, { "aria-hidden": "true", className: "mui-61c000c8a98a mui-36b12e8339c7" }), "Filters"] }), _jsx("span", { "aria-hidden": "true", className: "mui-35f35c41d134", children: "/" })] })), _jsx(MenuHeading, { label: selected.label }), selected.clear && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
|
|
112
177
|
selected.clear?.();
|
|
113
178
|
requestAnimationFrame(focusEditor);
|
|
114
|
-
}, "aria-label": `Clear ${selected.label} filter`, className: "mui-ce1945804664 mui-1da2e8f173c5 mui-3992de70b033 mui-52101fc7d8bb mui-35f35c41d134", children: "Clear" }))] }), _jsx("div", { ref: editor, id: panelId
|
|
179
|
+
}, "aria-label": `Clear ${selected.label} filter`, className: "mui-8cc46fa87f41 mui-ce1945804664 mui-1da2e8f173c5 mui-3992de70b033 mui-52101fc7d8bb mui-35f35c41d134", children: "Clear" }))] }), _jsx("div", { ref: editor, id: `${panelId}-${selected.id}`, role: "group", "aria-label": selected.editorLabel, tabIndex: -1, className: cn("mui-410da8dfa8ac mui-184ddc11e5f9 mui-2368e909f3a5 mui-998780cdef87 mui-b30fc56058b6 mui-f157ee6bae99 mui-a1c8c43d9be2 mui-b43343fe71c0", classNames?.editor), children: selected.content }, selected.id)] }))] }) }));
|
|
115
180
|
}
|
|
116
|
-
function
|
|
181
|
+
function MenuHeading({ label }) {
|
|
182
|
+
const ref = useRef(null);
|
|
183
|
+
const [overflow, setOverflow] = useState(false);
|
|
184
|
+
const [below, setBelow] = useState(false);
|
|
185
|
+
const measure = () => {
|
|
186
|
+
const element = ref.current;
|
|
187
|
+
if (!element)
|
|
188
|
+
return;
|
|
189
|
+
setOverflow(element.scrollHeight > element.clientHeight + 1);
|
|
190
|
+
setBelow(element.scrollHeight - element.clientHeight - element.scrollTop > 1);
|
|
191
|
+
};
|
|
192
|
+
useLayoutEffect(() => {
|
|
193
|
+
const element = ref.current;
|
|
194
|
+
if (!element)
|
|
195
|
+
return;
|
|
196
|
+
element.scrollTop = 0;
|
|
197
|
+
measure();
|
|
198
|
+
const observer = new ResizeObserver(measure);
|
|
199
|
+
observer.observe(element);
|
|
200
|
+
return () => observer.disconnect();
|
|
201
|
+
}, [label]);
|
|
202
|
+
return (_jsx("span", { ref: ref, onScroll: measure, title: label, tabIndex: overflow ? 0 : undefined, "data-more-below": below, className: "mui-1326520533f0 mui-184ddc11e5f9 mui-7bd5bab6d7f4 mui-2368e909f3a5 mui-726fca123972 mui-393883c1db68 mui-cacb19aa9640 mui-bbaaa5969856 mui-1df9125eebaf mui-223daa83ed2d", children: label }));
|
|
203
|
+
}
|
|
204
|
+
function FilterMenuList({ sections, selectedId, initialKey, panelId, desktop, rows, choose, keyboardNavigation, onPointerMove, }) {
|
|
117
205
|
const { classNames } = useMendyUI();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const start = sections.findIndex((item) => item.id === section.id) + 1;
|
|
155
|
-
const next = [...sections.slice(start), ...sections.slice(0, start)].find((item) => !item.disabled &&
|
|
156
|
-
item.label.toLocaleLowerCase().startsWith(event.key.toLocaleLowerCase()));
|
|
157
|
-
if (next) {
|
|
206
|
+
const [query, setQuery] = useValueDraft("types", () => "", "__menu:query");
|
|
207
|
+
const collection = useRef(null);
|
|
208
|
+
const matches = [];
|
|
209
|
+
const term = query.toLocaleLowerCase();
|
|
210
|
+
for (const section of sections) {
|
|
211
|
+
if (section.label.toLocaleLowerCase().includes(term))
|
|
212
|
+
matches.push({ ...section, key: section.id });
|
|
213
|
+
}
|
|
214
|
+
return (_jsxs("div", { className: cn("mui-222f930b8752 mui-410da8dfa8ac mui-302c0d124a94", desktop && selectedId && "mui-3b6d5fc7b061"), children: [sections.length > 20 && (_jsx("div", { className: "mui-27ead27a81df mui-bbe39cfb5cc6 mui-b97db4a9f432", children: _jsx(Input, { type: "search", "aria-label": "Find a filter", placeholder: "Find a filter\u2026", value: query, className: "mui-b51243872d85", onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
|
|
215
|
+
if (event.key === "ArrowDown") {
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
collection.current?.focusFirst();
|
|
218
|
+
}
|
|
219
|
+
if (event.key !== "Escape" && event.key !== "Tab")
|
|
220
|
+
event.stopPropagation();
|
|
221
|
+
} }) })), matches.length === 0 && (_jsx("p", { className: "mui-094f5333853b mui-c74ab393b96d mui-35f35c41d134", children: sections.length ? "No matching filters." : "No filters available." })), _jsx(FilterCollection, { items: matches, role: "group", label: "Filter types", collectionRef: collection, initialKey: initialKey, className: cn("mui-80baa5d03af7", classNames?.menuList), children: (section, index, row) => (_jsxs(Button, { ...row, ref: (node) => {
|
|
222
|
+
row.ref(node);
|
|
223
|
+
if (node)
|
|
224
|
+
rows.current.set(section.id, node);
|
|
225
|
+
else
|
|
226
|
+
rows.current.delete(section.id);
|
|
227
|
+
}, variant: "ghost", type: "button", "aria-label": section.label, "aria-description": matches.length > 100
|
|
228
|
+
? `${section.active ? "Filter applied. " : ""}${index + 1} of ${matches.length}`
|
|
229
|
+
: section.active
|
|
230
|
+
? "Filter applied"
|
|
231
|
+
: undefined, "aria-expanded": selectedId === section.id, "aria-controls": selectedId === section.id ? `${panelId}-${section.id}` : undefined, "data-navigation": keyboardNavigation ? "keyboard" : "pointer", disabled: section.disabled, className: cn("mui-a66e9985b093 mui-8423dc94ee06 mui-58d0413dd93c mui-c62ec162662e mui-074569488cca mui-02e603944040 mui-e7e01cc7f4df mui-b5edc3ea7c91 mui-c74ab393b96d mui-52101fc7d8bb mui-aa0e3c036805 mui-41d1475cf71a mui-dfc89afd7885", selectedId === section.id && "mui-292affc1f780 mui-cc209238d847", classNames?.menuRow), onPointerMove: (event) => {
|
|
232
|
+
if (!section.disabled)
|
|
233
|
+
onPointerMove(section.id, event);
|
|
234
|
+
}, onFocus: () => {
|
|
235
|
+
if (desktop)
|
|
236
|
+
choose(section.id);
|
|
237
|
+
}, onClick: () => choose(section.id, true), onKeyDown: (event) => {
|
|
238
|
+
if (event.nativeEvent.isComposing)
|
|
239
|
+
return;
|
|
240
|
+
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
|
|
241
|
+
if ([rtl ? "ArrowLeft" : "ArrowRight", "Enter", " "].includes(event.key)) {
|
|
158
242
|
event.preventDefault();
|
|
159
243
|
event.stopPropagation();
|
|
160
|
-
|
|
244
|
+
choose(section.id, true);
|
|
161
245
|
}
|
|
162
|
-
}
|
|
163
|
-
}, children: [section.icon && (_jsx("span", { "aria-hidden": "true", className: "mui-27ead27a81df mui-35f35c41d134 mui-c77732bd3ab4", children: section.icon })), _jsx("span", { className: "mui-184ddc11e5f9 mui-7bd5bab6d7f4 mui-726fca123972 mui-393883c1db68 mui-1a5d10977b93", title: section.label, children: section.label }), section.active && (_jsx("span", { "aria-hidden": "true", className: "mui-dc797fcc7165 mui-27ead27a81df mui-b005d61d0953 mui-0cf6c2e8fe7e" })), _jsx(ChevronRight, { "aria-hidden": "true", className: "mui-61c000c8a98a mui-27ead27a81df mui-0b5a4d1257ab mui-36b12e8339c7" })] }, section.id)))] }));
|
|
246
|
+
}, children: [section.icon && (_jsx("span", { "aria-hidden": "true", className: "mui-27ead27a81df mui-35f35c41d134 mui-c77732bd3ab4", children: section.icon })), _jsx("span", { className: "mui-184ddc11e5f9 mui-7bd5bab6d7f4 mui-726fca123972 mui-393883c1db68 mui-e9347b78185f", title: section.label, children: section.label }), section.active && (_jsx("span", { "aria-hidden": "true", className: "mui-dc797fcc7165 mui-27ead27a81df mui-b005d61d0953 mui-0cf6c2e8fe7e" })), _jsx(ChevronRight, { "aria-hidden": "true", className: "mui-61c000c8a98a mui-27ead27a81df mui-0b5a4d1257ab mui-36b12e8339c7" })] }, section.id)) })] }));
|
|
164
247
|
}
|