@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.
- package/README.md +398 -3
- package/dist/alert-dialog.d.ts +2 -0
- package/dist/alert-dialog.js +23 -0
- package/dist/avatar.d.ts +5 -0
- package/dist/avatar.js +8 -0
- package/dist/checkbox.d.ts +4 -0
- package/dist/checkbox.js +15 -0
- package/dist/combobox.d.ts +11 -0
- package/dist/combobox.js +301 -0
- package/dist/content-elements.d.ts +8 -0
- package/dist/content-elements.js +47 -0
- package/dist/data-display.d.ts +3 -0
- package/dist/data-display.js +11 -0
- package/dist/disclosure.js +2 -1
- package/dist/feedback-actions.d.ts +3 -0
- package/dist/feedback-actions.js +106 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +29 -0
- package/dist/file-input.d.ts +8 -0
- package/dist/file-input.js +86 -0
- package/dist/focusable-hidden.d.ts +3 -0
- package/dist/focusable-hidden.js +99 -0
- package/dist/fonts/InterVariable-Italic.woff2 +0 -0
- package/dist/fonts/README.md +9 -5
- package/dist/form-controls.d.ts +6 -0
- package/dist/form-controls.js +250 -0
- package/dist/foundations.css +543 -12
- package/dist/index.d.ts +513 -2
- package/dist/index.js +223 -36
- package/dist/menu.d.ts +2 -0
- package/dist/menu.js +130 -0
- package/dist/navigation-layout.d.ts +7 -0
- package/dist/navigation-layout.js +77 -0
- package/dist/navigation.js +8 -8
- package/dist/numeric-input.d.ts +8 -0
- package/dist/numeric-input.js +68 -0
- package/dist/overlay-runtime.js +43 -16
- package/dist/overlays.d.ts +7 -1
- package/dist/overlays.js +17 -6
- package/dist/scroll-area.js +17 -2
- package/dist/select.js +96 -47
- package/dist/slider.d.ts +7 -0
- package/dist/slider.js +89 -0
- package/dist/styles.css +9 -0
- package/dist/text-inputs.d.ts +9 -0
- package/dist/text-inputs.js +45 -0
- package/dist/toolbar.d.ts +2 -0
- package/dist/toolbar.js +80 -0
- 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
|
+
});
|
package/dist/navigation.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
49
|
-
setFocused(undefined); }, children: items.map((item, index) => _jsxs("
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
+
});
|
package/dist/overlay-runtime.js
CHANGED
|
@@ -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
|
|
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 (
|
|
25
|
+
if (node.localName !== "input" || node.type !== "radio" || !node.name)
|
|
21
26
|
return true;
|
|
22
|
-
const
|
|
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
|
|
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
|
|
74
|
+
const root = document.documentElement, style = root.style;
|
|
66
75
|
let lock = locks.get(document);
|
|
67
76
|
if (!lock) {
|
|
68
|
-
const
|
|
69
|
-
|
|
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 (
|
|
73
|
-
style.setProperty("
|
|
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 () => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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;
|
package/dist/overlays.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
10
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/scroll-area.js
CHANGED
|
@@ -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,
|
|
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
|
});
|