@flood-md/react 0.2.10 → 0.2.11

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 (49) hide show
  1. package/README.md +239 -1
  2. package/dist/app-shell.d.ts +3 -0
  3. package/dist/app-shell.js +66 -0
  4. package/dist/bulk-action-bar.d.ts +3 -0
  5. package/dist/bulk-action-bar.js +17 -0
  6. package/dist/collection-toolbar.d.ts +3 -0
  7. package/dist/collection-toolbar.js +26 -0
  8. package/dist/collection-view-switcher.d.ts +3 -0
  9. package/dist/collection-view-switcher.js +19 -0
  10. package/dist/combobox.d.ts +6 -1
  11. package/dist/combobox.js +77 -29
  12. package/dist/command-list.d.ts +5 -1
  13. package/dist/command-list.js +2 -2
  14. package/dist/data-display.js +1 -1
  15. package/dist/data-table.js +44 -9
  16. package/dist/date-picker.js +2 -2
  17. package/dist/date-range-picker.js +2 -2
  18. package/dist/date-time-picker.js +2 -2
  19. package/dist/date-time-range-picker.js +2 -2
  20. package/dist/disclosure.d.ts +2 -1
  21. package/dist/disclosure.js +4 -4
  22. package/dist/filter-condition.d.ts +3 -0
  23. package/dist/filter-condition.js +21 -0
  24. package/dist/filter-group.d.ts +2 -0
  25. package/dist/filter-group.js +62 -0
  26. package/dist/foundations.css +752 -76
  27. package/dist/index.d.ts +399 -3
  28. package/dist/index.js +61 -7
  29. package/dist/menu.js +5 -4
  30. package/dist/menubar.d.ts +2 -0
  31. package/dist/menubar.js +85 -0
  32. package/dist/navigation-group.d.ts +3 -0
  33. package/dist/navigation-group.js +13 -0
  34. package/dist/navigation-item.d.ts +3 -0
  35. package/dist/navigation-item.js +68 -0
  36. package/dist/navigation-layout.d.ts +2 -1
  37. package/dist/navigation-layout.js +12 -4
  38. package/dist/navigation-tree.js +51 -7
  39. package/dist/navigation.js +2 -2
  40. package/dist/overflow-actions.d.ts +2 -0
  41. package/dist/overflow-actions.js +75 -0
  42. package/dist/overlay-runtime.d.ts +3 -0
  43. package/dist/overlay-runtime.js +35 -1
  44. package/dist/overlays.js +32 -16
  45. package/dist/select.js +4 -1
  46. package/dist/sidebar.d.ts +2 -0
  47. package/dist/sidebar.js +58 -0
  48. package/dist/toolbar.js +2 -2
  49. package/package.json +2 -2
