@flood-md/react 0.2.2 → 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/README.md +758 -3
- 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/choice-group.d.ts +16 -0
- package/dist/choice-group.js +52 -0
- package/dist/data-display.d.ts +3 -0
- package/dist/data-display.js +11 -0
- package/dist/disclosure.d.ts +12 -0
- package/dist/disclosure.js +21 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +29 -0
- package/dist/field-state.d.ts +6 -0
- package/dist/field-state.js +8 -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/foundations.css +460 -11
- package/dist/index.d.ts +421 -4
- package/dist/index.js +289 -21
- package/dist/menu.d.ts +2 -0
- package/dist/menu.js +130 -0
- package/dist/navigation.d.ts +4 -0
- package/dist/navigation.js +68 -0
- package/dist/overlay-runtime.d.ts +38 -0
- package/dist/overlay-runtime.js +110 -0
- package/dist/overlays.d.ts +7 -0
- package/dist/overlays.js +179 -0
- package/dist/page-window.d.ts +1 -0
- package/dist/page-window.js +15 -0
- package/dist/scroll-area.d.ts +3 -0
- package/dist/scroll-area.js +235 -0
- package/dist/select.d.ts +3 -0
- package/dist/select.js +333 -0
- package/dist/styles.css +11 -0
- package/dist/toolbar.d.ts +2 -0
- package/dist/toolbar.js +80 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,39 +1,252 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { forwardRef, createElement, useId, } from "react";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, createElement, useId, useState, } from "react";
|
|
4
|
+
import { useFocusableHidden } from "./focusable-hidden.js";
|
|
5
|
+
import { resolveFieldStatus, hasFieldContent, mergeDescriptions } from "./field-state.js";
|
|
6
|
+
import { SingleSelect } from "./select.js";
|
|
7
|
+
import { MixedCheckbox } from "./checkbox.js";
|
|
8
|
+
import { OverlayScrollArea } from "./scroll-area.js";
|
|
9
|
+
import { ChoiceGroup } from "./choice-group.js";
|
|
10
|
+
import { Disclosure } from "./disclosure.js";
|
|
11
|
+
import { AnchoredOverlay, ModalOverlay } from "./overlays.js";
|
|
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";
|
|
4
18
|
const classes = (...values) => values.filter(Boolean).join(" ");
|
|
5
19
|
export const Button = forwardRef(function Button({ variant = "solid", size = "md", loading = false, disabled, type = "button", className, children, ...props }, ref) {
|
|
6
|
-
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(
|
|
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] }));
|
|
7
21
|
});
|
|
8
|
-
|
|
9
|
-
|
|
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 });
|
|
10
25
|
});
|
|
11
|
-
|
|
12
|
-
|
|
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 });
|
|
13
29
|
});
|
|
14
|
-
|
|
15
|
-
|
|
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 });
|
|
66
|
+
});
|
|
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 }); });
|
|
68
|
+
export const Breadcrumb = forwardRef(function Breadcrumb({ label = "Breadcrumb", separator = "/", ...props }, ref) { return _jsx(BreadcrumbNavigation, { ...props, ref: ref, label: label, separator: separator }); });
|
|
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
|
+
});
|
|
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 }); });
|
|
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 }); });
|
|
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 }); });
|
|
77
|
+
export const Collapsible = forwardRef(function Collapsible({ open, defaultOpen = false, onOpenChange, disabled = false, controlSize = "md", ...props }, ref) {
|
|
78
|
+
const [local, setLocal] = useState(defaultOpen);
|
|
79
|
+
return _jsx(Disclosure, { ...props, ref: ref, expanded: open ?? local, disabled: disabled, controlSize: controlSize, onExpandedChange: next => {
|
|
80
|
+
if (open === undefined)
|
|
81
|
+
setLocal(next);
|
|
82
|
+
onOpenChange?.(next);
|
|
83
|
+
} });
|
|
84
|
+
});
|
|
85
|
+
export const Accordion = forwardRef(function Accordion({ label, items, expansionMode = "single", value, defaultValue = [], onValueChange, disabled = false, controlSize = "md", headingLevel = 3, className, onKeyDown, ...props }, ref) {
|
|
86
|
+
const [local, setLocal] = useState(() => [...new Set(defaultValue)]);
|
|
87
|
+
const expanded = value ?? local;
|
|
88
|
+
if (new Set(items.map(item => item.value)).size !== items.length)
|
|
89
|
+
throw new Error("Flood accordion item values must be unique");
|
|
90
|
+
if (expansionMode === "single" && new Set(expanded).size > 1)
|
|
91
|
+
throw new Error("Flood single Accordion accepts at most one value");
|
|
92
|
+
return _jsx("div", { ...props, ref: ref, role: "group", "aria-label": label, className: classes("flood-accordion", className), onKeyDown: event => {
|
|
93
|
+
onKeyDown?.(event);
|
|
94
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
|
|
95
|
+
return;
|
|
96
|
+
const buttons = Array.from(event.currentTarget.querySelectorAll(":scope > .flood-disclosure > .flood-disclosure__heading > .flood-disclosure__trigger:not(:disabled)"));
|
|
97
|
+
const index = buttons.indexOf(event.target);
|
|
98
|
+
if (index < 0)
|
|
99
|
+
return;
|
|
100
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === "ArrowDown" ? (index + 1) % buttons.length : event.key === "ArrowUp" ? (index - 1 + buttons.length) % buttons.length : -1;
|
|
101
|
+
if (next >= 0) {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
buttons[next].focus();
|
|
104
|
+
}
|
|
105
|
+
}, children: items.map(item => _jsx(Collapsible, { title: item.title, open: expanded.includes(item.value), disabled: disabled || item.disabled, controlSize: controlSize, headingLevel: headingLevel, onOpenChange: active => {
|
|
106
|
+
const next = active ? expansionMode === "single" ? [item.value] : [...new Set([...expanded, item.value])] : expanded.filter(value => value !== item.value);
|
|
107
|
+
if (value === undefined)
|
|
108
|
+
setLocal(next);
|
|
109
|
+
onValueChange?.([...next]);
|
|
110
|
+
}, children: item.content }, item.value)) });
|
|
111
|
+
});
|
|
112
|
+
/** A native toggle button; its accessible name stays stable when pressed. */
|
|
113
|
+
export const Toggle = forwardRef(function Toggle({ pressed, defaultPressed = false, onPressedChange, iconOnly = false, size = "md", variant = "subtle", onClick, className, children, disabled, loading, ...props }, ref) {
|
|
114
|
+
const [local, setLocal] = useState(defaultPressed);
|
|
115
|
+
const selected = pressed ?? local;
|
|
116
|
+
return _jsx(Button, { ...props, ref: ref, type: "button", "aria-pressed": selected, size: size, variant: variant, disabled: disabled, loading: loading, className: classes("flood-toggle", iconOnly && "flood-icon-button", className), onClick: event => {
|
|
117
|
+
onClick?.(event);
|
|
118
|
+
if (event.defaultPrevented || disabled || loading)
|
|
119
|
+
return;
|
|
120
|
+
if (pressed === undefined)
|
|
121
|
+
setLocal(!selected);
|
|
122
|
+
onPressedChange?.(!selected);
|
|
123
|
+
}, children: iconOnly ? _jsx(IconSlot, { size: size, children: children }) : children });
|
|
124
|
+
});
|
|
125
|
+
/** Optional selections exposed as pressed buttons, not radios, tabs or a toolbar. */
|
|
126
|
+
export const ToggleGroup = forwardRef(function ToggleGroup({ label, options, selectionMode = "multiple", value, defaultValue = [], onValueChange, orientation = "horizontal", size = "md", variant = "subtle", disabled = false, iconOnly = false, className, onKeyDown, ...props }, ref) {
|
|
127
|
+
const [local, setLocal] = useState(() => [...new Set(defaultValue)]);
|
|
128
|
+
const selected = value ?? local;
|
|
129
|
+
if (new Set(options.map(option => option.value)).size !== options.length)
|
|
130
|
+
throw new Error("Flood toggle option values must be unique");
|
|
131
|
+
if (selectionMode === "single" && new Set(selected).size > 1)
|
|
132
|
+
throw new Error("Flood single ToggleGroup accepts at most one value");
|
|
133
|
+
if (iconOnly && options.some(option => !option.icon))
|
|
134
|
+
throw new Error("Flood icon-only toggle options require icons");
|
|
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)) }) });
|
|
157
|
+
});
|
|
158
|
+
/** Connected actions keep their ordinary Tab order; this is not a toolbar. */
|
|
159
|
+
export const ButtonGroup = forwardRef(function ButtonGroup({ label, orientation = "horizontal", className, ...props }, ref) {
|
|
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) }) });
|
|
161
|
+
});
|
|
162
|
+
export const Input = forwardRef(function Input({ invalid, status, controlSize = "md", className, ...props }, ref) {
|
|
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) }));
|
|
164
|
+
});
|
|
165
|
+
/** One real Input with host-owned logical start/end adornments. Ref and native attributes target the input. */
|
|
166
|
+
export const InputGroup = forwardRef(function InputGroup({ start, end, controlSize = "md", className, style, invalid, status, disabled, readOnly, ...props }, ref) {
|
|
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 })] });
|
|
168
|
+
});
|
|
169
|
+
export const Textarea = forwardRef(function Textarea({ invalid, status, controlSize = "md", className, ...props }, ref) {
|
|
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) }));
|
|
171
|
+
});
|
|
172
|
+
/** Native select and option semantics with an independent visual control size. */
|
|
173
|
+
export const Select = forwardRef(function Select({ invalid, status, controlSize = "md", className, multiple, size, ...props }, ref) {
|
|
174
|
+
const listbox = multiple || (size !== undefined && size > 1);
|
|
175
|
+
if (!listbox)
|
|
176
|
+
return _jsx(SingleSelect, { ...props, status: status, invalid: invalid, controlSize: controlSize, className: className, size: size, ref: ref });
|
|
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) }) }));
|
|
178
|
+
});
|
|
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 })] }));
|
|
16
181
|
});
|
|
17
182
|
export const Radio = forwardRef(function Radio({ label, className, ...props }, ref) {
|
|
18
|
-
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 })] }));
|
|
19
184
|
});
|
|
20
185
|
export const Switch = forwardRef(function Switch({ label, className, ...props }, ref) {
|
|
21
|
-
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" })] }));
|
|
187
|
+
});
|
|
188
|
+
/** A native label; the required marker never substitutes for the control's required prop. */
|
|
189
|
+
export const Label = forwardRef(function Label({ required, children, className, ...props }, ref) {
|
|
190
|
+
return _jsxs("label", { ...props, ref: ref, className: classes("flood-label", className), children: [children, required && _jsx("span", { "aria-hidden": "true", children: " *" })] });
|
|
22
191
|
});
|
|
23
|
-
|
|
192
|
+
/** A native group: disabled, form ownership and nested fieldsets retain browser behavior. */
|
|
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) {
|
|
24
194
|
const generatedId = useId();
|
|
25
195
|
const id = explicitId ?? `flood-${generatedId}`;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
196
|
+
const legendRef = useFocusableHidden(hideLabel);
|
|
197
|
+
const hasHint = hasFieldContent(hint), hasError = hasFieldContent(error), hasMessage = hasFieldContent(message) && !hasError;
|
|
198
|
+
const resolvedStatus = resolveFieldStatus(status, hasError, ariaInvalid);
|
|
199
|
+
const description = mergeDescriptions(describedBy, hasHint && `${id}-hint`, hasError && `${id}-error`, hasMessage && `${id}-message`);
|
|
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 })] })] });
|
|
201
|
+
});
|
|
202
|
+
export const RadioGroup = forwardRef(function RadioGroup({ value, defaultValue, onValueChange, orientation = "vertical", ...props }, ref) {
|
|
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 });
|
|
204
|
+
});
|
|
205
|
+
/** A segmented presentation of native single-choice inputs, retaining forms and reset. */
|
|
206
|
+
export const SegmentedControl = forwardRef(function SegmentedControl({ label, options, controlSize = "md", iconOnly = false, fullWidth = false, orientation = "horizontal", className, onKeyDown, ...props }, ref) {
|
|
207
|
+
if (iconOnly && options.some(option => !option.icon))
|
|
208
|
+
throw new Error("Flood icon-only segments require icons");
|
|
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 => {
|
|
210
|
+
onKeyDown?.(event);
|
|
211
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
|
|
212
|
+
return;
|
|
213
|
+
const inputs = Array.from(event.currentTarget.querySelectorAll(":scope > .flood-fieldset__body > .flood-choice-group > .flood-choice > input[type=radio]:not(:disabled)"));
|
|
214
|
+
const index = inputs.indexOf(event.target);
|
|
215
|
+
if (index < 0)
|
|
216
|
+
return;
|
|
217
|
+
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
|
|
218
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? inputs.length - 1
|
|
219
|
+
: event.key === "ArrowDown" || event.key === (rtl ? "ArrowLeft" : "ArrowRight") ? (index + 1) % inputs.length
|
|
220
|
+
: event.key === "ArrowUp" || event.key === (rtl ? "ArrowRight" : "ArrowLeft") ? (index - 1 + inputs.length) % inputs.length : -1;
|
|
221
|
+
if (next >= 0) {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
inputs[next].focus();
|
|
224
|
+
inputs[next].click();
|
|
225
|
+
}
|
|
226
|
+
} });
|
|
227
|
+
});
|
|
228
|
+
export const CheckboxGroup = forwardRef(function CheckboxGroup({ value, defaultValue = [], onValueChange, orientation = "vertical", ...props }, ref) {
|
|
229
|
+
return _jsx(ChoiceGroup, { ...props, ref: ref, kind: "checkbox", orientation: orientation, selected: value, initial: defaultValue, onSelectionChange: onValueChange, fieldsetComponent: Fieldset, choiceComponent: Checkbox });
|
|
230
|
+
});
|
|
231
|
+
export function Field({ status, message, id: explicitId, label, hideLabel = false, hint, error, required, describedBy, children, }) {
|
|
232
|
+
const generatedId = useId();
|
|
233
|
+
const id = explicitId ?? `flood-${generatedId}`;
|
|
234
|
+
const labelRef = useFocusableHidden(hideLabel);
|
|
235
|
+
const hasMessage = hasFieldContent(message);
|
|
236
|
+
const hasHint = hasFieldContent(hint);
|
|
237
|
+
const hasError = hasFieldContent(error);
|
|
238
|
+
const resolvedStatus = hasError ? "error" : status;
|
|
239
|
+
const description = mergeDescriptions(describedBy, hasHint && `${id}-hint`, hasError && `${id}-error`, hasMessage && !hasError && `${id}-message`);
|
|
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({
|
|
29
241
|
id,
|
|
30
242
|
"aria-describedby": description,
|
|
31
|
-
"aria-invalid": error ? true : undefined,
|
|
243
|
+
"aria-invalid": hasError || resolvedStatus === "error" ? true : undefined,
|
|
244
|
+
status: resolvedStatus,
|
|
32
245
|
required,
|
|
33
|
-
}),
|
|
246
|
+
}), hasHint && (_jsx("div", { id: `${id}-hint`, className: "flood-hint", children: hint })), hasMessage && !hasError && _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 }))] }));
|
|
34
247
|
}
|
|
35
|
-
export const Link = forwardRef(function Link({ className, ...props }, ref) {
|
|
36
|
-
return (_jsx("a", { ...props, ref: ref, className: classes("flood-link", className) }));
|
|
248
|
+
export const Link = forwardRef(function Link({ variant = "text", size = "md", className, ...props }, ref) {
|
|
249
|
+
return (_jsx("a", { ...props, ref: ref, className: classes("flood-link", variant !== "text" && "flood-link--button", variant !== "text" && "flood-button", variant !== "text" && `flood-button--${variant}`, variant !== "text" && `flood-button--${size}`, className) }));
|
|
37
250
|
});
|
|
38
251
|
/** Explicit opt-in to the dark foundations profile; no global theme mutation. */
|
|
39
252
|
export const FloodRoot = forwardRef(function FloodRoot({ className, ...props }, ref) {
|
|
@@ -54,6 +267,57 @@ export const Inline = forwardRef(function Inline({ gap = "2", align = "center",
|
|
|
54
267
|
export const Surface = forwardRef(function Surface({ elevation = "default", padding = "6", style, className, ...props }, ref) {
|
|
55
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 } });
|
|
56
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
|
+
});
|
|
57
321
|
/** Adapter for the host's SVG icon package; glyphs are not bundled. */
|
|
58
322
|
export const IconSlot = forwardRef(function IconSlot({ size = "md", label, className, children, ...props }, ref) {
|
|
59
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 });
|
|
@@ -65,6 +329,10 @@ export const Divider = forwardRef(function Divider({ className, ...props }, ref)
|
|
|
65
329
|
return _jsx("hr", { ...props, ref: ref, className: classes("flood-divider", className) });
|
|
66
330
|
});
|
|
67
331
|
/** Selectable code with an optional host-owned action; no clipboard side effects. */
|
|
68
|
-
export const CodeSnippet = forwardRef(function CodeSnippet({ code, action, className, ...props }, ref) {
|
|
69
|
-
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 })] }));
|
|
334
|
+
});
|
|
335
|
+
/** Native scrolling with inset overlay tracks; the ref targets the scrollable viewport. */
|
|
336
|
+
export const ScrollArea = forwardRef(function ScrollArea({ orientation = "vertical", contentInset = "scrollbar", ...props }, ref) {
|
|
337
|
+
return _jsx(OverlayScrollArea, { ...props, orientation: orientation, contentInset: contentInset, ref: ref });
|
|
70
338
|
});
|
package/dist/menu.d.ts
ADDED
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
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TabsProps, BreadcrumbProps, PaginationProps } from "./index.js";
|
|
2
|
+
export declare const TabNavigation: import("react").ForwardRefExoticComponent<TabsProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
3
|
+
export declare const BreadcrumbNavigation: import("react").ForwardRefExoticComponent<BreadcrumbProps & import("react").RefAttributes<HTMLElement>>;
|
|
4
|
+
export declare const PageNavigation: import("react").ForwardRefExoticComponent<PaginationProps & import("react").RefAttributes<HTMLElement>>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { Button, IconSlot, Link, ScrollArea } from "./index.js";
|
|
4
|
+
import { pageWindow } from "./page-window.js";
|
|
5
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
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) {
|
|
7
|
+
const generated = useId(), id = explicitId ?? `flood-tabs-${generated}`;
|
|
8
|
+
const [local, setLocal] = useState(defaultValue), [focused, setFocused] = useState();
|
|
9
|
+
const root = useRef(null), list = useRef(null), focusedPanel = useRef(undefined);
|
|
10
|
+
useImperativeHandle(ref, () => root.current, []);
|
|
11
|
+
if (new Set(items.map(item => item.value)).size !== items.length)
|
|
12
|
+
throw new Error("Flood tab values must be unique");
|
|
13
|
+
if (iconOnly && items.some(item => !item.icon))
|
|
14
|
+
throw new Error("Flood icon-only tabs require icons");
|
|
15
|
+
const available = items.filter(item => !item.disabled), enabled = disabled ? [] : available;
|
|
16
|
+
const selected = available.find(item => item.value === (value ?? local))?.value ?? available[0]?.value;
|
|
17
|
+
const tabStop = enabled.some(item => item.value === focused) ? focused : selected;
|
|
18
|
+
const select = (next) => { if (next === selected)
|
|
19
|
+
return; if (value === undefined)
|
|
20
|
+
setLocal(next); onValueChange?.(next); };
|
|
21
|
+
useBrowserLayoutEffect(() => {
|
|
22
|
+
if (focusedPanel.current === undefined || focusedPanel.current === selected)
|
|
23
|
+
return;
|
|
24
|
+
const index = items.findIndex(item => item.value === focusedPanel.current), panel = root.current?.ownerDocument.getElementById(`${id}-panel-${index}`);
|
|
25
|
+
const active = root.current?.ownerDocument.activeElement;
|
|
26
|
+
if (active === root.current?.ownerDocument.body || panel?.contains(active ?? null)) {
|
|
27
|
+
const next = items.findIndex(item => item.value === selected);
|
|
28
|
+
root.current?.ownerDocument.getElementById(`${id}-tab-${next}`)?.focus();
|
|
29
|
+
}
|
|
30
|
+
focusedPanel.current = undefined;
|
|
31
|
+
}, [selected, id, items]);
|
|
32
|
+
return _jsxs("div", { ...props, ref: root, id: id, className: ["flood-tabs", className].filter(Boolean).join(" "), "data-orientation": orientation, onKeyDown: event => {
|
|
33
|
+
onKeyDown?.(event);
|
|
34
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
|
|
35
|
+
return;
|
|
36
|
+
const buttons = Array.from(list.current?.querySelectorAll(':scope > button[role="tab"]:not(:disabled)') ?? []), index = buttons.indexOf(event.target);
|
|
37
|
+
if (index < 0)
|
|
38
|
+
return;
|
|
39
|
+
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
|
|
40
|
+
const nextKey = orientation === "vertical" ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight", previousKey = orientation === "vertical" ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
|
|
41
|
+
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;
|
|
42
|
+
if (next >= 0) {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
buttons[next].focus();
|
|
45
|
+
if (activationMode === "automatic")
|
|
46
|
+
select(buttons[next].dataset.value);
|
|
47
|
+
}
|
|
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
|
+
focusedPanel.current = undefined; }, children: item.content }, item.value))] });
|
|
51
|
+
});
|
|
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(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
|
+
});
|
|
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
|
+
const pages = pageWindow(page, pageCount, siblingCount);
|
|
57
|
+
const control = (target, text, name, unavailable = false) => {
|
|
58
|
+
const inactive = disabled || unavailable, current = target === page;
|
|
59
|
+
const shared = { className: "flood-page", "aria-label": name, "aria-current": current && !unavailable ? "page" : undefined };
|
|
60
|
+
if (getHref)
|
|
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
|
+
onPageChange?.(target); }, children: text });
|
|
63
|
+
return _jsx(Button, { ...shared, variant: "subtle", size: controlSize, type: "button", disabled: inactive, onClick: () => { if (!current)
|
|
64
|
+
onPageChange?.(target); }, children: text });
|
|
65
|
+
};
|
|
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" }) });
|
|
67
|
+
return _jsx("nav", { ...props, ref: ref, "aria-label": label, className: ["flood-pagination", className].filter(Boolean).join(" "), children: _jsxs("ul", { children: [_jsx("li", { children: control(page - 1, arrow(true), previousLabel, page === 1) }), pages.map(item => _jsx("li", { "aria-hidden": typeof item === "string" ? true : undefined, children: typeof item === "number" ? control(item, String(item), pageLabel(item)) : _jsx("span", { className: "flood-page-gap", children: "\u2026" }) }, item)), _jsx("li", { children: control(page + 1, arrow(false), nextLabel, page === pageCount) })] }) });
|
|
68
|
+
});
|