@flood-md/react 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +398 -3
- package/dist/alert-dialog.d.ts +2 -0
- package/dist/alert-dialog.js +23 -0
- package/dist/avatar.d.ts +5 -0
- package/dist/avatar.js +8 -0
- package/dist/checkbox.d.ts +4 -0
- package/dist/checkbox.js +15 -0
- package/dist/combobox.d.ts +11 -0
- package/dist/combobox.js +301 -0
- package/dist/content-elements.d.ts +8 -0
- package/dist/content-elements.js +47 -0
- package/dist/data-display.d.ts +3 -0
- package/dist/data-display.js +11 -0
- package/dist/disclosure.js +2 -1
- package/dist/feedback-actions.d.ts +3 -0
- package/dist/feedback-actions.js +106 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +29 -0
- package/dist/file-input.d.ts +8 -0
- package/dist/file-input.js +86 -0
- package/dist/focusable-hidden.d.ts +3 -0
- package/dist/focusable-hidden.js +99 -0
- package/dist/fonts/InterVariable-Italic.woff2 +0 -0
- package/dist/fonts/README.md +9 -5
- package/dist/form-controls.d.ts +6 -0
- package/dist/form-controls.js +250 -0
- package/dist/foundations.css +543 -12
- package/dist/index.d.ts +513 -2
- package/dist/index.js +223 -36
- package/dist/menu.d.ts +2 -0
- package/dist/menu.js +130 -0
- package/dist/navigation-layout.d.ts +7 -0
- package/dist/navigation-layout.js +77 -0
- package/dist/navigation.js +8 -8
- package/dist/numeric-input.d.ts +8 -0
- package/dist/numeric-input.js +68 -0
- package/dist/overlay-runtime.js +43 -16
- package/dist/overlays.d.ts +7 -1
- package/dist/overlays.js +17 -6
- package/dist/scroll-area.js +17 -2
- package/dist/select.js +96 -47
- package/dist/slider.d.ts +7 -0
- package/dist/slider.js +89 -0
- package/dist/styles.css +9 -0
- package/dist/text-inputs.d.ts +9 -0
- package/dist/text-inputs.js +45 -0
- package/dist/toolbar.d.ts +2 -0
- package/dist/toolbar.js +80 -0
- package/package.json +2 -2
|
@@ -0,0 +1,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,99 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
2
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
3
|
+
const heldAttribute = "data-flood-focus-held";
|
|
4
|
+
const scopes = new WeakMap();
|
|
5
|
+
/** Keep in-flow focus reveal stable until the native pointer gesture completes. */
|
|
6
|
+
function createScope(document) {
|
|
7
|
+
const view = document.defaultView;
|
|
8
|
+
const entries = new Set(), held = new Set(), pointers = new Set();
|
|
9
|
+
let releaseTask;
|
|
10
|
+
const cancelTask = () => { if (releaseTask !== undefined)
|
|
11
|
+
view.clearTimeout(releaseTask); releaseTask = undefined; };
|
|
12
|
+
const release = () => { cancelTask(); held.forEach(node => node.removeAttribute(heldAttribute)); held.clear(); };
|
|
13
|
+
const releaseAfterDispatch = () => {
|
|
14
|
+
if (pointers.size || !held.size)
|
|
15
|
+
return;
|
|
16
|
+
cancelTask();
|
|
17
|
+
// A task, rather than a microtask, keeps the reveal through all native click
|
|
18
|
+
// listeners. Pointerup also schedules this fallback when no click is fired.
|
|
19
|
+
releaseTask = view.setTimeout(() => { releaseTask = undefined; if (!pointers.size)
|
|
20
|
+
release(); }, 0);
|
|
21
|
+
};
|
|
22
|
+
const down = (event) => {
|
|
23
|
+
if (event.button !== 0)
|
|
24
|
+
return;
|
|
25
|
+
cancelTask();
|
|
26
|
+
pointers.add(event.pointerId);
|
|
27
|
+
entries.forEach(entry => {
|
|
28
|
+
if (entry.enabled() && entry.node.matches(":focus-within")) {
|
|
29
|
+
entry.node.setAttribute(heldAttribute, "");
|
|
30
|
+
held.add(entry.node);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
const up = (event) => { pointers.delete(event.pointerId); releaseAfterDispatch(); };
|
|
35
|
+
const click = () => releaseAfterDispatch();
|
|
36
|
+
const cancel = (event) => { pointers.delete(event.pointerId); if (!pointers.size)
|
|
37
|
+
release(); };
|
|
38
|
+
const blur = () => { pointers.clear(); release(); };
|
|
39
|
+
document.addEventListener("pointerdown", down, true);
|
|
40
|
+
document.addEventListener("pointerup", up, true);
|
|
41
|
+
document.addEventListener("click", click, true);
|
|
42
|
+
document.addEventListener("pointercancel", cancel, true);
|
|
43
|
+
view.addEventListener("blur", blur);
|
|
44
|
+
const scope = { add(entry) {
|
|
45
|
+
entries.add(entry);
|
|
46
|
+
return () => {
|
|
47
|
+
entries.delete(entry);
|
|
48
|
+
held.delete(entry.node);
|
|
49
|
+
entry.node.removeAttribute(heldAttribute);
|
|
50
|
+
if (entries.size)
|
|
51
|
+
return;
|
|
52
|
+
pointers.clear();
|
|
53
|
+
release();
|
|
54
|
+
document.removeEventListener("pointerdown", down, true);
|
|
55
|
+
document.removeEventListener("pointerup", up, true);
|
|
56
|
+
document.removeEventListener("click", click, true);
|
|
57
|
+
document.removeEventListener("pointercancel", cancel, true);
|
|
58
|
+
view.removeEventListener("blur", blur);
|
|
59
|
+
scopes.delete(document);
|
|
60
|
+
};
|
|
61
|
+
} };
|
|
62
|
+
return scope;
|
|
63
|
+
}
|
|
64
|
+
/** A native ref adapter; does not replace handlers or clone rich child content. */
|
|
65
|
+
export function useFocusableHidden(enabled, forwardedRef) {
|
|
66
|
+
const node = useRef(null), active = useRef(enabled), disconnect = useRef(() => { });
|
|
67
|
+
const forwardedCleanup = useRef(undefined);
|
|
68
|
+
useBrowserLayoutEffect(() => {
|
|
69
|
+
active.current = enabled;
|
|
70
|
+
if (!enabled)
|
|
71
|
+
node.current?.removeAttribute(heldAttribute);
|
|
72
|
+
}, [enabled]);
|
|
73
|
+
return useCallback((element) => {
|
|
74
|
+
disconnect.current();
|
|
75
|
+
disconnect.current = () => { };
|
|
76
|
+
node.current = element;
|
|
77
|
+
if (element?.ownerDocument.defaultView) {
|
|
78
|
+
const document = element.ownerDocument;
|
|
79
|
+
let scope = scopes.get(document);
|
|
80
|
+
if (!scope) {
|
|
81
|
+
scope = createScope(document);
|
|
82
|
+
scopes.set(document, scope);
|
|
83
|
+
}
|
|
84
|
+
disconnect.current = scope.add({ node: element, enabled: () => active.current });
|
|
85
|
+
}
|
|
86
|
+
if (typeof forwardedRef === "function") {
|
|
87
|
+
const previousCleanup = forwardedCleanup.current;
|
|
88
|
+
forwardedCleanup.current = undefined;
|
|
89
|
+
previousCleanup?.();
|
|
90
|
+
if (element !== null || !previousCleanup) {
|
|
91
|
+
const cleanup = forwardedRef(element);
|
|
92
|
+
if (typeof cleanup === "function")
|
|
93
|
+
forwardedCleanup.current = cleanup;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (forwardedRef)
|
|
97
|
+
forwardedRef.current = element;
|
|
98
|
+
}, [forwardedRef]);
|
|
99
|
+
}
|
|
Binary file
|
package/dist/fonts/README.md
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# Inter font asset
|
|
2
2
|
|
|
3
|
-
`InterVariable.woff2`
|
|
3
|
+
`InterVariable.woff2` and `InterVariable-Italic.woff2` are unmodified third-party fonts by the Inter Project
|
|
4
4
|
Authors, distributed under the [SIL Open Font License 1.1](LICENSE.txt).
|
|
5
|
-
|
|
5
|
+
They are not original Flood artwork. Retain the license with redistributed copies.
|
|
6
6
|
|
|
7
7
|
- Official source: <https://github.com/rsms/inter/releases/tag/v4.1>
|
|
8
8
|
- Archive: <https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip>
|
|
9
|
-
-
|
|
9
|
+
- Normal archive member: `web/InterVariable.woff2`
|
|
10
|
+
- Italic archive member: `web/InterVariable-Italic.woff2`
|
|
10
11
|
- Font internal version: `Version 4.001;git-9221beed3`
|
|
11
12
|
- Variable axes: weight 100–900; optical size 14–32.
|
|
12
13
|
- SHA-256: `693b77d4f32ee9b8bfc995589b5fad5e99adf2832738661f5402f9978429a8e3`
|
|
14
|
+
- Italic SHA-256: `e564f652916db6c139570fefb9524a77c4d48f30c92928de9db19b6b5c7a262a`
|
|
13
15
|
- License SHA-256: `262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a`
|
|
14
16
|
|
|
15
17
|
The full upstream font includes Cyrillic; it has not been subsetted or converted.
|
|
16
18
|
The original `LICENSE.txt` is copied verbatim from the same release archive.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
Both assets supply weight 100–900 and optical size 14–32, including Flood's 400/500
|
|
20
|
+
roles. CSS registers separate normal and italic faces under Inter; native em/i
|
|
21
|
+
content uses the real italic asset instead of synthetic slant. Both files include
|
|
22
|
+
Latin and Cyrillic and retain the same unmodified upstream license.
|
|
@@ -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
|
+
});
|