@flood-md/react 0.2.6 → 0.2.9

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.
Files changed (85) hide show
  1. package/README.md +45 -1
  2. package/dist/alert-dialog.js +1 -1
  3. package/dist/calendar-period-grid.d.ts +17 -0
  4. package/dist/calendar-period-grid.js +13 -0
  5. package/dist/calendar-value.d.ts +10 -0
  6. package/dist/calendar-value.js +48 -0
  7. package/dist/calendar.d.ts +21 -0
  8. package/dist/calendar.js +259 -0
  9. package/dist/checkbox.js +1 -1
  10. package/dist/choice-group.js +1 -1
  11. package/dist/collection-cards.d.ts +4 -0
  12. package/dist/collection-cards.js +21 -0
  13. package/dist/color-controls.d.ts +5 -0
  14. package/dist/color-controls.js +110 -0
  15. package/dist/combobox.js +22 -13
  16. package/dist/command-list.d.ts +3 -0
  17. package/dist/command-list.js +93 -0
  18. package/dist/content-elements.js +7 -7
  19. package/dist/context-menu.d.ts +2 -0
  20. package/dist/context-menu.js +76 -0
  21. package/dist/data-display.js +2 -2
  22. package/dist/data-table.d.ts +2 -0
  23. package/dist/data-table.js +58 -0
  24. package/dist/date-field.d.ts +2 -0
  25. package/dist/date-field.js +172 -0
  26. package/dist/date-picker.d.ts +3 -0
  27. package/dist/date-picker.js +108 -0
  28. package/dist/date-range-field.d.ts +3 -0
  29. package/dist/date-range-field.js +84 -0
  30. package/dist/date-range-picker.d.ts +2 -0
  31. package/dist/date-range-picker.js +81 -0
  32. package/dist/date-range-value.d.ts +8 -0
  33. package/dist/date-range-value.js +13 -0
  34. package/dist/date-time-picker.d.ts +3 -0
  35. package/dist/date-time-picker.js +31 -0
  36. package/dist/date-time-range-picker.d.ts +2 -0
  37. package/dist/date-time-range-picker.js +51 -0
  38. package/dist/date-value.d.ts +10 -0
  39. package/dist/date-value.js +31 -0
  40. package/dist/disclosure.js +1 -1
  41. package/dist/editable.d.ts +3 -0
  42. package/dist/editable.js +135 -0
  43. package/dist/error-summary.d.ts +3 -0
  44. package/dist/error-summary.js +39 -0
  45. package/dist/feedback-actions.js +2 -2
  46. package/dist/feedback.js +2 -2
  47. package/dist/file-input.js +1 -1
  48. package/dist/file-records.d.ts +5 -0
  49. package/dist/file-records.js +55 -0
  50. package/dist/form-controls.js +25 -13
  51. package/dist/foundations.css +401 -3
  52. package/dist/hover-card.d.ts +3 -0
  53. package/dist/hover-card.js +144 -0
  54. package/dist/index.d.ts +661 -2
  55. package/dist/index.js +190 -86
  56. package/dist/menu.d.ts +27 -1
  57. package/dist/menu.js +327 -89
  58. package/dist/navigation-layout.js +6 -6
  59. package/dist/navigation-tree.d.ts +3 -0
  60. package/dist/navigation-tree.js +98 -0
  61. package/dist/navigation.js +3 -3
  62. package/dist/numeric-input.js +1 -1
  63. package/dist/overlays.js +11 -10
  64. package/dist/range-calendar.d.ts +2 -0
  65. package/dist/range-calendar.js +35 -0
  66. package/dist/resizable-panels.d.ts +3 -0
  67. package/dist/resizable-panels.js +205 -0
  68. package/dist/scroll-area.js +1 -1
  69. package/dist/select.js +17 -6
  70. package/dist/slider.js +1 -1
  71. package/dist/tag-input.d.ts +3 -0
  72. package/dist/tag-input.js +181 -0
  73. package/dist/text-inputs.js +2 -2
  74. package/dist/time-field.d.ts +2 -0
  75. package/dist/time-field.js +176 -0
  76. package/dist/time-picker.d.ts +2 -0
  77. package/dist/time-picker.js +83 -0
  78. package/dist/time-value.d.ts +12 -0
  79. package/dist/time-value.js +36 -0
  80. package/dist/toolbar.js +1 -1
  81. package/dist/tree-select.d.ts +3 -0
  82. package/dist/tree-select.js +113 -0
  83. package/dist/tree.d.ts +3 -0
  84. package/dist/tree.js +206 -0
  85. package/package.json +2 -2
