@flood-md/react 0.2.4 → 0.2.6
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 +91 -1
- package/dist/alert-dialog.d.ts +2 -0
- package/dist/alert-dialog.js +23 -0
- package/dist/combobox.d.ts +11 -0
- package/dist/combobox.js +301 -0
- package/dist/content-elements.d.ts +8 -0
- package/dist/content-elements.js +47 -0
- package/dist/feedback-actions.d.ts +3 -0
- package/dist/feedback-actions.js +106 -0
- package/dist/file-input.d.ts +8 -0
- package/dist/file-input.js +86 -0
- package/dist/form-controls.d.ts +6 -0
- package/dist/form-controls.js +250 -0
- package/dist/foundations.css +348 -1
- package/dist/index.d.ts +323 -1
- package/dist/index.js +78 -0
- package/dist/navigation-layout.d.ts +7 -0
- package/dist/navigation-layout.js +77 -0
- package/dist/numeric-input.d.ts +8 -0
- package/dist/numeric-input.js +68 -0
- package/dist/overlay-runtime.js +33 -12
- package/dist/overlays.d.ts +7 -1
- package/dist/overlays.js +5 -3
- package/dist/select.js +2 -2
- package/dist/slider.d.ts +7 -0
- package/dist/slider.js +89 -0
- package/dist/text-inputs.d.ts +9 -0
- package/dist/text-inputs.js +45 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Original React 18.3/19 components for TypeScript and Next.js App Router. Native
|
|
4
4
|
HTML semantics and refs, no third-party component library or Tailwind dependency.
|
|
5
5
|
|
|
6
|
-
This package documents the **0.2.
|
|
6
|
+
This package documents the **0.2.6** source API. Keep Flood package versions aligned and follow the guide matching
|
|
7
7
|
your installed declarations; source changes do not modify an existing npm release.
|
|
8
8
|
|
|
9
9
|
```tsx
|
|
@@ -812,3 +812,93 @@ original OFL notices. Color alone never communicates validation or progress.
|
|
|
812
812
|
CodeSnippet codeDirection defaults to ltr independently of RTL parent action layout; choose rtl/auto explicitly for content that needs it. Native dir still applies to the wrapper. Protected ScrollArea reveals focused descendants fully when needed, composes the host focus handler and avoids scrolling to nested top-layer controls.
|
|
813
813
|
|
|
814
814
|
Checkbox, Radio and Switch apply native hidden/dir/lang to their visible label wrapper as well as the input, so a component-local language/direction applies to its whole choice.
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
0.2.5 adds NumberInput (native numeric editing and integrated step actions) and Slider (one value or an ordered pair). Use matching declarations and the numeric controls guide in docs/base-components.md. Registry publication is separate from source availability.
|
|
818
|
+
|
|
819
|
+
Combobox adds editable single selection in 0.2.5. Reuse Field for the
|
|
820
|
+
label, use options with stable value/label pairs, and observe onValueChange for
|
|
821
|
+
the selected ID. Search query text is not form data. See docs/base-components.md
|
|
822
|
+
for reset, required, read-only, localized messages and native Popover requirements.
|
|
823
|
+
|
|
824
|
+
Tag and MultiSelect are compatible additions in 0.2.5.
|
|
825
|
+
Tag labels an item and optionally requests host-owned removal. MultiSelect uses
|
|
826
|
+
arrays of stable option IDs and composes those tags with the shared searchable
|
|
827
|
+
selection model. See docs/base-components.md for keyboard behavior, locked values,
|
|
828
|
+
readonly/required forms, focus recovery and relative nested geometry. Existing
|
|
829
|
+
Combobox continues to accept and return a single string.
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
SearchInput and PasswordInput join 0.2.5. Both compose
|
|
833
|
+
InputGroup with native input props, Field feedback, ref, sizes and form ownership.
|
|
834
|
+
SearchInput uses type=search and a decorative leading icon; clearable defaults to
|
|
835
|
+
true and clearLabel names its sole suffix action. Clearing dispatches native input
|
|
836
|
+
editing, restores input focus and respects read-only/disabled. The host owns search
|
|
837
|
+
results and submission. PasswordInput keeps one visibility action; use
|
|
838
|
+
visible/onVisibleChange or defaultVisible, and localize showPasswordLabel and
|
|
839
|
+
hidePasswordLabel. Revealing retains the value and caret; read-only permits reveal.
|
|
840
|
+
Use autocomplete appropriate to the host form. Both accept accessible names via
|
|
841
|
+
Field, aria-label or aria-labelledby. These controls add no authentication logic.
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
FileInput (0.2.5) composes a native file input with Flood field geometry,
|
|
845
|
+
IconSlot and one optional clear IconButton. Pass Field's render props for its
|
|
846
|
+
accessible name/description/status. Native name, form, required, accept, multiple,
|
|
847
|
+
capture and input ref remain available. onChange is a native React change handler;
|
|
848
|
+
onFilesChange receives a new readonly File array for selection and clear. Do not
|
|
849
|
+
pass value/defaultValue/readOnly; FileList belongs to the browser. Uncontrolled
|
|
850
|
+
form reset clears the summary; cancelling the chooser keeps its prior selection.
|
|
851
|
+
Localize placeholder and clearLabel. Long names truncate visually, and the host
|
|
852
|
+
owns upload, file validation and progress. accept only guides the chooser.
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
FileInput multiple selection exposes a collapsible file list with full names and
|
|
856
|
+
localized file sizes (lang controls number formatting), and a count in the picker.
|
|
857
|
+
Translate selectionLabel and removeFileLabel. Individual removal updates the native
|
|
858
|
+
FileList through DataTransfer, then emits the same change callbacks as selection.
|
|
859
|
+
Focus moves to the next/previous remove action, or back to the input when empty.
|
|
860
|
+
clearable=false disables individual and global removal UI. In multiple mode, picking again appends files to the current selection; cancel
|
|
861
|
+
preserves it. Repeated files are deduplicated by name, size, type, lastModified and
|
|
862
|
+
relative path, without reading their contents. Single mode replaces its selection. The host remains responsible for upload.
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
AlertDialog (0.2.5) is a concise confirmation, composed from Flood's
|
|
866
|
+
native modal layer and Button. Supply title, description, trigger and onConfirm.
|
|
867
|
+
Cancel receives initial focus; Escape cancels; outside clicks do not dismiss.
|
|
868
|
+
Tab containment, background inertness, scroll lock and return focus reuse Dialog.
|
|
869
|
+
Translate confirmLabel/cancelLabel. onConfirm runs before requesting close, while
|
|
870
|
+
onCancel covers Cancel/Escape. Use open/onOpenChange for controlled state and
|
|
871
|
+
confirmDisabled to disable confirmation. Callbacks are not awaited; the host owns
|
|
872
|
+
async requests, pending UI and errors. Use Dialog for forms/complex content.
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
Drawer (0.2.5) reuses Dialog's modal behavior with an edge-panel layout.
|
|
876
|
+
side is logical start/end (default end) or top/bottom; size sm/md/lg sets the
|
|
877
|
+
cross-axis extent to 20/26/34rem within the viewport. Vertical panels fill height,
|
|
878
|
+
horizontal panels fill width. Use footer for host-owned actions; only body content
|
|
879
|
+
scrolls with Flood ScrollArea. Header/close action/footer remain visible. Native
|
|
880
|
+
ref, controlled open/onOpenChange, outside dismissal and initialFocusRef match
|
|
881
|
+
Dialog. Translate closeLabel/title/description; reduced motion disables entry
|
|
882
|
+
animation. Closing is immediate. There is no swipe/drag gesture or auto-save logic.
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
The 0.2.5 separates floating surfaces from modal surfaces.
|
|
886
|
+
Nine additional semantic roles bring the dark profile to 193 roles; previous token
|
|
887
|
+
values remain intact. surface.floating is the opaque fallback;
|
|
888
|
+
surface.floating-translucent, border.floating, selection.floating, shadow.floating
|
|
889
|
+
and effect.floating blur/saturation/shadow-offset/shadow-blur define the floating
|
|
890
|
+
material. Select, Combobox, MultiSelect, Menu, Popover and Tooltip share it; Dialog
|
|
891
|
+
and Drawer retain surface.overlay. Backdrop filtering is progressive enhancement;
|
|
892
|
+
reduced transparency uses the opaque fill and forced colors use system colors.
|
|
893
|
+
Keep text opaque and evaluate it against the actual composited background.
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
The 0.2.5 component expansion adds native content/typography,
|
|
897
|
+
collection/form controls, navigation/layout and clipboard/notification primitives.
|
|
898
|
+
See docs/base-components.md and the per-component catalog for contracts and limits.
|
|
899
|
+
The CSS build assembles the original component styles into the shared stylesheet.
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
Section headingLevel controls native h2–h6 semantics. Its independent headingSize
|
|
903
|
+
uses the existing Heading roles (section/page/large/display), defaulting to section.
|
|
904
|
+
Keep document hierarchy meaningful when changing visual emphasis.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useRef, useState } from "react";
|
|
3
|
+
import { Button } from "./index.js";
|
|
4
|
+
import { ModalOverlay } from "./overlays.js";
|
|
5
|
+
export const ConfirmationDialog = forwardRef(function ConfirmationDialog({ open: controlled, defaultOpen = false, onOpenChange, onConfirm, onCancel, confirmLabel = "Continue", cancelLabel = "Cancel", confirmDisabled = false, className, ...props }, ref) {
|
|
6
|
+
const [local, setLocal] = useState(defaultOpen);
|
|
7
|
+
const cancel = useRef(null);
|
|
8
|
+
const request = (next) => {
|
|
9
|
+
if (controlled === undefined)
|
|
10
|
+
setLocal(next);
|
|
11
|
+
onOpenChange?.(next);
|
|
12
|
+
};
|
|
13
|
+
const dismiss = () => { onCancel?.(); request(false); };
|
|
14
|
+
return _jsx(ModalOverlay, { ...props, ref: ref, role: "alertdialog", open: controlled ?? local, onOpenChange: next => { if (next)
|
|
15
|
+
request(true);
|
|
16
|
+
else
|
|
17
|
+
dismiss(); }, closeOnOutsideClick: false, showCloseButton: false, initialFocusRef: cancel, className: ["flood-alert-dialog", className].filter(Boolean).join(" "), children: _jsxs("div", { className: "flood-alert-dialog__actions", children: [_jsx(Button, { ref: cancel, type: "button", variant: "subtle", onClick: dismiss, children: cancelLabel }), _jsx(Button, { type: "button", disabled: confirmDisabled, onClick: () => {
|
|
18
|
+
if (confirmDisabled)
|
|
19
|
+
return;
|
|
20
|
+
onConfirm();
|
|
21
|
+
request(false);
|
|
22
|
+
}, children: confirmLabel })] }) });
|
|
23
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ComboboxProps } from "./index.js";
|
|
2
|
+
interface SearchSelectionProps extends Omit<ComboboxProps, "value" | "defaultValue" | "onValueChange"> {
|
|
3
|
+
selectedValues?: readonly string[];
|
|
4
|
+
initialValues: readonly string[];
|
|
5
|
+
onSelectionChange?: (value: string[]) => void;
|
|
6
|
+
multiple?: boolean;
|
|
7
|
+
removeItemLabel?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Shared search/layer model. Public single and multiple value contracts stay distinct. */
|
|
10
|
+
export declare const SearchSelection: import("react").ForwardRefExoticComponent<SearchSelectionProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
11
|
+
export {};
|
package/dist/combobox.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
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 { Input, InputGroup, IconButton, ScrollArea, Tag } from "./index.js";
|
|
4
|
+
import { mergeDescriptions, resolveFieldStatus } from "./field-state.js";
|
|
5
|
+
import { positionLayer, registerLayer } from "./overlay-runtime.js";
|
|
6
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
7
|
+
const searchable = (text) => text.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
|
|
8
|
+
/** Shared search/layer model. Public single and multiple value contracts stay distinct. */
|
|
9
|
+
export const SearchSelection = forwardRef(function SearchSelection({ options, selectedValues: value, initialValues: defaultValue, onSelectionChange: onValueChange, multiple = false, removeItemLabel = "Remove", clearable = false, emptyMessage = "No results found.", listLabel = "Options", toggleLabel = "Show options", clearLabel = "Clear selection", invalidSelectionMessage = "Select an option.", controlSize = "md", readOnly, disabled, required, name, id: explicitId, className, onKeyDown, onBlur, onClick, onInvalid, autoComplete = "off", ...props }, forwardedRef) {
|
|
10
|
+
if (new Set(options.map(option => option.value)).size !== options.length || options.some(option => !option.value || !option.label.trim()))
|
|
11
|
+
throw new Error("Flood Combobox options need unique nonempty values and labels");
|
|
12
|
+
if (![emptyMessage, listLabel, toggleLabel, clearLabel, invalidSelectionMessage].every(text => text.trim()))
|
|
13
|
+
throw new Error("Flood Combobox messages and action labels must be nonempty");
|
|
14
|
+
for (const values of [defaultValue, value])
|
|
15
|
+
if (values !== undefined && (!Array.isArray(values) || new Set(values).size !== values.length || values.some(item => typeof item !== "string" || !item)))
|
|
16
|
+
throw new Error("Flood selection values must be unique nonempty strings");
|
|
17
|
+
if (multiple && !removeItemLabel.trim())
|
|
18
|
+
throw new Error("Flood MultiSelect requires an accessible removal label");
|
|
19
|
+
const generatedId = useId(), id = explicitId ?? `flood-combobox-${generatedId}`;
|
|
20
|
+
const menuId = `${id}-options`, validationId = `${id}-validation`;
|
|
21
|
+
const input = useRef(null), root = useRef(null), panel = useRef(null), viewport = useRef(null);
|
|
22
|
+
useImperativeHandle(forwardedRef, () => input.current, []);
|
|
23
|
+
const initial = useRef([...defaultValue]);
|
|
24
|
+
const [local, setLocal] = useState(() => [...defaultValue]), [query, setQuery] = useState(null);
|
|
25
|
+
const [open, setOpen] = useState(false), [activeValue, setActiveValue] = useState();
|
|
26
|
+
const [validation, setValidation] = useState("");
|
|
27
|
+
const selections = (value ?? local).map(id => options.find(option => option.value === id)).filter((option) => Boolean(option));
|
|
28
|
+
const selected = selections[0], selectedIds = selections.map(option => option.value);
|
|
29
|
+
const displayed = query ?? (multiple ? "" : selected?.label ?? "");
|
|
30
|
+
const filtered = query === null ? options : options.filter(option => searchable([option.label, ...(option.keywords ?? [])].join(" ")).includes(searchable(query)));
|
|
31
|
+
const enabled = filtered.filter(option => !option.disabled);
|
|
32
|
+
const active = enabled.find(option => option.value === activeValue) ?? enabled[0];
|
|
33
|
+
const optionId = (optionValue) => `${menuId}-${options.findIndex(option => option.value === optionValue)}`;
|
|
34
|
+
const current = useRef({ value, selected, readOnly, disabled });
|
|
35
|
+
current.current = { value, selected, readOnly, disabled };
|
|
36
|
+
const close = () => { setOpen(false); setQuery(null); };
|
|
37
|
+
const inert = () => Boolean(readOnly || disabled || input.current?.matches(":disabled"));
|
|
38
|
+
const publish = (next) => {
|
|
39
|
+
if (value === undefined)
|
|
40
|
+
setLocal(next);
|
|
41
|
+
if (next.length !== selectedIds.length || next.some((id, index) => id !== selectedIds[index]))
|
|
42
|
+
onValueChange?.([...next]);
|
|
43
|
+
setValidation("");
|
|
44
|
+
};
|
|
45
|
+
const remove = (id) => {
|
|
46
|
+
if (inert() || options.find(option => option.value === id)?.disabled)
|
|
47
|
+
return;
|
|
48
|
+
publish(selectedIds.filter(value => value !== id));
|
|
49
|
+
input.current?.focus({ preventScroll: true });
|
|
50
|
+
};
|
|
51
|
+
const select = (next) => {
|
|
52
|
+
if (inert() || (next !== "" && !options.some(option => option.value === next && !option.disabled)))
|
|
53
|
+
return;
|
|
54
|
+
if (multiple) {
|
|
55
|
+
publish(next === "" ? selections.filter(option => option.disabled).map(option => option.value) : selectedIds.includes(next) ? selectedIds.filter(id => id !== next) : [...selectedIds, next]);
|
|
56
|
+
if (next) {
|
|
57
|
+
setQuery("");
|
|
58
|
+
setActiveValue(next);
|
|
59
|
+
setOpen(true);
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
close();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
publish(next ? [next] : []);
|
|
66
|
+
close();
|
|
67
|
+
}
|
|
68
|
+
input.current?.focus({ preventScroll: true });
|
|
69
|
+
};
|
|
70
|
+
const show = () => {
|
|
71
|
+
if (inert())
|
|
72
|
+
return;
|
|
73
|
+
setActiveValue(selected?.value);
|
|
74
|
+
setOpen(true);
|
|
75
|
+
};
|
|
76
|
+
useBrowserLayoutEffect(() => {
|
|
77
|
+
input.current?.setCustomValidity(required && !selections.length ? invalidSelectionMessage : "");
|
|
78
|
+
}, [required, selections.length, invalidSelectionMessage]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const form = input.current?.form;
|
|
81
|
+
if (!form)
|
|
82
|
+
return;
|
|
83
|
+
const reset = (event) => queueMicrotask(() => {
|
|
84
|
+
if (event.defaultPrevented)
|
|
85
|
+
return;
|
|
86
|
+
if (current.current.value === undefined)
|
|
87
|
+
setLocal(initial.current);
|
|
88
|
+
setQuery(null);
|
|
89
|
+
setOpen(false);
|
|
90
|
+
setValidation("");
|
|
91
|
+
});
|
|
92
|
+
form.addEventListener("reset", reset);
|
|
93
|
+
return () => form.removeEventListener("reset", reset);
|
|
94
|
+
}, [props.form]);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const control = input.current, Observer = control?.ownerDocument.defaultView?.MutationObserver;
|
|
97
|
+
if (!control || !Observer)
|
|
98
|
+
return;
|
|
99
|
+
const observer = new Observer(() => { if (control.matches(":disabled")) {
|
|
100
|
+
setOpen(false);
|
|
101
|
+
setQuery(null);
|
|
102
|
+
} });
|
|
103
|
+
let parent = control.parentElement;
|
|
104
|
+
while (parent) {
|
|
105
|
+
if (parent.localName === "fieldset")
|
|
106
|
+
observer.observe(parent, { attributes: true, attributeFilter: ["disabled"] });
|
|
107
|
+
parent = parent.parentElement;
|
|
108
|
+
}
|
|
109
|
+
return () => observer.disconnect();
|
|
110
|
+
}, []);
|
|
111
|
+
useEffect(() => { if (disabled || readOnly || props.hidden) {
|
|
112
|
+
setOpen(false);
|
|
113
|
+
setQuery(null);
|
|
114
|
+
} }, [disabled, readOnly, props.hidden]);
|
|
115
|
+
useBrowserLayoutEffect(() => {
|
|
116
|
+
if (!open || !panel.current || !root.current || !input.current)
|
|
117
|
+
return;
|
|
118
|
+
const popup = panel.current, anchor = root.current, control = input.current;
|
|
119
|
+
const document = popup.ownerDocument, view = document.defaultView;
|
|
120
|
+
let alive = true, frame = 0, unregister = () => { };
|
|
121
|
+
const assign = (element, property, value) => { if (element.style.getPropertyValue(property) !== value)
|
|
122
|
+
element.style.setProperty(property, value); };
|
|
123
|
+
const update = () => {
|
|
124
|
+
if (!alive)
|
|
125
|
+
return;
|
|
126
|
+
if (!anchor.isConnected || !anchor.getClientRects().length || control.matches(":disabled")) {
|
|
127
|
+
setOpen(false);
|
|
128
|
+
setQuery(null);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const rect = anchor.getBoundingClientRect(), visual = view.visualViewport;
|
|
132
|
+
const rootSize = parseFloat(view.getComputedStyle(document.documentElement).fontSize);
|
|
133
|
+
const length = (name) => { const raw = view.getComputedStyle(control).getPropertyValue(name).trim(); return parseFloat(raw) * (raw.endsWith("rem") ? rootSize : 1); };
|
|
134
|
+
const gap = length("--flood-sys-space-2") || 0;
|
|
135
|
+
const width = visual?.width ?? view.innerWidth, height = visual?.height ?? view.innerHeight, top = visual?.offsetTop ?? 0;
|
|
136
|
+
const room = Math.max(top + height - rect.bottom - gap * 2, rect.top - top - gap * 2);
|
|
137
|
+
const maxHeight = Math.min(length("--flood-sys-control-md-height") * 8, Math.max(0, room));
|
|
138
|
+
assign(popup, "width", `${Math.min(rect.width, Math.max(0, width - gap * 2))}px`);
|
|
139
|
+
assign(popup, "max-height", `${maxHeight}px`);
|
|
140
|
+
assign(popup, "column-gap", "var(--flood-sys-space-2)");
|
|
141
|
+
const css = view.getComputedStyle(popup);
|
|
142
|
+
const inset = parseFloat(css.paddingTop) + parseFloat(css.paddingBottom) + parseFloat(css.borderTopWidth) + parseFloat(css.borderBottomWidth);
|
|
143
|
+
if (viewport.current)
|
|
144
|
+
assign(viewport.current, "max-height", `${Math.max(0, maxHeight - inset)}px`);
|
|
145
|
+
positionLayer(anchor, popup, "bottom", "start");
|
|
146
|
+
frame = view.requestAnimationFrame(update);
|
|
147
|
+
};
|
|
148
|
+
const start = () => {
|
|
149
|
+
if (!alive)
|
|
150
|
+
return;
|
|
151
|
+
const dialog = anchor.closest("dialog");
|
|
152
|
+
if (dialog && !dialog.open) {
|
|
153
|
+
frame = view.requestAnimationFrame(start);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (typeof popup.showPopover !== "function")
|
|
157
|
+
throw new Error("Flood Combobox requires the native Popover API");
|
|
158
|
+
popup.style.visibility = "hidden";
|
|
159
|
+
popup.showPopover();
|
|
160
|
+
update();
|
|
161
|
+
unregister = registerLayer(document, { panel: popup, anchor: () => anchor, close: () => { setOpen(false); setQuery(null); } });
|
|
162
|
+
};
|
|
163
|
+
const toggle = (event) => { if (alive && event.newState === "closed" && !popup.matches(":popover-open")) {
|
|
164
|
+
setOpen(false);
|
|
165
|
+
setQuery(null);
|
|
166
|
+
} };
|
|
167
|
+
popup.addEventListener("toggle", toggle);
|
|
168
|
+
start();
|
|
169
|
+
return () => { alive = false; view.cancelAnimationFrame(frame); unregister(); popup.removeEventListener("toggle", toggle); if (popup.matches(":popover-open"))
|
|
170
|
+
popup.hidePopover(); };
|
|
171
|
+
}, [open]);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!open || !active || !viewport.current)
|
|
174
|
+
return;
|
|
175
|
+
const option = panel.current?.ownerDocument.getElementById(optionId(active.value));
|
|
176
|
+
if (!option)
|
|
177
|
+
return;
|
|
178
|
+
const area = viewport.current, a = option.getBoundingClientRect(), b = area.getBoundingClientRect();
|
|
179
|
+
if (a.top < b.top)
|
|
180
|
+
area.scrollTop -= b.top - a.top;
|
|
181
|
+
else if (a.bottom > b.bottom)
|
|
182
|
+
area.scrollTop += a.bottom - b.bottom;
|
|
183
|
+
}, [open, active?.value, query, options]);
|
|
184
|
+
const glyph = (clear) => _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: clear ? _jsx("path", { d: "m6 6 12 12M18 6 6 18" }) : _jsx("path", { d: "m6 9 6 6 6-6" }) });
|
|
185
|
+
const inputProps = {
|
|
186
|
+
...props, id, type: "text", value: displayed, disabled, readOnly, required: required && (!multiple || !selections.length), controlSize, autoComplete,
|
|
187
|
+
role: "combobox", "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? menuId : undefined,
|
|
188
|
+
"aria-required": required || undefined, "aria-activedescendant": open && active ? optionId(active.value) : undefined,
|
|
189
|
+
"aria-describedby": mergeDescriptions(props["aria-describedby"], validation && validationId, multiple && Boolean(selections.length) && `${id}-selection`),
|
|
190
|
+
invalid: props.invalid || Boolean(validation),
|
|
191
|
+
onChange: event => {
|
|
192
|
+
if (inert())
|
|
193
|
+
return;
|
|
194
|
+
const text = event.currentTarget.value;
|
|
195
|
+
setQuery(text);
|
|
196
|
+
setActiveValue(undefined);
|
|
197
|
+
setOpen(true);
|
|
198
|
+
setValidation("");
|
|
199
|
+
if (!multiple && text === "" && selected)
|
|
200
|
+
publish([]);
|
|
201
|
+
},
|
|
202
|
+
onClick: event => { onClick?.(event); if (!event.defaultPrevented)
|
|
203
|
+
show(); },
|
|
204
|
+
onBlur: event => {
|
|
205
|
+
onBlur?.(event);
|
|
206
|
+
if (event.defaultPrevented || root.current?.contains(event.relatedTarget))
|
|
207
|
+
return;
|
|
208
|
+
close();
|
|
209
|
+
},
|
|
210
|
+
onInvalid: event => { onInvalid?.(event); if (!event.defaultPrevented)
|
|
211
|
+
setValidation(event.currentTarget.validationMessage); },
|
|
212
|
+
onKeyDown: event => {
|
|
213
|
+
onKeyDown?.(event);
|
|
214
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
|
|
215
|
+
return;
|
|
216
|
+
// The composite owns Backspace; readonly selection must not become browser Back.
|
|
217
|
+
if (multiple && readOnly && event.key === "Backspace")
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
if (inert())
|
|
220
|
+
return;
|
|
221
|
+
const rtl = event.currentTarget.ownerDocument.defaultView?.getComputedStyle(event.currentTarget).direction === "rtl";
|
|
222
|
+
if (multiple && event.key === "Backspace" && displayed === "") {
|
|
223
|
+
const last = [...selections].reverse().find(option => !option.disabled);
|
|
224
|
+
if (last) {
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
remove(last.value);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (multiple && event.key === (rtl ? "ArrowRight" : "ArrowLeft") && event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0) {
|
|
230
|
+
const buttons = root.current?.querySelectorAll(".flood-tag__remove:not(:disabled)");
|
|
231
|
+
if (buttons?.length) {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
buttons[buttons.length - 1].focus();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
if (!open) {
|
|
239
|
+
setActiveValue(event.key === "ArrowUp" ? enabled.at(-1)?.value : selected?.value ?? enabled[0]?.value);
|
|
240
|
+
setOpen(true);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
const index = enabled.findIndex(option => option.value === active?.value);
|
|
244
|
+
const next = (index + (event.key === "ArrowDown" ? 1 : -1) + enabled.length) % enabled.length;
|
|
245
|
+
setActiveValue(enabled[next]?.value);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (event.key === "Enter" && open) {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
if (active)
|
|
251
|
+
select(active.value);
|
|
252
|
+
}
|
|
253
|
+
else if (event.key === "Escape" && open) {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
event.stopPropagation();
|
|
256
|
+
close();
|
|
257
|
+
}
|
|
258
|
+
else if (event.key === "Tab")
|
|
259
|
+
close();
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const removable = selections.some(option => !option.disabled);
|
|
263
|
+
const actions = _jsxs("span", { className: "flood-combobox__actions", children: [clearable && _jsx(IconButton, { label: clearLabel, variant: "ghost", size: controlSize, className: !selected ? "flood-combobox__clear-empty" : undefined, disabled: disabled || readOnly || !selected || (multiple && !removable), "aria-hidden": !selected || undefined, tabIndex: -1, onPointerDown: event => event.preventDefault(), onClick: () => select(""), children: glyph(true) }), _jsx(IconButton, { label: toggleLabel, variant: "ghost", size: controlSize, disabled: disabled || readOnly, tabIndex: -1, "aria-expanded": open, "aria-controls": open ? menuId : undefined, onPointerDown: event => event.preventDefault(), onClick: () => { if (open)
|
|
264
|
+
close();
|
|
265
|
+
else
|
|
266
|
+
show(); input.current?.focus({ preventScroll: true }); }, children: glyph(false) })] });
|
|
267
|
+
return _jsxs("div", { className: multiple ? "flood-combobox flood-multi-select" : "flood-combobox", ref: root, hidden: props.hidden, dir: props.dir, lang: props.lang, children: [multiple ? _jsxs("div", { className: ["flood-input-group", "flood-multi-select__control", `flood-control--${controlSize}`, className].filter(Boolean).join(" "), style: props.style, "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, "data-status": resolveFieldStatus(props.status, props.invalid || Boolean(validation), props["aria-invalid"]), onClick: event => { if (event.target === event.currentTarget && !inert()) {
|
|
268
|
+
input.current?.focus();
|
|
269
|
+
show();
|
|
270
|
+
} }, children: [selections.map(option => _jsx(Tag, { size: controlSize === "sm" ? "sm" : controlSize === "md" ? "md" : "lg", disabled: disabled || option.disabled, onRemove: readOnly ? undefined : () => remove(option.value), removeLabel: `${removeItemLabel} ${option.label}`, removeButtonProps: { tabIndex: -1, onKeyDown: event => {
|
|
271
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing || inert())
|
|
272
|
+
return;
|
|
273
|
+
const rtl = event.currentTarget.ownerDocument.defaultView?.getComputedStyle(event.currentTarget).direction === "rtl";
|
|
274
|
+
const buttons = [...root.current.querySelectorAll(".flood-tag__remove:not(:disabled)")];
|
|
275
|
+
const index = buttons.indexOf(event.currentTarget);
|
|
276
|
+
if (event.key === "Backspace" || event.key === "Delete") {
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
remove(option.value);
|
|
279
|
+
}
|
|
280
|
+
else if (event.key === "Escape") {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
event.stopPropagation();
|
|
283
|
+
close();
|
|
284
|
+
input.current?.focus();
|
|
285
|
+
}
|
|
286
|
+
else if (event.key === (rtl ? "ArrowLeft" : "ArrowRight")) {
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
(buttons[index + 1] ?? input.current)?.focus();
|
|
289
|
+
}
|
|
290
|
+
else if (event.key === (rtl ? "ArrowRight" : "ArrowLeft")) {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
(buttons[Math.max(0, index - 1)])?.focus();
|
|
293
|
+
}
|
|
294
|
+
} }, children: option.label }, option.value)), _jsxs("div", { className: "flood-multi-select__editor", children: [_jsx(Input, { ...inputProps, ref: input, style: undefined }), actions] })] }) : _jsx(InputGroup, { ...inputProps, ref: input, className: ["flood-combobox__control", className].filter(Boolean).join(" "), end: actions }), (multiple ? selections : [selected]).map((option, index) => _jsx("input", { type: "hidden", name: name, value: option?.value ?? "", disabled: disabled, form: props.form }, option?.value ?? index)), multiple && Boolean(selections.length) && _jsx("span", { id: `${id}-selection`, className: "flood-visually-hidden", children: selections.map(option => option.label).join(", ") }), open && _jsx("div", { ref: panel, popover: "manual", className: "flood-select__menu flood-combobox__menu", onPointerDown: event => { if (event.pointerType === "mouse")
|
|
295
|
+
event.preventDefault(); }, children: _jsxs(ScrollArea, { ref: viewport, id: menuId, role: "listbox", "aria-label": listLabel, "aria-multiselectable": multiple || undefined, orientation: "vertical", tabIndex: -1, className: "flood-select__viewport", children: [!filtered.length && _jsx("div", { className: "flood-select__empty", role: "option", "aria-disabled": "true", "aria-selected": "false", children: emptyMessage }), filtered.map(option => _jsxs("div", { id: optionId(option.value), role: "option", "aria-selected": selectedIds.includes(option.value), "aria-disabled": option.disabled || undefined, className: "flood-select__option", "data-active": option.value === active?.value || undefined, onPointerMove: event => { if (event.pointerType === "mouse" && !option.disabled)
|
|
296
|
+
setActiveValue(option.value); }, onPointerDown: event => {
|
|
297
|
+
// Touch must retain its compatibility click and native scrolling.
|
|
298
|
+
if (event.pointerType === "mouse")
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
}, onMouseDown: event => event.preventDefault(), onClick: () => select(option.value), children: [_jsxs("span", { className: "flood-combobox__option-text", children: [_jsx("span", { children: option.label }), option.description && _jsx("span", { className: "flood-combobox__description", children: option.description })] }), _jsx("span", { className: "flood-select__selected", "aria-hidden": "true", children: selectedIds.includes(option.value) ? "✓" : "" })] }, option.value))] }) }), open && !filtered.length && _jsx("span", { className: "flood-visually-hidden", role: "status", children: emptyMessage }), validation && _jsx("span", { id: validationId, className: "flood-select__validation", role: "alert", children: validation })] });
|
|
301
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AvatarGroupProps, StatusIndicatorProps, FigureProps, BlockquoteProps, InlineCodeProps, MetricProps, TimelineProps } from "./index.js";
|
|
2
|
+
export declare const GroupedAvatars: import("react").ForwardRefExoticComponent<AvatarGroupProps & import("react").RefAttributes<HTMLUListElement>>;
|
|
3
|
+
export declare const NamedStatus: import("react").ForwardRefExoticComponent<StatusIndicatorProps & import("react").RefAttributes<HTMLSpanElement>>;
|
|
4
|
+
export declare const CaptionedFigure: import("react").ForwardRefExoticComponent<FigureProps & import("react").RefAttributes<HTMLElement>>;
|
|
5
|
+
export declare const AttributedQuote: import("react").ForwardRefExoticComponent<BlockquoteProps & import("react").RefAttributes<HTMLQuoteElement>>;
|
|
6
|
+
export declare const InlineCodeText: import("react").ForwardRefExoticComponent<InlineCodeProps & import("react").RefAttributes<HTMLElement>>;
|
|
7
|
+
export declare const MeasuredValue: import("react").ForwardRefExoticComponent<MetricProps & import("react").RefAttributes<HTMLDListElement>>;
|
|
8
|
+
export declare const EventTimeline: import("react").ForwardRefExoticComponent<TimelineProps & import("react").RefAttributes<HTMLOListElement>>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import { Avatar, VisuallyHidden } from "./index.js";
|
|
4
|
+
const cx = (...parts) => parts.filter(Boolean).join(" ");
|
|
5
|
+
export const GroupedAvatars = forwardRef(function GroupedAvatars({ items, label, max = 4, size = "md", overflowLabel, className, ...props }, ref) {
|
|
6
|
+
if (!Number.isInteger(max) || max < 1)
|
|
7
|
+
throw new Error("Flood AvatarGroup max must be a positive integer");
|
|
8
|
+
if (!label.trim() || items.some(item => !item.id.trim() || !item.name.trim()) || new Set(items.map(item => item.id)).size !== items.length)
|
|
9
|
+
throw new Error("Flood AvatarGroup needs a label and unique nonempty item IDs/names");
|
|
10
|
+
const visible = items.slice(0, max), remaining = items.length - visible.length;
|
|
11
|
+
if (remaining > 0 && overflowLabel !== undefined && !overflowLabel.trim())
|
|
12
|
+
throw new Error("Flood AvatarGroup overflowLabel must be nonempty");
|
|
13
|
+
return _jsxs("ul", { ...props, ref: ref, "aria-label": label, className: cx("flood-avatar-group", className), "data-size": size, children: [visible.map(item => _jsx("li", { children: _jsx(Avatar, { name: item.name, src: item.src, fallback: item.fallback, size: size }) }, item.id)), remaining > 0 && _jsx("li", { children: _jsxs("span", { className: "flood-avatar flood-avatar-group__overflow", "data-size": size, role: "img", "aria-label": overflowLabel ?? `${remaining} more people`, children: ["+", remaining] }) })] });
|
|
14
|
+
});
|
|
15
|
+
export const NamedStatus = forwardRef(function NamedStatus({ label, tone = "neutral", size = "md", announce = false, className, role, ...props }, ref) {
|
|
16
|
+
if (!label.trim())
|
|
17
|
+
throw new Error("Flood StatusIndicator requires a visible label");
|
|
18
|
+
return _jsxs("span", { ...props, ref: ref, role: announce ? "status" : role, className: cx("flood-status-indicator", className), "data-tone": tone, "data-size": size, children: [_jsx("span", { className: "flood-status-indicator__dot", "aria-hidden": "true" }), label] });
|
|
19
|
+
});
|
|
20
|
+
export const CaptionedFigure = forwardRef(function CaptionedFigure({ caption, captionPosition = "after", children, className, ...props }, ref) {
|
|
21
|
+
const content = caption !== undefined && caption !== null && caption !== false ? _jsx("figcaption", { className: "flood-figure__caption", children: caption }) : null;
|
|
22
|
+
return _jsxs("figure", { ...props, ref: ref, className: cx("flood-figure", className), children: [captionPosition === "before" && content, children, captionPosition === "after" && content] });
|
|
23
|
+
});
|
|
24
|
+
export const AttributedQuote = forwardRef(function AttributedQuote({ attribution, children, className, ...props }, ref) {
|
|
25
|
+
return _jsxs("blockquote", { ...props, ref: ref, className: cx("flood-blockquote", className), children: [_jsx("div", { className: "flood-blockquote__quote", children: children }), attribution !== undefined && attribution !== null && attribution !== false && _jsx("footer", { className: "flood-blockquote__attribution", children: attribution })] });
|
|
26
|
+
});
|
|
27
|
+
export const InlineCodeText = forwardRef(function InlineCodeText({ size = "md", wrap = true, className, ...props }, ref) {
|
|
28
|
+
return _jsx("code", { ...props, ref: ref, className: cx("flood-inline-code", className), "data-size": size, "data-wrap": wrap });
|
|
29
|
+
});
|
|
30
|
+
export const MeasuredValue = forwardRef(function MeasuredValue({ label, value, hint, trend, size = "md", className, ...props }, ref) {
|
|
31
|
+
if (!label.trim() || (trend && !trend.label.trim()))
|
|
32
|
+
throw new Error("Flood Metric requires a label and meaningful trend text");
|
|
33
|
+
return _jsxs("dl", { ...props, ref: ref, className: cx("flood-metric", className), "data-size": size, children: [_jsx("dt", { className: "flood-metric__label", children: label }), _jsx("dd", { className: "flood-metric__value", children: value }), trend && _jsxs("dd", { className: "flood-metric__trend", "data-tone": trend.tone ?? "neutral", children: [_jsx("span", { "aria-hidden": "true", children: trend.direction === "up" ? "↑" : trend.direction === "down" ? "↓" : "→" }), trend.label] }), hint !== undefined && hint !== null && hint !== false && _jsx("dd", { className: "flood-metric__hint", children: hint })] });
|
|
34
|
+
});
|
|
35
|
+
export const EventTimeline = forwardRef(function EventTimeline({ items, label, stateLabels, className, ...props }, ref) {
|
|
36
|
+
if (!label.trim() || items.some(item => !item.id.trim() || !item.title.trim()) || new Set(items.map(item => item.id)).size !== items.length)
|
|
37
|
+
throw new Error("Flood Timeline needs a label and unique nonempty item IDs/titles");
|
|
38
|
+
if (items.filter(item => item.state === "current").length > 1)
|
|
39
|
+
throw new Error("Flood Timeline has at most one current event");
|
|
40
|
+
const labels = { complete: stateLabels?.complete ?? "Complete", current: stateLabels?.current ?? "Current", upcoming: stateLabels?.upcoming ?? "Upcoming" };
|
|
41
|
+
if (Object.values(labels).some(value => !value.trim()))
|
|
42
|
+
throw new Error("Flood Timeline state labels must be nonempty");
|
|
43
|
+
return _jsx("ol", { ...props, ref: ref, "aria-label": label, className: cx("flood-timeline", className), children: items.map(item => {
|
|
44
|
+
const state = item.state ?? "upcoming";
|
|
45
|
+
return _jsxs("li", { className: "flood-timeline__item", "data-state": state, "aria-current": state === "current" ? "step" : undefined, children: [_jsx("span", { className: "flood-timeline__marker", "aria-hidden": "true", children: item.icon ?? (state === "complete" ? _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m6 12 4 4 8-8" }) }) : state === "current" ? _jsx("span", {}) : null) }), _jsxs("div", { className: "flood-timeline__content", children: [_jsxs("div", { className: "flood-timeline__title", children: [_jsxs(VisuallyHidden, { children: [labels[state], ": "] }), item.title] }), item.description !== undefined && item.description !== null && _jsx("div", { className: "flood-timeline__description", children: item.description }), item.timeLabel && (item.dateTime ? _jsx("time", { className: "flood-timeline__time", dateTime: item.dateTime, children: item.timeLabel }) : _jsx("span", { className: "flood-timeline__time", children: item.timeLabel }))] })] }, item.id);
|
|
46
|
+
}) });
|
|
47
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { CopyButtonProps, ToastRegionProps } from "./index.js";
|
|
2
|
+
export declare const ClipboardAction: import("react").ForwardRefExoticComponent<CopyButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
3
|
+
export declare const NotificationRegion: import("react").ForwardRefExoticComponent<ToastRegionProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
3
|
+
import { Button, IconButton, IconSlot, Spinner, VisuallyHidden } from "./index.js";
|
|
4
|
+
export const ClipboardAction = forwardRef(function ClipboardAction({ value, copyLabel = "Copy", copiedLabel = "Copied", errorLabel = "Failed", resetDelay = 2000, onCopied, onCopyError, className, disabled, size = "md", variant = "subtle", "aria-label": accessibleLabel, ...props }, ref) {
|
|
5
|
+
if (!Number.isFinite(resetDelay) || resetDelay < 0)
|
|
6
|
+
throw new Error("Flood CopyButton resetDelay must be finite and nonnegative");
|
|
7
|
+
const [phase, setPhase] = useState("idle");
|
|
8
|
+
const [pending, setPending] = useState(false);
|
|
9
|
+
const timer = useRef(undefined);
|
|
10
|
+
const alive = useRef(true);
|
|
11
|
+
useEffect(() => { alive.current = true; return () => { alive.current = false; clearTimeout(timer.current); }; }, []);
|
|
12
|
+
const label = phase === "copied" ? copiedLabel : phase === "error" ? errorLabel : copyLabel;
|
|
13
|
+
const copy = async (node) => {
|
|
14
|
+
if (pending || node.matches(":disabled"))
|
|
15
|
+
return;
|
|
16
|
+
clearTimeout(timer.current);
|
|
17
|
+
setPending(true);
|
|
18
|
+
const view = node.ownerDocument.defaultView;
|
|
19
|
+
try {
|
|
20
|
+
if (!view?.navigator.clipboard?.writeText)
|
|
21
|
+
throw new Error("Clipboard writing is unavailable in this context");
|
|
22
|
+
await view.navigator.clipboard.writeText(value);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (alive.current) {
|
|
26
|
+
setPending(false);
|
|
27
|
+
setPhase("error");
|
|
28
|
+
}
|
|
29
|
+
onCopyError?.(error);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (alive.current) {
|
|
33
|
+
setPending(false);
|
|
34
|
+
setPhase("copied");
|
|
35
|
+
clearTimeout(timer.current);
|
|
36
|
+
timer.current = setTimeout(() => { if (alive.current)
|
|
37
|
+
setPhase("idle"); }, resetDelay);
|
|
38
|
+
}
|
|
39
|
+
onCopied?.();
|
|
40
|
+
};
|
|
41
|
+
return _jsxs(_Fragment, { children: [_jsx(Button, { ...props, ref: ref, type: "button", size: size, variant: variant, disabled: disabled || pending, "aria-label": accessibleLabel ? (phase === "idle" ? accessibleLabel : `${label}: ${accessibleLabel}`) : label, className: ["flood-copy-button", className].filter(Boolean).join(" "), onClick: event => { void copy(event.currentTarget); }, children: _jsx("span", { className: "flood-copy-button__labels", "aria-hidden": "true", children: [copyLabel, copiedLabel, errorLabel].map((text, index) => _jsxs("span", { "data-visible": index === (phase === "copied" ? 1 : phase === "error" ? 2 : 0) || undefined, children: [_jsx(IconSlot, { size: size, children: pending ? _jsx(Spinner, { decorative: true }) : _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: index === 1 ? _jsx("path", { d: "m5 12 4 4L19 6" }) : index === 2 ? _jsx("path", { d: "m6 6 12 12M18 6 6 18" }) : _jsxs(_Fragment, { children: [_jsx("rect", { x: "8", y: "8", width: "12", height: "12", rx: "2" }), _jsx("path", { d: "M15 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h3" })] }) }) }), _jsx("span", { children: text })] }, index)) }) }), _jsx(VisuallyHidden, { role: "status", children: phase === "idle" ? "" : label })] });
|
|
42
|
+
});
|
|
43
|
+
function TimedToast({ item, closeLabel, onDismiss }) {
|
|
44
|
+
const duration = item.duration ?? (item.action || item.status === "error" ? 0 : 5000);
|
|
45
|
+
if (!Number.isFinite(duration) || duration < 0)
|
|
46
|
+
throw new Error("Flood toast duration must be finite and nonnegative");
|
|
47
|
+
const [hover, setHover] = useState(false), [focus, setFocus] = useState(false), [hidden, setHidden] = useState(false);
|
|
48
|
+
const node = useRef(null), remaining = useRef(duration), callback = useRef(onDismiss), requested = useRef(false);
|
|
49
|
+
useEffect(() => { callback.current = onDismiss; }, [onDismiss]);
|
|
50
|
+
useEffect(() => { remaining.current = duration; requested.current = false; }, [duration]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const document = node.current?.ownerDocument;
|
|
53
|
+
if (!document)
|
|
54
|
+
return;
|
|
55
|
+
const update = () => setHidden(document.hidden);
|
|
56
|
+
update();
|
|
57
|
+
document.addEventListener("visibilitychange", update);
|
|
58
|
+
return () => document.removeEventListener("visibilitychange", update);
|
|
59
|
+
}, []);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!duration || hover || focus || hidden || requested.current)
|
|
62
|
+
return;
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
const timer = setTimeout(() => { if (requested.current)
|
|
65
|
+
return; requested.current = true; callback.current(item.id); }, remaining.current);
|
|
66
|
+
return () => { clearTimeout(timer); remaining.current = Math.max(0, remaining.current - (Date.now() - started)); };
|
|
67
|
+
}, [duration, hover, focus, hidden, item.id]);
|
|
68
|
+
const dismiss = () => {
|
|
69
|
+
requested.current = true;
|
|
70
|
+
const document = node.current?.ownerDocument;
|
|
71
|
+
if (node.current?.contains(document?.activeElement ?? null)) {
|
|
72
|
+
const region = node.current?.closest(".flood-toast-region");
|
|
73
|
+
const sibling = node.current?.nextElementSibling ?? node.current?.previousElementSibling;
|
|
74
|
+
(sibling?.querySelector("button:not(:disabled)") ?? region)?.focus({ preventScroll: true });
|
|
75
|
+
}
|
|
76
|
+
onDismiss(item.id);
|
|
77
|
+
};
|
|
78
|
+
return _jsxs("li", { ref: node, className: "flood-toast", "data-status": item.status ?? "neutral", onPointerEnter: () => setHover(true), onPointerLeave: () => setHover(false), onFocusCapture: () => setFocus(true), onBlurCapture: event => { if (!event.currentTarget.contains(event.relatedTarget))
|
|
79
|
+
setFocus(false); }, children: [_jsxs("div", { className: "flood-toast__message", role: item.status === "error" ? "alert" : "status", "aria-atomic": "true", children: [_jsx("div", { className: "flood-toast__title", children: item.title }), item.description && _jsx("div", { className: "flood-toast__description", children: item.description })] }), _jsx(IconButton, { label: `${closeLabel}: ${item.title}`, size: "sm", variant: "ghost", className: "flood-toast__close", onClick: dismiss, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", children: _jsx("path", { d: "m6 6 12 12M18 6 6 18" }) }) }), item.action && _jsx(Button, { size: "sm", variant: "subtle", className: "flood-toast__action", onClick: item.action.onClick, children: item.action.label })] });
|
|
80
|
+
}
|
|
81
|
+
export const NotificationRegion = forwardRef(function NotificationRegion({ items, onDismiss, label = "Notifications", closeLabel = "Dismiss notification", placement = "bottom-end", limit = 3, inline = false, hotkey = true, className, ...props }, forwardedRef) {
|
|
82
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
83
|
+
throw new Error("Flood ToastRegion limit must be a positive integer");
|
|
84
|
+
const ids = new Set();
|
|
85
|
+
for (const item of items) {
|
|
86
|
+
if (!item.id.trim() || ids.has(item.id) || !item.title.trim())
|
|
87
|
+
throw new Error("Flood toasts require unique nonempty IDs and titles");
|
|
88
|
+
ids.add(item.id);
|
|
89
|
+
}
|
|
90
|
+
const node = useRef(null);
|
|
91
|
+
useImperativeHandle(forwardedRef, () => node.current, []);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const element = node.current, document = element?.ownerDocument;
|
|
94
|
+
if (!hotkey || !element || !document)
|
|
95
|
+
return;
|
|
96
|
+
const key = (event) => {
|
|
97
|
+
if (event.defaultPrevented || event.key !== "F8" || event.altKey || event.ctrlKey || event.metaKey || !element.querySelector(".flood-toast") || element.hidden || Array.from(document.querySelectorAll(".flood-toast-region")).find(region => !region.hidden && region.getClientRects().length > 0 && region.querySelector(".flood-toast")) !== element)
|
|
98
|
+
return;
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
(element.querySelector('button:not(:disabled)') ?? element).focus({ preventScroll: true });
|
|
101
|
+
};
|
|
102
|
+
document.addEventListener("keydown", key);
|
|
103
|
+
return () => document.removeEventListener("keydown", key);
|
|
104
|
+
}, [hotkey]);
|
|
105
|
+
return _jsx("div", { ...props, ref: node, role: "region", "aria-label": label, tabIndex: -1, className: ["flood-toast-region", className].filter(Boolean).join(" "), "data-placement": placement, "data-inline": inline || undefined, children: _jsx("ol", { className: "flood-toast-region__list", children: items.slice(0, limit).map(item => _jsx(TimedToast, { item: item, closeLabel: closeLabel, onDismiss: onDismiss }, item.id)) }) });
|
|
106
|
+
});
|