@@ -0,0 +1,75 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { Button, IconSlot, Menu, Toolbar } from "./index.js";
4
+ const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
+ export const AdaptiveActions = /* @__PURE__ */ forwardRef(function AdaptiveActions({ label, items, onAction, moreLabel = "More actions", size = "md", variant = "subtle", shape = "pill", containerVariant = "contained", disabled = false, leading, trailing, maxVisibleActions, className, ...props }, ref) {
6
+ if (!label.trim() || !moreLabel.trim() || items.some(item => !item.id.trim() || !item.label.trim() || (item.priority !== undefined && !Number.isFinite(item.priority))) || new Set(items.map(item => item.id)).size !== items.length)
7
+ throw new Error("Flood OverflowActions requires unique named actions and finite priorities");
8
+ if (maxVisibleActions !== undefined && (!Number.isInteger(maxVisibleActions) || maxVisibleActions < 0))
9
+ throw new Error("Flood OverflowActions maxVisibleActions must be a nonnegative integer");
10
+ const root = useRef(null), measure = useRef(null), more = useRef(null);
11
+ const before = useRef(null), after = useRef(null);
12
+ const menuAnchor = useRef(null);
13
+ const setMore = useCallback((node) => { more.current = node; menuAnchor.current?.(node); }, []);
14
+ useImperativeHandle(ref, () => root.current, []);
15
+ const [visibleIds, setVisibleIds] = useState(() => items.map(item => item.id));
16
+ const [open, setOpen] = useState(false), focusAfter = useRef(false);
17
+ const visible = items.filter(item => visibleIds.includes(item.id)), hidden = items.filter(item => !visibleIds.includes(item.id));
18
+ const action = (item, measuring = false) => _jsxs(Button, { size: size, variant: variant, tone: item.tone, disabled: disabled || item.disabled, "data-overflow-action": measuring ? undefined : item.id, "data-measure-action": measuring ? item.id : undefined, tabIndex: measuring ? -1 : undefined, onClick: measuring ? undefined : () => onAction?.(item.id), children: [item.icon && _jsx(IconSlot, { size: size, children: item.icon }), item.label] }, item.id);
19
+ const overflow = (measuring = false) => _jsxs(Button, { size: size, variant: variant, disabled: disabled, tabIndex: measuring ? -1 : undefined, "data-measure-more": measuring || undefined, children: [moreLabel, _jsx(IconSlot, { size: size, children: _jsxs("svg", { viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [_jsx("circle", { cx: "5", cy: "12", r: "1.5" }), _jsx("circle", { cx: "12", cy: "12", r: "1.5" }), _jsx("circle", { cx: "19", cy: "12", r: "1.5" })] }) })] });
20
+ useBrowserLayoutEffect(() => {
21
+ const node = root.current, measured = measure.current, view = node?.ownerDocument.defaultView;
22
+ if (!node || !measured || !view)
23
+ return;
24
+ const update = () => {
25
+ const widths = new Map([...measured.querySelectorAll('[data-measure-action]')].map(item => [item.dataset.measureAction, item.getBoundingClientRect().width]));
26
+ const bar = node.querySelector('[role="toolbar"]'), gap = bar ? parseFloat(view.getComputedStyle(bar).columnGap) || 0 : 0;
27
+ const barStyle = bar ? view.getComputedStyle(bar) : null;
28
+ const rootStyle = view.getComputedStyle(node);
29
+ const available = Math.max(0, node.clientWidth - parseFloat(rootStyle.paddingLeft) - parseFloat(rootStyle.paddingRight) - (barStyle ? parseFloat(barStyle.paddingLeft) + parseFloat(barStyle.paddingRight) + parseFloat(barStyle.borderLeftWidth) + parseFloat(barStyle.borderRightWidth) : 0)), extra = measured.querySelector('[data-measure-more]')?.getBoundingClientRect().width ?? 0;
30
+ const fixed = [before.current, after.current].filter((item) => item !== null);
31
+ const fixedWidth = fixed.reduce((sum, item) => sum + item.getBoundingClientRect().width, 0);
32
+ const kept = new Set(items.map(item => item.id));
33
+ const width = (withMore) => fixedWidth + items.filter(item => kept.has(item.id)).reduce((sum, item) => sum + (widths.get(item.id) ?? 0), 0) + (withMore ? extra : 0) + Math.max(0, kept.size + fixed.length + (withMore ? 1 : 0) - 1) * gap;
34
+ const order = items.map((item, index) => ({ item, index })).sort((a, b) => (a.item.priority ?? 0) - (b.item.priority ?? 0) || b.index - a.index);
35
+ for (const { item } of order) {
36
+ if (kept.size <= (maxVisibleActions ?? items.length) && width(kept.size < items.length) <= available)
37
+ break;
38
+ kept.delete(item.id);
39
+ }
40
+ const next = items.filter(item => kept.has(item.id)).map(item => item.id);
41
+ const active = node.ownerDocument.activeElement;
42
+ const activeId = active?.dataset.overflowAction;
43
+ if ((activeId && !kept.has(activeId)) || (!items.some(item => !kept.has(item.id)) && more.current?.contains(active)))
44
+ focusAfter.current = true;
45
+ setVisibleIds(previous => previous.join("\0") === next.join("\0") ? previous : next);
46
+ };
47
+ update();
48
+ const observer = view.ResizeObserver ? new view.ResizeObserver(update) : undefined;
49
+ observer?.observe(node);
50
+ observer?.observe(measured);
51
+ if (before.current)
52
+ observer?.observe(before.current);
53
+ if (after.current)
54
+ observer?.observe(after.current);
55
+ let alive = true;
56
+ void node.ownerDocument.fonts?.ready.then(() => { if (alive)
57
+ update(); });
58
+ return () => { alive = false; observer?.disconnect(); };
59
+ }, [items, size, variant, moreLabel, disabled, leading, trailing, maxVisibleActions]);
60
+ useBrowserLayoutEffect(() => {
61
+ if (!hidden.length && open) {
62
+ setOpen(false);
63
+ focusAfter.current = true;
64
+ }
65
+ if (focusAfter.current) {
66
+ focusAfter.current = false;
67
+ (more.current ?? root.current?.querySelector('[data-overflow-action]:not(:disabled)'))?.focus({ preventScroll: true });
68
+ }
69
+ }, [visibleIds, hidden.length, open]);
70
+ return _jsxs("div", { ...props, ref: root, className: ["flood-overflow-actions", className].filter(Boolean).join(" "), children: [_jsxs(Toolbar, { label: label, shape: shape, variant: containerVariant, className: "flood-overflow-actions__bar", children: [leading != null && _jsx("div", { ref: before, className: "flood-overflow-actions__leading", children: leading }), visible.map(item => action(item)), hidden.length > 0 && _jsx(Menu, { label: moreLabel, items: hidden, open: open, onOpenChange: setOpen, onAction: onAction, disabled: disabled, controlSize: size, trigger: trigger => {
71
+ menuAnchor.current = trigger.ref;
72
+ return _jsxs(Button, { ...trigger, ref: setMore, size: size, variant: variant, children: [moreLabel, _jsx(IconSlot, { size: size, children: _jsxs("svg", { viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [_jsx("circle", { cx: "5", cy: "12", r: "1.5" }), _jsx("circle", { cx: "12", cy: "12", r: "1.5" }), _jsx("circle", { cx: "19", cy: "12", r: "1.5" })] }) })] });
73
+ } }), trailing != null && _jsx("div", { ref: after, className: "flood-overflow-actions__trailing", children: trailing })] }), _jsxs("div", { ref: node => { measure.current = node; if (node)
74
+ node.inert = true; }, className: "flood-overflow-actions__measure", "aria-hidden": "true", children: [items.map(item => action(item, true)), overflow(true)] })] });
75
+ });
@@ -1,3 +1,4 @@
1
+ import { type RefObject } from "react";
1
2
  export type Side = "top" | "bottom" | "left" | "right";