package/dist/menu.js CHANGED
@@ -1,42 +1,127 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
2
+ import { Fragment, forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
3
  import { Button, Kbd, ScrollArea } from "./index.js";
4
4
  import { positionLayer, registerLayer, returnFocus, tabbables } from "./overlay-runtime.js";
5
5
  const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
6
- export const ActionMenu = forwardRef(function ActionMenu({ label, trigger, items, onAction, open: controlled, defaultOpen, onOpenChange, disabled, side = "bottom", align = "start", controlSize = "sm", id: explicitId, className, onKeyDown, ...props }, ref) {
7
- const [local, setLocal] = useState(Boolean(defaultOpen)), open = controlled ?? local;
8
- const anchor = useRef(null), panel = useRef(null);
6
+ /** Validate each level while retaining globally unique action IDs. */
7
+ export function validateMenuTree(items) {
8
+ const ids = new Set();
9
+ const pending = [{ items, ancestors: new Set() }];
10
+ while (pending.length) {
11
+ const current = pending.pop();
12
+ if (current.ancestors.has(current.items))
13
+ throw new Error("Flood Menu does not accept recursive item arrays");
14
+ if (!current.items.some(item => item.type !== "separator"))
15
+ throw new Error("Flood Menu requires at least one action item in every menu");
16
+ const ancestors = new Set(current.ancestors);
17
+ ancestors.add(current.items);
18
+ const checkedGroups = new Set();
19
+ for (const item of current.items) {
20
+ if (!item.id.trim() || ids.has(item.id) || (item.type !== "separator" && !item.label.trim()))
21
+ throw new Error("Flood menu items need unique nonempty IDs across the tree and action labels");
22
+ ids.add(item.id);
23
+ if (item.type === "submenu")
24
+ pending.push({ items: item.items, ancestors });
25
+ if (item.type === "radio") {
26
+ if (!item.group.trim())
27
+ throw new Error("Flood Menu radio items need a nonempty group label");
28
+ if (item.checked && checkedGroups.has(item.group))
29
+ throw new Error("Flood Menu radio groups accept only one checked item");
30
+ if (item.checked)
31
+ checkedGroups.add(item.group);
32
+ }
33
+ }
34
+ }
35
+ }
36
+ const ownButtons = (panel) => Array.from(panel?.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]') ?? [])
37
+ .filter(button => button.closest('[role="menu"]') === panel);
38
+ export function focusMenuEdge(panel, edge = "first") {
39
+ const all = ownButtons(panel);
40
+ ((edge === "last" ? all.at(-1) : all[0]) ?? panel)?.focus({ preventScroll: true });
41
+ }
42
+ /** Internal native top-layer engine shared by Menu and ContextMenu. */
43
+ export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ label, items, open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, onAction, edge, nested = false, position, side = "bottom", align = "start", controlSize = "sm", id: explicitId, className, onKeyDown, ...props }, forwardedRef) {
9
44
  const generated = useId(), id = explicitId ?? `flood-menu-${generated}`;
10
- const pending = useRef(undefined);
11
- const edge = useRef("first"), search = useRef({ text: "", time: 0 });
12
- if (!items.some(item => item.type !== "separator"))
13
- throw new Error("Flood Menu requires at least one action item");
14
- if (new Set(items.map(item => item.id)).size !== items.length || items.some(item => !item.id.trim() || (item.type !== "separator" && !item.label.trim())))
15
- throw new Error("Flood menu items need unique nonempty IDs and action labels");
16
- const request = (next) => {
17
- if (pending.current === next)
45
+ const panel = useRef(null), anchors = useRef(new Map()), children = useRef(new Map());
46
+ const anchorRefs = useRef(new Map());
47
+ const childRefs = useRef(new Map());
48
+ const [openChild, setOpenChild] = useState(undefined);
49
+ const hover = useRef(undefined), pointerType = useRef("");
50
+ const hoverIntent = useRef(undefined);
51
+ const latestItems = useRef(new Map());
52
+ const search = useRef({ text: "", time: 0 }), pointerTarget = useRef(null);
53
+ const current = useRef({ open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge });
54
+ current.current = { open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge };
55
+ useImperativeHandle(forwardedRef, () => panel.current, []);
56
+ const clearHover = () => { clearTimeout(hover.current); hover.current = undefined; hoverIntent.current = undefined; };
57
+ useEffect(() => () => clearHover(), []);
58
+ useBrowserLayoutEffect(() => {
59
+ latestItems.current = new Map(items.map(item => [item.id, item]));
60
+ const intent = hoverIntent.current, target = intent && latestItems.current.get(intent.id);
61
+ // Ordinary host rerenders often create an equivalent array. Preserve the
62
+ // pending pointer intent unless the actual target can no longer receive it.
63
+ if (intent && (!target || target.type === "separator" || target.disabled || (target.type ?? "action") !== intent.type || !intent.node.isConnected || anchors.current.get(intent.id) !== intent.node))
64
+ clearHover();
65
+ const keys = new Set(items.filter(item => item.type !== "separator").map(item => item.id));
66
+ for (const key of anchorRefs.current.keys())
67
+ if (!keys.has(key))
68
+ anchorRefs.current.delete(key);
69
+ for (const key of childRefs.current.keys())
70
+ if (!keys.has(key))
71
+ childRefs.current.delete(key);
72
+ });
73
+ const anchorRef = (key) => {
74
+ if (!anchorRefs.current.has(key))
75
+ anchorRefs.current.set(key, node => { if (node)
76
+ anchors.current.set(key, node);
77
+ else
78
+ anchors.current.delete(key); });
79
+ return anchorRefs.current.get(key);
80
+ };
81
+ const childRef = (key) => {
82
+ if (!childRefs.current.has(key))
83
+ childRefs.current.set(key, node => { if (node)
84
+ children.current.set(key, node);
85
+ else
86
+ children.current.delete(key); });
87
+ return childRefs.current.get(key);
88
+ };
89
+ const wholePanel = () => current.current.rootPanel?.() ?? panel.current;
90
+ const place = (node) => {
91
+ const target = current.current.anchor();
92
+ if (!target)
18
93
  return;
19
- pending.current = next;
20
- queueMicrotask(() => { pending.current = undefined; });
21
- if (controlled === undefined)
22
- setLocal(next);
23
- onOpenChange?.(next);
94
+ const direction = node.ownerDocument.defaultView.getComputedStyle(target).direction;
95
+ node.dataset.menuDirection = direction;
96
+ if (current.current.position) {
97
+ current.current.position(node);
98
+ return;
99
+ }
100
+ positionLayer(target, node, nested ? direction === "rtl" ? "left" : "right" : side, nested ? "start" : align);
24
101
  };
25
- const current = useRef({ open, request });
26
- useBrowserLayoutEffect(() => { current.current = { open, request }; });
27
- const setAnchor = useCallback((node) => { anchor.current = node; }, []);
28
- useImperativeHandle(ref, () => panel.current, []);
29
- const buttons = () => Array.from(panel.current?.querySelectorAll('[role="menuitem"]') ?? []);
30
- const focusEdge = () => { const all = buttons(); ((edge.current === "last" ? all.at(-1) : all[0]) ?? panel.current)?.focus({ preventScroll: true }); };
31
102
  useBrowserLayoutEffect(() => {
32
- if (!open || !panel.current)
103
+ if (!open || !panel.current) {
104
+ clearHover();
105
+ if (openChild !== undefined)
106
+ setOpenChild(undefined);
33
107
  return;
108
+ }
34
109
  const element = panel.current, document = element.ownerDocument, view = document.defaultView;
35
110
  let alive = true, frame = 0, unregister = () => { };
111
+ const closeLevel = () => {
112
+ const active = document.activeElement;
113
+ if (nested && active && !wholePanel()?.contains(active) && !current.current.rootAnchor()?.contains(active))
114
+ current.current.closeTree();
115
+ else
116
+ current.current.requestOpen(false);
117
+ };
118
+ const trackPointer = (event) => { pointerTarget.current = event.target; };
119
+ document.addEventListener("pointerdown", trackPointer, true);
36
120
  const start = () => {
37
- if (!alive || !anchor.current?.isConnected)
121
+ const target = current.current.anchor();
122
+ if (!alive || !target?.isConnected)
38
123
  return;
39
- const parent = anchor.current.closest("dialog");
124
+ const parent = target.closest("dialog");
40
125
  if (parent && !parent.open) {
41
126
  frame = view.requestAnimationFrame(start);
42
127
  return;
@@ -45,86 +130,239 @@ export const ActionMenu = forwardRef(function ActionMenu({ label, trigger, items
45
130
  throw new Error("Flood Menu requires the native Popover API");
46
131
  element.style.visibility = "hidden";
47
132
  element.showPopover();
48
- positionLayer(anchor.current, element, side, align);
49
- unregister = registerLayer(document, { panel: element, anchor: () => anchor.current, close: () => current.current.request(false) });
133
+ place(element);
134
+ unregister = registerLayer(document, { panel: element, anchor: () => current.current.anchor(), close: closeLevel,
135
+ outside: () => {
136
+ if (nested && pointerTarget.current && wholePanel()?.contains(pointerTarget.current))
137
+ current.current.requestOpen(false);
138
+ else
139
+ current.current.closeTree();
140
+ } });
50
141
  search.current = { text: "", time: 0 };
51
- focusEdge();
142
+ focusMenuEdge(element, current.current.edge?.() ?? "first");
52
143
  const update = () => { if (!alive)
53
- return; if (anchor.current?.isConnected)
54
- positionLayer(anchor.current, element, side, align); frame = view.requestAnimationFrame(update); };
144
+ return; if (current.current.anchor()?.isConnected)
145
+ place(element); frame = view.requestAnimationFrame(update); };
55
146
  frame = view.requestAnimationFrame(update);
56
147
  };
57
148
  start();
58
149
  const toggle = (event) => {
59
150
  if (!alive || event.newState !== "closed" || element.matches(":popover-open") || !current.current.open)
60
151
  return;
61
- current.current.request(false);
62
- queueMicrotask(() => { if (alive && current.current.open && element.isConnected && !element.matches(":popover-open") && anchor.current?.isConnected) {
63
- element.showPopover();
64
- positionLayer(anchor.current, element, side, align);
65
- focusEdge();
66
- } });
152
+ current.current.requestOpen(false);
153
+ queueMicrotask(() => {
154
+ if (alive && current.current.open && element.isConnected && !element.matches(":popover-open") && current.current.anchor()?.isConnected) {
155
+ element.showPopover();
156
+ place(element);
157
+ focusMenuEdge(element, current.current.edge?.() ?? "first");
158
+ }
159
+ });
67
160
  };
68
161
  element.addEventListener("toggle", toggle);
69
- return () => { alive = false; view.cancelAnimationFrame(frame); unregister(); element.removeEventListener("toggle", toggle); returnFocus(element, anchor.current); if (element.matches(":popover-open"))
70
- element.hidePopover(); };
71
- }, [open, side, align]);
162
+ return () => {
163
+ alive = false;
164
+ clearHover();
165
+ view.cancelAnimationFrame(frame);
166
+ unregister();
167
+ document.removeEventListener("pointerdown", trackPointer, true);
168
+ element.removeEventListener("toggle", toggle);
169
+ returnFocus(element, current.current.anchor());
170
+ if (element.matches(":popover-open"))
171
+ element.hidePopover();
172
+ };
173
+ }, [open, side, align, nested]);
72
174
  useBrowserLayoutEffect(() => {
175
+ if (openChild && !items.some(item => item.id === openChild && item.type === "submenu" && !item.disabled))
176
+ setOpenChild(undefined);
73
177
  if (open && panel.current?.matches(":popover-open") && panel.current.ownerDocument.activeElement === panel.current.ownerDocument.body)
74
- focusEdge();
75
- }, [items, open]);
76
- const triggerProps = { ref: setAnchor, type: "button", disabled, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": id,
77
- onClick: () => { edge.current = "first"; request(!open); },
78
- onKeyDown: event => { if (event.defaultPrevented || disabled || event.altKey || event.ctrlKey || event.metaKey || !["ArrowDown", "ArrowUp"].includes(event.key))
79
- return; event.preventDefault(); edge.current = event.key === "ArrowUp" ? "last" : "first"; if (open && panel.current?.matches(":popover-open"))
80
- focusEdge();
178
+ focusMenuEdge(panel.current, edge?.() ?? "first");
179
+ }, [items, open, openChild]);
180
+ const showChild = (item) => {
181
+ if (item.disabled)
182
+ return;
183
+ clearHover();
184
+ if (openChild === item.id)
185
+ focusMenuEdge(children.current.get(item.id) ?? null);
81
186
  else
82
- request(true); },
187
+ setOpenChild(item.id);
188
+ };
189
+ const scheduleHover = (item, node) => {
190
+ clearHover();
191
+ if (!openChild && item.type !== "submenu") {
192
+ node.focus({ preventScroll: true });
193
+ return;
194
+ }
195
+ const intent = { id: item.id, type: item.type ?? "action", node };
196
+ hoverIntent.current = intent;
197
+ hover.current = setTimeout(() => {
198
+ if (hoverIntent.current !== intent)
199
+ return;
200
+ hover.current = undefined;
201
+ hoverIntent.current = undefined;
202
+ const target = latestItems.current.get(intent.id);
203
+ if (!target || target.type === "separator" || target.disabled || (target.type ?? "action") !== intent.type || !node.isConnected || anchors.current.get(intent.id) !== node || !panel.current?.matches(":popover-open"))
204
+ return;
205
+ node.focus({ preventScroll: true });
206
+ setOpenChild(target.type === "submenu" ? target.id : undefined);
207
+ }, 150);
83
208
  };
84
209
  const hasIcons = items.some(item => item.type !== "separator" && item.icon != null);
85
- return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx("div", { ...props, ref: panel, id: id, popover: "manual", role: "menu", "aria-label": label, "aria-orientation": "vertical", tabIndex: -1, className: ["flood-overlay-panel", "flood-menu", className].filter(Boolean).join(" "), onKeyDown: event => {
86
- onKeyDown?.(event);
87
- if (event.defaultPrevented)
88
- return;
89
- const all = buttons(), index = all.indexOf(event.target);
90
- if (event.key === "Tab") {
91
- const button = anchor.current;
92
- if (button) {
93
- const scope = button.closest("dialog[open]") ?? button.ownerDocument.body;
94
- const stops = tabbables(scope).filter(node => !panel.current?.contains(node));
95
- const at = stops.indexOf(button), target = event.shiftKey ? button : at >= 0 ? stops[at + 1] : undefined;
96
- if (target) {
97
- event.preventDefault();
98
- target.focus();
99
- }
210
+ const renderItem = (item) => {
211
+ if (item.type === "separator")
212
+ return _jsx("div", { role: "separator", className: "flood-menu__separator" }, item.id);
213
+ const submenu = item.type === "submenu", childId = `${id}-submenu-${encodeURIComponent(item.id)}`;
214
+ return _jsxs(Fragment, { children: [_jsxs(Button, { ref: anchorRef(item.id), role: item.type === "checkbox" ? "menuitemcheckbox" : item.type === "radio" ? "menuitemradio" : "menuitem", "aria-checked": item.type === "checkbox" || item.type === "radio" ? item.checked : undefined, "aria-haspopup": submenu ? "menu" : undefined, "aria-expanded": submenu ? openChild === item.id && !item.disabled : undefined, "aria-controls": submenu ? childId : undefined, tabIndex: -1, variant: "ghost", size: controlSize, "aria-label": item.label, "aria-disabled": item.disabled || undefined, "data-label": item.label, "data-menu-id": item.id, "data-tone": "tone" in item ? item.tone : undefined, "data-submenu": submenu || undefined, className: "flood-menu__item", onPointerDown: event => { pointerType.current = event.pointerType; }, onPointerEnter: event => { if (event.pointerType === "mouse")
215
+ scheduleHover(item, event.currentTarget); }, onPointerLeave: () => clearHover(), onPointerMove: event => {
216
+ if (event.pointerType === "touch" || openChild)
217
+ return;
218
+ if (event.currentTarget.ownerDocument.activeElement !== event.currentTarget)
219
+ event.currentTarget.focus({ preventScroll: true });
220
+ }, onClick: event => {
221
+ clearHover();
222
+ if (item.disabled) {
223
+ event.preventDefault();
224
+ return;
225
+ }
226
+ if (item.type === "submenu") {
227
+ if (event.detail > 0 && pointerType.current === "touch" && openChild === item.id)
228
+ setOpenChild(undefined);
100
229
  else
101
- button.focus();
230
+ showChild(item);
102
231
  }
103
- request(false);
104
- return;
105
- }
106
- if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
107
- return;
108
- let next = event.key === "Home" ? 0 : event.key === "End" ? all.length - 1 : event.key === "ArrowDown" ? (index + 1) % all.length : event.key === "ArrowUp" ? (index - 1 + all.length) % all.length : -1;
109
- if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key))
110
- search.current = { text: "", time: 0 };
111
- if (event.key.length === 1 && event.key !== " ") {
112
- const now = Date.now();
113
- search.current = { text: (now - search.current.time < 700 ? search.current.text : "") + event.key.toLocaleLowerCase(), time: now };
114
- const text = Array.from(search.current.text).every(char => char === search.current.text[0]) ? search.current.text[0] : search.current.text;
115
- const start = text.length === 1 ? index + 1 : Math.max(0, index);
116
- next = Array.from({ length: all.length }, (_, offset) => (start + offset) % all.length).find(candidate => all[candidate].dataset.label?.toLocaleLowerCase().startsWith(text)) ?? -1;
117
- event.preventDefault();
118
- }
119
- if (next >= 0 && all[next]) {
232
+ else if (item.type === "checkbox") {
233
+ item.onCheckedChange(item.checked !== true);
234
+ if (item.closeOnSelect)
235
+ closeTree();
236
+ }
237
+ else if (item.type === "radio") {
238
+ if (!item.checked)
239
+ item.onCheckedChange(true);
240
+ if (item.closeOnSelect)
241
+ closeTree();
242
+ }
243
+ else {
244
+ closeTree();
245
+ onAction?.(item.id);
246
+ }
247
+ }, children: [hasIcons && _jsx("span", { className: "flood-menu__icon", "aria-hidden": "true", children: item.icon }), _jsx("span", { className: "flood-menu__label", children: item.label }), "shortcut" in item && item.shortcut && _jsx("span", { className: "flood-menu__shortcut", "aria-hidden": "true", children: _jsx(Kbd, { children: item.shortcut }) }), (item.type === "checkbox" || item.type === "radio") && _jsx("span", { className: "flood-menu__icon flood-menu__indicator", "aria-hidden": "true", children: item.checked &&
248
+ _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: item.type === "radio" ? _jsx("circle", { cx: "12", cy: "12", r: "4", fill: "currentColor", stroke: "none" }) : item.checked === "mixed" ? _jsx("path", { d: "M6 12h12" }) : _jsx("path", { d: "m5 12 4 4L19 6" }) }) }), submenu && _jsx("span", { className: "flood-menu__icon flood-menu__submenu-indicator", "aria-hidden": "true", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m9 5 7 7-7 7" }) }) })] }), item.type === "submenu" && _jsx(MenuLayer, { id: childId, ref: childRef(item.id), label: item.label, items: item.items, open: openChild === item.id && !item.disabled, requestOpen: next => { if (next && !item.disabled)
249
+ setOpenChild(item.id);
250
+ else
251
+ setOpenChild(current => current === item.id ? undefined : current); }, closeTree: closeTree, anchor: () => anchors.current.get(item.id) ?? null, rootAnchor: rootAnchor, rootPanel: rootPanel ?? (() => panel.current), onAction: onAction, controlSize: controlSize, nested: true })] }, item.id);
252
+ };
253
+ const rows = [];
254
+ for (let index = 0; index < items.length; index++) {
255
+ const item = items[index];
256
+ if (item.type !== "radio") {
257
+ rows.push(renderItem(item));
258
+ continue;
259
+ }
260
+ const group = [item];
261
+ while (items[index + 1]?.type === "radio" && items[index + 1].group === item.group)
262
+ group.push(items[++index]);
263
+ rows.push(_jsxs("div", { role: "group", "aria-label": item.group, children: [_jsx("div", { className: "flood-menu__group-label", "aria-hidden": "true", children: item.group }), group.map(renderItem)] }, `radio-${item.id}`));
264
+ }
265
+ return _jsx("div", { ...props, ref: panel, id: id, popover: "manual", role: "menu", "aria-label": label, "aria-orientation": "vertical", tabIndex: -1, className: ["flood-overlay-panel", "flood-menu", nested && "flood-menu--submenu", className].filter(Boolean).join(" "), onKeyDown: event => {
266
+ if (event.target.closest('[role="menu"]') !== panel.current)
267
+ return;
268
+ onKeyDown?.(event);
269
+ if (event.defaultPrevented)
270
+ return;
271
+ const all = ownButtons(panel.current), index = all.indexOf(event.target);
272
+ if (event.key === "Tab") {
273
+ clearHover();
274
+ const source = current.current.rootAnchor();
275
+ if (source) {
276
+ const scope = source.closest("dialog[open]") ?? source.ownerDocument.body;
277
+ const stops = tabbables(scope).filter(node => !wholePanel()?.contains(node));
278
+ const at = stops.indexOf(source), target = event.shiftKey ? source : at >= 0 ? stops[at + 1] : undefined;
279
+ if (target) {
120
280
  event.preventDefault();
121
- all[next].focus({ preventScroll: true });
122
- all[next].scrollIntoView({ block: "nearest", inline: "nearest" });
281
+ target.focus();
123
282
  }
124
- }, children: open && _jsx(ScrollArea, { orientation: "vertical", role: "group", tabIndex: -1, className: "flood-menu__viewport", children: items.map(item => item.type === "separator" ? _jsx("div", { role: "separator", className: "flood-menu__separator" }, item.id) :
125
- _jsxs(Button, { role: "menuitem", tabIndex: -1, variant: "ghost", size: controlSize, "aria-label": item.label, "aria-disabled": item.disabled || undefined, "data-label": item.label, "data-tone": item.tone, className: "flood-menu__item", onPointerMove: event => { if (event.pointerType !== "touch" && event.currentTarget.ownerDocument.activeElement !== event.currentTarget)
126
- event.currentTarget.focus({ preventScroll: true }); }, onClick: event => { if (item.disabled) {
127
- event.preventDefault();
128
- return;
129
- } request(false); onAction?.(item.id); }, children: [hasIcons && _jsx("span", { className: "flood-menu__icon", "aria-hidden": "true", children: item.icon }), _jsx("span", { className: "flood-menu__label", children: item.label }), item.shortcut && _jsx("span", { className: "flood-menu__shortcut", "aria-hidden": "true", children: _jsx(Kbd, { children: item.shortcut }) })] }, item.id)) }) })] });
283
+ else
284
+ source.focus();
285
+ }
286
+ closeTree();
287
+ return;
288
+ }
289
+ if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
290
+ return;
291
+ const rtl = panel.current && getComputedStyle(panel.current).direction === "rtl";
292
+ if (nested && event.key === (rtl ? "ArrowRight" : "ArrowLeft")) {
293
+ event.preventDefault();
294
+ event.stopPropagation();
295
+ requestOpen(false);
296
+ return;
297
+ }
298
+ if (event.key === (rtl ? "ArrowLeft" : "ArrowRight")) {
299
+ const item = items.find(item => item.id === all[index]?.dataset.menuId);
300
+ if (item?.type === "submenu") {
301
+ event.preventDefault();
302
+ if (!item.disabled)
303
+ showChild(item);
304
+ }
305
+ return;
306
+ }
307
+ let next = event.key === "Home" ? 0 : event.key === "End" ? all.length - 1 : event.key === "ArrowDown" ? (index + 1) % all.length : event.key === "ArrowUp" ? (index - 1 + all.length) % all.length : -1;
308
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
309
+ clearHover();
310
+ setOpenChild(undefined);
311
+ search.current = { text: "", time: 0 };
312
+ }
313
+ if (event.key.length === 1 && event.key !== " ") {
314
+ clearHover();
315
+ setOpenChild(undefined);
316
+ const now = Date.now();
317
+ search.current = { text: (now - search.current.time < 700 ? search.current.text : "") + event.key.toLocaleLowerCase(), time: now };
318
+ const text = Array.from(search.current.text).every(char => char === search.current.text[0]) ? search.current.text[0] : search.current.text;
319
+ const start = text.length === 1 ? index + 1 : Math.max(0, index);
320
+ next = Array.from({ length: all.length }, (_, offset) => (start + offset) % all.length).find(candidate => all[candidate].dataset.label?.toLocaleLowerCase().startsWith(text)) ?? -1;
321
+ event.preventDefault();
322
+ }
323
+ if (next >= 0 && all[next]) {
324
+ event.preventDefault();
325
+ all[next].focus({ preventScroll: true });
326
+ all[next].scrollIntoView({ block: "nearest", inline: "nearest" });
327
+ }
328
+ }, children: open && _jsx(ScrollArea, { orientation: "vertical", role: "group", tabIndex: -1, className: "flood-menu__viewport", children: rows }) });
329
+ });
330
+ export const ActionMenu = /* @__PURE__ */ forwardRef(function ActionMenu({ label, trigger, items, onAction, open: controlled, defaultOpen, onOpenChange, disabled, side = "bottom", align = "start", controlSize = "sm", ...props }, forwardedRef) {
331
+ if (!label.trim())
332
+ throw new Error("Flood Menu requires a nonempty label");
333
+ validateMenuTree(items);
334
+ const [local, setLocal] = useState(Boolean(defaultOpen)), requestedOpen = controlled ?? local;
335
+ const open = !disabled && requestedOpen;
336
+ const anchor = useRef(null), panel = useRef(null), edge = useRef("first");
337
+ const pending = useRef(undefined);
338
+ const current = useRef({ controlled, onOpenChange });
339
+ current.current = { controlled, onOpenChange };
340
+ const request = (next) => {
341
+ if (pending.current === next)
342
+ return;
343
+ pending.current = next;
344
+ queueMicrotask(() => { pending.current = undefined; });
345
+ if (current.current.controlled === undefined)
346
+ setLocal(next);
347
+ current.current.onOpenChange?.(next);
348
+ };
349
+ useImperativeHandle(forwardedRef, () => panel.current, []);
350
+ useEffect(() => { if (disabled && requestedOpen)
351
+ request(false); }, [disabled, requestedOpen]);
352
+ const setAnchor = useCallback((node) => { anchor.current = node; }, []);
353
+ const triggerProps = { ref: setAnchor, type: "button", disabled, "aria-haspopup": "menu", "aria-expanded": open,
354
+ onClick: () => { edge.current = "first"; request(!open); },
355
+ onKeyDown: event => {
356
+ if (event.defaultPrevented || disabled || event.altKey || event.ctrlKey || event.metaKey || !["ArrowDown", "ArrowUp"].includes(event.key))
357
+ return;
358
+ event.preventDefault();
359
+ edge.current = event.key === "ArrowUp" ? "last" : "first";
360
+ if (open && panel.current?.matches(":popover-open"))
361
+ focusMenuEdge(panel.current, edge.current);
362
+ else
363
+ request(true);
364
+ } };
365
+ const generated = useId(), id = props.id ?? `flood-menu-${generated}`;
366
+ triggerProps["aria-controls"] = id;
367
+ return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx(MenuLayer, { ...props, id: id, ref: panel, label: label, items: items, open: open, requestOpen: request, closeTree: () => request(false), anchor: () => anchor.current, rootAnchor: () => anchor.current, onAction: onAction, edge: () => edge.current, side: side, align: align, controlSize: controlSize })] });
130
368
  });
