@flood-md/react 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/select.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { resolveFieldStatus } from "./field-state.js";
3
3
  import { Children, Fragment, forwardRef, isValidElement, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react";
4
- import { OverlayScrollArea } from "./scroll-area.js";
4
+ import { ScrollArea } from "./index.js";
5
+ import { positionLayer, registerLayer, returnFocus } from "./overlay-runtime.js";
5
6
  const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
6
7
  const text = (children) => Children.toArray(children).map((child) => typeof child === "string" || typeof child === "number" ? String(child) : isValidElement(child) ? text(child.props.children) : "").join("");
7
8
  function initialChoices(children, group, groupDisabled = false, groupHidden = false) {
@@ -28,8 +29,9 @@ export const SingleSelect = forwardRef(function SingleSelect({ children, value,
28
29
  const [choices, setChoices] = useState(() => initialChoices(children));
29
30
  const [selected, setSelected] = useState(() => String(defaultValue ?? initialChoices(children).find((choice) => !choice.disabled && !choice.hidden)?.value ?? ""));
30
31
  const [open, setOpen] = useState(false), [active, setActive] = useState(-1);
32
+ const activeIndex = useRef(active);
33
+ activeIndex.current = active;
31
34
  const [validation, setValidation] = useState("");
32
- const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 0, viewportHeight: 0 });
33
35
  const controlled = value !== undefined;
34
36
  const currentValue = controlled ? String(value) : selected;
35
37
  const current = useRef({ controlled, value: currentValue });
@@ -152,54 +154,101 @@ export const SingleSelect = forwardRef(function SingleSelect({ children, value,
152
154
  }
153
155
  }
154
156
  }
157
+ function revealOption(index) {
158
+ const option = menu.current?.querySelector(`[data-option-index="${index}"]`), viewport = scrollViewport.current;
159
+ if (!option || !viewport)
160
+ return;
161
+ const item = option.getBoundingClientRect(), bounds = viewport.getBoundingClientRect();
162
+ if (item.top < bounds.top)
163
+ viewport.scrollTop -= bounds.top - item.top;
164
+ else if (item.bottom > bounds.bottom)
165
+ viewport.scrollTop += item.bottom - bounds.bottom;
166
+ }
155
167
  useBrowserLayoutEffect(() => {
156
168
  if (!open || !menu.current || !trigger.current)
157
169
  return;
158
- const popup = menu.current, button = trigger.current;
159
- if (typeof popup.showPopover === "function" && !popup.matches(":popover-open"))
170
+ const popup = menu.current, button = trigger.current, document = popup.ownerDocument, view = document.defaultView;
171
+ let alive = true, frame = 0, lastViewportHeight = -1, unregister = () => { };
172
+ const assign = (element, property, value) => {
173
+ if (element.style.getPropertyValue(property) !== value)
174
+ element.style.setProperty(property, value);
175
+ };
176
+ const update = () => {
177
+ if (!alive)
178
+ return;
179
+ if (!button.isConnected || !button.getClientRects().length) {
180
+ setOpen(false);
181
+ return;
182
+ }
183
+ const rect = button.getBoundingClientRect(), visual = view.visualViewport;
184
+ const viewportTop = visual?.offsetTop ?? 0, viewportWidth = visual?.width ?? view.innerWidth, viewportHeight = visual?.height ?? view.innerHeight;
185
+ const rootSize = parseFloat(view.getComputedStyle(document.documentElement).fontSize);
186
+ const length = (name) => { const raw = view.getComputedStyle(button).getPropertyValue(name).trim(); return parseFloat(raw) * (raw.endsWith("rem") ? rootSize : 1); };
187
+ const gap = length("--flood-sys-space-2") || 0;
188
+ const below = Math.max(0, viewportTop + viewportHeight - rect.bottom - gap * 2), above = Math.max(0, rect.top - viewportTop - gap * 2);
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`);
191
+ assign(popup, "max-height", `${maxHeight}px`);
192
+ // The shared positioner reads column-gap as its placement gap. On this
193
+ // block listbox it does not add layout space between options.
194
+ assign(popup, "column-gap", "var(--flood-sys-space-2)");
195
+ const css = view.getComputedStyle(popup);
196
+ const inset = parseFloat(css.paddingTop) + parseFloat(css.paddingBottom) + parseFloat(css.borderTopWidth) + parseFloat(css.borderBottomWidth);
197
+ const optionHeight = Math.max(0, maxHeight - inset);
198
+ if (scrollViewport.current)
199
+ assign(scrollViewport.current, "max-height", `${optionHeight}px`);
200
+ positionLayer(button, popup, "bottom", "start");
201
+ if (optionHeight !== lastViewportHeight) {
202
+ lastViewportHeight = optionHeight;
203
+ revealOption(activeIndex.current);
204
+ }
205
+ frame = view.requestAnimationFrame(update);
206
+ };
207
+ const start = () => {
208
+ if (!alive || !button.isConnected)
209
+ return;
210
+ const parent = button.closest("dialog");
211
+ if (parent && !parent.open) {
212
+ frame = view.requestAnimationFrame(start);
213
+ return;
214
+ }
215
+ if (typeof popup.showPopover !== "function")
216
+ throw new Error("Flood Select requires the native Popover API");
217
+ popup.style.visibility = "hidden";
160
218
  popup.showPopover();
161
- const rect = button.getBoundingClientRect();
162
- const rootSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
163
- const length = (name) => { const raw = getComputedStyle(button).getPropertyValue(name).trim(); return parseFloat(raw) * (raw.endsWith("rem") ? rootSize : 1); };
164
- const gap = length("--flood-sys-space-2") || 0;
165
- const below = Math.max(0, innerHeight - rect.bottom - gap * 2), above = Math.max(0, rect.top - gap * 2);
166
- const maxHeight = Math.min(length("--flood-sys-control-md-height") * 8, Math.max(below, above));
167
- const width = Math.min(rect.width, Math.max(0, innerWidth - gap * 2));
168
- popup.style.width = `${width}px`;
169
- popup.style.maxHeight = `${maxHeight}px`;
170
- const popupStyle = getComputedStyle(popup);
171
- const inset = parseFloat(popupStyle.paddingTop) + parseFloat(popupStyle.paddingBottom) + parseFloat(popupStyle.borderTopWidth) + parseFloat(popupStyle.borderBottomWidth);
172
- const viewportHeight = Math.max(0, maxHeight - inset);
173
- if (scrollViewport.current)
174
- scrollViewport.current.style.maxHeight = `${viewportHeight}px`;
175
- const height = popup.getBoundingClientRect().height;
176
- const top = below >= height || below >= above ? rect.bottom + gap : Math.max(gap, rect.top - gap - height);
177
- setPosition({ left: Math.max(gap, Math.min(rect.left, innerWidth - gap - width)), top, width, maxHeight, viewportHeight });
178
- }, [open, choices]);
219
+ update();
220
+ unregister = registerLayer(document, {
221
+ panel: popup,
222
+ // Native callbacks are mirrored on the hidden select inside this span.
223
+ // Those synthetic focus events belong to this control, not outside it.
224
+ anchor: () => trigger.current?.parentElement ?? trigger.current,
225
+ close: () => setOpen(false),
226
+ });
227
+ };
228
+ const toggle = (event) => {
229
+ if (alive && event.newState === "closed" && !popup.matches(":popover-open"))
230
+ setOpen(false);
231
+ };
232
+ popup.addEventListener("toggle", toggle);
233
+ start();
234
+ return () => {
235
+ alive = false;
236
+ view.cancelAnimationFrame(frame);
237
+ unregister();
238
+ popup.removeEventListener("toggle", toggle);
239
+ returnFocus(popup, trigger.current);
240
+ if (popup.matches(":popover-open"))
241
+ popup.hidePopover();
242
+ };
243
+ }, [open]);
179
244
  useEffect(() => {
180
245
  if (!open || (choices[active] && enabled(choices[active])))
181
246
  return;
182
247
  const selectedIndex = choices.findIndex((choice) => choice.value === currentValue && enabled(choice));
183
248
  setActive(selectedIndex >= 0 ? selectedIndex : choices.findIndex(enabled));
184
249
  }, [choices, open, active, currentValue]);
185
- useEffect(() => {
186
- if (!open)
187
- return;
188
- menu.current?.querySelector(`[data-option-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
189
- }, [open, active]);
190
- useEffect(() => {
191
- if (!open)
192
- return;
193
- const outside = (event) => { if (event.target instanceof Node && !trigger.current?.contains(event.target) && !menu.current?.contains(event.target))
194
- setOpen(false); };
195
- const scroll = (event) => { if (event.target instanceof Node && menu.current?.contains(event.target))
196
- return; setOpen(false); };
197
- const resize = () => setOpen(false);
198
- document.addEventListener("pointerdown", outside, true);
199
- document.addEventListener("scroll", scroll, true);
200
- window.addEventListener("resize", resize);
201
- return () => { document.removeEventListener("pointerdown", outside, true); document.removeEventListener("scroll", scroll, true); window.removeEventListener("resize", resize); };
202
- }, [open]);
250
+ useEffect(() => { if (open)
251
+ revealOption(active); }, [open, active]);
203
252
  const displayed = choices.find((choice) => choice.value === currentValue)?.label ?? "Select an option";
204
253
  const descriptions = [describedBy, validation && validationId].filter(Boolean).join(" ") || undefined;
205
254
  const visibleAttributes = Object.fromEntries(Object.entries(nativeProps).filter(([key]) => key.startsWith("data-") || key.startsWith("aria-")));
@@ -214,14 +263,13 @@ export const SingleSelect = forwardRef(function SingleSelect({ children, value,
214
263
  else
215
264
  groups.push({ label: choice.group, items: [{ choice, index }] });
216
265
  });
217
- const renderOption = ({ choice, index }) => _jsxs("div", { id: `${menuId}-${index}`, role: "option", "aria-selected": choice.value === currentValue, "aria-disabled": choice.disabled || undefined, className: "flood-select__option", "data-active": index === active || undefined, "data-option-index": index, onPointerDown: (event) => event.preventDefault(), onPointerMove: () => { if (enabled(choice))
218
- setActive(index); }, onClick: () => { if (enabled(choice)) {
219
- commit(index);
220
- trigger.current?.focus();
221
- } }, children: [_jsx("span", { children: choice.label }), _jsx("span", { className: "flood-select__selected", "aria-hidden": "true", children: choice.value === currentValue ? "✓" : "" })] }, `${index}-${choice.value}`);
266
+ const renderOption = ({ choice, index }) => _jsxs("div", { id: `${menuId}-${index}`, role: "option", "aria-selected": choice.value === currentValue, "aria-disabled": choice.disabled || undefined, className: "flood-select__option", "data-active": index === active || undefined, "data-option-index": index, onPointerDown: (event) => { if (event.pointerType === "mouse")
267
+ event.preventDefault(); }, onPointerMove: () => { if (enabled(choice))
268
+ setActive(index); }, onClick: () => { if (enabled(choice))
269
+ commit(index); }, children: [_jsx("span", { children: choice.label }), _jsx("span", { className: "flood-select__selected", "aria-hidden": "true", children: choice.value === currentValue ? "✓" : "" })] }, `${index}-${choice.value}`);
222
270
  return _jsxs("span", { className: `flood-select flood-select--custom flood-control--${controlSize}`, dir: nativeProps.dir, lang: nativeProps.lang, hidden: nativeProps.hidden, children: [_jsx("button", { ...visibleAttributes, ref: trigger, id: triggerId, type: "button", role: "combobox", disabled: disabled, tabIndex: nativeProps.tabIndex, accessKey: nativeProps.accessKey, dir: nativeProps.dir, lang: nativeProps.lang, hidden: nativeProps.hidden, className: ["flood-input flood-select__control", `flood-control--${controlSize}`, className].filter(Boolean).join(" "), style: style, title: title, "aria-label": ariaLabel, "aria-labelledby": labelledBy, "aria-describedby": descriptions, "data-status": resolveFieldStatus(status, invalid || Boolean(validation), ariaInvalid), "aria-invalid": invalid || status === "error" || ariaInvalid || Boolean(validation) || undefined, "aria-required": required || undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-controls": menuId, "aria-activedescendant": open && active >= 0 && choices[active] && !choices[active].hidden ? `${menuId}-${active}` : undefined, onFocus: (event) => { if (onFocus)
223
271
  mirror(new FocusEvent("focusin", { bubbles: true, relatedTarget: event.relatedTarget })); }, onBlur: (event) => { if (onBlur)
224
- mirror(new FocusEvent("focusout", { bubbles: true, relatedTarget: event.relatedTarget })); setOpen(false); }, onClick: () => { if (onClick && !mirror(new MouseEvent("click", { bubbles: true, cancelable: true })))
272
+ mirror(new FocusEvent("focusout", { bubbles: true, relatedTarget: event.relatedTarget })); }, onClick: () => { if (onClick && !mirror(new MouseEvent("click", { bubbles: true, cancelable: true })))
225
273
  return; if (open)
226
274
  setOpen(false);
227
275
  else
@@ -280,5 +328,6 @@ export const SingleSelect = forwardRef(function SingleSelect({ children, value,
280
328
  onKeyDown: (event) => { onKeyDown?.(event); event.stopPropagation(); }, onKeyUp: onKeyUp, onClick: onClick, onInvalid: (event) => { onInvalid?.(event); const prevented = event.defaultPrevented; event.preventDefault(); if (!prevented) {
281
329
  setValidation(event.currentTarget.validationMessage);
282
330
  trigger.current?.focus();
283
- } }, children: children }), open && _jsx("div", { ref: menu, id: menuId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : labelledBy ?? triggerId, popover: "manual", className: "flood-select__menu", onPointerDown: (event) => event.preventDefault(), onClick: (event) => event.preventDefault(), style: { left: position.left, top: position.top, width: position.width, maxHeight: position.maxHeight || undefined }, children: _jsxs(OverlayScrollArea, { ref: scrollViewport, orientation: "vertical", tabIndex: -1, className: "flood-select__viewport", style: { maxHeight: position.viewportHeight || undefined }, children: [!choices.some((choice) => !choice.hidden) && _jsx("div", { className: "flood-select__empty", children: "No options" }), groups.map((group, index) => group.label ? _jsxs("div", { role: "group", "aria-label": group.label, children: [_jsx("div", { className: "flood-select__group", "aria-hidden": "true", children: group.label }), group.items.map(renderOption)] }, index) : _jsx(Fragment, { children: group.items.map(renderOption) }, index))] }) }), validation && _jsx("span", { className: "flood-select__validation", id: validationId, role: "alert", children: validation })] });
331
+ } }, children: children }), open && _jsx("div", { ref: menu, id: menuId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : labelledBy ?? triggerId, popover: "manual", className: "flood-select__menu", onPointerDown: (event) => { if (event.pointerType === "mouse")
332
+ event.preventDefault(); }, onClick: (event) => event.preventDefault(), children: _jsxs(ScrollArea, { ref: scrollViewport, orientation: "vertical", tabIndex: -1, className: "flood-select__viewport", children: [!choices.some((choice) => !choice.hidden) && _jsx("div", { className: "flood-select__empty", children: "No options" }), groups.map((group, index) => group.label ? _jsxs("div", { role: "group", "aria-label": group.label, children: [_jsx("div", { className: "flood-select__group", "aria-hidden": "true", children: group.label }), group.items.map(renderOption)] }, index) : _jsx(Fragment, { children: group.items.map(renderOption) }, index))] }) }), validation && _jsx("span", { className: "flood-select__validation", id: validationId, role: "alert", children: validation })] });
284
333
  });
