@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.
@@ -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
- return () => media.removeEventListener("change", listener);
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.matchMedia(desktopQuery).matches;
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 ? (target?.left ?? bounds.left) - bounds.left : 0);
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 = [...rows.current.values()].find((button) => !button.disabled);
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
- 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
- }
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: 12, onEscapeKeyDown: (event) => {
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
- }, onKeyDown: (event) => {
95
- // Allow normal Tab traversal within the dialog; Radix menus normally cancel Tab.
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
- event.stopPropagation();
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.closest('input, textarea, select, [role="grid"], [contenteditable=true]')) {
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-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: () => {
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, 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)] }))] }) }));
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 FilterMenuList({ sections, selectedId, panelId, desktop, rows, choose, }) {
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
- 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) {
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
- rows.current.get(next.id)?.focus();
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
  }