@flood-md/react 0.2.3 → 0.2.5

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 +398 -3
  2. package/dist/alert-dialog.d.ts +2 -0
  3. package/dist/alert-dialog.js +23 -0
  4. package/dist/avatar.d.ts +5 -0
  5. package/dist/avatar.js +8 -0
  6. package/dist/checkbox.d.ts +4 -0
  7. package/dist/checkbox.js +15 -0
  8. package/dist/combobox.d.ts +11 -0
  9. package/dist/combobox.js +301 -0
  10. package/dist/content-elements.d.ts +8 -0
  11. package/dist/content-elements.js +47 -0
  12. package/dist/data-display.d.ts +3 -0
  13. package/dist/data-display.js +11 -0
  14. package/dist/disclosure.js +2 -1
  15. package/dist/feedback-actions.d.ts +3 -0
  16. package/dist/feedback-actions.js +106 -0
  17. package/dist/feedback.d.ts +8 -0
  18. package/dist/feedback.js +29 -0
  19. package/dist/file-input.d.ts +8 -0
  20. package/dist/file-input.js +86 -0
  21. package/dist/focusable-hidden.d.ts +3 -0
  22. package/dist/focusable-hidden.js +99 -0
  23. package/dist/fonts/InterVariable-Italic.woff2 +0 -0
  24. package/dist/fonts/README.md +9 -5
  25. package/dist/form-controls.d.ts +6 -0
  26. package/dist/form-controls.js +250 -0
  27. package/dist/foundations.css +543 -12
  28. package/dist/index.d.ts +513 -2
  29. package/dist/index.js +223 -36
  30. package/dist/menu.d.ts +2 -0
  31. package/dist/menu.js +130 -0
  32. package/dist/navigation-layout.d.ts +7 -0
  33. package/dist/navigation-layout.js +77 -0
  34. package/dist/navigation.js +8 -8
  35. package/dist/numeric-input.d.ts +8 -0
  36. package/dist/numeric-input.js +68 -0
  37. package/dist/overlay-runtime.js +43 -16
  38. package/dist/overlays.d.ts +7 -1
  39. package/dist/overlays.js +17 -6
  40. package/dist/scroll-area.js +17 -2
  41. package/dist/select.js +96 -47
  42. package/dist/slider.d.ts +7 -0
  43. package/dist/slider.js +89 -0
  44. package/dist/styles.css +9 -0
  45. package/dist/text-inputs.d.ts +9 -0
  46. package/dist/text-inputs.js +45 -0
  47. package/dist/toolbar.d.ts +2 -0
  48. package/dist/toolbar.js +80 -0
  49. package/package.json +2 -2
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, popover: "manual", className: "flood-select__menu", onPointerDown: (event) => { if (event.pointerType === "mouse")
332
+ event.preventDefault(); }, onClick: (event) => event.preventDefault(), children: _jsxs(ScrollArea, { ref: scrollViewport, id: menuId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : labelledBy ?? triggerId, 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
  });