@@ -6,7 +6,7 @@ function uniqueValues(items, component) {
6
6
  if (items.some(item => !item.value.trim()) || new Set(items.map(item => item.value)).size !== items.length)
7
7
  throw new Error(`Flood ${component} items require unique nonempty values`);
8
8
  }
9
- export const NavigationMenu = forwardRef(function NavigationMenu({ items, label, currentValue, size = "md", className, ...props }, ref) {
9
+ export const NavigationMenu = /* @__PURE__ */ forwardRef(function NavigationMenu({ items, label, currentValue, size = "md", className, ...props }, ref) {
10
10
  uniqueValues(items, "NavigationList");
11
11
  if (!label.trim() || items.some(item => !item.label.trim() || !item.href.trim()))
12
12
  throw new Error("Flood NavigationList requires a name and labelled links with destinations");
@@ -22,7 +22,7 @@ export const NavigationMenu = forwardRef(function NavigationMenu({ items, label,
22
22
  const links = (entries) => entries.map(item => _jsx("li", { children: _jsxs("a", { ...item.linkProps, href: item.href, "aria-current": item.value === currentValue ? "page" : undefined, className: classes("flood-navigation-list__link", item.linkProps?.className), children: [item.icon != null && _jsx(IconSlot, { size: size, children: item.icon }), _jsxs("span", { className: "flood-navigation-list__text", children: [_jsx("span", { className: "flood-navigation-list__label", children: item.label }), item.description && _jsx("span", { className: "flood-navigation-list__description", children: item.description })] })] }) }, item.value));
23
23
  return _jsx("nav", { ...props, ref: ref, "aria-label": label, className: classes("flood-navigation-list", className), "data-size": size, children: _jsx("ul", { role: "list", className: "flood-navigation-list__groups", children: groups.map((group, index) => group.label ? _jsxs("li", { className: "flood-navigation-list__group", children: [_jsx("p", { className: "flood-navigation-list__group-title", id: `${id}-${index}`, children: group.label }), _jsx("ul", { role: "list", "aria-labelledby": `${id}-${index}`, children: links(group.items) })] }, index) : links(group.items)) }) });
24
24
  });
25
- export const StepSequence = forwardRef(function StepSequence({ items, label, currentValue = items[0]?.value, orientation = "horizontal", onStepChange, statusLabels = { complete: "Completed", current: "Current step", upcoming: "Upcoming", error: "Needs attention" }, className, ...props }, ref) {
25
+ export const StepSequence = /* @__PURE__ */ forwardRef(function StepSequence({ items, label, currentValue = items[0]?.value, orientation = "horizontal", onStepChange, statusLabels = { complete: "Completed", current: "Current step", upcoming: "Upcoming", error: "Needs attention" }, className, ...props }, ref) {
26
26
  uniqueValues(items, "Steps");
27
27
  if (!label.trim() || items.some(item => !item.title.trim()))
28
28
  throw new Error("Flood Steps requires a sequence name and step titles");
@@ -36,20 +36,20 @@ export const StepSequence = forwardRef(function StepSequence({ items, label, cur
36
36
  return _jsx("li", { "data-state": state, "aria-current": current ? "step" : undefined, children: onStepChange ? _jsx("button", { type: "button", className: "flood-steps__content flood-steps__action", disabled: item.disabled, onClick: () => onStepChange(item.value), children: content }) : _jsx("div", { className: "flood-steps__content", children: content }) }, item.value);
37
37
  }) });
38
38
  });
39
- export const ContentSection = forwardRef(function ContentSection({ title, description, actions, headingLevel = 2, headingSize = "section", children, id: explicitId, className, "aria-labelledby": labelledBy, ...props }, ref) {
39
+ export const ContentSection = /* @__PURE__ */ forwardRef(function ContentSection({ title, description, actions, headingLevel = 2, headingSize = "section", children, id: explicitId, className, "aria-labelledby": labelledBy, ...props }, ref) {
40
40
  if (!title.trim() || ![2, 3, 4, 5, 6].includes(headingLevel))
41
41
  throw new Error("Flood Section requires a nonempty title and headingLevel from 2 to 6");
42
42
  const generated = useId(), headingId = `${explicitId ?? `flood-section-${generated}`}-title`;
43
43
  return _jsxs("section", { ...props, ref: ref, id: explicitId, "aria-labelledby": labelledBy ?? headingId, className: classes("flood-section", className), children: [_jsxs("header", { className: "flood-section__header", children: [_jsxs("div", { className: "flood-section__heading", children: [_jsx(Heading, { id: headingId, level: headingLevel, size: headingSize, className: "flood-section__title", children: title }), description != null && _jsx("div", { className: "flood-section__description", children: description })] }), actions != null && _jsx("div", { className: "flood-section__actions", children: actions })] }), children != null && _jsx("div", { className: "flood-section__body", children: children })] });
44
44
  });
45
- export const SplitLayout = forwardRef(function SplitLayout({ main, aside, side = "end", ratio = "main", collapseAt = "md", gap = "6", className, style, ...props }, ref) {
45
+ export const SplitLayout = /* @__PURE__ */ forwardRef(function SplitLayout({ main, aside, side = "end", ratio = "main", collapseAt = "md", gap = "6", className, style, ...props }, ref) {
46
46
  const primary = _jsx("div", { className: "flood-split__main", children: main }, "main"), secondary = _jsx("div", { className: "flood-split__aside", children: aside }, "aside");
47
47
  return _jsx("div", { ...props, ref: ref, className: classes("flood-split", className), style: { "--flood-layout-gap": `var(--flood-sys-space-${gap})`, ...style }, children: _jsx("div", { className: "flood-split__layout", "data-side": side, "data-ratio": ratio, "data-collapse": collapseAt, children: side === "start" ? [secondary, primary] : [primary, secondary] }) });
48
48
  });
49
- export const CenterLayout = forwardRef(function CenterLayout({ inline = false, maxWidth, minHeight, className, style, children, ...props }, ref) {
49
+ export const CenterLayout = /* @__PURE__ */ forwardRef(function CenterLayout({ inline = false, maxWidth, minHeight, className, style, children, ...props }, ref) {
50
50
  return _jsx("div", { ...props, ref: ref, className: classes("flood-center", className), "data-inline": inline || undefined, style: { minBlockSize: minHeight, "--flood-center-max": maxWidth, ...style }, children: _jsx("div", { className: "flood-center__content", children: children }) });
51
51
  });
52
- export const SkipNavigation = forwardRef(function SkipNavigation({ children = "Skip to content", className, onClick, href, ...props }, ref) {
52
+ export const SkipNavigation = /* @__PURE__ */ forwardRef(function SkipNavigation({ children = "Skip to content", className, onClick, href, ...props }, ref) {
53
53
  if (!href.startsWith("#") || href.length < 2)
54
54
  throw new Error("Flood SkipLink requires a nonempty same-page fragment href");
55
55
  return _jsx("a", { ...props, ref: ref, href: href, className: classes("flood-skip-link", className), onClick: event => {
@@ -0,0 +1,3 @@
1
+ import type { NavigationTreeProps } from "./index.js";
2
+ /** Ordinary website navigation uses disclosure lists, not application tree selection semantics. */
3
+ export declare const HierarchicalNavigation: import("react").ForwardRefExoticComponent<NavigationTreeProps & import("react").RefAttributes<HTMLElement>>;
@@ -0,0 +1,98 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { IconButton, IconSlot, Link } from "./index.js";
4
+ const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
+ const branch = (item) => item.children !== undefined || item.content !== undefined;
6
+ /** Ordinary website navigation uses disclosure lists, not application tree selection semantics. */
7
+ export const HierarchicalNavigation = /* @__PURE__ */ forwardRef(function HierarchicalNavigation({ label, items, currentValue, expandedValues, defaultExpandedValues = [], onExpandedValuesChange, expandLabel = (item, expanded) => `${expanded ? "Collapse" : "Expand"} ${item.label}`, size = "md", id: explicitId, className, tabIndex = -1, ...props }, forwardedRef) {
8
+ if (!label.trim())
9
+ throw new Error("Flood NavigationTree requires a nonempty label");
10
+ const generated = useId(), id = explicitId ?? `flood-navigation-tree-${generated}`;
11
+ const root = useRef(null);
12
+ useImperativeHandle(forwardedRef, () => root.current, []);
13
+ const [local, setLocal] = useState(() => [...new Set(defaultExpandedValues)]);
14
+ const expanded = new Set(expandedValues ?? local);
15
+ const known = new Set(), visible = new Set();
16
+ const parents = new Map();
17
+ const pending = [{ items, shown: true, ancestors: new Set() }];
18
+ while (pending.length) {
19
+ const level = pending.pop();
20
+ if (level.ancestors.has(level.items))
21
+ throw new Error("Flood NavigationTree does not accept recursive item arrays");
22
+ const ancestors = new Set(level.ancestors);
23
+ ancestors.add(level.items);
24
+ for (const item of level.items) {
25
+ if (!item.value.trim() || !item.label.trim() || known.has(item.value))
26
+ throw new Error("Flood NavigationTree requires globally unique nonempty values and labels");
27
+ known.add(item.value);
28
+ parents.set(item.value, level.parent);
29
+ if (level.shown)
30
+ visible.add(item.value);
31
+ if (item.children)
32
+ pending.push({ items: item.children, parent: item.value, shown: level.shown && expanded.has(item.value), ancestors });
33
+ }
34
+ }
35
+ const latest = useRef({ visible, parents });
36
+ latest.current = { visible, parents };
37
+ const destinations = useRef(new Map());
38
+ const refs = useRef(new Map());
39
+ const refAncestors = useRef(new Map());
40
+ const repair = (removed, ancestors) => {
41
+ const active = removed.ownerDocument.activeElement;
42
+ if (!active || !removed.contains(active))
43
+ return;
44
+ for (const value of ancestors) {
45
+ const target = destinations.current.get(`disclosure:${value}`) ?? destinations.current.get(`link:${value}`);
46
+ if (latest.current.visible.has(value) && target?.isConnected && !target.matches(":disabled")) {
47
+ target.focus({ preventScroll: true });
48
+ return;
49
+ }
50
+ }
51
+ root.current?.focus({ preventScroll: true });
52
+ };
53
+ const bind = (key, ancestors) => {
54
+ refAncestors.current.set(key, ancestors);
55
+ if (!refs.current.has(key)) {
56
+ let previous = null;
57
+ refs.current.set(key, node => {
58
+ if (!node && previous)
59
+ repair(previous, refAncestors.current.get(key) ?? []);
60
+ previous = node;
61
+ if (node)
62
+ destinations.current.set(key, node);
63
+ else
64
+ destinations.current.delete(key);
65
+ });
66
+ }
67
+ return refs.current.get(key);
68
+ };
69
+ useBrowserLayoutEffect(() => {
70
+ for (const key of refs.current.keys())
71
+ if (!known.has(key.slice(key.indexOf(":") + 1))) {
72
+ refs.current.delete(key);
73
+ refAncestors.current.delete(key);
74
+ }
75
+ }, [items]);
76
+ const toggle = (value) => {
77
+ const next = new Set(expandedValues ?? local);
78
+ if (next.has(value))
79
+ next.delete(value);
80
+ else
81
+ next.add(value);
82
+ const values = [...next];
83
+ if (expandedValues === undefined)
84
+ setLocal(values);
85
+ onExpandedValuesChange?.(values);
86
+ };
87
+ const render = (entries, ancestors = []) => entries.map(item => {
88
+ const open = expanded.has(item.value), expandable = branch(item), current = item.value === currentValue;
89
+ const contentId = `${id}-branch-${encodeURIComponent(item.value)}`;
90
+ const disclosureLabel = expandable ? expandLabel(item, open) : "";
91
+ if (expandable && !disclosureLabel.trim())
92
+ throw new Error("Flood NavigationTree disclosure labels must be nonempty");
93
+ const body = _jsxs(_Fragment, { children: [item.icon != null && _jsx(IconSlot, { size: size, children: item.icon }), _jsx("span", { className: "flood-navigation-tree__label", children: item.label })] });
94
+ return _jsxs("li", { children: [_jsxs("div", { className: "flood-navigation-tree__row", "data-current": current || undefined, children: [item.href !== undefined && !item.disabled ? _jsx(Link, { ...item.linkProps, href: item.href, ref: bind(`link:${item.value}`, ancestors), variant: "ghost", size: size, "aria-current": current ? "page" : undefined, className: ["flood-navigation-tree__link", item.linkProps?.className].filter(Boolean).join(" "), children: body })
95
+ : _jsx("span", { className: "flood-navigation-tree__text", "aria-disabled": item.disabled || undefined, "aria-current": current ? "page" : undefined, children: body }), expandable && _jsx(IconButton, { ref: bind(`disclosure:${item.value}`, ancestors), variant: "ghost", size: size, label: disclosureLabel, className: "flood-navigation-tree__disclosure", disabled: item.disabled, "aria-expanded": open, "aria-controls": open ? contentId : undefined, onClick: () => toggle(item.value), children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m9 5 7 7-7 7" }) }) })] }), expandable && open && _jsxs("div", { id: contentId, ref: bind(`group:${item.value}`, [item.value, ...ancestors]), className: "flood-navigation-tree__branch", children: [item.children && item.children.length > 0 && _jsx("ul", { children: render(item.children, [item.value, ...ancestors]) }), item.content != null && _jsx("div", { className: "flood-navigation-tree__content", children: item.content })] })] }, item.value);
96
+ });
97
+ return _jsx("nav", { ...props, ref: root, id: id, "aria-label": label, tabIndex: tabIndex, "data-size": size, className: ["flood-navigation-tree", className].filter(Boolean).join(" "), children: _jsx("ul", { children: render(items) }) });
98
+ });