@mendylanda/ui 0.1.0 → 0.1.2

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.
@@ -2,64 +2,74 @@
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, handleMenuReturn, preserveOutsideFocus, } from "./filter-menu-focus.js";
11
+ import { useMenuPlacement, useEditorOffset } from "./use-menu-placement.js";
12
+ import { useMenuPointer } from "./use-menu-pointer.js";
8
13
  import { cn } from "../utils.js";
9
14
  const desktopQuery = "(min-width: 640px)";
10
15
  function subscribeViewport(listener) {
11
16
  const media = window.matchMedia(desktopQuery);
12
17
  media.addEventListener("change", listener);
13
- return () => media.removeEventListener("change", listener);
18
+ const observer = new ResizeObserver(listener);
19
+ observer.observe(document.documentElement);
20
+ window.addEventListener("resize", listener);
21
+ return () => {
22
+ media.removeEventListener("change", listener);
23
+ window.removeEventListener("resize", listener);
24
+ observer.disconnect();
25
+ };
14
26
  }
15
- const isDesktop = () => window.matchMedia(desktopQuery).matches;
27
+ const isDesktop = () => window.innerWidth >= 40 * parseFloat(getComputedStyle(document.documentElement).fontSize);
16
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
+ }
17
33
  /** 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, }) {
19
- const { classNames } = useMendyUI();
34
+ export function FilterMenuPanel({ sections, selectedId, onSelect, onClose, anchor, trigger, }) {
35
+ const { classNames, menuLayout } = useMendyUI();
20
36
  const desktop = useSyncExternalStore(subscribeViewport, isDesktop, serverDesktop);
21
- const selected = sections.find((section) => section.id === selectedId && !section.disabled) ??
22
- (desktop ? sections.find((section) => !section.disabled) : undefined);
37
+ const selected = selectedSection(sections, selectedId, desktop);
23
38
  const content = useRef(null);
24
39
  const editor = useRef(null);
25
40
  const rows = useRef(new Map());
26
41
  const pendingEditorFocus = useRef(false);
27
- const [alignOffset, setAlignOffset] = useState(0);
42
+ const { alignOffset, side } = useMenuPlacement(anchor, trigger, desktop);
28
43
  const panelId = useId();
29
- useLayoutEffect(() => {
30
- const button = trigger.current;
31
- if (!button)
32
- return;
33
- const update = () => {
34
- const bounds = button.getBoundingClientRect();
35
- const target = anchor?.current?.getBoundingClientRect();
36
- // Desktop starts at the search field's edge; mobile stays close to the icon.
37
- setAlignOffset(desktop ? (target?.left ?? bounds.left) - bounds.left : 0);
38
- };
39
- update();
40
- const observer = new ResizeObserver(update);
41
- observer.observe(anchor?.current ?? button);
42
- window.addEventListener("resize", update);
43
- return () => {
44
- observer.disconnect();
45
- window.removeEventListener("resize", update);
46
- };
47
- }, [anchor, desktop, trigger]);
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
+ });
53
+ const initialSelection = useRef(selected?.id);
54
+ const lastSelection = useRef(selected?.id);
55
+ const [keyboardNavigation, setKeyboardNavigation] = useState(() => trigger.current?.matches(":focus-visible") ?? false);
56
+ const pointer = useMenuPointer({
57
+ editor,
58
+ selectedId: selected?.id,
59
+ onChoose(id) {
60
+ choose(id);
61
+ rows.current.get(id)?.focus({ preventScroll: true });
62
+ },
63
+ });
48
64
  useEffect(() => {
49
65
  const frame = requestAnimationFrame(() => {
50
- const first = [...rows.current.values()].find((button) => !button.disabled);
66
+ const first = rows.current.get(initialSelection.current ?? "") ??
67
+ [...rows.current.values()].find((button) => !button.disabled);
51
68
  (first ?? content.current)?.focus();
52
69
  });
53
70
  return () => cancelAnimationFrame(frame);
54
71
  }, []);
55
- function focusEditor() {
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
- }
72
+ const focusEditor = () => focusMenuEditor(editor.current);
63
73
  useLayoutEffect(() => {
64
74
  if (!pendingEditorFocus.current)
65
75
  return;
@@ -67,6 +77,7 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigge
67
77
  focusEditor();
68
78
  });
69
79
  function choose(id, enter = false) {
80
+ pointer.cancel();
70
81
  if (enter && selected?.id === id && editor.current)
71
82
  focusEditor();
72
83
  else
@@ -75,90 +86,124 @@ export function FilterMenuPanel({ sections, selectedId, onSelect, anchor, trigge
75
86
  }
76
87
  function back() {
77
88
  const previous = selected?.id;
89
+ lastSelection.current = previous;
90
+ pointer.cancel();
78
91
  onSelect(null);
79
92
  requestAnimationFrame(() => previous && rows.current.get(previous)?.focus());
80
93
  }
81
94
  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: 12, 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, sideOffset: 7, collisionPadding: 16, onEscapeKeyDown: (event) => {
83
96
  if (selected && !desktop) {
84
97
  event.preventDefault();
85
98
  back();
86
99
  }
87
- }, onCloseAutoFocus: (event) => {
88
- const focused = document.activeElement;
89
- if (event.target instanceof HTMLElement &&
90
- focused &&
91
- focused !== document.body &&
92
- !event.target.contains(focused))
93
- event.preventDefault();
94
- }, onKeyDown: (event) => {
95
- // Allow normal Tab traversal within the dialog; Radix menus normally cancel Tab.
100
+ }, onCloseAutoFocus: preserveOutsideFocus, onPointerDownCapture: () => {
101
+ setKeyboardNavigation(false);
102
+ pointer.cancel();
103
+ }, onPointerLeave: pointer.cancel, onKeyDownCapture: (event) => {
104
+ if (!event.nativeEvent.isComposing &&
105
+ !["Shift", "Control", "Alt", "Meta"].includes(event.key))
106
+ setKeyboardNavigation(true);
96
107
  if (event.key === "Tab")
97
- event.stopPropagation();
98
- const target = event.target;
99
- const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
100
- if (event.key === (rtl ? "ArrowRight" : "ArrowLeft") &&
101
- target instanceof HTMLElement &&
102
- !target.closest('input, textarea, select, [role="grid"], [contenteditable=true]')) {
103
- event.preventDefault();
104
- event.stopPropagation();
105
- if (desktop)
106
- rows.current.get(selected?.id ?? "")?.focus();
107
- else
108
- back();
109
- }
110
- }, className: cn("mui-981e5fc95664 mui-571ea69568d3 mui-a5c6864f064f mui-d5111d0e9f48 mui-04760bcd507f mui-94ea94fde25f mui-393df0d154e0 mui-548a450e8e53", desktop && selected ? "mui-9958eb2b312a" : "mui-a4cce7869436", classNames?.menu), children: _jsxs("div", { className: cn("mui-571ea69568d3", desktop && selected
111
- ? "mui-0f2a693e93e2 mui-50f2bcd37640" : "mui-222f930b8752 mui-302c0d124a94"), children: [showList && (_jsx(FilterMenuList, { sections: sections, selectedId: selected?.id, panelId: panelId, desktop: desktop, rows: rows, choose: choose })), selected && (_jsxs("div", { 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", classNames?.menuHeader), children: [!desktop && (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: back, className: "mui-9b2a4c5f630c 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("span", { className: "mui-184ddc11e5f9 mui-7bd5bab6d7f4 mui-726fca123972 mui-393883c1db68", children: selected.label }), selected.clear && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
112
- selected.clear?.();
113
- 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, role: "group", "aria-label": selected.editorLabel, tabIndex: -1, className: cn("mui-410da8dfa8ac mui-184ddc11e5f9 mui-2368e909f3a5 mui-998780cdef87 mui-b30fc56058b6 mui-f157ee6bae99 mui-a1c8c43d9be2", classNames?.editor), children: selected.content }, selected.id)] }))] }) }));
108
+ pointer.cancel();
109
+ handleMenuTab(event, {
110
+ trigger: trigger.current,
111
+ editor: editor.current,
112
+ onClose,
113
+ hasSelection: Boolean(selected),
114
+ });
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", desktop && selected ? "mui-8dd3eece40db" : "mui-919182384559", detached && "mui-0fc84a692dad mui-dad556abfe15 mui-8fca9236d191 mui-89c3006b2722", classNames?.menu), children: _jsxs("div", { className: cn("mui-80baa5d03af7", desktop && selected
121
+ ? "mui-0f2a693e93e2 mui-b5985369ee35 mui-da607d0a2538" : "mui-222f930b8752 mui-302c0d124a94", detached && "mui-b9677bd1cb66 mui-70007b876865"), 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) => {
122
+ if (desktop && event.pointerType === "mouse") {
123
+ setKeyboardNavigation(false);
124
+ const focused = document.activeElement;
125
+ if (focused instanceof HTMLElement &&
126
+ editor.current?.contains(focused) &&
127
+ (focused.matches("input, textarea") || focused.isContentEditable)) {
128
+ pointer.cancel();
129
+ return;
130
+ }
131
+ pointer.move(id, event);
132
+ }
133
+ } })), 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-d5111d0e9f48 mui-3fa8c572949b mui-4f1a55de40bc mui-e593c38256e0 mui-5bd2afe89f0b mui-94ea94fde25f"), 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)] }))] }) }));
115
134
  }
116
- function FilterMenuList({ sections, selectedId, panelId, desktop, rows, choose, }) {
135
+ function EditorHeading({ section, desktop, back, onCleared, }) {
117
136
  const { classNames } = useMendyUI();
118
- return (_jsxs("div", { role: "group", "aria-label": "Filter types", className: cn("mui-410da8dfa8ac mui-2368e909f3a5 mui-998780cdef87 mui-1dee6e3ec67d", desktop && selectedId && "mui-baf794b99c10", classNames?.menuList), children: [sections.length === 0 && (_jsx("p", { className: "mui-094f5333853b mui-c74ab393b96d mui-35f35c41d134", children: "No filters available." })), sections.map((section) => (_jsxs(Button, { ref: (node) => {
119
- if (node)
120
- rows.current.set(section.id, node);
121
- else
122
- rows.current.delete(section.id);
123
- }, variant: "ghost", type: "button", "aria-label": section.label, "aria-expanded": selectedId === section.id, "aria-controls": selectedId === section.id ? panelId : 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", selectedId === section.id && "mui-292affc1f780 mui-cc209238d847", classNames?.menuRow), onPointerEnter: (event) => {
124
- if (desktop && event.pointerType === "mouse" && !section.disabled)
125
- choose(section.id);
126
- }, onFocus: () => {
127
- if (desktop)
128
- choose(section.id);
129
- }, onClick: () => choose(section.id, true), onKeyDown: (event) => {
130
- const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
131
- if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
132
- event.preventDefault();
133
- event.stopPropagation();
134
- const enabled = sections.filter((item) => !item.disabled);
135
- const index = enabled.findIndex((item) => item.id === section.id);
136
- const next = event.key === "Home"
137
- ? enabled[0]
138
- : event.key === "End"
139
- ? enabled.at(-1)
140
- : enabled[(index + (event.key === "ArrowDown" ? 1 : -1) + enabled.length) %
141
- enabled.length];
142
- if (next)
143
- rows.current.get(next.id)?.focus();
144
- }
145
- else if ([rtl ? "ArrowLeft" : "ArrowRight", "Enter", " "].includes(event.key)) {
146
- event.preventDefault();
147
- event.stopPropagation();
148
- choose(section.id, true);
149
- }
150
- else if (event.key.length === 1 &&
151
- !event.ctrlKey &&
152
- !event.metaKey &&
153
- !event.altKey) {
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) {
137
+ 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: () => {
138
+ section.clear?.();
139
+ requestAnimationFrame(onCleared);
140
+ }, "aria-label": `Clear ${section.label} filter`, className: "mui-8cc46fa87f41 mui-ce1945804664 mui-1da2e8f173c5 mui-3992de70b033 mui-52101fc7d8bb mui-35f35c41d134", children: "Clear" }))] }));
141
+ }
142
+ function MenuHeading({ label }) {
143
+ const ref = useRef(null);
144
+ const [overflow, setOverflow] = useState(false);
145
+ const [below, setBelow] = useState(false);
146
+ const measure = () => {
147
+ const element = ref.current;
148
+ if (!element)
149
+ return;
150
+ setOverflow(element.scrollHeight > element.clientHeight + 1);
151
+ setBelow(element.scrollHeight - element.clientHeight - element.scrollTop > 1);
152
+ };
153
+ useLayoutEffect(() => {
154
+ const element = ref.current;
155
+ if (!element)
156
+ return;
157
+ element.scrollTop = 0;
158
+ measure();
159
+ const observer = new ResizeObserver(measure);
160
+ observer.observe(element);
161
+ return () => observer.disconnect();
162
+ }, [label]);
163
+ 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 }));
164
+ }
165
+ function FilterMenuList({ sections, selectedId, initialKey, panelId, desktop, detached, rows, choose, keyboardNavigation, onPointerMove, }) {
166
+ const { classNames } = useMendyUI();
167
+ const [query, setQuery] = useValueDraft("types", () => "", "__menu:query");
168
+ const collection = useRef(null);
169
+ const matches = [];
170
+ const term = query.toLocaleLowerCase();
171
+ for (const section of sections) {
172
+ if (section.label.toLocaleLowerCase().includes(term))
173
+ matches.push({ ...section, key: section.id });
174
+ }
175
+ return (_jsxs("div", { className: cn("mui-222f930b8752 mui-410da8dfa8ac mui-302c0d124a94", desktop && selectedId && !detached && "mui-3b6d5fc7b061", detached && "mui-d5111d0e9f48 mui-3fa8c572949b mui-4f1a55de40bc mui-e593c38256e0 mui-5bd2afe89f0b mui-94ea94fde25f"), 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
+ if (event.key === "ArrowDown") {
177
+ event.preventDefault();
178
+ collection.current?.focusFirst();
179
+ }
180
+ if (event.key !== "Escape" && event.key !== "Tab")
181
+ event.stopPropagation();
182
+ } }) })), 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) => {
183
+ row.ref(node);
184
+ if (node)
185
+ rows.current.set(section.id, node);
186
+ else
187
+ rows.current.delete(section.id);
188
+ }, variant: "ghost", type: "button", "aria-label": section.label, "aria-description": matches.length > 100
189
+ ? `${section.active ? "Filter applied. " : ""}${index + 1} of ${matches.length}`
190
+ : section.active
191
+ ? "Filter applied"
192
+ : 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 &&
193
+ !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) => {
194
+ if (!section.disabled)
195
+ onPointerMove(section.id, event);
196
+ }, onFocus: () => {
197
+ if (desktop)
198
+ choose(section.id);
199
+ }, onClick: () => choose(section.id, true), onKeyDown: (event) => {
200
+ if (event.nativeEvent.isComposing)
201
+ return;
202
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
203
+ if ([rtl ? "ArrowLeft" : "ArrowRight", "Enter", " "].includes(event.key)) {
158
204
  event.preventDefault();
159
205
  event.stopPropagation();
160
- rows.current.get(next.id)?.focus();
206
+ choose(section.id, true);
161
207
  }
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)))] }));
208
+ }, 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
209
  }
@@ -2,8 +2,10 @@ 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
- export type FilterChipProps = ComponentProps<"div">;
6
- export declare function FilterChip({ className, ...props }: FilterChipProps): import("react/jsx-runtime").JSX.Element;
5
+ export type FilterChipProps = ComponentProps<"div"> & {
6
+ entranceDelay?: number;
7
+ };
8
+ export declare function FilterChip({ className, ref, entranceDelay, ...props }: FilterChipProps): import("react/jsx-runtime").JSX.Element;
7
9
  export type FilterEditorTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
8
10
  export declare function FilterEditorTrigger({ className, asChild, ...props }: FilterEditorTriggerProps): import("react/jsx-runtime").JSX.Element;
9
11
  export type FilterEditorContentProps = ComponentProps<typeof DropdownMenuContent>;
@@ -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 { useEffect, useLayoutEffect, useImperativeHandle, useRef, useState } from "react";
4
+ import { useAnimate, 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,11 +9,31 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe
8
9
  export function FilterEditor(props) {
9
10
  return _jsx(DropdownMenu, { modal: false, ...props });
10
11
  }
11
- export function FilterChip({ className, ...props }) {
12
- return (_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 mui-5e49f8678d00 mui-3bc474178684 motion-safe:slide-in-from-bottom-2.5 mui-b225f0361d28 mui-bc94a66158a3", className), ...props }));
12
+ export function FilterChip({ className, ref, entranceDelay = 0, ...props }) {
13
+ const [scope, animate] = useAnimate();
14
+ const reducedMotion = useReducedMotion();
15
+ const initialDelay = useRef(entranceDelay);
16
+ useImperativeHandle(ref, () => scope.current);
17
+ useLayoutEffect(() => {
18
+ if (reducedMotion) {
19
+ scope.current.style.transform = "none";
20
+ scope.current.style.opacity = "1";
21
+ return;
22
+ }
23
+ const animation = animate(scope.current, { y: [10, 0], opacity: [0, 1] }, {
24
+ type: "spring",
25
+ stiffness: 100,
26
+ damping: 10,
27
+ mass: 1,
28
+ delay: initialDelay.current,
29
+ opacity: { duration: 0.2 },
30
+ });
31
+ return () => animation.stop();
32
+ }, [animate, scope, reducedMotion]);
33
+ return (_jsx("div", { "data-mendy-ui": "", ref: scope, "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 }));
13
34
  }
14
35
  export function FilterEditorTrigger({ className, asChild, ...props }) {
15
- 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-1a5d10977b93 mui-37ad15a95595 mui-4d6720d47806 mui-2e357972fc10 mui-355efe22aedb mui-50aafa8694bb mui-db549d38ff19 mui-8d3c1014b967 mui-4600b77303d6 mui-4027d9c589b6 mui-6f73a280432e", className), ...props }) }));
36
+ 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 }) }));
16
37
  }
17
38
  export function FilterEditorContent({ className, onClick, ...props }) {
18
39
  return (_jsx(DropdownMenuContent, { "data-mendy-ui": "", "data-slot": "filter-editor-content", role: "dialog", "aria-orientation": undefined, align: "start", sideOffset: 6, loop: true, onClick: (event) => {
@@ -21,7 +42,7 @@ export function FilterEditorContent({ className, onClick, ...props }) {
21
42
  }, className: cn("mui-dcd942c18ddc mui-34055e6f8c1b mui-2368e909f3a5 mui-04760bcd507f mui-37d8a3f448ff", className), ...props }));
22
43
  }
23
44
  export function FilterRemove({ className, children, ...props }) {
24
- return (_jsx(Button, { variant: "ghost", type: "button", "data-mendy-ui": "", "data-slot": "filter-remove", className: cn("mui-222f930b8752 mui-57c930dc1c31 mui-52417259a4f5 mui-04760bcd507f mui-8867d85ed290 mui-27ead27a81df mui-71556df3b421 mui-a503dd374cca mui-8d940fccb093 mui-163a623b3716 mui-37ad15a95595 mui-4d6720d47806 mui-2e357972fc10 mui-355efe22aedb mui-50aafa8694bb mui-db549d38ff19 mui-8d3c1014b967 mui-4600b77303d6", className), ...props, children: children ?? _jsx(XIcon, { className: "mui-61c000c8a98a", "aria-hidden": "true" }) }));
45
+ return (_jsx(Button, { variant: "ghost", type: "button", "data-mendy-ui": "", "data-slot": "filter-remove", className: cn("mui-222f930b8752 mui-57c930dc1c31 mui-52417259a4f5 mui-04760bcd507f mui-8867d85ed290 mui-27ead27a81df mui-71556df3b421 mui-a503dd374cca mui-55860251be20 mui-163a623b3716 mui-37ad15a95595 mui-4d6720d47806 mui-2e357972fc10 mui-355efe22aedb mui-50aafa8694bb mui-db549d38ff19 mui-8d3c1014b967 mui-4600b77303d6", className), ...props, children: children ?? _jsx(XIcon, { className: "mui-61c000c8a98a", "aria-hidden": "true" }) }));
25
46
  }
26
47
  /** Editing and removal are independent. Values remain owned by the caller. */