@@ -0,0 +1,7 @@
1
+ import type { SliderProps, Fieldset } from "./index.js";
2
+ interface Props extends SliderProps {
3
+ fieldsetComponent: typeof Fieldset;
4
+ }
5
+ /** Native range inputs keep keyboard/AT interaction; the shared rail handles pointer selection. */
6
+ export declare const RangeControl: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLFieldSetElement>>;
7
+ export {};
package/dist/slider.js ADDED
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useId, useRef, useState } from "react";
3
+ import { hasFieldContent, mergeDescriptions, resolveFieldStatus } from "./field-state.js";
4
+ /** Native range inputs keep keyboard/AT interaction; the shared rail handles pointer selection. */
5
+ export const RangeControl = forwardRef(function RangeControl({ fieldsetComponent: Group, label, value, defaultValue = [50], onValueChange, min = 0, max = 100, step = 1, thumbLabels, showValue = true, readOnly = false, controlSize = "md", disabled, className, name, onKeyDown, ...props }, ref) {
6
+ if (![min, max, step, max - min, (max - min) / step].every(Number.isFinite) || min >= max || step <= 0)
7
+ throw new Error("Flood Slider requires finite increasing bounds and a positive representable step");
8
+ if (!label.trim())
9
+ throw new Error("Flood Slider requires an accessible label");
10
+ const generatedId = useId();
11
+ const id = props.id ?? `flood-slider-${generatedId}`;
12
+ const hasError = hasFieldContent(props.error);
13
+ const status = resolveFieldStatus(props.status, hasError, props["aria-invalid"]);
14
+ const describedBy = mergeDescriptions(props["aria-describedby"], hasFieldContent(props.hint) && `${id}-hint`, hasError && `${id}-error`, !hasError && hasFieldContent(props.message) && `${id}-message`);
15
+ const snap = (number) => Number(Math.max(min, Math.min(min + Math.floor((max - min) / step + 1e-9) * step, min + Math.round((number - min) / step) * step)).toPrecision(14));
16
+ const normalize = (numbers) => {
17
+ if ((numbers.length !== 1 && numbers.length !== 2) || numbers.some(number => !Number.isFinite(number)) || (numbers.length === 2 && numbers[0] > numbers[1]))
18
+ throw new Error("Flood Slider accepts one value or an ordered pair of finite values");
19
+ return numbers.map(snap);
20
+ };
21
+ const initial = useRef(normalize(defaultValue));
22
+ const [local, setLocal] = useState(initial.current);
23
+ const values = normalize(value ?? local);
24
+ if (thumbLabels && (thumbLabels.length !== values.length || thumbLabels.some(text => !text.trim())))
25
+ throw new Error("Flood Slider needs one nonempty label per thumb");
26
+ const inputs = useRef([]);
27
+ const drag = useRef(null);
28
+ const valuesRef = useRef(values);
29
+ valuesRef.current = values;
30
+ const update = (index, next) => {
31
+ const before = valuesRef.current;
32
+ const number = Math.max(index === 1 ? before[0] : min, Math.min(index === 0 && before.length === 2 ? before[1] : max, snap(next)));
33
+ const result = before.map((old, position) => position === index ? number : old);
34
+ if (result.every((number, position) => number === before[position]))
35
+ return;
36
+ if (value === undefined) {
37
+ valuesRef.current = result;
38
+ setLocal(result);
39
+ }
40
+ onValueChange?.([...result]);
41
+ };
42
+ useEffect(() => {
43
+ const form = inputs.current[0]?.form;
44
+ if (!form || value !== undefined)
45
+ return;
46
+ const reset = (event) => queueMicrotask(() => { if (!event.defaultPrevented)
47
+ setLocal(initial.current); });
48
+ form.addEventListener("reset", reset);
49
+ return () => form.removeEventListener("reset", reset);
50
+ }, [value, props.form]);
51
+ const percent = (number) => 100 * (number - min) / (max - min);
52
+ const pointValue = (node, clientX) => {
53
+ const box = node.getBoundingClientRect();
54
+ const ratio = Math.max(0, Math.min(1, (clientX - box.left) / box.width));
55
+ return min + (node.ownerDocument.defaultView?.getComputedStyle(node).direction === "rtl" ? 1 - ratio : ratio) * (max - min);
56
+ };
57
+ const inert = (node) => disabled || readOnly || Boolean(node.closest("fieldset:disabled"));
58
+ const start = values.length === 1 ? 0 : percent(values[0]);
59
+ const end = percent(values[values.length - 1]);
60
+ return _jsxs(Group, { ...props, id: id, ref: ref, legend: label, name: name, disabled: disabled, className: ["flood-slider", `flood-control--${controlSize}`, className].filter(Boolean).join(" "), "data-readonly": readOnly || undefined, onKeyDown: event => {
61
+ onKeyDown?.(event);
62
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
63
+ return;
64
+ if (readOnly && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key))
65
+ event.preventDefault();
66
+ const index = inputs.current.indexOf(event.target);
67
+ if (index < 0 || inert(event.currentTarget) || (event.key !== "ArrowLeft" && event.key !== "ArrowRight"))
68
+ return;
69
+ // Native WebKit does not reverse horizontal range keys in RTL.
70
+ const rtl = event.currentTarget.ownerDocument.defaultView?.getComputedStyle(event.currentTarget).direction === "rtl";
71
+ event.preventDefault();
72
+ update(index, values[index] + step * (event.key === (rtl ? "ArrowLeft" : "ArrowRight") ? 1 : -1));
73
+ }, children: [showValue && _jsx("div", { className: "flood-slider__value", "aria-hidden": "true", children: _jsx("bdi", { dir: "ltr", children: values.join(" – ") }) }), _jsxs("div", { className: "flood-slider__rail", style: { "--flood-slider-start": `${start}%`, "--flood-slider-end": `${100 - end}%` }, onPointerDown: event => {
74
+ if (inputs.current.includes(event.target) || inert(event.currentTarget) || event.button !== 0)
75
+ return;
76
+ event.preventDefault();
77
+ const next = pointValue(event.currentTarget, event.clientX);
78
+ const lowerDistance = Math.abs(next - values[0]);
79
+ const upperDistance = values.length === 2 ? Math.abs(next - values[1]) : Infinity;
80
+ const index = upperDistance < lowerDistance || (values.length === 2 && upperDistance === lowerDistance && next >= values[0]) ? 1 : 0;
81
+ drag.current = index;
82
+ inputs.current[index]?.focus();
83
+ event.currentTarget.setPointerCapture(event.pointerId);
84
+ update(index, next);
85
+ }, onPointerMove: event => { if (drag.current !== null && !inert(event.currentTarget))
86
+ update(drag.current, pointValue(event.currentTarget, event.clientX)); }, onPointerUp: () => { drag.current = null; }, onLostPointerCapture: () => { drag.current = null; }, onPointerCancel: () => { drag.current = null; }, children: [_jsx("span", { className: "flood-slider__track", "aria-hidden": "true", children: _jsx("span", { className: "flood-slider__fill" }) }), values.map((number, index) => _jsx("input", { ref: node => { inputs.current[index] = node; }, type: "range", className: "flood-slider__input", name: name, form: props.form, value: number, min: min, max: max, step: step, disabled: disabled, "aria-label": thumbLabels?.[index] ?? (values.length === 1 ? label : `${label}: ${index === 0 ? "minimum" : "maximum"}`), "aria-valuemin": index === 1 ? values[0] : min, "aria-valuemax": index === 0 && values.length === 2 ? values[1] : max, "aria-describedby": describedBy, "aria-readonly": readOnly || undefined, "aria-invalid": status === "error" || undefined, onPointerDown: event => { if (readOnly)
87
+ event.preventDefault(); }, onChange: event => { if (!inert(event.currentTarget))
88
+ update(index, event.currentTarget.valueAsNumber); } }, index))] })] });
89
+ });
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,9 @@
1
+ import type { SearchInputProps, PasswordInputProps, InputGroup, IconButton, IconSlot } from "./index.js";
2
+ type Parts = {
3
+ group: typeof InputGroup;
4
+ action: typeof IconButton;
5
+ icon: typeof IconSlot;
6
+ };
7
+ export declare const SearchField: import("react").ForwardRefExoticComponent<SearchInputProps & Parts & import("react").RefAttributes<HTMLInputElement>>;
8
+ export declare const PasswordField: import("react").ForwardRefExoticComponent<PasswordInputProps & Parts & import("react").RefAttributes<HTMLInputElement>>;
9
+ export {};
@@ -0,0 +1,45 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
3
+ const glyph = (kind, visible = false) => _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: kind === "search" ? _jsxs(_Fragment, { children: [_jsx("circle", { cx: "10.5", cy: "10.5", r: "6.5" }), _jsx("path", { d: "m16 16 4 4" })] }) : kind === "clear" ? _jsx("path", { d: "m6 6 12 12M18 6 6 18" }) : _jsxs(_Fragment, { children: [_jsx("path", { d: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" }), _jsx("circle", { cx: "12", cy: "12", r: "3" }), visible && _jsx("path", { d: "m3 3 18 18" })] }) });
4
+ export const SearchField = forwardRef(function SearchField({ group: Group, action: Action, icon: Icon, clearable = true, clearLabel = "Clear search", controlSize = "md", className, value, defaultValue, onChange, disabled, readOnly, ...props }, ref) {
5
+ const input = useRef(null);
6
+ useImperativeHandle(ref, () => input.current, []);
7
+ const [text, setText] = useState(String(defaultValue ?? ""));
8
+ const current = value === undefined ? text : String(value);
9
+ useEffect(() => {
10
+ const form = input.current?.form;
11
+ if (!form)
12
+ return;
13
+ const reset = (event) => setTimeout(() => { if (!event.defaultPrevented && input.current)
14
+ setText(input.current.value); }, 0);
15
+ form.addEventListener("reset", reset);
16
+ return () => form.removeEventListener("reset", reset);
17
+ }, [props.form]);
18
+ const clear = () => {
19
+ const node = input.current;
20
+ if (!node || readOnly || node.matches(":disabled"))
21
+ return;
22
+ const view = node.ownerDocument.defaultView;
23
+ Object.getOwnPropertyDescriptor(view.HTMLInputElement.prototype, "value").set.call(node, "");
24
+ node.dispatchEvent(new view.Event("input", { bubbles: true }));
25
+ node.focus();
26
+ };
27
+ return _jsx(Group, { ...props, ref: input, type: "search", controlSize: controlSize, className: ["flood-search-input", className].filter(Boolean).join(" "), value: value, defaultValue: defaultValue, disabled: disabled, readOnly: readOnly, onChange: event => { setText(event.currentTarget.value); onChange?.(event); }, start: _jsx(Icon, { size: controlSize, children: glyph("search") }), end: clearable && current.length > 0 && !readOnly ? _jsx(Action, { label: clearLabel, variant: "ghost", size: controlSize, disabled: disabled, onPointerDown: event => event.preventDefault(), onClick: clear, children: glyph("clear") }) : undefined });
28
+ });
29
+ export const PasswordField = forwardRef(function PasswordField({ group: Group, action: Action, icon: _Icon, visible, defaultVisible = false, onVisibleChange, showPasswordLabel = "Show password", hidePasswordLabel = "Hide password", controlSize = "md", className, disabled, ...props }, ref) {
30
+ const input = useRef(null);
31
+ useImperativeHandle(ref, () => input.current, []);
32
+ const [local, setLocal] = useState(defaultVisible);
33
+ const shown = visible ?? local;
34
+ return _jsx(Group, { ...props, ref: input, type: shown ? "text" : "password", disabled: disabled, controlSize: controlSize, className: ["flood-password-input", className].filter(Boolean).join(" "), end: _jsx(Action, { label: shown ? hidePasswordLabel : showPasswordLabel, variant: "ghost", size: controlSize, disabled: disabled, onPointerDown: event => event.preventDefault(), onClick: () => {
35
+ const node = input.current;
36
+ if (!node || node.matches(":disabled"))
37
+ return;
38
+ const start = node.selectionStart, end = node.selectionEnd, direction = node.selectionDirection;
39
+ if (visible === undefined)
40
+ setLocal(!shown);
41
+ onVisibleChange?.(!shown);
42
+ requestAnimationFrame(() => { if (start !== null && end !== null && node.isConnected)
43
+ node.setSelectionRange(start, end, direction ?? undefined); });
44
+ }, children: glyph("eye", shown) }) });
45
+ });
@@ -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.5",
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.5"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"