package/dist/styles.css CHANGED
@@ -1,6 +1,15 @@
1
1
  @import "@flood-md/tokens/tokens.css";
2
2
  @import "./foundations.css";
3
3
 
4
+ /* A named loading indicator; buttons opt into decorative presentation. */
5
+ .flood-loading { --flood-loading-size: 1.25rem; display: inline-flex; position: relative; flex: none; vertical-align: middle; inline-size: var(--flood-loading-size); block-size: var(--flood-loading-size); }
6
+ .flood-loading[data-size="sm"] { --flood-loading-size: 1rem; }
7
+ .flood-loading[data-size="lg"] { --flood-loading-size: 1.5rem; }
8
+ .flood-loading > .flood-spinner { display: block; box-sizing: border-box; width: 100%; height: 100%; }
9
+ .flood-root .flood-loading .flood-spinner { inline-size: 100%; block-size: 100%; }
10
+ .flood-root .flood-button .flood-loading { --flood-loading-size: var(--flood-control-icon); }
11
+ .flood-loading[hidden] { display: none; }
12
+
4
13
  :where(.flood-button, .flood-input, .flood-choice, .flood-field, .flood-link) {
5
14
  box-sizing: border-box;
6
15
  font-family: var(--flood-font-sans);
@@ -0,0 +1,2 @@
1
+ import type { ToolbarProps } from "./index.js";
2
+ export declare const RovingToolbar: import("react").ForwardRefExoticComponent<ToolbarProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,80 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from "react";
3
+ const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
4
+ const selector = 'button:not([role]),button[role="button"],a[href]:not([role]),a[href][role="link"]';
5
+ const independent = '[role="menu"],[role="tablist"],[role="radiogroup"],[role="listbox"],[role="combobox"],.flood-toggle-group';
6
+ export const RovingToolbar = forwardRef(function RovingToolbar({ label, orientation = "horizontal", loop = true, className, onKeyDown, onKeyDownCapture, onFocus, ...props }, ref) {
7
+ const root = useRef(null), current = useRef(null), within = useRef(false);
8
+ const refresh = useRef(() => []);
9
+ useImperativeHandle(ref, () => root.current, []);
10
+ useBrowserLayoutEffect(() => {
11
+ const element = root.current, document = element.ownerDocument, view = document.defaultView;
12
+ const original = new Map();
13
+ let alive = true;
14
+ const restore = (node) => { const value = original.get(node); if (value == null)
15
+ node.removeAttribute("tabindex");
16
+ else
17
+ node.setAttribute("tabindex", value); };
18
+ const sync = () => {
19
+ if (!alive)
20
+ return [];
21
+ const owned = Array.from(element.querySelectorAll(selector)).filter(node => node.closest('[role="toolbar"]') === element && !node.closest(independent));
22
+ const active = document.activeElement;
23
+ const hadFocus = active === element || original.has(active) || (active === document.body && within.current);
24
+ for (const node of original.keys())
25
+ if (!owned.includes(node)) {
26
+ restore(node);
27
+ original.delete(node);
28
+ }
29
+ const enabled = owned.filter(node => !node.matches(':disabled,[aria-disabled="true"]') && !node.closest('[hidden],[inert]') && node.getClientRects().length > 0 && getComputedStyle(node).visibility !== "hidden");
30
+ const entry = enabled.includes(active) ? active : enabled.includes(current.current) ? current.current : enabled[0] ?? null;
31
+ current.current = entry;
32
+ for (const node of owned) {
33
+ if (!original.has(node))
34
+ original.set(node, node.getAttribute("tabindex"));
35
+ const value = node === entry ? "0" : "-1";
36
+ if (node.getAttribute("tabindex") !== value)
37
+ node.setAttribute("tabindex", value);
38
+ }
39
+ const value = entry ? "-1" : "0";
40
+ if (element.getAttribute("tabindex") !== value)
41
+ element.setAttribute("tabindex", value);
42
+ if (hadFocus && active !== entry && !enabled.includes(active) && element.getClientRects().length)
43
+ (entry ?? element).focus({ preventScroll: true });
44
+ return enabled;
45
+ };
46
+ refresh.current = sync;
47
+ sync();
48
+ const observer = new MutationObserver(records => { if (records.some(record => !(record.target instanceof Element) || !record.target.closest(independent)))
49
+ sync(); });
50
+ observer.observe(element, { subtree: true, childList: true, attributes: true, attributeFilter: ["disabled", "aria-disabled", "hidden", "inert", "style", "class", "tabindex", "href", "role"] });
51
+ const resize = typeof ResizeObserver === "function" ? new ResizeObserver(() => sync()) : undefined;
52
+ resize?.observe(element);
53
+ const focus = (event) => { within.current = element.contains(event.target); };
54
+ document.addEventListener("focusin", focus);
55
+ view.addEventListener("resize", sync);
56
+ return () => { alive = false; observer.disconnect(); resize?.disconnect(); document.removeEventListener("focusin", focus); view.removeEventListener("resize", sync); for (const node of original.keys())
57
+ restore(node); refresh.current = () => []; };
58
+ }, []);
59
+ const move = (event) => {
60
+ const controls = refresh.current(), index = controls.indexOf(event.target);
61
+ if (index < 0 || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
62
+ return;
63
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
64
+ const nextKey = orientation === "vertical" ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight", previousKey = orientation === "vertical" ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
65
+ const step = event.key === nextKey ? 1 : event.key === previousKey ? -1 : 0;
66
+ const wanted = event.key === "Home" ? 0 : event.key === "End" ? controls.length - 1 : step ? index + step : -1;
67
+ if (wanted < 0 && !step)
68
+ return;
69
+ const next = loop ? (wanted + controls.length) % controls.length : Math.max(0, Math.min(controls.length - 1, wanted));
70
+ event.preventDefault();
71
+ current.current = controls[next];
72
+ controls[next].focus({ preventScroll: true });
73
+ controls[next].scrollIntoView({ block: "nearest", inline: "nearest" });
74
+ refresh.current();
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)
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
+ move(event); }, onKeyDown: event => { onKeyDown?.(event); if (!event.defaultPrevented)
79
+ move(event); } });
80
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flood-md/react",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
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.3"
33
+ "@flood-md/tokens": "0.2.4"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"