@flood-md/react 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { FileInputProps, IconButton, IconSlot } from "./index.js";
2
+ interface Props extends FileInputProps {
3
+ actionComponent: typeof IconButton;
4
+ iconComponent: typeof IconSlot;
5
+ }
6
+ /** FileList and native form submission stay owned by the browser. */
7
+ export declare const FilePicker: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLInputElement>>;
8
+ export {};
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
3
+ import { resolveFieldStatus } from "./field-state.js";
4
+ /** FileList and native form submission stay owned by the browser. */
5
+ export const FilePicker = forwardRef(function FilePicker({ actionComponent: Action, iconComponent: Icon, clearable = true, clearLabel = "Clear files", selectionLabel = "Selected files", removeFileLabel = "Remove file", placeholder, controlSize = "md", className, style, status, invalid, disabled, multiple, onChange, onFilesChange, ...props }, ref) {
6
+ const input = useRef(null);
7
+ useImperativeHandle(ref, () => input.current, []);
8
+ const [files, setFiles] = useState([]);
9
+ const selected = useRef([]);
10
+ const replacing = useRef(false);
11
+ useEffect(() => {
12
+ const form = input.current?.form;
13
+ if (!form)
14
+ return;
15
+ let timer;
16
+ const reset = (event) => {
17
+ timer = setTimeout(() => {
18
+ if (!event.defaultPrevented && input.current) {
19
+ selected.current = Array.from(input.current.files ?? []);
20
+ setFiles(selected.current);
21
+ }
22
+ }, 0);
23
+ };
24
+ form.addEventListener("reset", reset);
25
+ return () => { form.removeEventListener("reset", reset); clearTimeout(timer); };
26
+ }, [props.form]);
27
+ const clear = () => {
28
+ const node = input.current;
29
+ if (!node || node.matches(":disabled"))
30
+ return;
31
+ notify([]);
32
+ node.focus();
33
+ };
34
+ const notify = (next) => {
35
+ const node = input.current;
36
+ if (!node || node.matches(":disabled"))
37
+ return;
38
+ const transfer = new node.ownerDocument.defaultView.DataTransfer();
39
+ next.forEach(file => transfer.items.add(file));
40
+ node.files = transfer.files;
41
+ replacing.current = true;
42
+ try {
43
+ node.dispatchEvent(new node.ownerDocument.defaultView.Event("change", { bubbles: true }));
44
+ }
45
+ finally {
46
+ replacing.current = false;
47
+ }
48
+ };
49
+ const summary = multiple && files.length ? `${selectionLabel} (${files.length})` : files[0]?.name ?? "";
50
+ const cross = _jsx("svg", { "aria-hidden": "true", 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" }) });
51
+ const fileSize = (size) => new Intl.NumberFormat(props.lang, { style: "unit", unit: size < 1000 ? "byte" : size < 1000000 ? "kilobyte" : "megabyte", maximumFractionDigits: 1 }).format(size < 1000 ? size : size < 1000000 ? size / 1000 : size / 1000000);
52
+ return _jsxs("div", { className: "flood-file-input-field", hidden: props.hidden, dir: props.dir, lang: props.lang, children: [_jsxs("div", { hidden: props.hidden, dir: props.dir, lang: props.lang, style: style, className: ["flood-input-group", "flood-file-input", `flood-control--${controlSize}`, className].filter(Boolean).join(" "), "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flood-file-input__picker", children: [_jsx("input", { ...props, ref: input, type: "file", disabled: disabled, multiple: multiple, "aria-invalid": invalid || status === "error" || props["aria-invalid"], className: "flood-file-input__native", onChange: event => {
53
+ const node = event.currentTarget;
54
+ const picked = Array.from(node.files ?? []);
55
+ let next = picked;
56
+ if (multiple && !replacing.current && picked.length > 0) {
57
+ const seen = new Set();
58
+ next = [...selected.current, ...picked].filter(file => {
59
+ const identity = JSON.stringify([file.name, file.size, file.type, file.lastModified, file.webkitRelativePath]);
60
+ if (seen.has(identity))
61
+ return false;
62
+ seen.add(identity);
63
+ return true;
64
+ });
65
+ const transfer = new node.ownerDocument.defaultView.DataTransfer();
66
+ next.forEach(file => transfer.items.add(file));
67
+ node.files = transfer.files;
68
+ }
69
+ selected.current = next;
70
+ setFiles(next);
71
+ onChange?.(event);
72
+ onFilesChange?.(next);
73
+ } }), _jsxs("span", { className: "flood-file-input__display", "aria-hidden": "true", "data-empty": files.length === 0 || undefined, children: [_jsx(Icon, { size: controlSize, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M12 16V3m-4 4 4-4 4 4M4 15v5a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-5" }) }) }), _jsx("span", { className: "flood-file-input__summary", title: summary || undefined, children: summary || placeholder || (multiple ? "Choose files" : "Choose a file") })] })] }), clearable && files.length > 0 && _jsx("div", { className: "flood-input-group__addon flood-input-group__addon--end", children: _jsx(Action, { label: clearLabel, size: controlSize, variant: "ghost", disabled: disabled, onPointerDown: event => event.preventDefault(), onClick: clear, children: cross }) })] }), multiple && files.length > 0 && _jsxs("details", { className: "flood-file-input__selection", open: true, children: [_jsxs("summary", { children: [selectionLabel, _jsx("span", { className: "flood-file-input__count", children: files.length })] }), _jsx("ul", { className: "flood-file-input__list", children: files.map((file, index) => _jsxs("li", { className: "flood-file-input__file", children: [_jsxs("span", { className: "flood-file-input__metadata", children: [_jsx("span", { className: "flood-file-input__name", dir: "auto", children: file.name }), _jsx("span", { className: "flood-file-input__size", children: fileSize(file.size) })] }), clearable && _jsx(Action, { label: `${removeFileLabel} ${file.name}`, size: "sm", variant: "ghost", disabled: disabled, onClick: event => {
74
+ const node = input.current;
75
+ if (!node || node.matches(":disabled"))
76
+ return;
77
+ const list = event.currentTarget.closest("ul");
78
+ notify(Array.from(node.files ?? []).filter((_, position) => position !== index));
79
+ node.ownerDocument.defaultView.requestAnimationFrame(() => {
80
+ if (!node.isConnected)
81
+ return;
82
+ const buttons = list.querySelectorAll("button");
83
+ (buttons[Math.min(index, buttons.length - 1)] ?? node).focus();
84
+ });
85
+ }, children: cross })] }, `${file.name}-${file.lastModified}-${index}`)) })] })] });
86
+ });
@@ -0,0 +1,6 @@
1
+ import type { ListBoxProps, PinInputProps, RatingProps, DropzoneProps, CharacterCountProps } from "./index.js";
2
+ export declare const CollectionListBox: import("react").ForwardRefExoticComponent<ListBoxProps & import("react").RefAttributes<HTMLDivElement>>;
3
+ export declare const SegmentedPinInput: import("react").ForwardRefExoticComponent<PinInputProps & import("react").RefAttributes<HTMLInputElement>>;
4
+ export declare const StarRating: import("react").ForwardRefExoticComponent<RatingProps & import("react").RefAttributes<HTMLFieldSetElement>>;
5
+ export declare const FileDropzone: import("react").ForwardRefExoticComponent<DropzoneProps & import("react").RefAttributes<HTMLInputElement>>;
6
+ export declare const SupportingCharacterCount: import("react").ForwardRefExoticComponent<CharacterCountProps & import("react").RefAttributes<HTMLOutputElement>>;
@@ -0,0 +1,250 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from "react";
3
+ import { FileInput } from "./index.js";
4
+ import { resolveFieldStatus, mergeDescriptions } from "./field-state.js";
5
+ const cx = (...values) => values.filter(Boolean).join(" ");
6
+ function useNativeReset(ref, form, reset) {
7
+ const callback = useRef(reset);
8
+ useEffect(() => { callback.current = reset; }, [reset]);
9
+ useEffect(() => {
10
+ const owner = ref.current?.form;
11
+ if (!owner)
12
+ return;
13
+ let timer;
14
+ const listener = (event) => { timer = setTimeout(() => { if (!event.defaultPrevented && ref.current)
15
+ callback.current(); }, 0); };
16
+ owner.addEventListener("reset", listener);
17
+ return () => { owner.removeEventListener("reset", listener); clearTimeout(timer); };
18
+ }, [ref, form]);
19
+ }
20
+ function useInheritedDisabled(ref, disabled) {
21
+ const [inherited, setInherited] = useState(false);
22
+ useEffect(() => {
23
+ const node = ref.current;
24
+ if (!node)
25
+ return;
26
+ const update = () => setInherited(node.matches(":disabled"));
27
+ update();
28
+ const observer = new node.ownerDocument.defaultView.MutationObserver(update);
29
+ let ancestor = node.parentElement;
30
+ while (ancestor) {
31
+ if (ancestor.localName === "fieldset")
32
+ observer.observe(ancestor, { attributes: true, attributeFilter: ["disabled"] });
33
+ ancestor = ancestor.parentElement;
34
+ }
35
+ return () => observer.disconnect();
36
+ }, [ref, disabled]);
37
+ return disabled || inherited;
38
+ }
39
+ export const CollectionListBox = forwardRef(function CollectionListBox({ label, hideLabel = false, options, value, defaultValue, onValueChange, multiple = false, disabled = false, readOnly = false, name, form, controlSize = "md", id: explicitId, className, onKeyDown, onFocus, ...props }, forwardedRef) {
40
+ const generated = useId(), id = explicitId ?? `flood-list-box-${generated}`;
41
+ const root = useRef(null), bridge = useRef(null);
42
+ useImperativeHandle(forwardedRef, () => root.current, []);
43
+ const unique = new Set();
44
+ for (const option of options) {
45
+ if (!option.value || !option.label.trim() || unique.has(option.value))
46
+ throw new Error("Flood ListBox options need unique nonempty values and labels");
47
+ unique.add(option.value);
48
+ }
49
+ const normalize = (raw) => {
50
+ const entries = typeof raw === "string" ? [raw] : [...raw ?? []];
51
+ const result = [...new Set(entries)].filter(entry => unique.has(entry));
52
+ return multiple ? result : result.slice(0, 1);
53
+ };
54
+ const initial = useRef(defaultValue), [local, setLocal] = useState(() => normalize(defaultValue));
55
+ const selected = normalize(value === undefined ? local : value);
56
+ const first = Math.max(0, options.findIndex(option => !option.disabled));
57
+ const [active, setActive] = useState(() => { const index = options.findIndex(option => selected.includes(option.value) && !option.disabled); return index < 0 ? first : index; });
58
+ const effectiveDisabled = useInheritedDisabled(bridge, disabled);
59
+ const search = useRef({ text: "", time: 0 });
60
+ useNativeReset(bridge, form, () => { if (value === undefined) {
61
+ setLocal(normalize(initial.current));
62
+ setActive(first);
63
+ } });
64
+ const blocked = () => effectiveDisabled || bridge.current?.matches(":disabled");
65
+ const commit = (index) => {
66
+ const option = options[index];
67
+ if (!option || option.disabled || blocked() || readOnly)
68
+ return;
69
+ const next = multiple ? selected.includes(option.value) ? selected.filter(entry => entry !== option.value) : [...selected, option.value] : [option.value];
70
+ if (value === undefined)
71
+ setLocal(next);
72
+ onValueChange?.(multiple ? [...next] : next[0] ?? "");
73
+ };
74
+ const move = (index, choose = !multiple) => {
75
+ setActive(index);
76
+ if (choose)
77
+ commit(index);
78
+ const target = root.current?.ownerDocument.getElementById(`${id}-option-${index}`);
79
+ target?.scrollIntoView({ block: "nearest" });
80
+ };
81
+ return _jsxs("div", { className: "flood-list-box-field", hidden: props.hidden, dir: props.dir, lang: props.lang, children: [_jsx("span", { id: `${id}-label`, className: cx("flood-form-control__label", hideLabel && "flood-form-control__sr-only"), children: label }), _jsxs("div", { ...props, ref: root, id: id, role: "listbox", tabIndex: effectiveDisabled ? -1 : props.tabIndex ?? 0, "aria-label": props["aria-label"], "aria-labelledby": props["aria-label"] ? undefined : props["aria-labelledby"] ?? `${id}-label`, "aria-multiselectable": multiple || undefined, "aria-disabled": effectiveDisabled || undefined, "aria-readonly": readOnly || undefined, "aria-activedescendant": options[active] ? `${id}-option-${active}` : undefined, className: cx("flood-list-box", `flood-control--${controlSize}`, className), onFocus: event => { onFocus?.(event); if (!options[active] || options[active].disabled)
82
+ setActive(first); }, onKeyDown: event => {
83
+ onKeyDown?.(event);
84
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing || blocked())
85
+ return;
86
+ const enabled = options.map((option, index) => !option.disabled ? index : -1).filter(index => index >= 0);
87
+ if (!enabled.length)
88
+ return;
89
+ const position = enabled.indexOf(active);
90
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
91
+ event.preventDefault();
92
+ const next = event.key === "Home" ? enabled[0] : event.key === "End" ? enabled.at(-1) : event.key === "ArrowDown" ? enabled[(position + 1) % enabled.length] : enabled[(position - 1 + enabled.length) % enabled.length];
93
+ move(next);
94
+ return;
95
+ }
96
+ if (event.key === " " || event.key === "Enter") {
97
+ event.preventDefault();
98
+ commit(active);
99
+ return;
100
+ }
101
+ if (event.key.length === 1) {
102
+ event.preventDefault();
103
+ const time = Date.now(), previous = time - search.current.time < 700 ? search.current.text : "";
104
+ const text = previous + event.key.toLocaleLowerCase(props.lang);
105
+ search.current = { text, time };
106
+ const query = [...text].every(letter => letter === text[0]) ? text[0] : text;
107
+ const match = [...enabled.slice(position + 1), ...enabled.slice(0, position + 1)].find(index => options[index].label.toLocaleLowerCase(props.lang).startsWith(query));
108
+ if (match !== undefined)
109
+ move(match);
110
+ }
111
+ }, children: [options.map((option, index) => _jsxs("div", { id: `${id}-option-${index}`, role: "option", "aria-selected": selected.includes(option.value), "aria-disabled": option.disabled || undefined, "data-active": index === active || undefined, className: "flood-list-box__option", onClick: () => { if (blocked())
112
+ return; root.current?.focus(); setActive(index); commit(index); }, children: [_jsxs("span", { className: "flood-list-box__copy", children: [_jsx("span", { children: option.label }), option.description && _jsx("span", { className: "flood-list-box__description", children: option.description })] }), _jsx("span", { className: "flood-list-box__check", "aria-hidden": "true", children: selected.includes(option.value) ? "✓" : "" })] }, option.value)), !options.length && _jsx("div", { role: "option", "aria-disabled": "true", className: "flood-list-box__option", children: "No options" })] }), _jsx("input", { ref: bridge, type: "hidden", form: form, disabled: disabled }), name && selected.map(entry => _jsx("input", { type: "hidden", name: name, value: entry, form: form, disabled: disabled }, entry))] });
113
+ });
114
+ export const SegmentedPinInput = forwardRef(function SegmentedPinInput({ label, hideLabel = false, length = 6, mode = "numeric", mask = false, value, defaultValue = "", onValueChange, controlSize = "md", disabled, readOnly, className, style, status, invalid, id: explicitId, onFocus, onBlur, onSelect, onPointerDown, onPaste, onKeyUp, autoComplete = "one-time-code", inputMode, ...props }, forwardedRef) {
115
+ if (!Number.isInteger(length) || length < 1 || length > 12)
116
+ throw new Error("Flood PinInput length must be an integer from 1 to 12");
117
+ const clean = (text) => text.replace(mode === "numeric" ? /[^0-9]/g : /[^a-zA-Z0-9]/g, "").slice(0, length);
118
+ const initial = useRef(clean(defaultValue)), [local, setLocal] = useState(initial.current);
119
+ const text = clean(value ?? local), input = useRef(null), generated = useId(), id = explicitId ?? `flood-pin-${generated}`;
120
+ const [active, setActive] = useState(0), [focused, setFocused] = useState(false);
121
+ useImperativeHandle(forwardedRef, () => input.current, []);
122
+ useNativeReset(input, props.form, () => { if (value === undefined) {
123
+ setLocal(initial.current);
124
+ setActive(0);
125
+ } });
126
+ const syncCaret = () => setActive(Math.min(input.current?.selectionStart ?? 0, length - 1));
127
+ const insert = (next, caret) => {
128
+ const node = input.current;
129
+ if (!node || readOnly || node.matches(":disabled"))
130
+ return;
131
+ const view = node.ownerDocument.defaultView;
132
+ Object.getOwnPropertyDescriptor(view.HTMLInputElement.prototype, "value").set.call(node, next);
133
+ node.dispatchEvent(new view.Event("input", { bubbles: true }));
134
+ node.setSelectionRange(caret, caret);
135
+ syncCaret();
136
+ };
137
+ return _jsxs("div", { className: "flood-pin-input-field", hidden: props.hidden, dir: props.dir, lang: props.lang, children: [_jsx("label", { htmlFor: id, className: cx("flood-form-control__label", hideLabel && "flood-form-control__sr-only"), children: label }), _jsxs("div", { className: cx("flood-pin-input", `flood-control--${controlSize}`, className), style: style, "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, children: [_jsx("input", { ...props, id: id, ref: input, className: "flood-pin-input__editor", type: mask ? "password" : "text", value: text, maxLength: length, disabled: disabled, readOnly: readOnly, autoComplete: autoComplete, inputMode: inputMode ?? (mode === "numeric" ? "numeric" : "text"), pattern: mode === "numeric" ? `[0-9]{${length}}` : `[a-zA-Z0-9]{${length}}`, "aria-invalid": invalid || status === "error" || props["aria-invalid"], onChange: event => { const next = clean(event.currentTarget.value); if (value === undefined)
138
+ setLocal(next); onValueChange?.(next); syncCaret(); }, onFocus: event => { setFocused(true); syncCaret(); onFocus?.(event); }, onBlur: event => { setFocused(false); onBlur?.(event); }, onSelect: event => { syncCaret(); onSelect?.(event); }, onKeyUp: event => { syncCaret(); onKeyUp?.(event); }, onPointerDown: event => {
139
+ onPointerDown?.(event);
140
+ if (event.defaultPrevented || event.button !== 0 || disabled)
141
+ return;
142
+ const slots = Array.from(event.currentTarget.parentElement.querySelectorAll("[data-pin-slot]"));
143
+ const index = slots.findIndex(slot => { const rect = slot.getBoundingClientRect(); return event.clientX >= rect.left && event.clientX <= rect.right; });
144
+ if (index < 0)
145
+ return;
146
+ event.preventDefault();
147
+ event.currentTarget.focus();
148
+ const position = Math.min(index, text.length);
149
+ // A filled visual slot edits its character, rather than placing the caret
150
+ // before it (which makes Backspace on the first slot appear unresponsive).
151
+ event.currentTarget.setSelectionRange(position, position < text.length ? position + 1 : position);
152
+ setActive(Math.min(position, length - 1));
153
+ }, onPaste: event => {
154
+ onPaste?.(event);
155
+ if (event.defaultPrevented || readOnly || disabled)
156
+ return;
157
+ event.preventDefault();
158
+ const node = event.currentTarget, start = node.selectionStart ?? 0, end = node.selectionEnd ?? start;
159
+ const pasted = clean(event.clipboardData.getData("text")), next = clean(text.slice(0, start) + pasted + text.slice(end));
160
+ insert(next, Math.min(start + pasted.length, length));
161
+ } }), Array.from({ length }, (_, index) => _jsx("span", { "data-pin-slot": "", "aria-hidden": "true", className: "flood-pin-input__slot", "data-active": focused && index === active || undefined, children: text[index] ? mask ? "•" : text[index] : "" }, index))] })] });
162
+ });
163
+ export const StarRating = forwardRef(function StarRating({ label, hideLabel = false, value, defaultValue = 0, max = 5, onValueChange, getValueLabel, readOnly = false, required, name, form, size = "md", disabled, className, onKeyDown, ...props }, forwardedRef) {
164
+ if (!name?.trim())
165
+ throw new Error("Flood Rating requires a nonempty native group name");
166
+ if (!Number.isInteger(max) || max < 1 || max > 10)
167
+ throw new Error("Flood Rating max must be an integer from 1 to 10");
168
+ if (![value ?? 0, defaultValue].every(entry => Number.isInteger(entry) && entry >= 0 && entry <= max))
169
+ throw new Error("Flood Rating values must be integers from zero to max");
170
+ const root = useRef(null), first = useRef(null), initial = useRef(defaultValue);
171
+ useImperativeHandle(forwardedRef, () => root.current, []);
172
+ const [local, setLocal] = useState(defaultValue), [hover, setHover] = useState(null), current = value ?? local;
173
+ useNativeReset(first, form, () => {
174
+ if (value === undefined)
175
+ setLocal(initial.current);
176
+ else
177
+ root.current?.querySelectorAll('input[type="radio"]').forEach(input => { input.checked = Number(input.value) === value; });
178
+ });
179
+ const change = (next) => { if (readOnly || disabled || first.current?.matches(":disabled"))
180
+ return; if (value === undefined)
181
+ setLocal(next); onValueChange?.(next); };
182
+ const display = hover ?? current;
183
+ return _jsxs("fieldset", { ...props, ref: root, disabled: disabled, form: form, role: "radiogroup", "aria-readonly": readOnly || undefined, className: cx("flood-rating", `flood-rating--${size}`, className), onPointerLeave: () => setHover(null), onKeyDown: event => {
184
+ onKeyDown?.(event);
185
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing || disabled)
186
+ return;
187
+ const inputs = Array.from(event.currentTarget.querySelectorAll('input[type="radio"]:not(:disabled)'));
188
+ const index = inputs.indexOf(event.target);
189
+ if (index < 0 || !inputs.length)
190
+ return;
191
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
192
+ let next = -1;
193
+ if (event.key === "Home")
194
+ next = 0;
195
+ else if (event.key === "End")
196
+ next = inputs.length - 1;
197
+ else if (event.key === "ArrowUp" || event.key === (rtl ? "ArrowLeft" : "ArrowRight"))
198
+ next = (index + 1) % inputs.length;
199
+ else if (event.key === "ArrowDown" || event.key === (rtl ? "ArrowRight" : "ArrowLeft"))
200
+ next = (index - 1 + inputs.length) % inputs.length;
201
+ if (next >= 0) {
202
+ event.preventDefault();
203
+ inputs[next].focus();
204
+ change(next + 1);
205
+ }
206
+ else if (readOnly && event.key === " ")
207
+ event.preventDefault();
208
+ }, children: [_jsx("legend", { className: cx("flood-form-control__label", hideLabel && "flood-form-control__sr-only"), children: label }), _jsx("div", { className: "flood-rating__choices", children: Array.from({ length: max }, (_, index) => {
209
+ const number = index + 1;
210
+ return _jsxs("label", { className: "flood-rating__choice", "data-filled": number <= display || undefined, onPointerEnter: () => { if (!readOnly && !disabled && !first.current?.matches(":disabled"))
211
+ setHover(number); }, children: [_jsx("input", { ref: index === 0 ? first : undefined, type: "radio", name: name, form: form, value: number, required: required, disabled: disabled, checked: current === number, tabIndex: current === number || current === 0 && number === 1 ? 0 : -1, "aria-label": getValueLabel?.(number, max) ?? `${number} of ${max} stars`, onClick: event => { if (readOnly)
212
+ event.preventDefault(); }, onChange: () => change(number) }), _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinejoin: "round", children: _jsx("path", { d: "m12 3 2.8 5.8 6.4.9-4.6 4.5 1.1 6.4-5.7-3-5.7 3 1.1-6.4-4.6-4.5 6.4-.9L12 3Z" }) })] }, number);
213
+ }) })] });
214
+ });
215
+ export const FileDropzone = forwardRef(function FileDropzone({ label, description = "Drop files here or choose from your device.", multiple = true, disabled, className, style, id: explicitId, status, invalid, ...props }, forwardedRef) {
216
+ const input = useRef(null), generated = useId(), id = explicitId ?? `flood-dropzone-${generated}`;
217
+ useImperativeHandle(forwardedRef, () => input.current, []);
218
+ const [dragging, setDragging] = useState(false), depth = useRef(0);
219
+ const isFiles = (types) => Array.from(types).includes("Files");
220
+ return _jsxs("div", { hidden: props.hidden, dir: props.dir, lang: props.lang, style: style, className: cx("flood-dropzone", className), "data-dragging": dragging || undefined, "data-disabled": disabled || undefined, "data-status": resolveFieldStatus(status, invalid, props["aria-invalid"]), onDragEnter: event => { if (!isFiles(event.dataTransfer.types))
221
+ return; event.preventDefault(); depth.current++; if (!disabled && !input.current?.matches(":disabled"))
222
+ setDragging(true); }, onDragLeave: event => { if (!isFiles(event.dataTransfer.types))
223
+ return; depth.current = Math.max(0, depth.current - 1); if (!depth.current)
224
+ setDragging(false); }, onDragOver: event => { if (!isFiles(event.dataTransfer.types))
225
+ return; event.preventDefault(); event.dataTransfer.dropEffect = disabled || input.current?.matches(":disabled") ? "none" : "copy"; }, onDrop: event => {
226
+ if (!isFiles(event.dataTransfer.types))
227
+ return;
228
+ event.preventDefault();
229
+ depth.current = 0;
230
+ setDragging(false);
231
+ const node = input.current;
232
+ if (!node || disabled || node.matches(":disabled"))
233
+ return;
234
+ const picked = Array.from(event.dataTransfer.files);
235
+ if (!picked.length)
236
+ return;
237
+ const transfer = new node.ownerDocument.defaultView.DataTransfer();
238
+ (multiple ? picked : picked.slice(0, 1)).forEach(file => transfer.items.add(file));
239
+ node.files = transfer.files;
240
+ node.dispatchEvent(new node.ownerDocument.defaultView.Event("change", { bubbles: true }));
241
+ }, children: [_jsxs("div", { className: "flood-dropzone__intro", children: [_jsxs("label", { htmlFor: id, className: "flood-dropzone__label", children: [_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M12 16V3m-4 4 4-4 4 4M4 15v5a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-5" }) }), _jsx("span", { children: label })] }), description && _jsx("p", { id: `${id}-description`, className: "flood-dropzone__description", children: description })] }), _jsx(FileInput, { ...props, ref: input, id: id, multiple: multiple, disabled: disabled, status: status, invalid: invalid, "aria-describedby": mergeDescriptions(props["aria-describedby"], Boolean(description) && `${id}-description`) })] });
242
+ });
243
+ export const SupportingCharacterCount = forwardRef(function SupportingCharacterCount({ value = "", count, max, mode = "count", label = "Characters", overLimitLabel = "Over limit", className, ...props }, ref) {
244
+ const current = count ?? [...value].length;
245
+ if (!Number.isInteger(current) || current < 0 || max !== undefined && (!Number.isInteger(max) || max < 0))
246
+ throw new Error("Flood CharacterCount requires nonnegative integer counts and limits");
247
+ const over = max !== undefined && current > max, number = new Intl.NumberFormat(props.lang);
248
+ const text = mode === "remaining" && max !== undefined ? number.format(max - current) : max === undefined ? number.format(current) : `${number.format(current)} / ${number.format(max)}`;
249
+ return _jsxs("output", { ...props, ref: ref, "aria-live": props["aria-live"] ?? "off", className: cx("flood-character-count", className), "data-over-limit": over || undefined, "aria-label": props["aria-label"] ?? `${label}: ${text}${over ? `. ${overLimitLabel}` : ""}`, children: [_jsx("span", { children: text }), over && _jsx("span", { className: "flood-character-count__warning", children: overLimitLabel })] });
250
+ });