@flood-md/react 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,20 +1,76 @@
1
1
  "use client";
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { forwardRef, createElement, useId, useState, } from "react";
4
+ import { useFocusableHidden } from "./focusable-hidden.js";
4
5
  import { resolveFieldStatus, hasFieldContent, mergeDescriptions } from "./field-state.js";
5
6
  import { SingleSelect } from "./select.js";
7
+ import { MixedCheckbox } from "./checkbox.js";
6
8
  import { OverlayScrollArea } from "./scroll-area.js";
7
9
  import { ChoiceGroup } from "./choice-group.js";
8
10
  import { Disclosure } from "./disclosure.js";
9
11
  import { AnchoredOverlay, ModalOverlay } from "./overlays.js";
10
12
  import { TabNavigation, BreadcrumbNavigation, PageNavigation } from "./navigation.js";
13
+ import { AvatarArtwork } from "./avatar.js";
14
+ import { ValueIndicator, FeedbackMessage } from "./feedback.js";
15
+ import { DescribedData, TabularData } from "./data-display.js";
16
+ import { ActionMenu } from "./menu.js";
17
+ import { RovingToolbar } from "./toolbar.js";
11
18
  const classes = (...values) => values.filter(Boolean).join(" ");
12
19
  export const Button = forwardRef(function Button({ variant = "solid", size = "md", loading = false, disabled, type = "button", className, children, ...props }, ref) {
13
- return (_jsxs("button", { ...props, ref: ref, type: type, disabled: disabled || loading, "aria-busy": loading || undefined, className: classes("flood-button", `flood-button--${variant}`, `flood-button--${size}`, className), children: [loading && _jsx("span", { className: "flood-spinner", "aria-hidden": "true" }), children] }));
20
+ return (_jsxs("button", { ...props, ref: ref, type: type, disabled: disabled || loading, "aria-busy": loading || undefined, className: classes("flood-button", `flood-button--${variant}`, `flood-button--${size}`, className), children: [loading && _jsx(Spinner, { decorative: true }), children] }));
21
+ });
22
+ /** @since 0.2.4 */
23
+ export const Kbd = forwardRef(function Kbd({ size = "sm", className, ...props }, ref) {
24
+ return _jsx("kbd", { ...props, ref: ref, className: classes("flood-kbd", className), "data-size": size });
25
+ });
26
+ /** @since 0.2.4 */
27
+ export const Toolbar = forwardRef(function Toolbar({ label, orientation = "horizontal", loop = true, ...props }, ref) {
28
+ return _jsx(RovingToolbar, { ...props, ref: ref, label: label, orientation: orientation, loop: loop });
29
+ });
30
+ /** @since 0.2.4 */
31
+ export const DescriptionList = forwardRef(function DescriptionList({ items, layout = "inline", density = "compact", dividers = false, ...props }, ref) {
32
+ return _jsx(DescribedData, { ...props, ref: ref, items: items, layout: layout, density: density, dividers: dividers });
33
+ });
34
+ /** @since 0.2.4 */
35
+ export const Table = forwardRef(function Table({ caption, children, hideCaption = false, density = "comfortable", striped = false, scrollLabel, ...props }, ref) {
36
+ return _jsx(TabularData, { ...props, ref: ref, caption: caption, hideCaption: hideCaption, density: density, striped: striped, scrollLabel: scrollLabel, children: children });
37
+ });
38
+ /** @since 0.2.4 */
39
+ export const Badge = forwardRef(function Badge({ size = "md", tone = "neutral", variant = "subtle", className, ...props }, ref) {
40
+ return _jsx("span", { ...props, ref: ref, className: classes("flood-badge", className), "data-size": size, "data-tone": tone, "data-variant": variant });
41
+ });
42
+ /** @since 0.2.4 */
43
+ export const Spinner = forwardRef(function Spinner({ size = "md", label = "Loading", decorative = false, className, ...props }, ref) {
44
+ return _jsxs("span", { ...props, ref: ref, className: classes("flood-loading", className), "data-size": size, role: decorative ? undefined : "status", "aria-hidden": decorative || undefined, children: [_jsx("span", { className: "flood-spinner", "aria-hidden": "true" }), !decorative && _jsx(VisuallyHidden, { children: label })] });
45
+ });
46
+ /** @since 0.2.4 */
47
+ export const Skeleton = forwardRef(function Skeleton({ shape = "text", width, height, animate = true, className, style, ...props }, ref) {
48
+ return _jsx("span", { ...props, ref: ref, className: classes("flood-skeleton", className), "data-shape": shape, "data-animate": animate, "aria-hidden": "true", style: { width, height, ...style } });
49
+ });
50
+ /** @since 0.2.4 */
51
+ export const Avatar = forwardRef(function Avatar({ name, src, fallback, size = "md", shape = "circle", decorative = false, className, ...props }, ref) {
52
+ const initials = name.trim().split(/\s+/u).filter(Boolean).slice(0, 2).map(word => Array.from(word)[0]).join("").toUpperCase();
53
+ return _jsx("span", { ...props, ref: ref, className: classes("flood-avatar", className), "data-size": size, "data-shape": shape, role: decorative ? undefined : "img", "aria-label": decorative ? undefined : name, "aria-hidden": decorative || undefined, children: _jsx(AvatarArtwork, { src: src, fallback: fallback ?? (initials || "?") }, src ?? "") });
54
+ });
55
+ /** @since 0.2.4 */
56
+ export const Progress = forwardRef(function Progress({ label, value, max = 100, valueText, size = "md", tone = "accent", hideLabel = false, ...props }, ref) {
57
+ return _jsx(ValueIndicator, { ...props, ref: ref, kind: "progress", label: label, value: value, min: 0, max: max, valueText: valueText, size: size, tone: tone, hideLabel: hideLabel });
58
+ });
59
+ /** @since 0.2.4 */
60
+ export const Meter = forwardRef(function Meter({ label, value, min = 0, max = 100, valueText, size = "md", tone = "neutral", hideLabel = false, ...props }, ref) {
61
+ return _jsx(ValueIndicator, { ...props, ref: ref, kind: "meter", label: label, value: value, min: min, max: max, valueText: valueText, size: size, tone: tone, hideLabel: hideLabel });
62
+ });
63
+ /** @since 0.2.4 */
64
+ export const Alert = forwardRef(function Alert({ title, tone = "info", icon, announcement = "off", ...props }, ref) {
65
+ return _jsx(FeedbackMessage, { ...props, ref: ref, title: title, tone: tone, icon: icon, announcement: announcement });
14
66
  });
