@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
@@ -0,0 +1,77 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId } from "react";
3
+ import { IconSlot, Heading } from "./index.js";
4
+ const classes = (...names) => names.filter(Boolean).join(" ");
5
+ function uniqueValues(items, component) {
6
+ if (items.some(item => !item.value.trim()) || new Set(items.map(item => item.value)).size !== items.length)
7
+ throw new Error(`Flood ${component} items require unique nonempty values`);
8
+ }
9
+ export const NavigationMenu = forwardRef(function NavigationMenu({ items, label, currentValue, size = "md", className, ...props }, ref) {
10
+ uniqueValues(items, "NavigationList");
11
+ if (!label.trim() || items.some(item => !item.label.trim() || !item.href.trim()))
12
+ throw new Error("Flood NavigationList requires a name and labelled links with destinations");
13
+ const id = useId();
14
+ const groups = [];
15
+ for (const item of items) {
16
+ const previous = groups.at(-1);
17
+ if (previous?.label === (item.group ?? ""))
18
+ previous.items.push(item);
19
+ else
20
+ groups.push({ label: item.group ?? "", items: [item] });
21
+ }
22
+ const links = (entries) => entries.map(item => _jsx("li", { children: _jsxs("a", { ...item.linkProps, href: item.href, "aria-current": item.value === currentValue ? "page" : undefined, className: classes("flood-navigation-list__link", item.linkProps?.className), children: [item.icon != null && _jsx(IconSlot, { size: size, children: item.icon }), _jsxs("span", { className: "flood-navigation-list__text", children: [_jsx("span", { className: "flood-navigation-list__label", children: item.label }), item.description && _jsx("span", { className: "flood-navigation-list__description", children: item.description })] })] }) }, item.value));
23
+ return _jsx("nav", { ...props, ref: ref, "aria-label": label, className: classes("flood-navigation-list", className), "data-size": size, children: _jsx("ul", { role: "list", className: "flood-navigation-list__groups", children: groups.map((group, index) => group.label ? _jsxs("li", { className: "flood-navigation-list__group", children: [_jsx("p", { className: "flood-navigation-list__group-title", id: `${id}-${index}`, children: group.label }), _jsx("ul", { role: "list", "aria-labelledby": `${id}-${index}`, children: links(group.items) })] }, index) : links(group.items)) }) });
24
+ });
25
+ export const StepSequence = forwardRef(function StepSequence({ items, label, currentValue = items[0]?.value, orientation = "horizontal", onStepChange, statusLabels = { complete: "Completed", current: "Current step", upcoming: "Upcoming", error: "Needs attention" }, className, ...props }, ref) {
26
+ uniqueValues(items, "Steps");
27
+ if (!label.trim() || items.some(item => !item.title.trim()))
28
+ throw new Error("Flood Steps requires a sequence name and step titles");
29
+ const currentIndex = items.findIndex(item => item.value === currentValue);
30
+ if (currentValue !== undefined && currentIndex < 0)
31
+ throw new Error("Flood Steps currentValue must identify a step");
32
+ return _jsx("ol", { ...props, ref: ref, role: "list", "aria-label": label, "data-orientation": orientation, className: classes("flood-steps", className), children: items.map((item, index) => {
33
+ const current = index === currentIndex;
34
+ const state = item.status === "error" ? "error" : current ? "current" : item.status ?? (index < currentIndex ? "complete" : "upcoming");
35
+ const content = _jsxs(_Fragment, { children: [_jsx("span", { className: "flood-steps__indicator", "aria-hidden": "true", children: state === "complete" ? _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m5 12 4 4 10-10" }) }) : state === "error" ? "!" : index + 1 }), _jsxs("span", { className: "flood-steps__text", children: [_jsx("span", { className: "flood-steps__title", children: item.title }), _jsx("span", { className: "flood-visually-hidden", children: statusLabels[state] }), item.description && _jsx("span", { className: "flood-steps__description", children: item.description })] })] });
36
+ return _jsx("li", { "data-state": state, "aria-current": current ? "step" : undefined, children: onStepChange ? _jsx("button", { type: "button", className: "flood-steps__content flood-steps__action", disabled: item.disabled, onClick: () => onStepChange(item.value), children: content }) : _jsx("div", { className: "flood-steps__content", children: content }) }, item.value);
37
+ }) });
38
+ });
39
+ export const ContentSection = forwardRef(function ContentSection({ title, description, actions, headingLevel = 2, headingSize = "section", children, id: explicitId, className, "aria-labelledby": labelledBy, ...props }, ref) {
40
+ if (!title.trim() || ![2, 3, 4, 5, 6].includes(headingLevel))
41
+ throw new Error("Flood Section requires a nonempty title and headingLevel from 2 to 6");
42
+ const generated = useId(), headingId = `${explicitId ?? `flood-section-${generated}`}-title`;
43
+ return _jsxs("section", { ...props, ref: ref, id: explicitId, "aria-labelledby": labelledBy ?? headingId, className: classes("flood-section", className), children: [_jsxs("header", { className: "flood-section__header", children: [_jsxs("div", { className: "flood-section__heading", children: [_jsx(Heading, { id: headingId, level: headingLevel, size: headingSize, className: "flood-section__title", children: title }), description != null && _jsx("div", { className: "flood-section__description", children: description })] }), actions != null && _jsx("div", { className: "flood-section__actions", children: actions })] }), children != null && _jsx("div", { className: "flood-section__body", children: children })] });
44
+ });
45
+ export const SplitLayout = forwardRef(function SplitLayout({ main, aside, side = "end", ratio = "main", collapseAt = "md", gap = "6", className, style, ...props }, ref) {
46
+ const primary = _jsx("div", { className: "flood-split__main", children: main }, "main"), secondary = _jsx("div", { className: "flood-split__aside", children: aside }, "aside");
47
+ return _jsx("div", { ...props, ref: ref, className: classes("flood-split", className), style: { "--flood-layout-gap": `var(--flood-sys-space-${gap})`, ...style }, children: _jsx("div", { className: "flood-split__layout", "data-side": side, "data-ratio": ratio, "data-collapse": collapseAt, children: side === "start" ? [secondary, primary] : [primary, secondary] }) });
48
+ });
49
+ export const CenterLayout = forwardRef(function CenterLayout({ inline = false, maxWidth, minHeight, className, style, children, ...props }, ref) {
50
+ return _jsx("div", { ...props, ref: ref, className: classes("flood-center", className), "data-inline": inline || undefined, style: { minBlockSize: minHeight, "--flood-center-max": maxWidth, ...style }, children: _jsx("div", { className: "flood-center__content", children: children }) });
51
+ });
52
+ export const SkipNavigation = forwardRef(function SkipNavigation({ children = "Skip to content", className, onClick, href, ...props }, ref) {
53
+ if (!href.startsWith("#") || href.length < 2)
54
+ throw new Error("Flood SkipLink requires a nonempty same-page fragment href");
55
+ return _jsx("a", { ...props, ref: ref, href: href, className: classes("flood-skip-link", className), onClick: event => {
56
+ onClick?.(event);
57
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || (props.target && props.target !== "_self"))
58
+ return;
59
+ const document = event.currentTarget.ownerDocument, view = document.defaultView;
60
+ let id;
61
+ try {
62
+ id = decodeURIComponent(href.slice(1));
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ const target = document.getElementById(id);
68
+ if (!target || !view)
69
+ return;
70
+ view.requestAnimationFrame(() => {
71
+ if (!target.isConnected)
72
+ return;
73
+ target.focus({ preventScroll: true });
74
+ target.scrollIntoView({ block: "start", behavior: view.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "auto" });
75
+ });
76
+ }, children: children });
77
+ });
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
- import { OverlayScrollArea } from "./scroll-area.js";
3
+ import { Button, IconSlot, Link, ScrollArea } from "./index.js";
4
4
  import { pageWindow } from "./page-window.js";
5
5
  const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
6
6
  export const TabNavigation = forwardRef(function TabNavigation({ label, items, value, defaultValue, onValueChange, activationMode = "automatic", orientation = "horizontal", controlSize = "md", disabled = false, iconOnly = false, className, onKeyDown, id: explicitId, ...props }, ref) {
@@ -31,7 +31,7 @@ export const TabNavigation = forwardRef(function TabNavigation({ label, items, v
31
31
  }, [selected, id, items]);
32
32
  return _jsxs("div", { ...props, ref: root, id: id, className: ["flood-tabs", className].filter(Boolean).join(" "), "data-orientation": orientation, onKeyDown: event => {
33
33
  onKeyDown?.(event);
34
- if (event.defaultPrevented)
34
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
35
35
  return;
36
36
  const buttons = Array.from(list.current?.querySelectorAll(':scope > button[role="tab"]:not(:disabled)') ?? []), index = buttons.indexOf(event.target);
37
37
  if (index < 0)
@@ -45,22 +45,22 @@ export const TabNavigation = forwardRef(function TabNavigation({ label, items, v
45
45
  if (activationMode === "automatic")
46
46
  select(buttons[next].dataset.value);
47
47
  }
48
- }, children: [_jsx(OverlayScrollArea, { orientation: orientation, tabIndex: -1, className: "flood-tabs__scroll", children: _jsx("div", { ref: list, role: "tablist", "aria-label": label, "aria-orientation": orientation, className: "flood-tabs__list", onBlur: event => { if (!event.currentTarget.contains(event.relatedTarget))
49
- setFocused(undefined); }, children: items.map((item, index) => _jsxs("button", { id: `${id}-tab-${index}`, type: "button", role: "tab", "aria-selected": item.value === selected, "aria-controls": `${id}-panel-${index}`, "data-value": item.value, disabled: disabled || item.disabled, tabIndex: item.value === tabStop ? 0 : -1, "aria-label": iconOnly ? item.label : undefined, className: `flood-button flood-button--ghost flood-button--${controlSize} flood-tab${iconOnly ? " flood-icon-button" : ""}`, onFocus: () => setFocused(item.value), onClick: () => select(item.value), children: [item.icon && _jsx("span", { className: `flood-icon flood-icon--${controlSize}`, "aria-hidden": "true", children: item.icon }), !iconOnly && item.label] }, item.value)) }) }), items.map((item, index) => _jsx("div", { id: `${id}-panel-${index}`, role: "tabpanel", "aria-labelledby": `${id}-tab-${index}`, tabIndex: 0, hidden: item.value !== selected, className: "flood-tabs__panel", onFocusCapture: () => { focusedPanel.current = item.value; }, onBlurCapture: event => { if (!event.currentTarget.hidden)
48
+ }, children: [_jsx(ScrollArea, { orientation: orientation, tabIndex: -1, className: "flood-tabs__scroll", children: _jsx("div", { ref: list, role: "tablist", "aria-label": label, "aria-orientation": orientation, className: "flood-tabs__list", onBlur: event => { if (!event.currentTarget.contains(event.relatedTarget))
49
+ setFocused(undefined); }, children: items.map((item, index) => _jsxs(Button, { variant: "ghost", size: controlSize, id: `${id}-tab-${index}`, type: "button", role: "tab", "aria-selected": item.value === selected, "aria-controls": `${id}-panel-${index}`, "data-value": item.value, disabled: disabled || item.disabled, tabIndex: item.value === tabStop ? 0 : -1, "aria-label": iconOnly ? item.label : undefined, className: `flood-tab${iconOnly ? " flood-icon-button" : ""}`, onFocus: () => setFocused(item.value), onClick: () => select(item.value), children: [item.icon && _jsx(IconSlot, { size: controlSize, children: item.icon }), !iconOnly && item.label] }, item.value)) }) }), items.map((item, index) => _jsx("div", { id: `${id}-panel-${index}`, role: "tabpanel", "aria-labelledby": `${id}-tab-${index}`, tabIndex: 0, hidden: item.value !== selected, className: "flood-tabs__panel", onFocusCapture: () => { focusedPanel.current = item.value; }, onBlurCapture: event => { if (!event.currentTarget.hidden)
50
50
  focusedPanel.current = undefined; }, children: item.content }, item.value))] });
51
51
  });
52
52
  export const BreadcrumbNavigation = forwardRef(function BreadcrumbNavigation({ items, label = "Breadcrumb", separator = "/", className, ...props }, ref) {
53
- return _jsx("nav", { ...props, ref: ref, "aria-label": label, className: ["flood-breadcrumb", className].filter(Boolean).join(" "), children: _jsx("ol", { children: items.map((item, index) => _jsxs("li", { children: [index > 0 && _jsx("span", { className: "flood-breadcrumb__separator", "aria-hidden": "true", children: separator }), item.href !== undefined ? _jsx("a", { className: "flood-link", href: item.href, "aria-current": index === items.length - 1 ? "page" : undefined, children: item.label }) : _jsx("span", { "aria-current": index === items.length - 1 ? "page" : undefined, children: item.label })] }, index)) }) });
53
+ return _jsx("nav", { ...props, ref: ref, "aria-label": label, className: ["flood-breadcrumb", className].filter(Boolean).join(" "), children: _jsx("ol", { children: items.map((item, index) => _jsxs("li", { children: [index > 0 && _jsx("span", { className: "flood-breadcrumb__separator", "aria-hidden": "true", children: separator }), item.href !== undefined ? _jsx(Link, { href: item.href, "aria-current": index === items.length - 1 ? "page" : undefined, children: item.label }) : _jsx("span", { "aria-current": index === items.length - 1 ? "page" : undefined, children: item.label })] }, index)) }) });
54
54
  });
55
55
  export const PageNavigation = forwardRef(function PageNavigation({ page, pageCount, onPageChange, getHref, siblingCount = 1, controlSize = "sm", disabled = false, label = "Pagination", previousLabel = "Previous page", nextLabel = "Next page", pageLabel = page => `Page ${page}`, className, ...props }, ref) {
56
56
  const pages = pageWindow(page, pageCount, siblingCount);
57
57
  const control = (target, text, name, unavailable = false) => {
58
58
  const inactive = disabled || unavailable, current = target === page;
59
- const shared = { className: `flood-button flood-button--subtle flood-button--${controlSize} flood-page`, "aria-label": name, "aria-current": current && !unavailable ? "page" : undefined };
59
+ const shared = { className: "flood-page", "aria-label": name, "aria-current": current && !unavailable ? "page" : undefined };
60
60
  if (getHref)
61
- return inactive ? _jsx("span", { ...shared, role: "link", "aria-disabled": "true", children: text }) : _jsx("a", { ...shared, href: getHref(target), onClick: event => { if (!event.defaultPrevented && event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey && !current)
61
+ return inactive ? _jsx("span", { ...shared, className: `flood-button flood-button--subtle flood-button--${controlSize} flood-page`, role: "link", "aria-disabled": "true", children: text }) : _jsx(Link, { ...shared, variant: "subtle", size: controlSize, href: getHref(target), onClick: event => { if (!event.defaultPrevented && event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey && !current)
62
62
  onPageChange?.(target); }, children: text });
63
- return _jsx("button", { ...shared, type: "button", disabled: inactive, onClick: () => { if (!current)
63
+ return _jsx(Button, { ...shared, variant: "subtle", size: controlSize, type: "button", disabled: inactive, onClick: () => { if (!current)
64
64
  onPageChange?.(target); }, children: text });
65
65
  };
66
66
  const arrow = (previous) => _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: previous ? "m15 5-7 7 7 7" : "m9 5 7 7-7 7" }) });
@@ -0,0 +1,8 @@
1
+ import type { NumberInputProps, InputGroup, IconButton } from "./index.js";
2
+ interface Props extends NumberInputProps {
3
+ inputComponent: typeof InputGroup;
4
+ buttonComponent: typeof IconButton;
5
+ }
6
+ /** The browser owns decimal stepping, validity, editing and form submission. */
7
+ export declare const NumericStepper: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLInputElement>>;
8
+ export {};
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
3
+ /** The browser owns decimal stepping, validity, editing and form submission. */
4
+ export const NumericStepper = forwardRef(function NumericStepper({ inputComponent: Group, buttonComponent: Action, value, defaultValue = 0, onValueChange, min, max, step = 1, disabled, readOnly, controlSize = "md", decreaseLabel = "Decrease value", increaseLabel = "Increase value", className, onBlur, onKeyDown, ...props }, ref) {
5
+ if (!Number.isFinite(step) || step <= 0 || (min !== undefined && !Number.isFinite(min)) || (max !== undefined && !Number.isFinite(max)) || (min !== undefined && max !== undefined && min > max))
6
+ throw new Error("Flood NumberInput requires finite bounds and a positive step");
7
+ for (const number of [value, defaultValue])
8
+ if (number !== undefined && number !== null && !Number.isFinite(number))
9
+ throw new Error("Flood NumberInput value must be finite or null");
10
+ if (!decreaseLabel.trim() || !increaseLabel.trim())
11
+ throw new Error("Flood NumberInput step actions require accessible names");
12
+ const input = useRef(null);
13
+ useImperativeHandle(ref, () => input.current, []);
14
+ const initial = useRef(defaultValue === null ? "" : String(defaultValue));
15
+ const [local, setLocal] = useState(initial.current);
16
+ const [, refresh] = useState(0);
17
+ const current = value === undefined ? local : value === null ? "" : String(value);
18
+ const number = current === "" ? null : Number(current);
19
+ const publish = (text) => {
20
+ setLocal(text);
21
+ refresh(revision => revision + 1);
22
+ onValueChange?.(text === "" ? null : Number(text));
23
+ };
24
+ useEffect(() => {
25
+ const form = input.current?.form;
26
+ if (!form || value !== undefined)
27
+ return;
28
+ const reset = (event) => queueMicrotask(() => { if (!event.defaultPrevented)
29
+ setLocal(initial.current); });
30
+ form.addEventListener("reset", reset);
31
+ return () => form.removeEventListener("reset", reset);
32
+ }, [value, props.form]);
33
+ const move = (direction) => {
34
+ const node = input.current;
35
+ if (!node || disabled || readOnly || node.matches(":disabled"))
36
+ return;
37
+ const before = node.value;
38
+ if (direction > 0)
39
+ node.stepUp();
40
+ else
41
+ node.stepDown();
42
+ if (node.value !== before)
43
+ publish(node.value);
44
+ };
45
+ const glyph = (increase) => _jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M5 12h14" }), increase && _jsx("path", { d: "M12 5v14" })] });
46
+ return _jsx(Group, { ...props, ref: input, type: "number", value: current, min: min, max: max, step: step, controlSize: controlSize, disabled: disabled, readOnly: readOnly, className: ["flood-number-input", className].filter(Boolean).join(" "), onChange: event => { if (!readOnly && !disabled)
47
+ publish(event.currentTarget.value); }, onBlur: event => {
48
+ onBlur?.(event);
49
+ if (event.defaultPrevented || readOnly || disabled || event.currentTarget.value === "")
50
+ return;
51
+ const node = event.currentTarget;
52
+ const before = node.value;
53
+ if (node.validity.rangeUnderflow && min !== undefined)
54
+ node.value = String(min);
55
+ if (node.validity.rangeOverflow && max !== undefined)
56
+ node.value = String(max);
57
+ if (node.value !== before)
58
+ publish(node.value);
59
+ }, onKeyDown: event => {
60
+ onKeyDown?.(event);
61
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing || readOnly || disabled)
62
+ return;
63
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
64
+ event.preventDefault();
65
+ move(event.key === "ArrowUp" ? 1 : -1);
66
+ }
67
+ }, start: _jsx(Action, { label: decreaseLabel, variant: "ghost", size: controlSize, disabled: disabled || readOnly || (number !== null && min !== undefined && number <= min), onClick: () => move(-1), children: glyph(false) }), end: _jsx(Action, { label: increaseLabel, variant: "ghost", size: controlSize, disabled: disabled || readOnly || (number !== null && max !== undefined && number >= max), onClick: () => move(1), children: glyph(true) }) });
68
+ });
@@ -15,11 +15,17 @@ export function placeOverlay(anchor, size, viewport, side, align, gap, rtl = fal
15
15
  return { left, top, side };
16
16
  }
17
17
  export function tabbables(root) {
18
- const candidates = Array.from(root.querySelectorAll('a[href],button,input,select,textarea,[tabindex],[contenteditable="true"],iframe')).filter(node => node.tabIndex >= 0 && !node.matches(':disabled,input[type="hidden"]') && !node.closest('[hidden],[inert]') && node.getClientRects().length > 0 && getComputedStyle(node).visibility !== "hidden");
18
+ const candidates = Array.from(root.querySelectorAll('a[href],button,input,select,textarea,[tabindex],[contenteditable]:not([contenteditable="false"]),details > summary:first-of-type,audio[controls],video[controls],iframe')).filter(node => {
19
+ // Editing roots and native media may report -1 without an explicit tabindex
20
+ // while still participating in sequential focus. Nested editing hosts do not.
21
+ const implicit = !node.hasAttribute("tabindex") && ((node.isContentEditable && !node.parentElement?.isContentEditable) || node.matches("audio[controls],video[controls]"));
22
+ return (node.tabIndex >= 0 || implicit) && !node.matches(':disabled,input[type="hidden"]') && !node.closest('[hidden],[inert]') && node.getClientRects().length > 0 && getComputedStyle(node).visibility !== "hidden";
23
+ });
19
24
  return candidates.filter(node => {
20
- if (!(node instanceof HTMLInputElement) || node.type !== "radio" || !node.name)
25
+ if (node.localName !== "input" || node.type !== "radio" || !node.name)
21
26
  return true;
22
- const group = candidates.filter((other) => other instanceof HTMLInputElement && other.type === "radio" && other.name === node.name && other.form === node.form);
27
+ const radio = node;
28
+ const group = candidates.filter((other) => other.localName === "input" && other.type === "radio" && other.name === radio.name && other.form === radio.form);
23
29
  return node === (group.find(other => other.checked) ?? group[0]);
24
30
  }).sort((a, b) => (a.tabIndex > 0 ? a.tabIndex : Infinity) - (b.tabIndex > 0 ? b.tabIndex : Infinity));
25
31
  }
@@ -31,7 +37,7 @@ export function registerLayer(document, layer) {
31
37
  stacks.set(document, layers);
32
38
  }
33
39
  layers.push(layer);
34
- const top = () => layers.filter(item => item.panel.isConnected && (item.panel instanceof HTMLDialogElement ? item.panel.open : item.panel.matches(":popover-open"))).at(-1);
40
+ const top = () => layers.filter(item => item.panel.isConnected && (item.panel.localName === "dialog" ? item.panel.open : item.panel.matches(":popover-open"))).at(-1);
35
41
  const key = (event) => { if (event.defaultPrevented || event.key !== "Escape" || top() !== layer)
36
42
  return; event.preventDefault(); event.stopPropagation(); layer.close(); };
37
43
  const pointer = (event) => {
@@ -45,6 +51,9 @@ export function registerLayer(document, layer) {
45
51
  }
46
52
  else if (path.includes(layer.panel) || path.includes(layer.anchor()))
47
53
  return;
54
+ // A modal backdrop must not move focus to the document after close cleanup.
55
+ if (layer.modal)
56
+ event.preventDefault();
48
57
  (layer.outside ?? layer.close)();
49
58
  };
50
59
  const focus = (event) => {
@@ -62,24 +71,42 @@ export function registerLayer(document, layer) {
62
71
  }
63
72
  const locks = new WeakMap();
64
73
  export function lockScroll(document) {
65
- const style = document.documentElement.style;
74
+ const root = document.documentElement, style = root.style;
66
75
  let lock = locks.get(document);
67
76
  if (!lock) {
68
- const reserve = (document.defaultView?.innerWidth ?? 0) - document.documentElement.clientWidth > 0;
69
- lock = { count: 0, overflow: style.getPropertyValue("overflow"), overflowPriority: style.getPropertyPriority("overflow"), gutter: style.getPropertyValue("scrollbar-gutter"), gutterPriority: style.getPropertyPriority("scrollbar-gutter") };
77
+ const view = document.defaultView;
78
+ const computed = getComputedStyle(root);
79
+ const rect = root.getBoundingClientRect();
80
+ // Chromium can include a stable thin gutter in clientWidth while excluding it
81
+ // from the root's actual layout box. Preserve that box, not just clientWidth.
82
+ const gutter = Math.max(0, (view?.innerWidth ?? root.clientWidth) - Math.min(root.clientWidth, rect.width));
83
+ const leftGutter = Math.min(gutter, Math.max(0, rect.left));
84
+ const rightGutter = gutter - leftGutter;
85
+ const paddingLeft = parseFloat(computed.paddingLeft) || 0;
86
+ const paddingRight = parseFloat(computed.paddingRight) || 0;
87
+ lock = { count: 0, saved: ["overflow", "scrollbar-gutter", "padding-left", "padding-right"].map(key => [key, style.getPropertyValue(key), style.getPropertyPriority(key)]) };
70
88
  locks.set(document, lock);
89
+ // A stable root gutter clips top-layer painting even when its rect fills the viewport.
90
+ // Replace the gutter with normal-flow padding while keeping fixed modal edges flush.
91
+ style.setProperty("scrollbar-gutter", "auto", "important");
71
92
  style.setProperty("overflow", "hidden", "important");
72
- if (reserve)
73
- style.setProperty("scrollbar-gutter", "stable", "important");
93
+ if (leftGutter)
94
+ style.setProperty("padding-left", `${paddingLeft + leftGutter}px`, "important");
95
+ if (rightGutter)
96
+ style.setProperty("padding-right", `${paddingRight + rightGutter}px`, "important");
74
97
  }
75
98
  lock.count++;
76
- return () => { if (--lock.count !== 0)
77
- return; for (const [key, value, priority] of [["overflow", lock.overflow, lock.overflowPriority], ["scrollbar-gutter", lock.gutter, lock.gutterPriority]]) {
78
- if (value)
79
- style.setProperty(key, value, priority);
80
- else
81
- style.removeProperty(key);
82
- } locks.delete(document); };
99
+ return () => {
100
+ if (--lock.count !== 0)
101
+ return;
102
+ for (const [key, value, priority] of lock.saved) {
103
+ if (value)
104
+ style.setProperty(key, value, priority);
105
+ else
106
+ style.removeProperty(key);
107
+ }
108
+ locks.delete(document);
109
+ };
83
110
  }
84
111
  export function positionLayer(anchor, panel, side, align) {
85
112
  const view = panel.ownerDocument.defaultView;
@@ -3,5 +3,11 @@ type AnchoredProps = (TooltipProps | PopoverProps) & {
3
3
  kind: "tooltip" | "popover";
4
4
  };
5
5
  export declare const AnchoredOverlay: import("react").ForwardRefExoticComponent<AnchoredProps & import("react").RefAttributes<HTMLDivElement>>;
6
- export declare const ModalOverlay: import("react").ForwardRefExoticComponent<DialogProps & import("react").RefAttributes<HTMLDialogElement>>;
6
+ interface ModalOverlayProps extends DialogProps {
7
+ showCloseButton?: boolean;
8
+ role?: "dialog" | "alertdialog";
9
+ scrollBody?: boolean;
10
+ footer?: import("react").ReactNode;
11
+ }
12
+ export declare const ModalOverlay: import("react").ForwardRefExoticComponent<ModalOverlayProps & import("react").RefAttributes<HTMLDialogElement>>;
7
13
  export {};
package/dist/overlays.js CHANGED
@@ -1,13 +1,22 @@
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
- import { IconButton } from "./index.js";
3
+ import { IconButton, ScrollArea } from "./index.js";
4
4
  import { positionLayer, registerLayer, returnFocus, lockScroll, tabbables } 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; }
8
8
  function focusContent(panel, preferred) {
9
- const target = preferred?.isConnected && panel.contains(preferred) ? preferred : panel.querySelector('[autofocus],[data-flood-autofocus]') ?? tabbables(panel.querySelector(".flood-overlay-body") ?? panel)[0] ?? tabbables(panel)[0] ?? panel;
10
- target.focus({ preventScroll: true });
9
+ const available = (node) => Boolean(node?.isConnected && panel.contains(node) && !node.matches(":disabled") && !node.closest("[hidden],[inert]") && node.getClientRects().length && getComputedStyle(node).visibility !== "hidden");
10
+ const candidates = [preferred, ...panel.querySelectorAll('[autofocus],[data-flood-autofocus]'), ...tabbables(panel.querySelector(".flood-overlay-body") ?? panel), ...tabbables(panel), panel];
11
+ for (const target of candidates) {
12
+ if (!available(target))
13
+ continue;
14
+ const previous = panel.ownerDocument.activeElement;
15
+ target.focus({ preventScroll: true });
16
+ // A host focus handler may intentionally redirect focus elsewhere.
17
+ if (panel.ownerDocument.activeElement === target || panel.ownerDocument.activeElement !== previous)
18
+ return;
19
+ }
11
20
  }
12
21
  function OverlayClose({ label, onClick }) {
13
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" }) }) });
@@ -71,7 +80,7 @@ export const AnchoredOverlay = forwardRef(function AnchoredOverlay(allProps, for
71
80
  positionLayer(anchor.current, element, side, align); frame = view.requestAnimationFrame(update); };
72
81
  frame = view.requestAnimationFrame(update);
73
82
  };
74
- frame = view.requestAnimationFrame(start);
83
+ start();
75
84
  const toggle = (event) => {
76
85
  if (!alive || event.newState !== "closed" || element.matches(":popover-open") || !current.current.open)
77
86
  return;
@@ -104,7 +113,7 @@ export const AnchoredOverlay = forwardRef(function AnchoredOverlay(allProps, for
104
113
  leave();
105
114
  } }, children: kind === "tooltip" ? tip.content : _jsxs(_Fragment, { children: [_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 })] }) })] });
106
115
  });
107
- export const ModalOverlay = forwardRef(function ModalOverlay({ 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) {
116
+ export const ModalOverlay = 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) {
108
117
  const [local, setLocal] = useState(defaultOpen), open = controlled ?? local;
109
118
  const dialog = useRef(null), anchor = useRef(null), generated = useId(), id = explicitId ?? `flood-dialog-${generated}`;
110
119
  const request = (next) => { if (controlled === undefined)
@@ -129,6 +138,8 @@ export const ModalOverlay = forwardRef(function ModalOverlay({ title, descriptio
129
138
  if (typeof element.showModal !== "function")
130
139
  throw new Error("Flood Dialog requires native showModal");
131
140
  const previous = document.activeElement;
141
+ if (scrollBody)
142
+ element.dataset.drawerDirection = getComputedStyle(element).direction;
132
143
  element.showModal();
133
144
  const unlock = lockScroll(document);
134
145
  const unregister = registerLayer(document, { panel: element, anchor: () => anchor.current, modal: true, close: () => current.current.request(false), outside: () => { if (current.current.closeOnOutsideClick)
@@ -166,5 +177,5 @@ export const ModalOverlay = forwardRef(function ModalOverlay({ title, descriptio
166
177
  event.preventDefault();
167
178
  first.focus();
168
179
  }
169
- }, children: [_jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: title }), _jsx(OverlayClose, { label: closeLabel, onClick: () => request(false) })] }), description && _jsx("p", { id: `${id}-description`, className: "flood-overlay-description", children: description }), open && _jsx("div", { className: "flood-overlay-body", children: children })] })] });
180
+ }, children: [_jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: title }), showCloseButton && _jsx(OverlayClose, { label: closeLabel, onClick: () => request(false) })] }), description && _jsx("p", { id: `${id}-description`, className: "flood-overlay-description", children: description }), open && (scrollBody ? _jsx("div", { className: "flood-drawer__body", children: _jsx(ScrollArea, { className: "flood-overlay-body", style: { height: "100%" }, children: children }) }) : _jsx("div", { className: "flood-overlay-body", children: children })), open && footer && _jsx("div", { className: "flood-drawer__footer", children: footer })] })] });
170
181
  });