2
3
  export type Align = "start" | "center" | "end";
3
4
  type Rect = {
@@ -35,4 +36,6 @@ export declare function registerLayer(document: Document, layer: Layer): () => v
35
36
  export declare function lockScroll(document: Document): () => void;
36
37
  export declare function positionLayer(anchor: HTMLElement, panel: HTMLElement, side: Side, align: Align): void;
37
38
  export declare function returnFocus(panel: HTMLElement, anchor: HTMLElement | null): void;
39
+ /** Retain pixels for a CSS exit after the native layer has released focus. */
40
+ export declare function useLayerExitContent(panel: RefObject<HTMLElement | null>, open: boolean, enabled?: boolean): boolean;
38
41
  export {};
@@ -1,3 +1,4 @@
1
+ import { useEffect, useLayoutEffect, useState } from "react";
1
2
  /** Pure collision handling in layout-viewport coordinates. */
2
3
  export function placeOverlay(anchor, size, viewport, side, align, gap, rtl = false) {
3
4
  const edge = { left: viewport.left + gap, right: viewport.left + viewport.width - gap, top: viewport.top + gap, bottom: viewport.top + viewport.height - gap };
@@ -115,7 +116,12 @@ export function positionLayer(anchor, panel, side, align) {
115
116
  const gap = parseFloat(getComputedStyle(panel).columnGap) || 8;
116
117
  panel.style.setProperty("--flood-overlay-available-width", `${Math.max(0, viewport.width - 2 * gap)}px`);
117
118
  panel.style.setProperty("--flood-overlay-available-height", `${Math.max(0, viewport.height - 2 * gap)}px`);
118
- const position = placeOverlay(anchor.getBoundingClientRect(), panel.getBoundingClientRect(), viewport, side, align, gap, getComputedStyle(anchor).direction === "rtl");
119
+ const bounds = anchor.getBoundingClientRect(), anchorWidth = `${bounds.width}px`, anchorHeight = `${bounds.height}px`;
120
+ if (panel.style.getPropertyValue("--flood-overlay-anchor-width") !== anchorWidth)
121
+ panel.style.setProperty("--flood-overlay-anchor-width", anchorWidth);
122
+ if (panel.style.getPropertyValue("--flood-overlay-anchor-height") !== anchorHeight)
123
+ panel.style.setProperty("--flood-overlay-anchor-height", anchorHeight);
124
+ const position = placeOverlay(bounds, panel.getBoundingClientRect(), viewport, side, align, gap, getComputedStyle(anchor).direction === "rtl");
119
125
  const left = `${position.left}px`, top = `${position.top}px`;
120
126
  if (panel.style.left !== left)
121
127
  panel.style.left = left;
@@ -129,3 +135,31 @@ export function returnFocus(panel, anchor) {
129
135
  if (anchor?.isConnected && !anchor.matches(":disabled") && (panel.contains(active) || active === panel.ownerDocument.body))
130
136
  anchor.focus({ preventScroll: true });
131
137
  }
138
+ /** Retain pixels for a CSS exit after the native layer has released focus. */
139
+ export function useLayerExitContent(panel, open, enabled = true) {
140
+ const [retained, setRetained] = useState(false);
141
+ useLayerLayoutEffect(() => {
142
+ const node = panel.current;
143
+ if (!enabled || !node) {
144
+ setRetained(false);
145
+ return;
146
+ }
147
+ if (open) {
148
+ setRetained(true);
149
+ return;
150
+ }
151
+ node.inert = true;
152
+ const transitions = node.getAnimations().filter(animation => "transitionProperty" in animation && animation.transitionProperty === "opacity");
153
+ let active = true;
154
+ if (!transitions.length)
155
+ setRetained(false);
156
+ else
157
+ void Promise.allSettled(transitions.map(animation => animation.finished)).then(() => {
158
+ if (active)
159
+ setRetained(false);
160
+ });
161
+ return () => { active = false; node.inert = false; };
162
+ }, [panel, open, enabled]);
163
+ return open || enabled && retained;
164
+ }
165
+ const useLayerLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
package/dist/overlays.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
3
  import { IconButton, ScrollArea, VisuallyHidden } from "./index.js";
4
- import { positionLayer, registerLayer, returnFocus, lockScroll, tabbables } from "./overlay-runtime.js";
4
+ import { positionLayer, registerLayer, returnFocus, lockScroll, tabbables, useLayerExitContent } from "./overlay-runtime.js";
5
5
  import { mergeDescriptions } from "./field-state.js";
6
6
  const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
7
7
  function useCurrent(value) { const ref = useRef(value); useBrowserLayoutEffect(() => { ref.current = value; }); return ref; }
@@ -19,7 +19,7 @@ function focusContent(panel, preferred) {
19
19
  }
20
20
  }
21
21
  function OverlayClose({ label, onClick }) {
22
- return _jsx(IconButton, { label: label, size: "sm", variant: "subtle", className: "flood-overlay-close", onClick: onClick, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "m4 4 16 16M20 4 4 20" }) }) });
22
+ return _jsx(IconButton, { label: label, size: "sm", variant: "ghost", className: "flood-overlay-close", onClick: onClick, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "m4 4 16 16M20 4 4 20" }) }) });
23
23
  }