15
67
  export const Tabs = forwardRef(function Tabs({ activationMode = "automatic", orientation = "horizontal", controlSize = "md", disabled = false, iconOnly = false, ...props }, ref) { return _jsx(TabNavigation, { ...props, ref: ref, activationMode: activationMode, orientation: orientation, controlSize: controlSize, disabled: disabled, iconOnly: iconOnly }); });
16
68
  export const Breadcrumb = forwardRef(function Breadcrumb({ label = "Breadcrumb", separator = "/", ...props }, ref) { return _jsx(BreadcrumbNavigation, { ...props, ref: ref, label: label, separator: separator }); });
17
69
  export const Pagination = forwardRef(function Pagination({ siblingCount = 1, controlSize = "sm", disabled = false, label = "Pagination", previousLabel = "Previous page", nextLabel = "Next page", ...props }, ref) { return _jsx(PageNavigation, { ...props, ref: ref, siblingCount: siblingCount, controlSize: controlSize, disabled: disabled, label: label, previousLabel: previousLabel, nextLabel: nextLabel }); });
70
+ /** @since 0.2.4 */
71
+ export const Menu = forwardRef(function Menu({ defaultOpen = false, disabled = false, side = "bottom", align = "start", controlSize = "sm", ...props }, ref) {
72
+ return _jsx(ActionMenu, { ...props, ref: ref, defaultOpen: defaultOpen, disabled: disabled, side: side, align: align, controlSize: controlSize });
73
+ });
18
74
  export const Tooltip = forwardRef(function Tooltip({ side = "top", align = "center", delay = 300, disabled = false, ...props }, ref) { return _jsx(AnchoredOverlay, { ...props, ref: ref, kind: "tooltip", side: side, align: align, delay: delay, disabled: disabled }); });
19
75
  export const Popover = forwardRef(function Popover({ side = "bottom", align = "start", defaultOpen = false, disabled = false, closeLabel = "Close", ...props }, ref) { return _jsx(AnchoredOverlay, { ...props, ref: ref, kind: "popover", side: side, align: align, defaultOpen: defaultOpen, disabled: disabled, closeLabel: closeLabel }); });
20
76
  export const Dialog = forwardRef(function Dialog({ defaultOpen = false, disabled = false, closeLabel = "Close", closeOnOutsideClick = true, ...props }, ref) { return _jsx(ModalOverlay, { ...props, ref: ref, defaultOpen: defaultOpen, disabled: disabled, closeLabel: closeLabel, closeOnOutsideClick: closeOnOutsideClick }); });