27
48
  export function AppliedFilter({ label, editor, onRemove, disabled, open, onOpenChange, editLabel = `Edit ${label} filter`, removeLabel = `Remove ${label} filter`, contentProps, triggerProps, removeProps, children, ...props }) {
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import { useValueDraft } from "./use-value-draft.js";
2
3
  import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
3
4
  export const FilterOptionCache = createContext(null);
4
5
  function acquire(cache, key, load) {
@@ -39,6 +40,7 @@ function acquire(cache, key, load) {
39
40
  let released = false;
40
41
  return {
41
42
  promise: current.promise,
43
+ signal: current.abort.signal,
42
44
  release() {
43
45
  if (released)
44
46
  return;
@@ -71,12 +73,12 @@ export function useFilterOptions(id, field, value, enabled) {
71
73
  useLayoutEffect(() => {
72
74
  latest.current = source;
73
75
  }, [source]);
74
- const [localQuery, setLocalQuery] = useState("");
76
+ const scopeKey = JSON.stringify([id, source?.kind, source?.scope, source?.params]);
77
+ const [localQuery, setLocalQuery] = useValueDraft(scopeKey, () => "", `${id}:query`);
75
78
  const query = source?.query ?? localQuery;
76
79
  const [retryKey, retry] = useState(0);
77
- const scopeKey = JSON.stringify([id, source?.scope, source?.params]);
78
80
  const requestKey = JSON.stringify([scopeKey, query, retryKey]);
79
- const identity = JSON.stringify([id, source?.scope]);
81
+ const identity = JSON.stringify([id, source?.kind, source?.scope]);
80
82
  const ids = selectedIds(value);
81
83
  const idsKey = JSON.stringify(ids);
82
84
  const [page, setPage] = useState({ key: "", items: [], loading: false });
@@ -182,7 +184,7 @@ export function useFilterOptions(id, field, value, enabled) {
182
184
  moreRelease.current = request.release;
183
185
  request.promise
184
186
  .then((result) => {
185
- if (currentRequest.current !== requestKey)
187
+ if (request.signal.aborted || currentRequest.current !== requestKey)
186
188
  return;
187
189
  setPage((previous) => ({
188
190
  key: requestKey,
@@ -193,7 +195,7 @@ export function useFilterOptions(id, field, value, enabled) {
193
195
  loading: false,
194
196
  }));
195
197
  }, (error) => {
196
- if (currentRequest.current === requestKey)
198
+ if (!request.signal.aborted && currentRequest.current === requestKey)
197
199
  setPage((previous) => ({ ...previous, loading: false, error: errorMessage(error) }));
198
200
  })
199
201
  .finally(request.release);
@@ -0,0 +1,14 @@
1
+ import type { 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
+ };
7
+ /** Follow the selected row while keeping the editor inside the viewport. */
8
+ export declare function useEditorOffset({ detached, editorPanel, content, rows, selectedId, }: {
9
+ detached: boolean;
10
+ editorPanel: RefObject<HTMLDivElement | null>;
11
+ content: RefObject<HTMLDivElement | null>;
12
+ rows: RefObject<Map<string, HTMLButtonElement>>;
13
+ selectedId?: string;
14
+ }): number;
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import { useLayoutEffect, 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 [side, setSide] = useState("bottom");
7
+ useLayoutEffect(() => {
8
+ const button = trigger.current;
9
+ if (!button)
10
+ return;
11
+ const update = () => {
12
+ const bounds = button.getBoundingClientRect();
13
+ const target = anchor?.current?.getBoundingClientRect();
14
+ const rtl = getComputedStyle(button).direction === "rtl";
15
+ const viewport = window.visualViewport;
16
+ const viewportTop = viewport?.offsetTop ?? 0;
17
+ const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight);
18
+ const below = viewportBottom - bounds.bottom - 23;
19
+ const above = bounds.top - viewportTop - 23;
20
+ const preferredHeight = 30 * parseFloat(getComputedStyle(document.documentElement).fontSize);
21
+ // A short list can fit below the trigger while its editor cannot. Choose room
22
+ // for the editor before Radix constrains its height to the current side.
23
+ setSide(below < preferredHeight && above > below ? "top" : "bottom");
24
+ // Desktop starts at the search field's edge; mobile stays close to the icon.
25
+ setAlignOffset(desktop
26
+ ? rtl
27
+ ? bounds.right - (target?.right ?? bounds.right)
28
+ : (target?.left ?? bounds.left) - bounds.left
29
+ : 0);
30
+ };
31
+ update();
32
+ const observer = new ResizeObserver(update);
33
+ observer.observe(anchor?.current ?? button);
34
+ window.addEventListener("resize", update);
35
+ window.addEventListener("scroll", update, true);
36
+ window.visualViewport?.addEventListener("resize", update);
37
+ window.visualViewport?.addEventListener("scroll", update);
38
+ return () => {
39
+ observer.disconnect();
40
+ window.removeEventListener("resize", update);
41
+ window.removeEventListener("scroll", update, true);
42
+ window.visualViewport?.removeEventListener("resize", update);
43
+ window.visualViewport?.removeEventListener("scroll", update);
44
+ };
45
+ }, [anchor, desktop, trigger]);
46
+ return { alignOffset, side };
47
+ }
48
+ /** Follow the selected row while keeping the editor inside the viewport. */
49
+ export function useEditorOffset({ detached, editorPanel, content, rows, selectedId, }) {
50
+ const [editorOffset, setEditorOffset] = useState(0);
51
+ useLayoutEffect(() => {
52
+ if (!detached)
53
+ return;
54
+ const panel = editorPanel.current;
55
+ const root = content.current;
56
+ if (!panel || !root)
57
+ return;
58
+ const update = () => {
59
+ const row = rows.current.get(selectedId ?? "");
60
+ if (!row)
61
+ return;
62
+ const available = parseFloat(getComputedStyle(root).getPropertyValue("--radix-dropdown-menu-content-available-height")) || window.innerHeight - 32;
63
+ const offset = row.getBoundingClientRect().top - root.getBoundingClientRect().top;
64
+ setEditorOffset(Math.max(0, Math.min(offset - 5, available - panel.getBoundingClientRect().height - 2)));
65
+ };
66
+ update();
67
+ const observer = new ResizeObserver(update);
68
+ observer.observe(panel);
69
+ observer.observe(root);
70
+ root.addEventListener("scroll", update, true);
71
+ return () => {
72
+ observer.disconnect();
73
+ root.removeEventListener("scroll", update, true);
74
+ };
75
+ }, [detached, selectedId, editorPanel, content, rows]);
76
+ return editorOffset;
77
+ }
@@ -0,0 +1,10 @@
1
+ import type { PointerEvent, RefObject } from "react";
2
+ /** Allow a short diagonal path from a filter row into its adjacent editor. */
3
+ export declare function useMenuPointer({ editor, selectedId, onChoose, }: {
4
+ editor: RefObject<HTMLDivElement | null>;
5
+ selectedId?: string;
6
+ onChoose(id: string): void;
7
+ }): {
8
+ move: (id: string, event: PointerEvent<HTMLButtonElement>) => void;
9
+ cancel: () => void;
10
+ };
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import { useEffect, useLayoutEffect, useRef } from "react";
3
+ /** Allow a short diagonal path from a filter row into its adjacent editor. */
4
+ export function useMenuPointer({ editor, selectedId, onChoose, }) {
5
+ const origin = useRef(null);
6
+ const last = useRef(null);
7
+ const pending = useRef(null);
8
+ const candidate = useRef(null);
9
+ const choose = useRef(onChoose);
10
+ useLayoutEffect(() => {
11
+ choose.current = onChoose;
12
+ });
13
+ function cancel() {
14
+ if (pending.current !== null)
15
+ clearTimeout(pending.current);
16
+ pending.current = null;
17
+ candidate.current = null;
18
+ }
19
+ useEffect(() => cancel, []);
20
+ useEffect(() => {
21
+ cancel();
22
+ origin.current = null;
23
+ }, [selectedId]);
24
+ function move(id, event) {
25
+ if (event.pointerType !== "mouse")
26
+ return;
27
+ const point = { x: event.clientX, y: event.clientY };
28
+ const previous = last.current;
29
+ last.current = point;
30
+ // Layout changes beneath a stationary cursor are not navigation intent.
31
+ if (previous?.x === point.x && previous.y === point.y)
32
+ return;
33
+ if (id === selectedId) {
34
+ cancel();
35
+ origin.current = point;
36
+ return;
37
+ }
38
+ const start = origin.current;
39
+ const bounds = editor.current?.getBoundingClientRect();
40
+ if (start && previous && bounds) {
41
+ const edge = start.x < bounds.left ? bounds.left : bounds.right;
42
+ const distance = edge - start.x;
43
+ const progress = (point.x - start.x) / distance;
44
+ const towardsEditor = (point.x - previous.x) * distance > 0;
45
+ const top = start.y + (bounds.top - 8 - start.y) * progress;
46
+ const bottom = start.y + (bounds.bottom + 8 - start.y) * progress;
47
+ if (towardsEditor && progress >= 0 && progress <= 1 && point.y >= top && point.y <= bottom) {
48
+ candidate.current = id;
49
+ if (pending.current === null) {
50
+ pending.current = setTimeout(() => {
51
+ const next = candidate.current;
52
+ cancel();
53
+ if (next)
54
+ choose.current(next);
55
+ }, 300);
56
+ }
57
+ return;
58
+ }
59
+ }
60
+ cancel();
61
+ choose.current(id);
62
+ }
63
+ return { move, cancel };
64
+ }