24
24
  export const AnchoredOverlay = /* @__PURE__ */ forwardRef(function AnchoredOverlay(allProps, forwardedRef) {
25
25
  const { kind, trigger, side = "bottom", align = "start", disabled = false, id: explicitId, className, style, ...native } = allProps;
@@ -38,7 +38,9 @@ export const AnchoredOverlay = /* @__PURE__ */ forwardRef(function AnchoredOverl
38
38
  suppressed.current = true;
39
39
  overPanel.current = false;
40
40
  } request(false); };
41
- const current = useCurrent({ close, request, disabled, open, initialFocusRef: pop.initialFocusRef, positionReference: pop.positionReference });
41
+ const focusOnOpen = pop.focusOnOpen ?? true;
42
+ const current = useCurrent({ close, request, disabled, open, focusOnOpen, initialFocusRef: pop.initialFocusRef, positionReference: pop.positionReference });
43
+ const previousFocusOnOpen = useRef(focusOnOpen);
42
44
  const positionReference = () => { const node = current.current.positionReference?.current; return node?.isConnected && node.ownerDocument === panel.current?.ownerDocument ? node : anchor.current; };
43
45
  useImperativeHandle(forwardedRef, () => panel.current, []);
44
46
  useEffect(() => () => clearTimeout(timer.current), []);
@@ -74,7 +76,7 @@ export const AnchoredOverlay = /* @__PURE__ */ forwardRef(function AnchoredOverl
74
76
  element.showPopover();
75
77
  positionLayer(positionReference(), element, side, align);
76
78
  unregister = registerLayer(document, { panel: element, anchor: () => anchor.current, close: () => current.current.close(), tooltip: kind === "tooltip" });
77
- if (kind === "popover")
79
+ if (kind === "popover" && current.current.focusOnOpen)
78
80
  focusContent(element, current.current.initialFocusRef?.current);
79
81
  const update = () => { if (!alive)
80
82
  return; if (anchor.current?.isConnected)
@@ -89,30 +91,40 @@ export const AnchoredOverlay = /* @__PURE__ */ forwardRef(function AnchoredOverl
89
91
  queueMicrotask(() => { if (alive && current.current.open && element.isConnected && !element.matches(":popover-open") && anchor.current?.isConnected) {
90
92
  element.showPopover();
91
93
  positionLayer(positionReference(), element, side, align);
92
- if (kind === "popover")
94
+ if (kind === "popover" && current.current.focusOnOpen)
93
95
  focusContent(element, current.current.initialFocusRef?.current);
94
96
  } });
95
97
  };
96
98
  element.addEventListener("toggle", toggle);
97
- return () => { alive = false; view.cancelAnimationFrame(frame); unregister(); element.removeEventListener("toggle", toggle); if (kind === "popover")
99
+ return () => { alive = false; view.cancelAnimationFrame(frame); unregister(); element.removeEventListener("toggle", toggle); if (kind === "popover" && (previousFocusOnOpen.current || element.contains(document.activeElement)))
98
100
  returnFocus(element, anchor.current); if (element.matches(":popover-open"))
99
101
  element.hidePopover(); };
100
102
  }, [open, side, align, kind]);
101
- const triggerProps = { ref: setAnchor, type: "button", ...(kind === "tooltip" ? {
102
- "aria-describedby": disabled ? undefined : id,
103
- onPointerEnter: event => { if (event.pointerType === "touch")
104
- return; hovered.current = true; enter(); },
105
- onPointerLeave: () => { hovered.current = false; leave(); }, onFocus: () => { focused.current = true; enter(true); }, onBlur: () => { focused.current = false; leave(); },
106
- } : { disabled, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": id, onClick: () => request(!open) }) };
103
+ const titleOnly = kind === "popover" && pop.chrome === "title-only";
104
+ const showContent = useLayerExitContent(panel, open, titleOnly);
105
+ useBrowserLayoutEffect(() => {
106
+ const element = panel.current;
107
+ if (focusOnOpen && !previousFocusOnOpen.current && element?.matches(":popover-open") && !element.contains(element.ownerDocument.activeElement)) {
108
+ focusContent(element, pop.initialFocusRef?.current);
109
+ }
110
+ previousFocusOnOpen.current = focusOnOpen;
111
+ }, [focusOnOpen]);
112
+ const tooltipTriggerProps = { ref: setAnchor,
113
+ "aria-describedby": disabled ? undefined : id,
114
+ onPointerEnter: event => { if (event.pointerType === "touch")
115
+ return; hovered.current = true; enter(); },
116
+ onPointerLeave: () => { hovered.current = false; leave(); }, onFocus: () => { focused.current = true; enter(true); }, onBlur: () => { focused.current = false; leave(); },
117
+ };
118
+ const popoverTriggerProps = { ref: setAnchor, type: "button", disabled, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": id, onClick: () => request(!open) };
107
119
  // Remove component-only props before forwarding native div attributes.
108
- const { title: unusedTitle, chrome: unusedChrome, content: unusedContent, children: unusedChildren, open: unusedOpen, defaultOpen: unusedDefault, onOpenChange: unusedChange, delay: unusedDelay, closeLabel: unusedClose, initialFocusRef: unusedFocus, positionReference: unusedPosition, ...attributes } = native;
109
- return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx("div", { ...attributes, ref: panel, id: id, popover: "manual", role: kind === "tooltip" ? "tooltip" : "dialog", "aria-labelledby": kind === "popover" ? `${id}-title` : undefined, tabIndex: kind === "popover" ? -1 : undefined, "data-chrome": kind === "popover" ? pop.chrome : undefined, className: ["flood-overlay-panel", kind === "tooltip" ? "flood-tooltip" : "flood-popover", className].filter(Boolean).join(" "), style: style, onPointerEnter: event => { attributes.onPointerEnter?.(event); if (kind === "tooltip") {
120
+ const { title: unusedTitle, chrome: unusedChrome, content: unusedContent, children: unusedChildren, open: unusedOpen, defaultOpen: unusedDefault, onOpenChange: unusedChange, delay: unusedDelay, closeLabel: unusedClose, initialFocusRef: unusedFocus, positionReference: unusedPosition, focusOnOpen: unusedFocusOnOpen, ...attributes } = native;
121
+ return _jsxs(_Fragment, { children: [kind === "tooltip" ? tip.trigger(tooltipTriggerProps) : pop.trigger(popoverTriggerProps), _jsx("div", { ...attributes, ref: panel, id: id, popover: "manual", role: kind === "tooltip" ? "tooltip" : "dialog", "aria-labelledby": kind === "popover" ? `${id}-title` : undefined, tabIndex: kind === "popover" ? -1 : undefined, "aria-hidden": titleOnly && !open ? true : attributes["aria-hidden"], "data-chrome": kind === "popover" ? pop.chrome : undefined, className: ["flood-overlay-panel", kind === "tooltip" ? "flood-tooltip" : "flood-popover", className].filter(Boolean).join(" "), style: style, onPointerEnter: event => { attributes.onPointerEnter?.(event); if (kind === "tooltip") {
110
122
  overPanel.current = true;
111
123
  clear();
112
124
  } }, onPointerLeave: event => { attributes.onPointerLeave?.(event); if (kind === "tooltip") {
113
125
  overPanel.current = false;
114
126
  leave();
115
- } }, children: kind === "tooltip" ? tip.content : _jsxs(_Fragment, { children: [pop.chrome === "minimal" ? _jsx(VisuallyHidden, { children: _jsx("h2", { id: `${id}-title`, children: pop.title }) }) : _jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: pop.title }), _jsx(OverlayClose, { label: pop.closeLabel ?? "Close", onClick: close })] }), open && _jsx("div", { className: "flood-overlay-body", children: pop.children })] }) })] });
127
+ } }, children: kind === "tooltip" ? tip.content : _jsxs(_Fragment, { children: [pop.chrome === "minimal" ? _jsx(VisuallyHidden, { children: _jsx("h2", { id: `${id}-title`, children: pop.title }) }) : _jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: pop.title }), !titleOnly && _jsx(OverlayClose, { label: pop.closeLabel ?? "Close", onClick: close })] }), showContent && _jsx("div", { className: "flood-overlay-body", children: pop.children })] }) })] });
116
128
  });