@@ -35,7 +91,7 @@ export const Accordion = forwardRef(function Accordion({ label, items, expansion
35
91
  throw new Error("Flood single Accordion accepts at most one value");
36
92
  return _jsx("div", { ...props, ref: ref, role: "group", "aria-label": label, className: classes("flood-accordion", className), onKeyDown: event => {
37
93
  onKeyDown?.(event);
38
- if (event.defaultPrevented)
94
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
39
95
  return;
40
96
  const buttons = Array.from(event.currentTarget.querySelectorAll(":scope > .flood-disclosure > .flood-disclosure__heading > .flood-disclosure__trigger:not(:disabled)"));
41
97
  const index = buttons.indexOf(event.target);
@@ -76,39 +132,39 @@ export const ToggleGroup = forwardRef(function ToggleGroup({ label, options, sel
76
132
  throw new Error("Flood single ToggleGroup accepts at most one value");
77
133
  if (iconOnly && options.some(option => !option.icon))
78
134
  throw new Error("Flood icon-only toggle options require icons");
79
- return _jsx("div", { ...props, ref: ref, role: "group", "aria-label": label, "data-orientation": orientation, "data-selection-mode": selectionMode, className: classes("flood-button-group", "flood-toggle-group", className), onKeyDown: event => {
80
- onKeyDown?.(event);
81
- if (event.defaultPrevented)
82
- return;
83
- const buttons = Array.from(event.currentTarget.querySelectorAll(":scope > .flood-toggle:not(:disabled)"));
84
- const index = buttons.indexOf(event.target);
85
- if (index < 0)
86
- return;
87
- const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
88
- const nextKey = orientation === "vertical" ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight";
89
- const previousKey = orientation === "vertical" ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
90
- const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === nextKey ? (index + 1) % buttons.length : event.key === previousKey ? (index - 1 + buttons.length) % buttons.length : -1;
91
- if (next >= 0) {
92
- event.preventDefault();
93
- buttons[next].focus();
94
- }
95
- }, children: options.map(option => _jsx(Toggle, { size: size, variant: variant, disabled: disabled || option.disabled, pressed: selected.includes(option.value), iconOnly: iconOnly, "aria-label": iconOnly ? option.label : undefined, onPressedChange: active => {
96
- const next = active ? selectionMode === "single" ? [option.value] : [...new Set([...selected, option.value])] : selected.filter(item => item !== option.value);
97
- if (value === undefined)
98
- setLocal(next);
99
- onValueChange?.([...next]);
100
- }, children: iconOnly ? option.icon : _jsxs(_Fragment, { children: [option.icon && _jsx(IconSlot, { size: size, children: option.icon }), option.label] }) }, option.value)) });
135
+ return _jsx(ScrollArea, { orientation: "horizontal", tabIndex: -1, role: "group", "aria-label": `${label} scrolling`, className: "flood-connected-scroll", hidden: props.hidden, dir: props.dir, lang: props.lang, style: { maxInlineSize: "100%" }, children: _jsx("div", { ...props, ref: ref, role: "group", "aria-label": label, "data-orientation": orientation, "data-selection-mode": selectionMode, className: classes("flood-button-group", "flood-toggle-group", className), onKeyDown: event => {
136
+ onKeyDown?.(event);
137
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
138
+ return;
139
+ const buttons = Array.from(event.currentTarget.querySelectorAll(":scope > .flood-toggle:not(:disabled)"));
140
+ const index = buttons.indexOf(event.target);
141
+ if (index < 0)
142
+ return;
143
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
144
+ const nextKey = orientation === "vertical" ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight";
145
+ const previousKey = orientation === "vertical" ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
146
+ const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === nextKey ? (index + 1) % buttons.length : event.key === previousKey ? (index - 1 + buttons.length) % buttons.length : -1;
147
+ if (next >= 0) {
148
+ event.preventDefault();
149
+ buttons[next].focus();
150
+ }
151
+ }, children: options.map(option => _jsx(Toggle, { size: size, variant: variant, disabled: disabled || option.disabled, pressed: selected.includes(option.value), iconOnly: iconOnly, "aria-label": iconOnly ? option.label : undefined, onPressedChange: active => {
152
+ const next = active ? selectionMode === "single" ? [option.value] : [...new Set([...selected, option.value])] : selected.filter(item => item !== option.value);
153
+ if (value === undefined)
154
+ setLocal(next);
155
+ onValueChange?.([...next]);
156
+ }, children: iconOnly ? option.icon : _jsxs(_Fragment, { children: [option.icon && _jsx(IconSlot, { size: size, children: option.icon }), option.label] }) }, option.value)) }) });
101
157
  });
102
158
  /** Connected actions keep their ordinary Tab order; this is not a toolbar. */
103
159
  export const ButtonGroup = forwardRef(function ButtonGroup({ label, orientation = "horizontal", className, ...props }, ref) {
104
- return _jsx("div", { ...props, ref: ref, role: "group", "aria-label": label, "data-orientation": orientation, className: classes("flood-button-group", className) });
160
+ return _jsx(ScrollArea, { orientation: "horizontal", tabIndex: -1, role: "group", "aria-label": `${label} scrolling`, className: "flood-connected-scroll", hidden: props.hidden, dir: props.dir, lang: props.lang, style: { maxInlineSize: "100%" }, children: _jsx("div", { ...props, ref: ref, role: "group", "aria-label": label, "data-orientation": orientation, className: classes("flood-button-group", className) }) });
105
161
  });
106
162
  export const Input = forwardRef(function Input({ invalid, status, controlSize = "md", className, ...props }, ref) {
107
163
  return (_jsx("input", { ...props, ref: ref, "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), "aria-invalid": invalid || status === "error" || props["aria-invalid"], className: classes("flood-input", `flood-control--${controlSize}`, className) }));
108
164
  });
109
165
  /** One real Input with host-owned logical start/end adornments. Ref and native attributes target the input. */
110
166
  export const InputGroup = forwardRef(function InputGroup({ start, end, controlSize = "md", className, style, invalid, status, disabled, readOnly, ...props }, ref) {
111
- return _jsxs("div", { hidden: props.hidden, dir: props.dir, lang: props.lang, className: classes("flood-input-group", `flood-control--${controlSize}`, className), style: style, "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, children: [hasFieldContent(start) && _jsx("div", { className: "flood-input-group__addon flood-input-group__addon--start", children: start }), _jsx(Input, { ...props, ref: ref, controlSize: controlSize, invalid: invalid, status: status, disabled: disabled, readOnly: readOnly }), hasFieldContent(end) && _jsx("div", { className: "flood-input-group__addon flood-input-group__addon--end", children: end })] });
167
+ return _jsxs("div", { hidden: props.hidden, dir: props.dir, lang: props.lang, className: classes("flood-input-group", `flood-control--${controlSize}`, className), style: style, "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, children: [hasFieldContent(start) && _jsx("div", { className: "flood-input-group__addon flood-input-group__addon--start", children: typeof start === "string" ? _jsx("bdi", { dir: "auto", children: start }) : start }), _jsx(Input, { ...props, ref: ref, controlSize: controlSize, invalid: invalid, status: status, disabled: disabled, readOnly: readOnly }), hasFieldContent(end) && _jsx("div", { className: "flood-input-group__addon flood-input-group__addon--end", children: typeof end === "string" ? _jsx("bdi", { dir: "auto", children: end }) : end })] });
112
168
  });
113
169
  export const Textarea = forwardRef(function Textarea({ invalid, status, controlSize = "md", className, ...props }, ref) {
114
170
  return (_jsx("textarea", { ...props, ref: ref, "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), "aria-invalid": invalid || status === "error" || props["aria-invalid"], className: classes("flood-input", "flood-textarea", `flood-control--${controlSize}`, className) }));
@@ -118,16 +174,16 @@ export const Select = forwardRef(function Select({ invalid, status, controlSize
118
174
  const listbox = multiple || (size !== undefined && size > 1);
119
175
  if (!listbox)
120
176
  return _jsx(SingleSelect, { ...props, status: status, invalid: invalid, controlSize: controlSize, className: className, size: size, ref: ref });
121
- return (_jsx("span", { className: classes("flood-select", `flood-control--${controlSize}`, listbox && "flood-select--listbox"), children: _jsx("select", { ...props, ref: ref, multiple: multiple, size: size, "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), "aria-invalid": invalid || status === "error" || props["aria-invalid"], className: classes("flood-input", "flood-select__control", `flood-control--${controlSize}`, className) }) }));
177
+ return (_jsx("span", { hidden: props.hidden, dir: props.dir, lang: props.lang, className: classes("flood-select", `flood-control--${controlSize}`, listbox && "flood-select--listbox"), children: _jsx("select", { ...props, ref: ref, multiple: multiple, size: size, "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), "aria-invalid": invalid || status === "error" || props["aria-invalid"], className: classes("flood-input", "flood-select__control", `flood-control--${controlSize}`, className) }) }));
122
178
  });
123
- export const Checkbox = forwardRef(function Checkbox({ label, className, ...props }, ref) {
124
- return (_jsxs("label", { className: classes("flood-choice", className), children: [_jsx("input", { ...props, ref: ref, type: "checkbox", className: "flood-check" }), _jsx("span", { children: label })] }));
179
+ export const Checkbox = forwardRef(function Checkbox({ label, indeterminate = false, className, ...props }, ref) {
180
+ return (_jsxs("label", { hidden: props.hidden, dir: props.dir, lang: props.lang, className: classes("flood-choice", className), children: [_jsx(MixedCheckbox, { ...props, ref: ref, indeterminate: indeterminate }), _jsx("span", { children: label })] }));
125
181
  });
126
182
  export const Radio = forwardRef(function Radio({ label, className, ...props }, ref) {
127
- return (_jsxs("label", { className: classes("flood-choice", className), children: [_jsx("input", { ...props, ref: ref, type: "radio", className: "flood-check" }), _jsx("span", { children: label })] }));
183
+ return (_jsxs("label", { hidden: props.hidden, dir: props.dir, lang: props.lang, className: classes("flood-choice", className), children: [_jsx("input", { ...props, ref: ref, type: "radio", className: "flood-check" }), _jsx("span", { children: label })] }));
128
184
  });
129
185
  export const Switch = forwardRef(function Switch({ label, className, ...props }, ref) {
130
- return (_jsxs("label", { className: classes("flood-choice", "flood-switch", className), children: [_jsx("span", { children: label }), _jsx("input", { ...props, ref: ref, type: "checkbox", role: "switch", className: "flood-switch-input" })] }));
186
+ return (_jsxs("label", { hidden: props.hidden, dir: props.dir, lang: props.lang, className: classes("flood-choice", "flood-switch", className), children: [_jsx("span", { children: label }), _jsx("input", { ...props, ref: ref, type: "checkbox", role: "switch", className: "flood-switch-input" })] }));
131
187
  });
132
188
  /** A native label; the required marker never substitutes for the control's required prop. */
133
189
  export const Label = forwardRef(function Label({ required, children, className, ...props }, ref) {
@@ -137,10 +193,11 @@ export const Label = forwardRef(function Label({ required, children, className,
137
193
  export const Fieldset = forwardRef(function Fieldset({ legend, hideLabel = false, hint, error, message, status, children, className, id: explicitId, "aria-describedby": describedBy, "aria-invalid": ariaInvalid, ...props }, ref) {
138
194
  const generatedId = useId();
139
195
  const id = explicitId ?? `flood-${generatedId}`;
196
+ const legendRef = useFocusableHidden(hideLabel);
140
197
  const hasHint = hasFieldContent(hint), hasError = hasFieldContent(error), hasMessage = hasFieldContent(message) && !hasError;
141
198
  const resolvedStatus = resolveFieldStatus(status, hasError, ariaInvalid);
142
199
  const description = mergeDescriptions(describedBy, hasHint && `${id}-hint`, hasError && `${id}-error`, hasMessage && `${id}-message`);
143
- return _jsxs("fieldset", { ...props, ref: ref, id: id, className: classes("flood-fieldset", className), "data-hide-label": hideLabel || undefined, "data-status": resolvedStatus, "aria-describedby": description, "aria-invalid": resolvedStatus === "error" || ariaInvalid || undefined, children: [_jsx("legend", { className: classes("flood-legend", hideLabel && "flood-visually-hidden"), children: legend }), _jsxs("div", { className: "flood-fieldset__body", children: [hasHint && _jsx("div", { id: `${id}-hint`, className: "flood-hint", children: hint }), children, hasMessage && _jsx("div", { id: `${id}-message`, className: "flood-message", role: resolvedStatus === "error" ? "alert" : undefined, children: message }), hasError && _jsx("div", { id: `${id}-error`, className: "flood-error", role: "alert", children: error })] })] });
200
+ return _jsxs("fieldset", { ...props, ref: ref, id: id, className: classes("flood-fieldset", className), "data-hide-label": hideLabel || undefined, "data-status": resolvedStatus, "aria-describedby": description, "aria-invalid": resolvedStatus === "error" || ariaInvalid || undefined, children: [_jsx("legend", { ref: legendRef, className: classes("flood-legend", hideLabel && "flood-visually-hidden--focusable"), children: legend }), _jsxs("div", { className: "flood-fieldset__body", children: [hasHint && _jsx("div", { id: `${id}-hint`, className: "flood-hint", children: hint }), children, hasMessage && _jsx("div", { id: `${id}-message`, className: "flood-message", role: resolvedStatus === "error" ? "alert" : undefined, children: message }), hasError && _jsx("div", { id: `${id}-error`, className: "flood-error", role: "alert", children: error })] })] });
144
201
  });
145
202
  export const RadioGroup = forwardRef(function RadioGroup({ value, defaultValue, onValueChange, orientation = "vertical", ...props }, ref) {
146
203
  return _jsx(ChoiceGroup, { ...props, ref: ref, kind: "radio", orientation: orientation, selected: value === undefined ? undefined : [value], initial: defaultValue === undefined ? [] : [defaultValue], onSelectionChange: next => onValueChange?.(next[0] ?? ""), fieldsetComponent: Fieldset, choiceComponent: Radio });
@@ -151,7 +208,7 @@ export const SegmentedControl = forwardRef(function SegmentedControl({ label, op
151
208
  throw new Error("Flood icon-only segments require icons");
152
209
  return _jsx(RadioGroup, { ...props, ref: ref, legend: label, orientation: orientation, className: classes("flood-segmented", `flood-control--${controlSize}`, iconOnly && "flood-segmented--icons", fullWidth && "flood-segmented--full", className), options: options.map(option => ({ ...option, label: _jsxs(_Fragment, { children: [option.icon && _jsx(IconSlot, { size: controlSize, children: option.icon }), iconOnly ? _jsx("span", { className: "flood-segmented__name", children: option.label }) : option.label] }) })), onKeyDown: event => {
153
210
  onKeyDown?.(event);
154
- if (event.defaultPrevented)
211
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
155
212
  return;
156
213
  const inputs = Array.from(event.currentTarget.querySelectorAll(":scope > .flood-fieldset__body > .flood-choice-group > .flood-choice > input[type=radio]:not(:disabled)"));
157
214
  const index = inputs.indexOf(event.target);
@@ -174,12 +231,13 @@ export const CheckboxGroup = forwardRef(function CheckboxGroup({ value, defaultV
174
231
  export function Field({ status, message, id: explicitId, label, hideLabel = false, hint, error, required, describedBy, children, }) {
175
232
  const generatedId = useId();
176
233
  const id = explicitId ?? `flood-${generatedId}`;
234
+ const labelRef = useFocusableHidden(hideLabel);
177
235
  const hasMessage = hasFieldContent(message);
178
236
  const hasHint = hasFieldContent(hint);
179
237
  const hasError = hasFieldContent(error);
180
238
  const resolvedStatus = hasError ? "error" : status;
181
239
  const description = mergeDescriptions(describedBy, hasHint && `${id}-hint`, hasError && `${id}-error`, hasMessage && !hasError && `${id}-message`);
182
- return (_jsxs("div", { className: "flood-field", "data-hide-label": hideLabel || undefined, "data-status": resolvedStatus, children: [_jsx(Label, { htmlFor: id, required: required, className: hideLabel ? "flood-visually-hidden" : undefined, children: label }), children({
240
+ return (_jsxs("div", { className: "flood-field", "data-hide-label": hideLabel || undefined, "data-status": resolvedStatus, children: [_jsx(Label, { ref: labelRef, htmlFor: id, required: required, className: hideLabel ? "flood-visually-hidden--focusable" : undefined, children: label }), children({
183
241
  id,
184
242
  "aria-describedby": description,
185
243
  "aria-invalid": hasError || resolvedStatus === "error" ? true : undefined,
@@ -209,6 +267,57 @@ export const Inline = forwardRef(function Inline({ gap = "2", align = "center",
209
267
  export const Surface = forwardRef(function Surface({ elevation = "default", padding = "6", style, className, ...props }, ref) {
210
268
  return _jsx("div", { ...props, ref: ref, "data-flood-elevation": elevation, className: classes("flood-surface", className), style: { background: `var(--flood-sys-color-surface-${elevation})`, padding: `var(--flood-sys-space-${padding})`, ...style } });
211
269
  });
270
+ /** Centered content with logical gutters; max width includes padding. @since 0.2.4 */
271
+ export const Container = forwardRef(function Container({ size = "lg", gutter = "6", className, style, ...props }, ref) {
272
+ return _jsx("div", { ...props, ref: ref, className: classes("flood-container", className), style: {
273
+ "--flood-container-max": size === "full" ? "none" : `var(--flood-sys-size-container-${size})`,
274
+ "--flood-container-gutter": `var(--flood-sys-space-${gutter})`, ...style,
275
+ } });
276
+ });
277
+ /** Auto-fit by default; explicit columns retain their count at every width. @since 0.2.4 */
278
+ export const Grid = forwardRef(function Grid({ columns, minItemWidth, gap = "4", className, style, ...props }, ref) {
279
+ return _jsx("div", { ...props, ref: ref, className: classes("flood-grid", className), style: {
280
+ gridTemplateColumns: columns ? `repeat(${columns}, minmax(0, 1fr))` : undefined,
281
+ "--flood-grid-min": minItemWidth, "--flood-layout-gap": `var(--flood-sys-space-${gap})`, ...style,
282
+ } });
283
+ });
284
+ /** Nonvisual content, with optional reveal when a descendant receives focus. @since 0.2.4 */
285
+ export const VisuallyHidden = forwardRef(function VisuallyHidden({ as = "span", focusable = false, className, ...props }, ref) {
286
+ const hiddenRef = useFocusableHidden(focusable, ref);
287
+ return createElement(as, { ...props, ref: hiddenRef, className: classes(focusable ? "flood-visually-hidden--focusable" : "flood-visually-hidden", className) });
288
+ });
289
+ /** Reserve media geometry. Child content may overflow rather than being clipped. @since 0.2.4 */
290
+ export const AspectRatio = forwardRef(function AspectRatio({ ratio = 16 / 9, className, style, children, ...props }, ref) {
291
+ if (!Number.isFinite(ratio) || ratio <= 0)
292
+ throw new RangeError("Flood AspectRatio requires a finite positive ratio");
293
+ return _jsx("div", { ...props, ref: ref, className: classes("flood-aspect-ratio", className), style: { aspectRatio: String(ratio), ...style }, children: _jsx("div", { className: "flood-aspect-ratio__content", children: children }) });
294
+ });
295
+ /** Native image semantics, explicit alternative text and bounded media fitting. @since 0.2.4 */
296
+ export const Image = forwardRef(function Image({ alt, fit = "cover", className, ...props }, ref) {
297
+ if (typeof alt !== "string")
298
+ throw new TypeError("Flood Image requires explicit alt text (empty for decorative images)");
299
+ return _jsx("img", { ...props, ref: ref, alt: alt, "data-fit": fit, className: classes("flood-image", className) });
300
+ });
301
+ /** Native list items supplied by the host; hidden markers retain list semantics. @since 0.2.4 */
302
+ export const List = forwardRef(function List({ ordered = false, start, reversed, marker = true, gap = "2", className, style, ...props }, ref) {
303
+ if (start !== undefined && !Number.isSafeInteger(start))
304
+ throw new RangeError("Flood List start requires a safe integer");
305
+ return createElement(ordered ? "ol" : "ul", { ...props, ref, start: ordered ? start : undefined,
306
+ reversed: ordered ? reversed : undefined, role: marker ? undefined : "list", "data-marker": marker,
307
+ className: classes("flood-list", className), style: { "--flood-layout-gap": `var(--flood-sys-space-${gap})`, ...style } });
308
+ });
309
+ /** Static empty content, not loading, an error boundary or a live announcement. @since 0.2.4 */
310
+ export const EmptyState = forwardRef(function EmptyState({ title, description, icon, actions, headingLevel = 2, density = "comfortable", className, ...props }, ref) {
311
+ if (typeof title !== "string" || !title.trim())
312
+ throw new Error("Flood EmptyState requires a nonempty title");
313
+ return _jsxs(Stack, { ...props, ref: ref, gap: "4", "data-density": density, className: classes("flood-empty-state", className), children: [hasFieldContent(icon) && _jsx("div", { className: "flood-empty-state__icon", children: _jsx(IconSlot, { size: "2xl", children: icon }) }), _jsxs(Stack, { gap: "1", children: [_jsx(Heading, { level: headingLevel, size: "section", children: title }), hasFieldContent(description) && _jsx(Text, { as: "div", size: "compact", tone: "subtle", children: description })] }), hasFieldContent(actions) && _jsx(Inline, { gap: "2", className: "flood-empty-state__actions", children: actions })] });
314
+ });
315
+ /** A structured content surface, not an interactive wrapper. @since 0.2.4 */
316
+ export const Card = forwardRef(function Card({ title, description, actions, footer, headingLevel = 2, density = "comfortable", elevation = "default", children, className, ...props }, ref) {
317
+ const present = hasFieldContent;
318
+ const heading = present(title), hint = present(description), action = present(actions);
319
+ return _jsxs(Surface, { ...props, ref: ref, elevation: elevation, padding: density === "compact" ? "4" : "6", "data-density": density, className: classes("flood-card", className), children: [(heading || hint || action) && _jsxs("div", { className: "flood-card__header", children: [(heading || hint) && _jsxs("div", { className: "flood-card__intro", children: [heading && _jsx(Heading, { level: headingLevel, size: "section", children: title }), hint && _jsx(Text, { as: "div", size: "compact", tone: "subtle", children: description })] }), action && _jsx("div", { className: "flood-card__actions", children: actions })] }), present(children) && _jsx("div", { className: "flood-card__body", children: children }), present(footer) && _jsx("div", { className: "flood-card__footer", children: footer })] });
320
+ });
212
321
  /** Adapter for the host's SVG icon package; glyphs are not bundled. */
213
322
  export const IconSlot = forwardRef(function IconSlot({ size = "md", label, className, children, ...props }, ref) {
214
323
  return _jsx("span", { ...props, ref: ref, role: label ? "img" : undefined, "aria-label": label, "aria-hidden": label ? undefined : true, className: classes("flood-icon", `flood-icon--${size}`, className), children: children });
@@ -220,8 +329,8 @@ export const Divider = forwardRef(function Divider({ className, ...props }, ref)
220
329
  return _jsx("hr", { ...props, ref: ref, className: classes("flood-divider", className) });
221
330
  });
222
331
  /** Selectable code with an optional host-owned action; no clipboard side effects. */
223
- export const CodeSnippet = forwardRef(function CodeSnippet({ code, action, className, ...props }, ref) {
224
- return (_jsxs("div", { ...props, ref: ref, className: classes("flood-code-snippet", className), children: [_jsx("code", { className: "flood-code-snippet__code", children: code }), action != null && _jsx("div", { className: "flood-code-snippet__action", children: action })] }));
332
+ export const CodeSnippet = forwardRef(function CodeSnippet({ code, codeDirection = "ltr", action, className, ...props }, ref) {
333
+ return (_jsxs("div", { ...props, ref: ref, className: classes("flood-code-snippet", className), children: [_jsx("code", { dir: codeDirection, className: "flood-code-snippet__code", children: code }), action != null && _jsx("div", { className: "flood-code-snippet__action", children: action })] }));
225
334
  });
226
335
  /** Native scrolling with inset overlay tracks; the ref targets the scrollable viewport. */
227
336
  export const ScrollArea = forwardRef(function ScrollArea({ orientation = "vertical", contentInset = "scrollbar", ...props }, ref) {
package/dist/menu.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { MenuProps } from "./index.js";
2
+ export declare const ActionMenu: import("react").ForwardRefExoticComponent<MenuProps & import("react").RefAttributes<HTMLDivElement>>;
package/dist/menu.js ADDED
@@ -0,0 +1,130 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { Button, Kbd, ScrollArea } from "./index.js";
4
+ import { positionLayer, registerLayer, returnFocus, tabbables } from "./overlay-runtime.js";
5
+ const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
6
+ export const ActionMenu = forwardRef(function ActionMenu({ label, trigger, items, onAction, open: controlled, defaultOpen, onOpenChange, disabled, side = "bottom", align = "start", controlSize = "sm", id: explicitId, className, onKeyDown, ...props }, ref) {
7
+ const [local, setLocal] = useState(Boolean(defaultOpen)), open = controlled ?? local;
8
+ const anchor = useRef(null), panel = useRef(null);
9
+ const generated = useId(), id = explicitId ?? `flood-menu-${generated}`;
10
+ const pending = useRef(undefined);
11
+ const edge = useRef("first"), search = useRef({ text: "", time: 0 });
12
+ if (!items.some(item => item.type !== "separator"))
13
+ throw new Error("Flood Menu requires at least one action item");
14
+ if (new Set(items.map(item => item.id)).size !== items.length || items.some(item => !item.id.trim() || (item.type !== "separator" && !item.label.trim())))
15
+ throw new Error("Flood menu items need unique nonempty IDs and action labels");
16
+ const request = (next) => {
17
+ if (pending.current === next)
18
+ return;
19
+ pending.current = next;
20
+ queueMicrotask(() => { pending.current = undefined; });
21
+ if (controlled === undefined)
22
+ setLocal(next);
23
+ onOpenChange?.(next);
24
+ };
25
+ const current = useRef({ open, request });
26
+ useBrowserLayoutEffect(() => { current.current = { open, request }; });
27
+ const setAnchor = useCallback((node) => { anchor.current = node; }, []);
28
+ useImperativeHandle(ref, () => panel.current, []);
29
+ const buttons = () => Array.from(panel.current?.querySelectorAll('[role="menuitem"]') ?? []);
30
+ const focusEdge = () => { const all = buttons(); ((edge.current === "last" ? all.at(-1) : all[0]) ?? panel.current)?.focus({ preventScroll: true }); };
31
+ useBrowserLayoutEffect(() => {
32
+ if (!open || !panel.current)
33
+ return;
34
+ const element = panel.current, document = element.ownerDocument, view = document.defaultView;
35
+ let alive = true, frame = 0, unregister = () => { };
36
+ const start = () => {
37
+ if (!alive || !anchor.current?.isConnected)
38
+ return;
39
+ const parent = anchor.current.closest("dialog");
40
+ if (parent && !parent.open) {
41
+ frame = view.requestAnimationFrame(start);
42
+ return;
43
+ }
44
+ if (typeof element.showPopover !== "function")
45
+ throw new Error("Flood Menu requires the native Popover API");
46
+ element.style.visibility = "hidden";
47
+ element.showPopover();
48
+ positionLayer(anchor.current, element, side, align);
49
+ unregister = registerLayer(document, { panel: element, anchor: () => anchor.current, close: () => current.current.request(false) });
50
+ search.current = { text: "", time: 0 };
51
+ focusEdge();
52
+ const update = () => { if (!alive)
53
+ return; if (anchor.current?.isConnected)
54
+ positionLayer(anchor.current, element, side, align); frame = view.requestAnimationFrame(update); };
55
+ frame = view.requestAnimationFrame(update);
56
+ };
57
+ start();
58
+ const toggle = (event) => {
59
+ if (!alive || event.newState !== "closed" || element.matches(":popover-open") || !current.current.open)
60
+ return;
61
+ current.current.request(false);
62
+ queueMicrotask(() => { if (alive && current.current.open && element.isConnected && !element.matches(":popover-open") && anchor.current?.isConnected) {
63
+ element.showPopover();
64
+ positionLayer(anchor.current, element, side, align);
65
+ focusEdge();
66
+ } });
67
+ };
68
+ element.addEventListener("toggle", toggle);
69
+ return () => { alive = false; view.cancelAnimationFrame(frame); unregister(); element.removeEventListener("toggle", toggle); returnFocus(element, anchor.current); if (element.matches(":popover-open"))
70
+ element.hidePopover(); };
71
+ }, [open, side, align]);
72
+ useBrowserLayoutEffect(() => {
73
+ if (open && panel.current?.matches(":popover-open") && panel.current.ownerDocument.activeElement === panel.current.ownerDocument.body)
74
+ focusEdge();
75
+ }, [items, open]);
76
+ const triggerProps = { ref: setAnchor, type: "button", disabled, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": id,
77
+ onClick: () => { edge.current = "first"; request(!open); },
78
+ onKeyDown: event => { if (event.defaultPrevented || disabled || event.altKey || event.ctrlKey || event.metaKey || !["ArrowDown", "ArrowUp"].includes(event.key))
79
+ return; event.preventDefault(); edge.current = event.key === "ArrowUp" ? "last" : "first"; if (open && panel.current?.matches(":popover-open"))
80
+ focusEdge();
81
+ else
82
+ request(true); },
83
+ };
84
+ const hasIcons = items.some(item => item.type !== "separator" && item.icon != null);
85
+ return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx("div", { ...props, ref: panel, id: id, popover: "manual", role: "menu", "aria-label": label, "aria-orientation": "vertical", tabIndex: -1, className: ["flood-overlay-panel", "flood-menu", className].filter(Boolean).join(" "), onKeyDown: event => {
86
+ onKeyDown?.(event);
87
+ if (event.defaultPrevented)
88
+ return;
89
+ const all = buttons(), index = all.indexOf(event.target);
90
+ if (event.key === "Tab") {
91
+ const button = anchor.current;
92
+ if (button) {
93
+ const scope = button.closest("dialog[open]") ?? button.ownerDocument.body;
94
+ const stops = tabbables(scope).filter(node => !panel.current?.contains(node));
95
+ const at = stops.indexOf(button), target = event.shiftKey ? button : at >= 0 ? stops[at + 1] : undefined;
96
+ if (target) {
97
+ event.preventDefault();
98
+ target.focus();
99
+ }
100
+ else
101
+ button.focus();
102
+ }
103
+ request(false);
104
+ return;
105
+ }
106
+ if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
107
+ return;
108
+ let next = event.key === "Home" ? 0 : event.key === "End" ? all.length - 1 : event.key === "ArrowDown" ? (index + 1) % all.length : event.key === "ArrowUp" ? (index - 1 + all.length) % all.length : -1;
109
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key))
110
+ search.current = { text: "", time: 0 };
111
+ if (event.key.length === 1 && event.key !== " ") {
112
+ const now = Date.now();
113
+ search.current = { text: (now - search.current.time < 700 ? search.current.text : "") + event.key.toLocaleLowerCase(), time: now };
114
+ const text = Array.from(search.current.text).every(char => char === search.current.text[0]) ? search.current.text[0] : search.current.text;
115
+ const start = text.length === 1 ? index + 1 : Math.max(0, index);
116
+ next = Array.from({ length: all.length }, (_, offset) => (start + offset) % all.length).find(candidate => all[candidate].dataset.label?.toLocaleLowerCase().startsWith(text)) ?? -1;
117
+ event.preventDefault();
118
+ }
119
+ if (next >= 0 && all[next]) {
120
+ event.preventDefault();
121
+ all[next].focus({ preventScroll: true });
122
+ all[next].scrollIntoView({ block: "nearest", inline: "nearest" });
123
+ }
124
+ }, children: open && _jsx(ScrollArea, { orientation: "vertical", role: "group", tabIndex: -1, className: "flood-menu__viewport", children: items.map(item => item.type === "separator" ? _jsx("div", { role: "separator", className: "flood-menu__separator" }, item.id) :
125
+ _jsxs(Button, { role: "menuitem", tabIndex: -1, variant: "ghost", size: controlSize, "aria-label": item.label, "aria-disabled": item.disabled || undefined, "data-label": item.label, "data-tone": item.tone, className: "flood-menu__item", onPointerMove: event => { if (event.pointerType !== "touch" && event.currentTarget.ownerDocument.activeElement !== event.currentTarget)
126
+ event.currentTarget.focus({ preventScroll: true }); }, onClick: event => { if (item.disabled) {
127
+ event.preventDefault();
128
+ return;
129
+ } request(false); onAction?.(item.id); }, children: [hasIcons && _jsx("span", { className: "flood-menu__icon", "aria-hidden": "true", children: item.icon }), _jsx("span", { className: "flood-menu__label", children: item.label }), item.shortcut && _jsx("span", { className: "flood-menu__shortcut", "aria-hidden": "true", children: _jsx(Kbd, { children: item.shortcut }) })] }, item.id)) }) })] });
130
+ });
@@ -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" }) });
@@ -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) => {
package/dist/overlays.js CHANGED
@@ -6,8 +6,17 @@ 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;
@@ -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
  });