@@ -84,7 +84,7 @@ function cornerBounds(node, axis, crossStart, crossEnd, start, end, inset) {
84
84
  return { start, end: Math.max(start, end) };
85
85
  }
86
86
  /** Original overlay chrome; the viewport itself remains a browser-native scroll container. */
87
- export const OverlayScrollArea = forwardRef(function OverlayScrollArea({ orientation = "vertical", contentInset = "none", children, className, style, tabIndex = 0, role, ...props }, forwardedRef) {
87
+ export const OverlayScrollArea = forwardRef(function OverlayScrollArea({ orientation = "vertical", contentInset = "none", children, className, style, tabIndex = 0, role, onFocus, ...props }, forwardedRef) {
88
88
  const id = useId();
89
89
  const viewport = useRef(null), content = useRef(null);
90
90
  const vertical = useRef(null), horizontal = useRef(null);
@@ -216,5 +216,20 @@ export const OverlayScrollArea = forwardRef(function OverlayScrollArea({ orienta
216
216
  "--flood-scroll-content-block": measurements.horizontal.visible ? "calc(var(--flood-sys-scrollbar-size) + 2 * var(--flood-sys-scrollbar-inset))" : "0px",
217
217
  } : undefined;
218
218
  const named = props["aria-label"] || props["aria-labelledby"];
219
- return _jsxs("div", { className: "flood-scroll-area", style: rootStyle, children: [_jsx("div", { ...props, id: props.id ?? `flood-scroll-${id}`, ref: viewport, tabIndex: tabIndex, role: role ?? (named ? "region" : undefined), className: ["flood-scroll-area__viewport", className].filter(Boolean).join(" "), style: style, "data-orientation": orientation, "data-scrollbars": measurements.enhanced ? "overlay" : undefined, children: _jsx("div", { className: "flood-scroll-area__content", ref: content, style: contentStyle, children: children }) }), renderBar("vertical"), renderBar("horizontal")] });
219
+ return _jsxs("div", { className: "flood-scroll-area", hidden: props.hidden, dir: props.dir, lang: props.lang, style: rootStyle, children: [_jsx("div", { ...props, id: props.id ?? `flood-scroll-${id}`, ref: viewport, tabIndex: tabIndex, role: role ?? (named ? "region" : undefined), className: ["flood-scroll-area__viewport", className].filter(Boolean).join(" "), style: style, onFocus: event => {
220
+ onFocus?.(event);
221
+ if (event.defaultPrevented || event.target === event.currentTarget)
222
+ return;
223
+ const target = event.target, node = event.currentTarget;
224
+ if (node.ownerDocument.activeElement !== target)
225
+ return;
226
+ const layer = target.closest("[popover], dialog[open]");
227
+ if (layer && node.contains(layer))
228
+ return;
229
+ const box = target.getBoundingClientRect(), bounds = node.getBoundingClientRect();
230
+ const outsideX = orientation !== "vertical" && (box.left < bounds.left || box.right > bounds.right);
231
+ const outsideY = orientation !== "horizontal" && (box.top < bounds.top || box.bottom > bounds.bottom);
232
+ if (outsideX || outsideY)
233
+ target.scrollIntoView({ block: "nearest", inline: "nearest" });
234
+ }, "data-orientation": orientation, "data-scrollbars": measurements.enhanced ? "overlay" : undefined, children: _jsx("div", { className: "flood-scroll-area__content", ref: content, style: contentStyle, children: children }) }), renderBar("vertical"), renderBar("horizontal")] });
220
235
  });