117
129
  export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ showCloseButton = true, scrollBody = false, footer, title, description, trigger, children, open: controlled, defaultOpen = false, onOpenChange, disabled = false, closeLabel = "Close", closeOnOutsideClick = true, initialFocusRef, id: explicitId, className, onKeyDown, "aria-describedby": describedBy, ...props }, forwardedRef) {
118
130
  const [local, setLocal] = useState(defaultOpen), open = controlled ?? local;
@@ -161,7 +173,11 @@ export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ s
161
173
  start();
162
174
  return () => { alive = false; view.cancelAnimationFrame(frame); dispose(); };
163
175
  }, [open]);
164
- return _jsxs(_Fragment, { children: [trigger({ ref: setAnchor, type: "button", disabled, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": id, onClick: () => request(true) }), _jsxs("dialog", { ...props, ref: dialog, id: id, "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": mergeDescriptions(describedBy, Boolean(description) && `${id}-description`), className: ["flood-dialog", className].filter(Boolean).join(" "), onKeyDown: event => {
176
+ return _jsxs(_Fragment, { children: [trigger({ ref: setAnchor, type: "button", disabled, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": id, onClick: event => {
177
+ // WebKit can leave pointer-activated buttons unfocused before showModal().
178
+ event.currentTarget.focus({ preventScroll: true });
179
+ request(true);
180
+ } }), _jsxs("dialog", { ...props, ref: dialog, id: id, "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": mergeDescriptions(describedBy, Boolean(description) && `${id}-description`), className: ["flood-dialog", className].filter(Boolean).join(" "), onKeyDown: event => {
165
181
  onKeyDown?.(event);
166
182
  if (event.defaultPrevented || event.key !== "Tab")
167
183
  return;
package/dist/select.js CHANGED
@@ -187,7 +187,10 @@ export const SingleSelect = /* @__PURE__ */ forwardRef(function SingleSelect({ c
187
187
  const gap = length("--flood-sys-space-2") || 0;
188
188
  const below = Math.max(0, viewportTop + viewportHeight - rect.bottom - gap * 2), above = Math.max(0, rect.top - viewportTop - gap * 2);
189
189
  const maxHeight = Math.min(length("--flood-sys-control-md-height") * 8, Math.max(below, above));
190
- assign(popup, "width", `${Math.min(rect.width, Math.max(0, viewportWidth - gap * 2))}px`);
190
+ const availableWidth = Math.max(0, viewportWidth - gap * 2);
191
+ assign(popup, "width", "max-content");
192
+ assign(popup, "min-width", `${Math.min(rect.width, availableWidth)}px`);
193
+ assign(popup, "max-width", `${availableWidth}px`);
191
194
  assign(popup, "max-height", `${maxHeight}px`);
192
195
  // The shared positioner reads column-gap as its placement gap. On this
193
196
  // block listbox it does not add layout space between options.
@@ -0,0 +1,2 @@
1
+ import { type SidebarProps } from './index.js';
2
+ export declare const WorkspaceSidebar: import("react").ForwardRefExoticComponent<SidebarProps & import("react").RefAttributes<HTMLElement>>;
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';
3
+ import { IconButton, NavigationTree, NavigationGroup, ScrollArea, Stack } from './index.js';
4
+ const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
5
+ export const WorkspaceSidebar = /* @__PURE__ */ forwardRef(function WorkspaceSidebar({ label, groups, currentValue, currentKind = "page", collapsed: controlled, defaultCollapsed = false, onCollapsedChange, collapsible = true, collapseLabel = 'Collapse sidebar', expandLabel = 'Expand sidebar', expandedValues, defaultExpandedValues = [], onExpandedValuesChange, expandItemLabel, size = 'md', variant = 'quiet', side = 'start', width = '16rem', header, headerLayout = 'stacked', footer, footerSeparator = false, headerSeparator = false, className, style, ...props }, ref) {
6
+ if (!label.trim() || !collapseLabel.trim() || !expandLabel.trim() || new Set(groups.map(group => group.id)).size !== groups.length || groups.some(group => !group.id.trim() || !group.label.trim()))
7
+ throw new Error('Flood Sidebar requires named unique groups and controls');
8
+ const known = new Set(), pending = groups.flatMap(group => [...group.items]);
9
+ while (pending.length) {
10
+ const item = pending.pop();
11
+ if (known.has(item.value))
12
+ throw new Error('Flood Sidebar item values must be globally unique');
13
+ known.add(item.value);
14
+ if (item.children)
15
+ pending.push(...item.children);
16
+ }
17
+ const [local, setLocal] = useState(defaultCollapsed), [expanded, setExpanded] = useState(defaultExpandedValues);
18
+ const collapsed = controlled ?? local, root = useRef(null), toggle = useRef(null), body = useRef(null);
19
+ const lastOwnedFocus = useRef(null);
20
+ const [rtl, setRtl] = useState(false);
21
+ useImperativeHandle(ref, () => root.current, []);
22
+ useBrowserLayoutEffect(() => {
23
+ const node = root.current;
24
+ if (!node)
25
+ return;
26
+ const view = node.ownerDocument.defaultView, update = () => setRtl(view.getComputedStyle(node).direction === 'rtl');
27
+ update();
28
+ const observer = new view.MutationObserver(update);
29
+ for (let ancestor = node; ancestor; ancestor = ancestor.parentElement)
30
+ observer.observe(ancestor, { attributes: true, attributeFilter: ['dir', 'style', 'class'] });
31
+ view.addEventListener('resize', update);
32
+ return () => { observer.disconnect(); view.removeEventListener('resize', update); };
33
+ }, []);
34
+ useBrowserLayoutEffect(() => {
35
+ const node = root.current;
36
+ if (!node)
37
+ return;
38
+ const document = node.ownerDocument;
39
+ const track = (event) => { const target = event.target; lastOwnedFocus.current = node.contains(target) ? target : null; };
40
+ document.addEventListener('focusin', track, true);
41
+ return () => document.removeEventListener('focusin', track, true);
42
+ }, []);
43
+ useBrowserLayoutEffect(() => {
44
+ const node = root.current, previous = lastOwnedFocus.current;
45
+ if (node && previous && !previous.isConnected && node.ownerDocument.activeElement === node.ownerDocument.body)
46
+ (toggle.current ?? node).focus({ preventScroll: true });
47
+ }, [collapsed]);
48
+ const slot = (value) => typeof value === 'function' ? value({ collapsed }) : value;
49
+ const headerContent = slot(header), footerContent = slot(footer), panelOnLeft = (side === 'start') !== rtl;
50
+ return _jsxs("aside", { ...props, ref: root, "aria-label": label, tabIndex: -1, "data-footer-separator": footerSeparator, "data-header-separator": headerSeparator, "data-header-layout": headerLayout, "data-flood-surface": "filled", "data-size": size, "data-variant": variant, "data-collapsed": collapsed, "data-side": side, className: ['flood-sidebar', className].filter(Boolean).join(' '), style: { '--flood-sidebar-width': typeof width === 'number' ? `${width}px` : width, ...style }, children: [(headerContent != null || collapsible) && _jsxs("div", { className: "flood-sidebar__header", "data-collapsible": collapsible, children: [collapsible && _jsx(IconButton, { ref: toggle, className: "flood-sidebar__toggle", label: collapsed ? expandLabel : collapseLabel, title: collapsed ? expandLabel : collapseLabel, size: size, variant: "ghost", "aria-expanded": !collapsed, onClick: () => { toggle.current?.focus({ preventScroll: true }); if (controlled === undefined)
51
+ setLocal(!collapsed); onCollapsedChange?.(!collapsed); }, children: _jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("rect", { x: "3", y: "4", width: "18", height: "16", rx: "4" }), _jsx("path", { d: panelOnLeft ? 'M9 4v16' : 'M15 4v16' }), _jsx("path", { d: panelOnLeft ? (collapsed ? 'm14 9 3 3-3 3' : 'm17 9-3 3 3 3') : (collapsed ? 'm10 9-3 3 3 3' : 'm7 9 3 3-3 3') })] }) }), headerContent != null && _jsx("div", { className: "flood-sidebar__identity", children: headerContent })] }), _jsx(ScrollArea, { ref: body, className: "flood-sidebar__body", contentInset: "none", onFocus: event => {
52
+ // WebKit can focus the tree landmark on pointer down. Keep the row under
53
+ // the pointer instead of scrolling to reveal the entire tree.
54
+ if (event.target.matches('nav.flood-navigation-tree'))
55
+ event.preventDefault();
56
+ }, children: _jsx(Stack, { gap: "4", className: "flood-sidebar__navigation", children: groups.map(group => _jsx(NavigationGroup, { label: group.label, hideLabel: group.hideLabel, headingAction: group.headingAction, separator: group.separator, collapsible: group.collapsible, expanded: group.expanded, defaultExpanded: group.defaultExpanded, onExpandedChange: group.onExpandedChange, compact: collapsed, size: size, className: "flood-sidebar__group", "data-icon-column": group.items.some(item => item.icon != null) || undefined, children: _jsx(NavigationTree, { label: group.label, items: group.items, currentValue: currentValue, currentKind: currentKind, size: size, variant: variant, compact: collapsed, branchGuides: group.branchGuides, flyoutSide: panelOnLeft ? 'right' : 'left', expandedValues: expandedValues ?? expanded, onExpandedValuesChange: values => { if (expandedValues === undefined)
57
+ setExpanded(values); onExpandedValuesChange?.(values); }, expandLabel: expandItemLabel }) }, group.id)) }) }), footerContent != null && _jsx("div", { className: "flood-sidebar__footer", children: footerContent })] });
58
+ });
package/dist/toolbar.js CHANGED
@@ -3,7 +3,7 @@ import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } f
3
3
  const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
4
4
  const selector = 'button:not([role]),button[role="button"],a[href]:not([role]),a[href][role="link"]';
5
5
  const independent = '[role="menu"],[role="tablist"],[role="radiogroup"],[role="listbox"],[role="combobox"],.flood-toggle-group';
6
- export const RovingToolbar = /* @__PURE__ */ forwardRef(function RovingToolbar({ label, orientation = "horizontal", loop = true, className, onKeyDown, onKeyDownCapture, onFocus, ...props }, ref) {
6
+ export const RovingToolbar = /* @__PURE__ */ forwardRef(function RovingToolbar({ label, orientation = "horizontal", loop = true, shape = "rounded", variant = "contained", className, onKeyDown, onKeyDownCapture, onFocus, ...props }, ref) {
7
7
  const root = useRef(null), current = useRef(null), within = useRef(false);
8
8
  const refresh = useRef(() => []);
9
9
  useImperativeHandle(ref, () => root.current, []);
@@ -73,7 +73,7 @@ export const RovingToolbar = /* @__PURE__ */ forwardRef(function RovingToolbar({
73
73
  controls[next].scrollIntoView({ block: "nearest", inline: "nearest" });
74
74
  refresh.current();
75
75
  };
76
- return _jsx("div", { ...props, ref: root, role: "toolbar", "aria-label": label, "aria-orientation": orientation, tabIndex: -1, "data-orientation": orientation, className: ["flood-toolbar", className].filter(Boolean).join(" "), onFocus: event => { onFocus?.(event); if (event.defaultPrevented)
76
+ return _jsx("div", { ...props, ref: root, role: "toolbar", "aria-label": label, "aria-orientation": orientation, tabIndex: -1, "data-orientation": orientation, "data-shape": shape, "data-variant": variant, className: ["flood-toolbar", className].filter(Boolean).join(" "), onFocus: event => { onFocus?.(event); if (event.defaultPrevented)
77
77
  return; within.current = true; refresh.current(); }, onKeyDownCapture: event => { onKeyDownCapture?.(event); if (!event.defaultPrevented && orientation === "vertical" && event.target.getAttribute("aria-haspopup") === "menu" && ["ArrowUp", "ArrowDown"].includes(event.key))
78
78
  move(event); }, onKeyDown: event => { onKeyDown?.(event); if (!event.defaultPrevented)
79
79
  move(event); } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flood-md/react",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "type": "module",
5
5
  "description": "Original Flood components for React and Next.js.",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "react-dom": "^18.3.0 || ^19.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "@flood-md/tokens": "0.2.10"
33
+ "@flood-md/tokens": "0.2.11"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"