@mendylanda/ui 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/class-names.js +1 -1
- package/dist/customization.d.ts +3 -1
- package/dist/customization.js +3 -2
- package/dist/filters/filter-bar.d.ts +4 -1
- package/dist/filters/filter-bar.js +31 -25
- package/dist/filters/filter-definition.d.ts +15 -0
- package/dist/filters/filter-menu-focus.d.ts +3 -0
- package/dist/filters/filter-menu-focus.js +22 -0
- package/dist/filters/filter-menu-panel.d.ts +1 -0
- package/dist/filters/filter-menu-panel.js +40 -77
- package/dist/filters/filter-option-search.d.ts +4 -0
- package/dist/filters/filter-option-search.js +9 -0
- package/dist/filters/filter-select-editor.d.ts +1 -0
- package/dist/filters/filter-select-editor.js +7 -8
- package/dist/filters/filters.d.ts +8 -2
- package/dist/filters/filters.js +29 -3
- package/dist/filters/use-filter-options.js +5 -1
- package/dist/filters/use-menu-placement.d.ts +15 -0
- package/dist/filters/use-menu-placement.js +104 -0
- package/dist/primitives/dropdown-menu.js +2 -2
- package/dist/styles.css +80 -52
- package/package.json +8 -6
|
@@ -7,7 +7,8 @@ import { FilterCollection } from "./filter-collection.js";
|
|
|
7
7
|
import { useValueDraft } from "./use-value-draft.js";
|
|
8
8
|
import { DropdownMenuContent } from "../primitives/dropdown-menu.js";
|
|
9
9
|
import { useMendyUI } from "../customization.js";
|
|
10
|
-
import { focusMenuEditor, handleMenuTab } from "./filter-menu-focus.js";
|
|
10
|
+
import { focusMenuEditor, handleMenuTab, handleMenuReturn, preserveOutsideFocus, } from "./filter-menu-focus.js";
|
|
11
|
+
import { useMenuPlacement, useEditorOffset } from "./use-menu-placement.js";
|
|
11
12
|
import { useMenuPointer } from "./use-menu-pointer.js";
|
|
12
13
|
import { cn } from "../utils.js";
|
|
13
14
|
const desktopQuery = "(min-width: 640px)";
|
|
@@ -25,19 +26,30 @@ function subscribeViewport(listener) {
|
|
|
25
26
|
}
|
|
26
27
|
const isDesktop = () => window.innerWidth >= 40 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
27
28
|
const serverDesktop = () => false;
|
|
29
|
+
function selectedSection(sections, selectedId, desktop) {
|
|
30
|
+
return (sections.find((section) => section.id === selectedId && !section.disabled) ??
|
|
31
|
+
(desktop ? sections.find((section) => !section.disabled) : undefined));
|
|
32
|
+
}
|
|
28
33
|
/** One dialog contains the filter list and its editor, with a single-panel layout on phones. */
|
|
29
34
|
export function FilterMenuPanel({ sections, selectedId, onSelect, onClose, anchor, trigger, }) {
|
|
30
|
-
const { classNames } = useMendyUI();
|
|
35
|
+
const { classNames, menuLayout } = useMendyUI();
|
|
31
36
|
const desktop = useSyncExternalStore(subscribeViewport, isDesktop, serverDesktop);
|
|
32
|
-
const selected = sections
|
|
33
|
-
(desktop ? sections.find((section) => !section.disabled) : undefined);
|
|
37
|
+
const selected = selectedSection(sections, selectedId, desktop);
|
|
34
38
|
const content = useRef(null);
|
|
35
39
|
const editor = useRef(null);
|
|
36
40
|
const rows = useRef(new Map());
|
|
37
41
|
const pendingEditorFocus = useRef(false);
|
|
38
|
-
const
|
|
39
|
-
const [side, setSide] = useState("bottom");
|
|
42
|
+
const { alignOffset, side, anchorStyle } = useMenuPlacement(anchor, trigger, desktop);
|
|
40
43
|
const panelId = useId();
|
|
44
|
+
const detached = desktop && menuLayout === "anchored";
|
|
45
|
+
const editorPanel = useRef(null);
|
|
46
|
+
const editorOffset = useEditorOffset({
|
|
47
|
+
detached,
|
|
48
|
+
editorPanel,
|
|
49
|
+
content,
|
|
50
|
+
rows,
|
|
51
|
+
selectedId: selected?.id,
|
|
52
|
+
});
|
|
41
53
|
const initialSelection = useRef(selected?.id);
|
|
42
54
|
const lastSelection = useRef(selected?.id);
|
|
43
55
|
const [keyboardNavigation, setKeyboardNavigation] = useState(() => trigger.current?.matches(":focus-visible") ?? false);
|
|
@@ -49,45 +61,6 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, onClose, ancho
|
|
|
49
61
|
rows.current.get(id)?.focus({ preventScroll: true });
|
|
50
62
|
},
|
|
51
63
|
});
|
|
52
|
-
useLayoutEffect(() => {
|
|
53
|
-
const button = trigger.current;
|
|
54
|
-
if (!button)
|
|
55
|
-
return;
|
|
56
|
-
const update = () => {
|
|
57
|
-
const bounds = button.getBoundingClientRect();
|
|
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");
|
|
69
|
-
// Desktop starts at the search field's edge; mobile stays close to the icon.
|
|
70
|
-
setAlignOffset(desktop
|
|
71
|
-
? rtl
|
|
72
|
-
? bounds.right - (target?.right ?? bounds.right)
|
|
73
|
-
: (target?.left ?? bounds.left) - bounds.left
|
|
74
|
-
: 0);
|
|
75
|
-
};
|
|
76
|
-
update();
|
|
77
|
-
const observer = new ResizeObserver(update);
|
|
78
|
-
observer.observe(anchor?.current ?? button);
|
|
79
|
-
window.addEventListener("resize", update);
|
|
80
|
-
window.addEventListener("scroll", update, true);
|
|
81
|
-
window.visualViewport?.addEventListener("resize", update);
|
|
82
|
-
window.visualViewport?.addEventListener("scroll", update);
|
|
83
|
-
return () => {
|
|
84
|
-
observer.disconnect();
|
|
85
|
-
window.removeEventListener("resize", update);
|
|
86
|
-
window.removeEventListener("scroll", update, true);
|
|
87
|
-
window.visualViewport?.removeEventListener("resize", update);
|
|
88
|
-
window.visualViewport?.removeEventListener("scroll", update);
|
|
89
|
-
};
|
|
90
|
-
}, [anchor, desktop, trigger]);
|
|
91
64
|
useEffect(() => {
|
|
92
65
|
const frame = requestAnimationFrame(() => {
|
|
93
66
|
const first = rows.current.get(initialSelection.current ?? "") ??
|
|
@@ -119,19 +92,12 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, onClose, ancho
|
|
|
119
92
|
requestAnimationFrame(() => previous && rows.current.get(previous)?.focus());
|
|
120
93
|
}
|
|
121
94
|
const showList = desktop || !selected;
|
|
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) => {
|
|
95
|
+
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, style: anchorStyle, sideOffset: 7, collisionPadding: 16, onEscapeKeyDown: (event) => {
|
|
123
96
|
if (selected && !desktop) {
|
|
124
97
|
event.preventDefault();
|
|
125
98
|
back();
|
|
126
99
|
}
|
|
127
|
-
}, onCloseAutoFocus: (
|
|
128
|
-
const focused = document.activeElement;
|
|
129
|
-
if (event.target instanceof HTMLElement &&
|
|
130
|
-
focused &&
|
|
131
|
-
focused !== document.body &&
|
|
132
|
-
!event.target.contains(focused))
|
|
133
|
-
event.preventDefault();
|
|
134
|
-
}, onPointerDownCapture: () => {
|
|
100
|
+
}, onCloseAutoFocus: preserveOutsideFocus, onPointerDownCapture: () => {
|
|
135
101
|
setKeyboardNavigation(false);
|
|
136
102
|
pointer.cancel();
|
|
137
103
|
}, onPointerLeave: pointer.cancel, onKeyDownCapture: (event) => {
|
|
@@ -146,22 +112,14 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, onClose, ancho
|
|
|
146
112
|
onClose,
|
|
147
113
|
hasSelection: Boolean(selected),
|
|
148
114
|
});
|
|
149
|
-
}, onKeyDown: (event) => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
event.stopPropagation();
|
|
158
|
-
if (desktop)
|
|
159
|
-
rows.current.get(selected?.id ?? "")?.focus();
|
|
160
|
-
else
|
|
161
|
-
back();
|
|
162
|
-
}
|
|
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) => {
|
|
115
|
+
}, onKeyDown: (event) => handleMenuReturn(event, () => {
|
|
116
|
+
if (desktop)
|
|
117
|
+
rows.current.get(selected?.id ?? "")?.focus();
|
|
118
|
+
else
|
|
119
|
+
back();
|
|
120
|
+
}), className: cn("mui-971625533e56 mui-571ea69568d3 mui-34055e6f8c1b mui-d5111d0e9f48 mui-04760bcd507f mui-94ea94fde25f mui-393df0d154e0 mui-548a450e8e53", "mui-d7d803c609c5", desktop && selected
|
|
121
|
+
? "mui-bd8a502f98ae" : "mui-0b9c84552e40", detached && "mui-0fc84a692dad mui-dad556abfe15 mui-8fca9236d191 mui-89c3006b2722", classNames?.menu), children: _jsxs("div", { className: cn("mui-80baa5d03af7", desktop && selected
|
|
122
|
+
? "mui-0f2a693e93e2 mui-b5985369ee35 mui-a3c6c8c31aed" : "mui-222f930b8752 mui-302c0d124a94", detached && "mui-b9677bd1cb66 mui-e0122edc668b"), children: [showList && (_jsx(FilterMenuList, { sections: sections, selectedId: selected?.id, initialKey: selected?.id ?? lastSelection.current, panelId: panelId, desktop: desktop, detached: detached, rows: rows, choose: choose, keyboardNavigation: keyboardNavigation, onPointerMove: (id, event) => {
|
|
165
123
|
if (desktop && event.pointerType === "mouse") {
|
|
166
124
|
setKeyboardNavigation(false);
|
|
167
125
|
const focused = document.activeElement;
|
|
@@ -173,10 +131,14 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, onClose, ancho
|
|
|
173
131
|
}
|
|
174
132
|
pointer.move(id, event);
|
|
175
133
|
}
|
|
176
|
-
} })), selected && (_jsxs("div", { onPointerEnter: pointer.cancel,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
134
|
+
} })), selected && (_jsxs("div", { ref: editorPanel, "data-slot": "filter-menu-editor", onPointerEnter: pointer.cancel, style: detached ? { marginTop: editorOffset } : undefined, className: cn("mui-222f930b8752 mui-410da8dfa8ac mui-184ddc11e5f9 mui-80baa5d03af7 mui-302c0d124a94", detached && "mui-291952c2525a mui-d5111d0e9f48 mui-3fa8c572949b mui-b31810dbd8a9 mui-4f1a55de40bc mui-e593c38256e0 mui-5bd2afe89f0b"), children: [_jsx(EditorHeading, { section: selected, desktop: desktop, back: back, onCleared: focusEditor }), _jsx("div", { ref: editor, id: `${panelId}-${selected.id}`, role: "group", "aria-label": selected.editorLabel, tabIndex: -1, className: cn("mui-410da8dfa8ac mui-184ddc11e5f9 mui-3b82279f859f mui-2368e909f3a5 mui-998780cdef87 mui-b30fc56058b6 mui-f157ee6bae99 mui-a1c8c43d9be2 mui-b43343fe71c0", classNames?.editor), children: selected.content }, selected.id)] }))] }) }));
|
|
135
|
+
}
|
|
136
|
+
function EditorHeading({ section, desktop, back, onCleared, }) {
|
|
137
|
+
const { classNames } = useMendyUI();
|
|
138
|
+
return (_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: section.label }), section.clear && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
|
|
139
|
+
section.clear?.();
|
|
140
|
+
requestAnimationFrame(onCleared);
|
|
141
|
+
}, "aria-label": `Clear ${section.label} filter`, className: "mui-8cc46fa87f41 mui-ce1945804664 mui-1da2e8f173c5 mui-3992de70b033 mui-52101fc7d8bb mui-35f35c41d134", children: "Clear" }))] }));
|
|
180
142
|
}
|
|
181
143
|
function MenuHeading({ label }) {
|
|
182
144
|
const ref = useRef(null);
|
|
@@ -201,7 +163,7 @@ function MenuHeading({ label }) {
|
|
|
201
163
|
}, [label]);
|
|
202
164
|
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
165
|
}
|
|
204
|
-
function FilterMenuList({ sections, selectedId, initialKey, panelId, desktop, rows, choose, keyboardNavigation, onPointerMove, }) {
|
|
166
|
+
function FilterMenuList({ sections, selectedId, initialKey, panelId, desktop, detached, rows, choose, keyboardNavigation, onPointerMove, }) {
|
|
205
167
|
const { classNames } = useMendyUI();
|
|
206
168
|
const [query, setQuery] = useValueDraft("types", () => "", "__menu:query");
|
|
207
169
|
const collection = useRef(null);
|
|
@@ -211,7 +173,7 @@ function FilterMenuList({ sections, selectedId, initialKey, panelId, desktop, ro
|
|
|
211
173
|
if (section.label.toLocaleLowerCase().includes(term))
|
|
212
174
|
matches.push({ ...section, key: section.id });
|
|
213
175
|
}
|
|
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) => {
|
|
176
|
+
return (_jsxs("div", { "data-slot": "filter-menu-list", className: cn("mui-222f930b8752 mui-410da8dfa8ac mui-302c0d124a94", desktop && selectedId && !detached && "mui-3b6d5fc7b061", detached && "mui-d5111d0e9f48 mui-3fa8c572949b mui-4f1a55de40bc mui-e593c38256e0 mui-5bd2afe89f0b"), 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
177
|
if (event.key === "ArrowDown") {
|
|
216
178
|
event.preventDefault();
|
|
217
179
|
collection.current?.focusFirst();
|
|
@@ -228,7 +190,8 @@ function FilterMenuList({ sections, selectedId, initialKey, panelId, desktop, ro
|
|
|
228
190
|
? `${section.active ? "Filter applied. " : ""}${index + 1} of ${matches.length}`
|
|
229
191
|
: section.active
|
|
230
192
|
? "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",
|
|
193
|
+
: undefined, "aria-expanded": selectedId === section.id, "aria-controls": selectedId === section.id ? `${panelId}-${section.id}` : undefined, "data-navigation": keyboardNavigation ? "keyboard" : "pointer", "data-separator": section.separatorBefore && !query ? "true" : undefined, 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", section.separatorBefore &&
|
|
194
|
+
!query && "mui-d2d9e1f13413 mui-a22ea0ba562a mui-be02e79e6a27 mui-a267680f9f49 mui-e22234c7b474 mui-4d24300d447b mui-37a30b916086", selectedId === section.id && "mui-292affc1f780 mui-cc209238d847", classNames?.menuRow), onPointerMove: (event) => {
|
|
232
195
|
if (!section.disabled)
|
|
233
196
|
onPointerMove(section.id, event);
|
|
234
197
|
}, onFocus: () => {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { Input } from "../customization.js";
|
|
3
|
+
/** The search row belongs to the option panel, with a visible inset keyboard focus ring. */
|
|
4
|
+
export declare function FilterOptionSearch({ className, ...props }: ComponentProps<typeof Input>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Search } from "lucide-react";
|
|
4
|
+
import { Input } from "../customization.js";
|
|
5
|
+
import { cn } from "../utils.js";
|
|
6
|
+
/** The search row belongs to the option panel, with a visible inset keyboard focus ring. */
|
|
7
|
+
export function FilterOptionSearch({ className, ...props }) {
|
|
8
|
+
return (_jsxs("div", { "data-mendy-ui": "", "data-slot": "filter-option-search", className: "mui-d2d9e1f13413 mui-bbe39cfb5cc6", children: [_jsx(Search, { className: "mui-33228ff7d583 mui-747355bdc2a2 mui-cea70f23972e mui-4e45f4841abb mui-100c22d5776b mui-5dde0fd996f0 mui-35f35c41d134", "aria-hidden": "true" }), _jsx(Input, { ...props, type: "search", className: cn("mui-2bf5d58a824b mui-52417259a4f5 mui-dad556abfe15 mui-8fca9236d191 mui-b2100041c585 mui-a202804a1738 mui-89c3006b2722 mui-39be5a15b766 mui-50aafa8694bb mui-db549d38ff19 mui-4f05a238500e", className) })] }));
|
|
9
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect,
|
|
4
|
-
import {
|
|
5
|
-
import { Label } from "../customization.js";
|
|
6
|
-
import { Search } from "lucide-react";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { FilterOptionSearch } from "./filter-option-search.js";
|
|
7
5
|
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from "../primitives/dropdown-menu.js";
|
|
8
6
|
import { FilterCheckboxItem } from "./filters.js";
|
|
9
7
|
function handleSearchKey(event) {
|
|
@@ -17,7 +15,7 @@ function handleSearchKey(event) {
|
|
|
17
15
|
if (event.key !== "Escape" && event.key !== "Tab")
|
|
18
16
|
event.stopPropagation();
|
|
19
17
|
}
|
|
20
|
-
function SearchableOptions({ label, options, searchable, searchPlaceholder, emptyMessage = "No options found.", children, }) {
|
|
18
|
+
function SearchableOptions({ label, options, searchable = true, searchPlaceholder, emptyMessage = "No options found.", children, }) {
|
|
21
19
|
const [query, setQuery] = useState("");
|
|
22
20
|
const inputRef = useRef(null);
|
|
23
21
|
useEffect(() => {
|
|
@@ -26,12 +24,13 @@ function SearchableOptions({ label, options, searchable, searchPlaceholder, empt
|
|
|
26
24
|
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
|
27
25
|
return () => cancelAnimationFrame(frame);
|
|
28
26
|
}, [searchable]);
|
|
29
|
-
const
|
|
30
|
-
|
|
27
|
+
const filtered = !searchable
|
|
28
|
+
? options
|
|
29
|
+
: options.filter((option) => option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
|
31
30
|
return (_jsxs("div", { className: "mui-72b37c1a040d mui-81b198a2db84", "data-mendy-ui": "", "data-slot": "filter-options", onKeyDown: (event) => {
|
|
32
31
|
if (event.key === "Tab")
|
|
33
32
|
event.stopPropagation();
|
|
34
|
-
}, children: [searchable && (
|
|
33
|
+
}, children: [searchable && (_jsx(FilterOptionSearch, { ref: inputRef, "aria-label": label, placeholder: searchPlaceholder ?? label, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: handleSearchKey })), filtered.length ? (_jsx("div", { role: "menu", "aria-label": label, className: "mui-1dee6e3ec67d", children: children(filtered) })) : (_jsx("p", { role: "status", className: "mui-094f5333853b mui-c74ab393b96d mui-35f35c41d134", children: emptyMessage }))] }));
|
|
35
34
|
}
|
|
36
35
|
/** Single selection applies immediately and closes the dropdown. */
|
|
37
36
|
export function FilterSelectEditor({ value, onValueChange, ...props }) {
|
|
@@ -2,8 +2,14 @@ import type { ComponentProps, ReactNode } from "react";
|
|
|
2
2
|
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuTrigger } from "../primitives/dropdown-menu.js";
|
|
3
3
|
export type FilterEditorProps = ComponentProps<typeof DropdownMenu>;
|
|
4
4
|
export declare function FilterEditor(props: FilterEditorProps): import("react/jsx-runtime").JSX.Element;
|
|
5
|
-
|
|
6
|
-
export declare function
|
|
5
|
+
/** Preserve the original list stagger; newly inserted chips animate immediately. */
|
|
6
|
+
export declare function FilterChipList({ children }: {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export type FilterChipProps = ComponentProps<"div"> & {
|
|
10
|
+
entranceDelay?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function FilterChip({ className, entranceDelay, ...props }: FilterChipProps): import("react/jsx-runtime").JSX.Element;
|
|
7
13
|
export type FilterEditorTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
|
|
8
14
|
export declare function FilterEditorTrigger({ className, asChild, ...props }: FilterEditorTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
9
15
|
export type FilterEditorContentProps = ComponentProps<typeof DropdownMenuContent>;
|
package/dist/filters/filters.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { m, LazyMotion, domAnimation, useReducedMotion } from "motion/react";
|
|
4
5
|
import { XIcon } from "lucide-react";
|
|
5
6
|
import { Button } from "../customization.js";
|
|
6
7
|
import { cn } from "../utils.js";
|
|
@@ -8,8 +9,33 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe
|
|
|
8
9
|
export function FilterEditor(props) {
|
|
9
10
|
return _jsx(DropdownMenu, { modal: false, ...props });
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const ChipListContext = createContext(false);
|
|
13
|
+
const listVariant = {
|
|
14
|
+
hidden: { y: 10, opacity: 0 },
|
|
15
|
+
show: {
|
|
16
|
+
y: 0,
|
|
17
|
+
opacity: 1,
|
|
18
|
+
transition: { duration: 0.05, staggerChildren: 0.06 },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const itemVariant = {
|
|
22
|
+
hidden: { y: 10, opacity: 0 },
|
|
23
|
+
show: { y: 0, opacity: 1 },
|
|
24
|
+
};
|
|
25
|
+
const reducedVariant = {
|
|
26
|
+
hidden: { y: 0, opacity: 1 },
|
|
27
|
+
show: { y: 0, opacity: 1, transition: { duration: 0 } },
|
|
28
|
+
};
|
|
29
|
+
/** Preserve the original list stagger; newly inserted chips animate immediately. */
|
|
30
|
+
export function FilterChipList({ children }) {
|
|
31
|
+
const reducedMotion = useReducedMotion();
|
|
32
|
+
return (_jsx(LazyMotion, { features: domAnimation, children: _jsx(ChipListContext.Provider, { value: true, children: _jsx(m.div, { variants: reducedMotion ? reducedVariant : listVariant, initial: "hidden", animate: "show", className: "mui-d1b2a59fbea7", children: children }) }) }));
|
|
33
|
+
}
|
|
34
|
+
export function FilterChip({ className, entranceDelay, ...props }) {
|
|
35
|
+
const inList = useContext(ChipListContext);
|
|
36
|
+
const reducedMotion = useReducedMotion();
|
|
37
|
+
const content = (_jsx(m.div, { "data-mendy-ui": "", "data-slot": "filter-chip-entrance", variants: reducedMotion ? reducedVariant : itemVariant, initial: inList ? undefined : "hidden", animate: inList ? undefined : "show", transition: entranceDelay === undefined || reducedMotion ? undefined : { delay: entranceDelay }, className: "mui-184ddc11e5f9 mui-81b198a2db84", children: _jsx("div", { "data-mendy-ui": "", "data-slot": "filter-chip", className: cn("mui-123306850fe1 mui-539ae144f076 mui-81b198a2db84 mui-d5111d0e9f48 mui-3fa8c572949b mui-71556df3b421 mui-d191cb569aac mui-c74ab393b96d mui-35f35c41d134", className), ...props }) }));
|
|
38
|
+
return inList ? content : _jsx(LazyMotion, { features: domAnimation, children: content });
|
|
13
39
|
}
|
|
14
40
|
export function FilterEditorTrigger({ className, asChild, ...props }) {
|
|
15
41
|
return (_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { asChild: asChild, variant: "ghost", "data-mendy-ui": "", "data-slot": "filter-editor-trigger", "aria-haspopup": "dialog", className: cn("mui-222f930b8752 mui-57c930dc1c31 mui-66b490dd6005 mui-52417259a4f5 mui-52101fc7d8bb mui-184ddc11e5f9 mui-71556df3b421 mui-70007b876865 mui-e7e01cc7f4df mui-e9347b78185f mui-37ad15a95595 mui-4d6720d47806 mui-2e357972fc10 mui-355efe22aedb mui-50aafa8694bb mui-db549d38ff19 mui-8d3c1014b967 mui-4600b77303d6 mui-4027d9c589b6 mui-6f73a280432e", className), ...props }) }));
|
|
@@ -64,6 +64,10 @@ export function selectedIds(value) {
|
|
|
64
64
|
? [value]
|
|
65
65
|
: [];
|
|
66
66
|
}
|
|
67
|
+
// External queries belong to the consumer; opting out only disables our local search.
|
|
68
|
+
function optionSearchQuery(field, localQuery) {
|
|
69
|
+
return field.source?.query ?? (field.searchable === false ? "" : localQuery);
|
|
70
|
+
}
|
|
67
71
|
export function useFilterOptions(id, field, value, enabled) {
|
|
68
72
|
const sharedCache = useContext(FilterOptionCache);
|
|
69
73
|
const privateCache = useRef(new Map());
|
|
@@ -75,7 +79,7 @@ export function useFilterOptions(id, field, value, enabled) {
|
|
|
75
79
|
}, [source]);
|
|
76
80
|
const scopeKey = JSON.stringify([id, source?.kind, source?.scope, source?.params]);
|
|
77
81
|
const [localQuery, setLocalQuery] = useValueDraft(scopeKey, () => "", `${id}:query`);
|
|
78
|
-
const query =
|
|
82
|
+
const query = optionSearchQuery(field, localQuery);
|
|
79
83
|
const [retryKey, retry] = useState(0);
|
|
80
84
|
const requestKey = JSON.stringify([scopeKey, query, retryKey]);
|
|
81
85
|
const identity = JSON.stringify([id, source?.kind, source?.scope]);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CSSProperties, RefObject } from "react";
|
|
2
|
+
/** Position the menu against the search field and the available viewport. */
|
|
3
|
+
export declare function useMenuPlacement(anchor: RefObject<HTMLDivElement | null> | undefined, trigger: RefObject<HTMLButtonElement | null>, desktop: boolean): {
|
|
4
|
+
alignOffset: number;
|
|
5
|
+
side: "top" | "bottom";
|
|
6
|
+
anchorStyle: CSSProperties | undefined;
|
|
7
|
+
};
|
|
8
|
+
/** Follow the selected row while keeping the editor inside the viewport. */
|
|
9
|
+
export declare function useEditorOffset({ detached, editorPanel, content, rows, selectedId, }: {
|
|
10
|
+
detached: boolean;
|
|
11
|
+
editorPanel: RefObject<HTMLDivElement | null>;
|
|
12
|
+
content: RefObject<HTMLDivElement | null>;
|
|
13
|
+
rows: RefObject<Map<string, HTMLButtonElement>>;
|
|
14
|
+
selectedId?: string;
|
|
15
|
+
}): number;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
/** Position the menu against the search field and the available viewport. */
|
|
4
|
+
export function useMenuPlacement(anchor, trigger, desktop) {
|
|
5
|
+
const [alignOffset, setAlignOffset] = useState(0);
|
|
6
|
+
const [anchorWidth, setAnchorWidth] = useState();
|
|
7
|
+
const [side, setSide] = useState("bottom");
|
|
8
|
+
useLayoutEffect(() => {
|
|
9
|
+
const button = trigger.current;
|
|
10
|
+
if (!button)
|
|
11
|
+
return;
|
|
12
|
+
const update = () => {
|
|
13
|
+
const bounds = button.getBoundingClientRect();
|
|
14
|
+
const target = anchor?.current?.getBoundingClientRect();
|
|
15
|
+
setAnchorWidth(target?.width);
|
|
16
|
+
const rtl = getComputedStyle(button).direction === "rtl";
|
|
17
|
+
const viewport = window.visualViewport;
|
|
18
|
+
const viewportTop = viewport?.offsetTop ?? 0;
|
|
19
|
+
const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight);
|
|
20
|
+
const below = viewportBottom - bounds.bottom - 23;
|
|
21
|
+
const above = bounds.top - viewportTop - 23;
|
|
22
|
+
const preferredHeight = 30 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
23
|
+
// A short list can fit below the trigger while its editor cannot. Choose room
|
|
24
|
+
// for the editor before Radix constrains its height to the current side.
|
|
25
|
+
setSide(below < preferredHeight && above > below ? "top" : "bottom");
|
|
26
|
+
// Follow the search edge in both layouts, including inset mobile triggers.
|
|
27
|
+
setAlignOffset(desktop
|
|
28
|
+
? rtl
|
|
29
|
+
? bounds.right - (target?.right ?? bounds.right)
|
|
30
|
+
: (target?.left ?? bounds.left) - bounds.left
|
|
31
|
+
: rtl
|
|
32
|
+
? (target?.left ?? bounds.left) - bounds.left
|
|
33
|
+
: bounds.right - (target?.right ?? bounds.right));
|
|
34
|
+
};
|
|
35
|
+
update();
|
|
36
|
+
const observer = new ResizeObserver(update);
|
|
37
|
+
observer.observe(anchor?.current ?? button);
|
|
38
|
+
window.addEventListener("resize", update);
|
|
39
|
+
window.addEventListener("scroll", update, true);
|
|
40
|
+
window.visualViewport?.addEventListener("resize", update);
|
|
41
|
+
window.visualViewport?.addEventListener("scroll", update);
|
|
42
|
+
return () => {
|
|
43
|
+
observer.disconnect();
|
|
44
|
+
window.removeEventListener("resize", update);
|
|
45
|
+
window.removeEventListener("scroll", update, true);
|
|
46
|
+
window.visualViewport?.removeEventListener("resize", update);
|
|
47
|
+
window.visualViewport?.removeEventListener("scroll", update);
|
|
48
|
+
};
|
|
49
|
+
}, [anchor, desktop, trigger]);
|
|
50
|
+
const anchorStyle = anchorWidth
|
|
51
|
+
? { "--mendy-filter-anchor-width": `${anchorWidth}px` }
|
|
52
|
+
: undefined;
|
|
53
|
+
return { alignOffset, side, anchorStyle };
|
|
54
|
+
}
|
|
55
|
+
/** Follow the selected row while keeping the editor inside the viewport. */
|
|
56
|
+
export function useEditorOffset({ detached, editorPanel, content, rows, selectedId, }) {
|
|
57
|
+
const [editorOffset, setEditorOffset] = useState(0);
|
|
58
|
+
const measured = useRef({ id: selectedId, height: 0 });
|
|
59
|
+
useLayoutEffect(() => {
|
|
60
|
+
if (!detached)
|
|
61
|
+
return;
|
|
62
|
+
const panel = editorPanel.current;
|
|
63
|
+
const root = content.current;
|
|
64
|
+
if (!panel || !root)
|
|
65
|
+
return;
|
|
66
|
+
const update = () => {
|
|
67
|
+
const row = rows.current.get(selectedId ?? "");
|
|
68
|
+
if (!row) {
|
|
69
|
+
setEditorOffset(0);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const rootTop = root.getBoundingClientRect().top;
|
|
73
|
+
const list = root.querySelector('[data-slot="filter-menu-list"]');
|
|
74
|
+
const listBottom = list?.getBoundingClientRect().bottom ?? rootTop;
|
|
75
|
+
const currentHeight = panel.getBoundingClientRect().height;
|
|
76
|
+
if (measured.current.id !== selectedId)
|
|
77
|
+
measured.current = { id: selectedId, height: 0 };
|
|
78
|
+
// Result filtering may shrink the editor. Keep its input in place instead
|
|
79
|
+
// of moving it down under the pointer on every query change.
|
|
80
|
+
measured.current.height = Math.max(measured.current.height, currentHeight);
|
|
81
|
+
const height = measured.current.height;
|
|
82
|
+
const viewport = window.visualViewport;
|
|
83
|
+
const viewportBottom = (viewport?.offsetTop ?? 0) + (viewport?.height ?? window.innerHeight);
|
|
84
|
+
// Keep short editors inside the list's vertical span. Lower rows therefore
|
|
85
|
+
// open upward even on a tall viewport, instead of dangling below the list.
|
|
86
|
+
const latestTop = Math.max(rootTop, Math.min(listBottom, viewportBottom - 16) - height);
|
|
87
|
+
const preferredTop = row.getBoundingClientRect().top - 5;
|
|
88
|
+
setEditorOffset(Math.max(0, Math.min(preferredTop, latestTop) - rootTop));
|
|
89
|
+
};
|
|
90
|
+
update();
|
|
91
|
+
const observer = new ResizeObserver(update);
|
|
92
|
+
observer.observe(panel);
|
|
93
|
+
observer.observe(root);
|
|
94
|
+
const list = root.querySelector('[data-slot="filter-menu-list"]');
|
|
95
|
+
if (list)
|
|
96
|
+
observer.observe(list);
|
|
97
|
+
root.addEventListener("scroll", update, true);
|
|
98
|
+
return () => {
|
|
99
|
+
observer.disconnect();
|
|
100
|
+
root.removeEventListener("scroll", update, true);
|
|
101
|
+
};
|
|
102
|
+
}, [detached, selectedId, editorPanel, content, rows]);
|
|
103
|
+
return editorOffset;
|
|
104
|
+
}
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { useMendyUI } from "../customization.js";
|
|
5
5
|
import { cn } from "../utils.js";
|
|
6
|
-
import { CheckIcon, ChevronRightIcon
|
|
6
|
+
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
|
7
7
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
8
8
|
function DropdownMenu({ ...props }) {
|
|
9
9
|
return _jsx(DropdownMenuPrimitive.Root, { "data-mendy-ui": "", "data-slot": "dropdown-menu", ...props });
|
|
@@ -31,7 +31,7 @@ function DropdownMenuRadioGroup({ ...props }) {
|
|
|
31
31
|
return (_jsx(DropdownMenuPrimitive.RadioGroup, { "data-mendy-ui": "", "data-slot": "dropdown-menu-radio-group", ...props }));
|
|
32
32
|
}
|
|
33
33
|
function DropdownMenuRadioItem({ className, children, ...props }) {
|
|
34
|
-
return (_jsxs(DropdownMenuPrimitive.RadioItem, { "data-mendy-ui": "", "data-slot": "dropdown-menu-radio-item", className: cn("mui-d2d9e1f13413 mui-222f930b8752 mui-d343f41ba73e mui-71556df3b421 mui-074569488cca mui-02e603944040 mui-a8c1dfb1d4ef mui-05d4103bd67f mui-078d945937da mui-c74ab393b96d mui-59edd3d33673 mui-78905c9e865c mui-a8c1861c5d03 mui-a85c4c5d7465 mui-397f5ba426a0 mui-9beb654fcb0d mui-bcae9d37090c mui-040e748a3fa0 mui-eb5ef2dbc2d4", className), ...props, children: [_jsx("span", { className: "mui-33228ff7d583 mui-747355bdc2a2 mui-207bd0b3f259 mui-222f930b8752 mui-61c000c8a98a mui-71556df3b421 mui-a503dd374cca", children: _jsx(DropdownMenuPrimitive.ItemIndicator, { children: _jsx(
|
|
34
|
+
return (_jsxs(DropdownMenuPrimitive.RadioItem, { "data-mendy-ui": "", "data-slot": "dropdown-menu-radio-item", className: cn("mui-d2d9e1f13413 mui-222f930b8752 mui-d343f41ba73e mui-71556df3b421 mui-074569488cca mui-02e603944040 mui-a8c1dfb1d4ef mui-05d4103bd67f mui-078d945937da mui-c74ab393b96d mui-59edd3d33673 mui-78905c9e865c mui-a8c1861c5d03 mui-a85c4c5d7465 mui-397f5ba426a0 mui-9beb654fcb0d mui-bcae9d37090c mui-040e748a3fa0 mui-eb5ef2dbc2d4", className), ...props, children: [_jsx("span", { className: "mui-33228ff7d583 mui-747355bdc2a2 mui-207bd0b3f259 mui-222f930b8752 mui-61c000c8a98a mui-71556df3b421 mui-a503dd374cca", children: _jsx(DropdownMenuPrimitive.ItemIndicator, { children: _jsx(CheckIcon, { className: "mui-100c22d5776b" }) }) }), children] }));
|
|
35
35
|
}
|
|
36
36
|
function DropdownMenuLabel({ className, inset, ...props }) {
|
|
37
37
|
return (_jsx(DropdownMenuPrimitive.Label, { "data-mendy-ui": "", "data-slot": "dropdown-menu-label", "data-inset": inset, className: cn("mui-e7e01cc7f4df mui-a8c1dfb1d4ef mui-c74ab393b96d mui-daaac3fbf55e mui-6fa6a8ade7c4", className), ...props }));
|