@flood-md/react 0.2.6 → 0.2.8
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 +42 -1
- package/dist/alert-dialog.js +1 -1
- package/dist/calendar-period-grid.d.ts +17 -0
- package/dist/calendar-period-grid.js +13 -0
- package/dist/calendar-value.d.ts +10 -0
- package/dist/calendar-value.js +48 -0
- package/dist/calendar.d.ts +21 -0
- package/dist/calendar.js +259 -0
- package/dist/checkbox.js +1 -1
- package/dist/choice-group.js +1 -1
- package/dist/collection-cards.d.ts +4 -0
- package/dist/collection-cards.js +21 -0
- package/dist/color-controls.d.ts +5 -0
- package/dist/color-controls.js +110 -0
- package/dist/combobox.js +22 -13
- package/dist/command-list.d.ts +3 -0
- package/dist/command-list.js +93 -0
- package/dist/content-elements.js +7 -7
- package/dist/context-menu.d.ts +2 -0
- package/dist/context-menu.js +76 -0
- package/dist/data-display.js +2 -2
- package/dist/data-table.d.ts +2 -0
- package/dist/data-table.js +58 -0
- package/dist/date-field.d.ts +2 -0
- package/dist/date-field.js +172 -0
- package/dist/date-picker.d.ts +3 -0
- package/dist/date-picker.js +108 -0
- package/dist/date-range-field.d.ts +3 -0
- package/dist/date-range-field.js +84 -0
- package/dist/date-range-picker.d.ts +2 -0
- package/dist/date-range-picker.js +81 -0
- package/dist/date-range-value.d.ts +8 -0
- package/dist/date-range-value.js +13 -0
- package/dist/date-time-picker.d.ts +3 -0
- package/dist/date-time-picker.js +31 -0
- package/dist/date-time-range-picker.d.ts +2 -0
- package/dist/date-time-range-picker.js +51 -0
- package/dist/date-value.d.ts +10 -0
- package/dist/date-value.js +31 -0
- package/dist/disclosure.js +1 -1
- package/dist/editable.d.ts +3 -0
- package/dist/editable.js +135 -0
- package/dist/error-summary.d.ts +3 -0
- package/dist/error-summary.js +39 -0
- package/dist/feedback-actions.js +2 -2
- package/dist/feedback.js +2 -2
- package/dist/file-input.js +1 -1
- package/dist/file-records.d.ts +5 -0
- package/dist/file-records.js +55 -0
- package/dist/form-controls.js +25 -13
- package/dist/foundations.css +387 -1
- package/dist/hover-card.d.ts +3 -0
- package/dist/hover-card.js +144 -0
- package/dist/index.d.ts +661 -2
- package/dist/index.js +190 -86
- package/dist/menu.d.ts +27 -1
- package/dist/menu.js +327 -89
- package/dist/navigation-layout.js +6 -6
- package/dist/navigation-tree.d.ts +3 -0
- package/dist/navigation-tree.js +98 -0
- package/dist/navigation.js +3 -3
- package/dist/numeric-input.js +1 -1
- package/dist/overlays.js +11 -10
- package/dist/range-calendar.d.ts +2 -0
- package/dist/range-calendar.js +35 -0
- package/dist/resizable-panels.d.ts +3 -0
- package/dist/resizable-panels.js +205 -0
- package/dist/scroll-area.js +1 -1
- package/dist/select.js +1 -1
- package/dist/slider.js +1 -1
- package/dist/tag-input.d.ts +3 -0
- package/dist/tag-input.js +181 -0
- package/dist/text-inputs.js +2 -2
- package/dist/time-field.d.ts +2 -0
- package/dist/time-field.js +176 -0
- package/dist/time-picker.d.ts +2 -0
- package/dist/time-picker.js +83 -0
- package/dist/time-value.d.ts +12 -0
- package/dist/time-value.js +36 -0
- package/dist/toolbar.js +1 -1
- package/dist/tree-select.d.ts +3 -0
- package/dist/tree-select.js +113 -0
- package/dist/tree.d.ts +3 -0
- package/dist/tree.js +206 -0
- package/package.json +2 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from "react";
|
|
3
|
+
import { Label } from "./index.js";
|
|
4
|
+
import { mergeDescriptions, resolveFieldStatus } from "./field-state.js";
|
|
5
|
+
import { canonicalTime, emptyTimeParts, isEmptyTime, parseTime, stepTimePart, timeInRange, timeOnStep } from "./time-value.js";
|
|
6
|
+
const cx = (...values) => values.filter(Boolean).join(" ");
|
|
7
|
+
export const SegmentedTimeField = /* @__PURE__ */ forwardRef(function SegmentedTimeField({ label, end, hideLabel = false, value, defaultValue = "", onValueChange, precision = "minute", min, max, step: explicitStep, required = false, readOnly = false, disabled = false, name, form, controlSize = "md", invalid = false, status, description, segmentLabels, messages, id: explicitId, className, onKeyDown, onPaste, onBlur, "aria-describedby": describedBy, "aria-invalid": ariaInvalid, ...props }, forwardedRef) {
|
|
8
|
+
if (precision !== "minute" && precision !== "second")
|
|
9
|
+
throw new Error("Flood TimeField precision must be minute or second");
|
|
10
|
+
if (!label.trim())
|
|
11
|
+
throw new Error("Flood TimeField requires a nonempty label");
|
|
12
|
+
const step = explicitStep ?? (precision === "minute" ? 60 : 1);
|
|
13
|
+
if (!Number.isSafeInteger(step) || step <= 0 || (precision === "minute" && step % 60 !== 0))
|
|
14
|
+
throw new Error("Flood TimeField step must be positive integer seconds compatible with precision");
|
|
15
|
+
for (const [key, entry] of [["value", value], ["defaultValue", defaultValue], ["min", min], ["max", max]]) {
|
|
16
|
+
if (entry !== undefined && entry !== "" && !parseTime(entry, precision))
|
|
17
|
+
throw new Error(`Flood TimeField ${key} must match its canonical 24-hour precision`);
|
|
18
|
+
}
|
|
19
|
+
if (min === "" || max === "" || (min && max && min > max))
|
|
20
|
+
throw new Error("Flood TimeField requires ordered same-day bounds");
|
|
21
|
+
const labels = { hour: segmentLabels?.hour ?? "Hour", minute: segmentLabels?.minute ?? "Minute", second: segmentLabels?.second ?? "Second" };
|
|
22
|
+
if (!Object.values(labels).every(text => text.trim()) || (messages && Object.values(messages).some(text => text !== undefined && !text.trim())))
|
|
23
|
+
throw new Error("Flood TimeField segment labels and supplied messages must be nonempty");
|
|
24
|
+
const generated = useId(), id = explicitId ?? `flood-time-${generated}`;
|
|
25
|
+
const root = useRef(null), bridge = useRef(null), inputs = useRef({}), lastFocused = useRef("hour");
|
|
26
|
+
useImperativeHandle(forwardedRef, () => root.current, []);
|
|
27
|
+
const ordered = precision === "second" ? ["hour", "minute", "second"] : ["hour", "minute"];
|
|
28
|
+
const initial = useRef(defaultValue), [local, setLocal] = useState(defaultValue), committed = value ?? local;
|
|
29
|
+
const [parts, setParts] = useState(() => parseTime(committed, precision) ?? emptyTimeParts());
|
|
30
|
+
const [touched, setTouched] = useState(false), [inheritedDisabled, setInheritedDisabled] = useState(false);
|
|
31
|
+
const effectiveDisabled = disabled || inheritedDisabled, composing = useRef(undefined), snapshot = useRef(parts), lastPublished = useRef(committed);
|
|
32
|
+
const settled = composing.current ? snapshot.current : parts, canonical = canonicalTime(settled, precision), empty = isEmptyTime(settled, precision);
|
|
33
|
+
const inRange = Boolean(canonical && timeInRange(canonical, min, max)), onStep = Boolean(canonical && timeOnStep(settled, step, min, precision));
|
|
34
|
+
const proposal = empty ? "" : canonical && inRange && onStep ? canonical : undefined;
|
|
35
|
+
const pendingAcceptance = value !== undefined && proposal !== undefined && proposal !== value;
|
|
36
|
+
const message = pendingAcceptance ? messages?.invalid ?? "Enter a valid time." : empty ? required ? messages?.required ?? "Enter a time." : "" : !canonical ? messages?.invalid ?? "Enter a valid time." : !inRange ? messages?.outOfRange ?? "Enter a time within the allowed range." : !onStep ? messages?.stepMismatch ?? "Enter a time matching the allowed step." : "";
|
|
37
|
+
const current = useRef({ value, committed, parts });
|
|
38
|
+
current.current = { value, committed, parts };
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (value !== undefined && value !== lastPublished.current)
|
|
41
|
+
setParts(parseTime(value, precision) ?? emptyTimeParts());
|
|
42
|
+
if (value !== undefined)
|
|
43
|
+
lastPublished.current = value;
|
|
44
|
+
}, [value, precision]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
for (const part of ["hour", "minute", "second"])
|
|
47
|
+
inputs.current[part]?.setCustomValidity(part === "hour" && !effectiveDisabled && !readOnly ? message : "");
|
|
48
|
+
}, [message, effectiveDisabled, readOnly, precision]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const node = inputs.current.hour;
|
|
51
|
+
if (!node)
|
|
52
|
+
return;
|
|
53
|
+
const update = () => setInheritedDisabled(node.matches(":disabled"));
|
|
54
|
+
update();
|
|
55
|
+
const observer = new node.ownerDocument.defaultView.MutationObserver(update);
|
|
56
|
+
for (let ancestor = node.parentElement; ancestor; ancestor = ancestor.parentElement)
|
|
57
|
+
if (ancestor.localName === "fieldset")
|
|
58
|
+
observer.observe(ancestor, { attributes: true, attributeFilter: ["disabled"] });
|
|
59
|
+
return () => observer.disconnect();
|
|
60
|
+
}, [disabled]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const owner = bridge.current?.form;
|
|
63
|
+
if (!owner)
|
|
64
|
+
return;
|
|
65
|
+
const timers = new Set();
|
|
66
|
+
const reset = (event) => {
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
timers.delete(timer);
|
|
69
|
+
if (event.defaultPrevented || !root.current)
|
|
70
|
+
return;
|
|
71
|
+
const next = current.current.value === undefined ? initial.current : current.current.committed;
|
|
72
|
+
if (current.current.value === undefined)
|
|
73
|
+
setLocal(next);
|
|
74
|
+
composing.current = undefined;
|
|
75
|
+
setParts(parseTime(next, precision) ?? emptyTimeParts());
|
|
76
|
+
setTouched(false);
|
|
77
|
+
lastPublished.current = next;
|
|
78
|
+
}, 0);
|
|
79
|
+
timers.add(timer);
|
|
80
|
+
};
|
|
81
|
+
owner.addEventListener("reset", reset);
|
|
82
|
+
return () => { owner.removeEventListener("reset", reset); timers.forEach(clearTimeout); };
|
|
83
|
+
}, [form, precision]);
|
|
84
|
+
const blocked = () => Boolean(readOnly || effectiveDisabled || inputs.current.hour?.matches(":disabled"));
|
|
85
|
+
const select = (part) => { const node = inputs.current[part]; node?.focus({ preventScroll: true }); node?.select(); };
|
|
86
|
+
const change = (next) => {
|
|
87
|
+
if (blocked())
|
|
88
|
+
return;
|
|
89
|
+
setParts(next);
|
|
90
|
+
const time = canonicalTime(next, precision), nextValue = time && timeInRange(time, min, max) && timeOnStep(next, step, min, precision) ? time : "";
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
setLocal(nextValue);
|
|
93
|
+
if (lastPublished.current !== nextValue) {
|
|
94
|
+
lastPublished.current = nextValue;
|
|
95
|
+
onValueChange?.(nextValue);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const edit = (part, raw) => {
|
|
99
|
+
if (!/^\d*$/.test(raw))
|
|
100
|
+
return;
|
|
101
|
+
const text = raw.slice(0, 2);
|
|
102
|
+
change({ ...current.current.parts, [part]: text });
|
|
103
|
+
const next = ordered[ordered.indexOf(part) + 1];
|
|
104
|
+
if (text.length === 2 && next)
|
|
105
|
+
select(next);
|
|
106
|
+
};
|
|
107
|
+
const errorShown = touched && !effectiveDisabled && !readOnly && Boolean(message), resolvedStatus = resolveFieldStatus(status, invalid || errorShown, ariaInvalid);
|
|
108
|
+
const descriptionId = description ? `${id}-description` : undefined, errorId = errorShown ? `${id}-error` : undefined;
|
|
109
|
+
const descriptions = mergeDescriptions(describedBy, descriptionId, errorId);
|
|
110
|
+
return _jsxs("div", { ...props, ref: root, id: id, className: cx("flood-time-field", className), "data-disabled": effectiveDisabled || undefined, onBlur: event => {
|
|
111
|
+
onBlur?.(event);
|
|
112
|
+
if (event.defaultPrevented || event.currentTarget.contains(event.relatedTarget) || composing.current)
|
|
113
|
+
return;
|
|
114
|
+
setTouched(true);
|
|
115
|
+
const draft = canonicalTime(current.current.parts, precision), isEmpty = isEmptyTime(current.current.parts, precision);
|
|
116
|
+
if (value !== undefined && (isEmpty || (draft && timeInRange(draft, min, max) && timeOnStep(current.current.parts, step, min, precision))) && (isEmpty ? "" : draft) !== value) {
|
|
117
|
+
setParts(parseTime(value, precision) ?? emptyTimeParts());
|
|
118
|
+
lastPublished.current = value;
|
|
119
|
+
}
|
|
120
|
+
else if (draft && timeInRange(draft, min, max) && timeOnStep(current.current.parts, step, min, precision))
|
|
121
|
+
setParts(parseTime(draft, precision));
|
|
122
|
+
}, onKeyDown: event => {
|
|
123
|
+
onKeyDown?.(event);
|
|
124
|
+
if (event.defaultPrevented || event.nativeEvent.isComposing || composing.current || event.keyCode === 229 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
|
125
|
+
return;
|
|
126
|
+
const target = event.target, part = target.getAttribute("data-time-segment");
|
|
127
|
+
if (!part || inputs.current[part] !== target)
|
|
128
|
+
return;
|
|
129
|
+
const index = ordered.indexOf(part), rtl = root.current?.ownerDocument.defaultView?.getComputedStyle(root.current).direction === "rtl";
|
|
130
|
+
const offset = (event.key === "ArrowLeft" ? -1 : 1) * (rtl ? -1 : 1);
|
|
131
|
+
if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && ordered[index + offset]) {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
select(ordered[index + offset]);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (readOnly && event.key === "Backspace") {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (blocked())
|
|
141
|
+
return;
|
|
142
|
+
if (["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
change(stepTimePart(current.current.parts, precision, part, event.key === "ArrowDown" ? -1 : 1, step, min, max, event.key === "Home" ? "start" : event.key === "End" ? "end" : undefined));
|
|
145
|
+
target.select();
|
|
146
|
+
}
|
|
147
|
+
else if (event.key === "Backspace" && !parts[part] && index > 0) {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
select(ordered[index - 1]);
|
|
150
|
+
}
|
|
151
|
+
}, onPaste: event => {
|
|
152
|
+
onPaste?.(event);
|
|
153
|
+
if (event.defaultPrevented || blocked() || composing.current || !ordered.some(part => inputs.current[part] === event.target))
|
|
154
|
+
return;
|
|
155
|
+
const parsed = parseTime(event.clipboardData.getData("text/plain").trim(), precision);
|
|
156
|
+
if (parsed) {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
change(parsed);
|
|
159
|
+
}
|
|
160
|
+
}, children: [_jsx(Label, { htmlFor: `${id}-hour`, id: `${id}-label`, required: required, className: hideLabel ? "flood-visually-hidden" : undefined, children: label }), _jsxs("div", { role: "group", "aria-labelledby": `${id}-label`, "aria-describedby": descriptions, className: cx("flood-time-field__control", `flood-control--${controlSize}`), "data-status": resolvedStatus, "data-readonly": readOnly || undefined, "data-disabled": effectiveDisabled || undefined, onClick: event => {
|
|
161
|
+
if (event.defaultPrevented || effectiveDisabled || inputs.current.hour?.matches(":disabled") || event.target.closest("input,button,a,[role=button]"))
|
|
162
|
+
return;
|
|
163
|
+
select(ordered.find(part => !current.current.parts[part]) ?? (ordered.includes(lastFocused.current) ? lastFocused.current : "hour"));
|
|
164
|
+
}, children: [ordered.map((part, index) => _jsxs("span", { className: "flood-time-field__part", children: [index > 0 && _jsx("span", { className: "flood-time-field__separator", "aria-hidden": "true", children: ":" }), _jsx("input", { ref: node => { inputs.current[part] = node; }, id: `${id}-${part}`, "data-time-segment": part, className: "flood-time-field__segment", type: "text", role: "spinbutton", inputMode: "numeric", autoComplete: "off", spellCheck: false, maxLength: 2, form: form, "aria-label": `${label}, ${labels[part]}`, "aria-valuemin": 0, "aria-valuemax": part === "hour" ? 23 : 59, "aria-valuenow": /^\d{1,2}$/.test(parts[part]) && Number(parts[part]) <= (part === "hour" ? 23 : 59) ? Number(parts[part]) : undefined, "aria-valuetext": parts[part] || undefined, "aria-invalid": resolvedStatus === "error" || undefined, "aria-required": required || undefined, "aria-describedby": descriptions, value: parts[part], disabled: disabled, readOnly: readOnly, placeholder: part === "hour" ? "HH" : part === "minute" ? "MM" : "SS", onFocus: event => { lastFocused.current = part; event.currentTarget.select(); }, onClick: event => event.currentTarget.select(), onChange: event => { if (blocked())
|
|
165
|
+
return; const raw = event.currentTarget.value; if (composing.current === part)
|
|
166
|
+
setParts(previous => ({ ...previous, [part]: raw }));
|
|
167
|
+
else
|
|
168
|
+
edit(part, raw); }, onCompositionStart: () => { if (blocked())
|
|
169
|
+
return; snapshot.current = current.current.parts; composing.current = part; }, onCompositionEnd: event => { composing.current = undefined; if (blocked()) {
|
|
170
|
+
setParts(snapshot.current);
|
|
171
|
+
return;
|
|
172
|
+
} if (/^\d*$/.test(event.currentTarget.value))
|
|
173
|
+
edit(part, event.currentTarget.value);
|
|
174
|
+
else
|
|
175
|
+
setParts(snapshot.current); }, onInvalid: () => setTouched(true) })] }, part)), end && _jsx("span", { className: "flood-time-field__end", children: end })] }), _jsx("input", { ref: bridge, type: "hidden", name: name, form: form, value: canonical && (readOnly || inRange && onStep) && (value === undefined || canonical === value) ? canonical : "", disabled: disabled }), description && _jsx("div", { id: descriptionId, className: "flood-time-field__description", children: description }), errorShown && _jsx("div", { id: errorId, role: "alert", className: "flood-time-field__error", children: message })] });
|
|
176
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { TimeField, Popover, IconButton, ListBox, ScrollArea } from "./index.js";
|
|
4
|
+
import { parseTime, timeSeconds } from "./time-value.js";
|
|
5
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
6
|
+
const triggerSizes = { sm: "xs", md: "sm", lg: "md", xl: "lg" };
|
|
7
|
+
export const ClockTimePicker = /* @__PURE__ */ forwardRef(function ClockTimePicker({ label, value, defaultValue = "", onValueChange, precision = "minute", step: explicitStep, interval: explicitInterval, min, max, open: controlledOpen, defaultOpen = false, onOpenChange, panelLabel, openTimeLabel, disabled = false, readOnly = false, controlSize = "md", form, className, onKeyDown, ...props }, ref) {
|
|
8
|
+
const step = explicitStep ?? (precision === "minute" ? 60 : 1), interval = explicitInterval ?? Math.ceil(1800 / step) * step;
|
|
9
|
+
if (!Number.isSafeInteger(step) || step < 1 || !Number.isSafeInteger(interval) || interval < 1 || interval % step !== 0 || (precision === "minute" && (step % 60 !== 0 || interval % 60 !== 0)))
|
|
10
|
+
throw new Error("Flood TimePicker interval must be positive seconds compatible with step and precision");
|
|
11
|
+
if ((min !== undefined && !parseTime(min, precision)) || (max !== undefined && !parseTime(max, precision)) || (min && max && min > max))
|
|
12
|
+
throw new Error("Flood TimePicker requires canonical ordered clock bounds");
|
|
13
|
+
const first = min ? timeSeconds(parseTime(min, precision)) : 0, last = max ? timeSeconds(parseTime(max, precision)) : precision === "minute" ? 86340 : 86399;
|
|
14
|
+
const count = Math.floor((last - first) / interval) + 1;
|
|
15
|
+
if (count > 1000)
|
|
16
|
+
throw new Error("Flood TimePicker interval must limit the list to at most 1000 options");
|
|
17
|
+
const options = Array.from({ length: count }, (_, index) => {
|
|
18
|
+
const seconds = first + index * interval, value = [Math.floor(seconds / 3600), Math.floor(seconds % 3600 / 60), ...(precision === "second" ? [seconds % 60] : [])].map(n => String(n).padStart(2, "0")).join(":");
|
|
19
|
+
return { value, label: value };
|
|
20
|
+
});
|
|
21
|
+
const root = useRef(null), positionReference = useRef(null), list = useRef(null);
|
|
22
|
+
useImperativeHandle(ref, () => root.current, []);
|
|
23
|
+
const initial = useRef(defaultValue), [local, setLocal] = useState(defaultValue), [localOpen, setOpen] = useState(defaultOpen), [width, setWidth] = useState(), [inheritedDisabled, setInheritedDisabled] = useState(false);
|
|
24
|
+
const committed = value ?? local, requestedOpen = controlledOpen ?? localOpen, blocked = disabled || readOnly || inheritedDisabled;
|
|
25
|
+
const current = useRef({ value, controlledOpen, committed });
|
|
26
|
+
current.current = { value, controlledOpen, committed };
|
|
27
|
+
const nativeDisabled = () => root.current?.querySelector("input[data-time-segment]")?.matches(":disabled") ?? false;
|
|
28
|
+
const request = (next) => { if (next && (blocked || nativeDisabled()))
|
|
29
|
+
return; if (controlledOpen === undefined)
|
|
30
|
+
setOpen(next); onOpenChange?.(next); };
|
|
31
|
+
const change = (next) => { if (blocked || nativeDisabled())
|
|
32
|
+
return; if (value === undefined)
|
|
33
|
+
setLocal(next); onValueChange?.(next); };
|
|
34
|
+
useBrowserLayoutEffect(() => {
|
|
35
|
+
const control = root.current?.querySelector(".flood-time-field__control");
|
|
36
|
+
if (!control)
|
|
37
|
+
return;
|
|
38
|
+
positionReference.current = control;
|
|
39
|
+
const update = () => { const next = control.getBoundingClientRect().width; if (next > 0)
|
|
40
|
+
setWidth(next); };
|
|
41
|
+
update();
|
|
42
|
+
const Observer = control.ownerDocument.defaultView?.ResizeObserver, observer = Observer ? new Observer(update) : undefined;
|
|
43
|
+
observer?.observe(control);
|
|
44
|
+
return () => { observer?.disconnect(); positionReference.current = null; };
|
|
45
|
+
}, []);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const node = root.current;
|
|
48
|
+
if (!node)
|
|
49
|
+
return;
|
|
50
|
+
const update = () => setInheritedDisabled(nativeDisabled());
|
|
51
|
+
update();
|
|
52
|
+
const observer = new node.ownerDocument.defaultView.MutationObserver(update);
|
|
53
|
+
for (let ancestor = node.parentElement; ancestor; ancestor = ancestor.parentElement)
|
|
54
|
+
if (ancestor.localName === "fieldset")
|
|
55
|
+
observer.observe(ancestor, { attributes: true, attributeFilter: ["disabled"] });
|
|
56
|
+
return () => observer.disconnect();
|
|
57
|
+
}, [disabled]);
|
|
58
|
+
useEffect(() => { if (blocked && requestedOpen) {
|
|
59
|
+
if (controlledOpen === undefined)
|
|
60
|
+
setOpen(false);
|
|
61
|
+
onOpenChange?.(false);
|
|
62
|
+
} }, [blocked, requestedOpen]);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const owner = root.current?.querySelector('input[type="hidden"]')?.form;
|
|
65
|
+
if (!owner)
|
|
66
|
+
return;
|
|
67
|
+
const timers = new Set();
|
|
68
|
+
const reset = (event) => { const timer = setTimeout(() => { timers.delete(timer); if (event.defaultPrevented)
|
|
69
|
+
return; if (current.current.value === undefined)
|
|
70
|
+
setLocal(initial.current); if (current.current.controlledOpen === undefined)
|
|
71
|
+
setOpen(false); }, 0); timers.add(timer); };
|
|
72
|
+
owner.addEventListener("reset", reset);
|
|
73
|
+
return () => { owner.removeEventListener("reset", reset); timers.forEach(clearTimeout); };
|
|
74
|
+
}, [form]);
|
|
75
|
+
const title = panelLabel ?? `${label} options`;
|
|
76
|
+
return _jsx(TimeField, { ...props, ref: root, label: label, value: committed, defaultValue: defaultValue, onValueChange: change, precision: precision, step: step, min: min, max: max, form: form, disabled: disabled, readOnly: readOnly, controlSize: controlSize, className: ["flood-time-picker", className].filter(Boolean).join(" "), onKeyDown: event => {
|
|
77
|
+
onKeyDown?.(event);
|
|
78
|
+
if (!event.defaultPrevented && !event.nativeEvent.isComposing && event.keyCode !== 229 && event.altKey && event.key === "ArrowDown" && event.target.matches("input[data-time-segment]")) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
request(true);
|
|
81
|
+
}
|
|
82
|
+
}, end: _jsx(Popover, { title: title, chrome: "minimal", align: "end", open: !blocked && requestedOpen, onOpenChange: request, disabled: blocked, initialFocusRef: list, positionReference: positionReference, className: "flood-time-picker__panel", style: { "--flood-time-picker-width": width ? `${width}px` : undefined }, trigger: trigger => _jsx(IconButton, { ...trigger, label: openTimeLabel ?? `Choose time: ${label}`, variant: "ghost", size: triggerSizes[controlSize], children: _jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", children: [_jsx("circle", { cx: "12", cy: "12", r: "9" }), _jsx("path", { d: "M12 7v5l3 2" })] }) }), children: _jsx(ScrollArea, { className: "flood-time-picker__scroll", orientation: "vertical", tabIndex: -1, children: _jsx(ListBox, { ref: list, label: title, hideLabel: true, appearance: "plain", selectionBehavior: "manual", options: options, value: options.some(option => option.value === committed) ? committed : "", controlSize: controlSize, onValueChange: next => { change(typeof next === "string" ? next : next[0] ?? ""); request(false); } }) }) }) });
|
|
83
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Clock times in one 24-hour day. No Date, locale conversion or timezone arithmetic. */
|
|
2
|
+
export type TimePrecision = "minute" | "second";
|
|
3
|
+
export type TimeSegment = "hour" | "minute" | "second";
|
|
4
|
+
export type TimeParts = Record<TimeSegment, string>;
|
|
5
|
+
export declare const emptyTimeParts: () => TimeParts;
|
|
6
|
+
export declare const isEmptyTime: (parts: TimeParts, precision: TimePrecision) => boolean;
|
|
7
|
+
export declare function canonicalTime(parts: TimeParts, precision: TimePrecision): string | undefined;
|
|
8
|
+
export declare function parseTime(value: string, precision: TimePrecision): TimeParts | undefined;
|
|
9
|
+
export declare const timeSeconds: (parts: TimeParts) => number;
|
|
10
|
+
export declare const timeInRange: (value: string, min?: string, max?: string) => boolean;
|
|
11
|
+
export declare const timeOnStep: (parts: TimeParts, step: number, min?: string, precision?: TimePrecision) => boolean;
|
|
12
|
+
export declare function stepTimePart(parts: TimeParts, precision: TimePrecision, part: TimeSegment, direction: number, step: number, min?: string, max?: string, edge?: "start" | "end"): TimeParts;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const emptyTimeParts = () => ({ hour: "", minute: "", second: "" });
|
|
2
|
+
export const isEmptyTime = (parts, precision) => !parts.hour && !parts.minute && (precision === "minute" || !parts.second);
|
|
3
|
+
export function canonicalTime(parts, precision) {
|
|
4
|
+
const names = precision === "second" ? ["hour", "minute", "second"] : ["hour", "minute"];
|
|
5
|
+
if (names.some(name => !/^\d{1,2}$/.test(parts[name]) || Number(parts[name]) > (name === "hour" ? 23 : 59)))
|
|
6
|
+
return;
|
|
7
|
+
return names.map(name => parts[name].padStart(2, "0")).join(":");
|
|
8
|
+
}
|
|
9
|
+
export function parseTime(value, precision) {
|
|
10
|
+
if (!(precision === "second" ? /^\d{2}:\d{2}:\d{2}$/ : /^\d{2}:\d{2}$/).test(value))
|
|
11
|
+
return;
|
|
12
|
+
const [hour, minute, second = ""] = value.split(":");
|
|
13
|
+
const parts = { hour, minute, second };
|
|
14
|
+
return canonicalTime(parts, precision) === value ? parts : undefined;
|
|
15
|
+
}
|
|
16
|
+
export const timeSeconds = (parts) => Number(parts.hour) * 3600 + Number(parts.minute) * 60 + Number(parts.second);
|
|
17
|
+
export const timeInRange = (value, min, max) => (!min || value >= min) && (!max || value <= max);
|
|
18
|
+
export const timeOnStep = (parts, step, min, precision = "minute") => (timeSeconds(parts) - (min ? timeSeconds(parseTime(min, precision)) : 0)) % step === 0;
|
|
19
|
+
function fromSeconds(seconds, precision) {
|
|
20
|
+
return { hour: String(Math.floor(seconds / 3600)).padStart(2, "0"), minute: String(Math.floor(seconds % 3600 / 60)).padStart(2, "0"), second: precision === "second" ? String(seconds % 60).padStart(2, "0") : "" };
|
|
21
|
+
}
|
|
22
|
+
export function stepTimePart(parts, precision, part, direction, step, min, max, edge) {
|
|
23
|
+
const base = min ? timeSeconds(parseTime(min, precision)) : 0;
|
|
24
|
+
const ceiling = max ? timeSeconds(parseTime(max, precision)) : precision === "second" ? 86399 : 86340;
|
|
25
|
+
const last = Math.floor((ceiling - base) / step);
|
|
26
|
+
if (edge)
|
|
27
|
+
return fromSeconds(base + (edge === "start" ? 0 : last) * step, precision);
|
|
28
|
+
if (isEmptyTime(parts, precision))
|
|
29
|
+
return fromSeconds(direction > 0 ? base : base + last * step, precision);
|
|
30
|
+
const bounded = (name) => /^\d{1,2}$/.test(parts[name]) ? Math.min(Number(parts[name]), name === "hour" ? 23 : 59) : 0;
|
|
31
|
+
const current = bounded("hour") * 3600 + bounded("minute") * 60 + (precision === "second" ? bounded("second") : 0);
|
|
32
|
+
const unit = part === "hour" ? 3600 : part === "minute" ? 60 : 1;
|
|
33
|
+
const proposed = current + direction * Math.max(step, unit);
|
|
34
|
+
const position = direction > 0 ? Math.ceil((proposed - base) / step) : Math.floor((proposed - base) / step);
|
|
35
|
+
return fromSeconds(base + Math.max(0, Math.min(last, position)) * step, precision);
|
|
36
|
+
}
|
package/dist/toolbar.js
CHANGED
|
@@ -3,7 +3,7 @@ import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } f
|
|
|
3
3
|
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
4
4
|
const selector = 'button:not([role]),button[role="button"],a[href]:not([role]),a[href][role="link"]';
|
|
5
5
|
const independent = '[role="menu"],[role="tablist"],[role="radiogroup"],[role="listbox"],[role="combobox"],.flood-toggle-group';
|
|
6
|
-
export const RovingToolbar = forwardRef(function RovingToolbar({ label, orientation = "horizontal", loop = true, className, onKeyDown, onKeyDownCapture, onFocus, ...props }, ref) {
|
|
6
|
+
export const RovingToolbar = /* @__PURE__ */ forwardRef(function RovingToolbar({ label, orientation = "horizontal", loop = true, className, onKeyDown, onKeyDownCapture, onFocus, ...props }, ref) {
|
|
7
7
|
const root = useRef(null), current = useRef(null), within = useRef(false);
|
|
8
8
|
const refresh = useRef(() => []);
|
|
9
9
|
useImperativeHandle(ref, () => root.current, []);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type TreeSelectProps } from "./index.js";
|
|
2
|
+
/** A single hierarchical form selection. Tree owns navigation; the select owns submission. */
|
|
3
|
+
export declare const HierarchicalSelect: import("react").ForwardRefExoticComponent<TreeSelectProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { Popover, ScrollArea, Tree } from "./index.js";
|
|
4
|
+
import { mergeDescriptions, resolveFieldStatus } from "./field-state.js";
|
|
5
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
6
|
+
/** A single hierarchical form selection. Tree owns navigation; the select owns submission. */
|
|
7
|
+
export const HierarchicalSelect = /* @__PURE__ */ forwardRef(function HierarchicalSelect({ label, hideLabel = false, items, value, defaultValue = "", onValueChange, placeholder = "Choose an item…", name, form, required = false, disabled = false, readOnly = false, invalid, status, controlSize = "md", open: controlledOpen, defaultOpen = false, onOpenChange, expandedValues, defaultExpandedValues = [], onExpandedValuesChange, emptyMessage = "No items.", invalidSelectionMessage = "Choose an item.", id: explicitId, className, "aria-describedby": describedBy, "aria-invalid": ariaInvalid, onKeyDown, ...props }, forwardedRef) {
|
|
8
|
+
if (![label, placeholder, emptyMessage, invalidSelectionMessage].every(text => text.trim()))
|
|
9
|
+
throw new Error("Flood TreeSelect requires nonempty labels and messages");
|
|
10
|
+
const entries = new Map();
|
|
11
|
+
const collect = (nodes, ancestors) => {
|
|
12
|
+
if (ancestors.length > 256)
|
|
13
|
+
throw new Error("Flood TreeSelect nesting exceeds the supported depth");
|
|
14
|
+
for (const item of nodes) {
|
|
15
|
+
if (!item.value.trim() || !item.label.trim() || entries.has(item.value))
|
|
16
|
+
throw new Error("Flood TreeSelect items need globally unique nonempty values and labels");
|
|
17
|
+
entries.set(item.value, { item, ancestors });
|
|
18
|
+
if (item.children)
|
|
19
|
+
collect(item.children, [...ancestors, item.value]);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
collect(items, []);
|
|
23
|
+
const generated = useId(), id = explicitId ?? `flood-tree-select-${generated}`;
|
|
24
|
+
const root = useRef(null), native = useRef(null), trigger = useRef(null), initialFocus = useRef(null);
|
|
25
|
+
const overlayAnchor = useRef(undefined);
|
|
26
|
+
const setTrigger = useCallback((node) => { trigger.current = node; overlayAnchor.current?.(node); }, []);
|
|
27
|
+
useImperativeHandle(forwardedRef, () => root.current, []);
|
|
28
|
+
const initial = useRef(defaultValue), [local, setLocal] = useState(defaultValue), [localOpen, setLocalOpen] = useState(defaultOpen);
|
|
29
|
+
const [width, setWidth] = useState(), [validation, setValidation] = useState(""), [inheritedDisabled, setInheritedDisabled] = useState(false);
|
|
30
|
+
const requested = value ?? local, entry = entries.get(requested), selected = entry?.item.selectable === false ? undefined : entry?.item;
|
|
31
|
+
const committed = selected?.value ?? "", blocked = disabled || readOnly || inheritedDisabled, requestedOpen = controlledOpen ?? localOpen;
|
|
32
|
+
const [expanded, setExpanded] = useState(() => [...new Set([...defaultExpandedValues, ...(entries.get(value ?? defaultValue)?.ancestors ?? [])])]);
|
|
33
|
+
const current = useRef({ value, controlledOpen, onOpenChange, requestedOpen, onValueChange });
|
|
34
|
+
current.current = { value, controlledOpen, onOpenChange, requestedOpen, onValueChange };
|
|
35
|
+
const requestOpen = (next) => {
|
|
36
|
+
if (next && (blocked || native.current?.matches(":disabled")))
|
|
37
|
+
return;
|
|
38
|
+
if (current.current.controlledOpen === undefined)
|
|
39
|
+
setLocalOpen(next);
|
|
40
|
+
if (next !== current.current.requestedOpen)
|
|
41
|
+
current.current.onOpenChange?.(next);
|
|
42
|
+
};
|
|
43
|
+
const choose = (next) => {
|
|
44
|
+
const key = typeof next === "string" ? next : next[0] ?? "", choice = entries.get(key)?.item;
|
|
45
|
+
if (blocked || native.current?.matches(":disabled") || !choice || choice.disabled || choice.readOnly || choice.selectable === false)
|
|
46
|
+
return;
|
|
47
|
+
if (current.current.value === undefined)
|
|
48
|
+
setLocal(key);
|
|
49
|
+
if (key !== committed)
|
|
50
|
+
current.current.onValueChange?.(key);
|
|
51
|
+
setValidation("");
|
|
52
|
+
requestOpen(false);
|
|
53
|
+
};
|
|
54
|
+
useBrowserLayoutEffect(() => {
|
|
55
|
+
const node = trigger.current;
|
|
56
|
+
if (!node)
|
|
57
|
+
return;
|
|
58
|
+
const update = () => { const next = node.getBoundingClientRect().width; if (next > 0)
|
|
59
|
+
setWidth(previous => previous === next ? previous : next); };
|
|
60
|
+
update();
|
|
61
|
+
const Observer = node.ownerDocument.defaultView?.ResizeObserver, observer = Observer ? new Observer(update) : undefined;
|
|
62
|
+
observer?.observe(node);
|
|
63
|
+
return () => observer?.disconnect();
|
|
64
|
+
}, []);
|
|
65
|
+
useBrowserLayoutEffect(() => {
|
|
66
|
+
const node = native.current;
|
|
67
|
+
if (!node)
|
|
68
|
+
return;
|
|
69
|
+
node.setCustomValidity(!readOnly && required && !committed ? invalidSelectionMessage : "");
|
|
70
|
+
if (validation && node.validity.valid)
|
|
71
|
+
setValidation("");
|
|
72
|
+
});
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const node = native.current, Observer = node?.ownerDocument.defaultView?.MutationObserver;
|
|
75
|
+
if (!node || !Observer)
|
|
76
|
+
return;
|
|
77
|
+
const update = () => setInheritedDisabled(node.matches(":disabled")), observer = new Observer(update);
|
|
78
|
+
update();
|
|
79
|
+
let ancestor = node.parentElement;
|
|
80
|
+
while (ancestor) {
|
|
81
|
+
if (ancestor.localName === "fieldset")
|
|
82
|
+
observer.observe(ancestor, { attributes: true, attributeFilter: ["disabled"] });
|
|
83
|
+
ancestor = ancestor.parentElement;
|
|
84
|
+
}
|
|
85
|
+
return () => observer.disconnect();
|
|
86
|
+
}, []);
|
|
87
|
+
useEffect(() => { if (blocked && requestedOpen)
|
|
88
|
+
requestOpen(false); }, [blocked, requestedOpen]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const owner = native.current?.form;
|
|
91
|
+
if (!owner)
|
|
92
|
+
return;
|
|
93
|
+
const timers = new Set();
|
|
94
|
+
const reset = (event) => { const timer = setTimeout(() => { timers.delete(timer); if (event.defaultPrevented)
|
|
95
|
+
return; if (current.current.value === undefined)
|
|
96
|
+
setLocal(initial.current); setValidation(""); requestOpen(false); }, 0); timers.add(timer); };
|
|
97
|
+
owner.addEventListener("reset", reset);
|
|
98
|
+
return () => { owner.removeEventListener("reset", reset); for (const timer of timers)
|
|
99
|
+
clearTimeout(timer); };
|
|
100
|
+
}, [form]);
|
|
101
|
+
const described = mergeDescriptions(describedBy, readOnly && `${id}-readonly`, required && `${id}-required`, validation && `${id}-validation`);
|
|
102
|
+
return _jsxs("div", { ...props, ref: root, id: id, className: ["flood-tree-select", className].filter(Boolean).join(" "), "data-disabled": disabled || inheritedDisabled || undefined, "data-readonly": readOnly || undefined, onKeyDown: event => { onKeyDown?.(event); if (event.defaultPrevented || blocked || event.nativeEvent.isComposing || event.keyCode === 229)
|
|
103
|
+
return; if (event.target === trigger.current && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
requestOpen(true);
|
|
106
|
+
} }, children: [_jsx("label", { htmlFor: `${id}-trigger`, id: `${id}-label`, className: ["flood-label", hideLabel ? "flood-visually-hidden" : ""].filter(Boolean).join(" "), children: label }), _jsx(Popover, { title: label, className: "flood-tree-select__panel", open: !blocked && requestedOpen, onOpenChange: requestOpen, disabled: blocked, initialFocusRef: initialFocus, style: { "--flood-tree-select-width": width === undefined ? undefined : `${width}px` }, trigger: overlay => {
|
|
107
|
+
overlayAnchor.current = overlay.ref;
|
|
108
|
+
return _jsx("span", { className: `flood-select flood-select--custom flood-control--${controlSize}`, children: _jsx("button", { ...overlay, ref: setTrigger, id: `${id}-trigger`, className: `flood-input flood-select__control flood-control--${controlSize}`, "aria-labelledby": `${id}-label ${id}-value`, "aria-describedby": described, "aria-invalid": invalid || status === "error" || Boolean(validation) || ariaInvalid || undefined, "data-status": resolveFieldStatus(status, invalid || Boolean(validation), ariaInvalid), "data-readonly": readOnly || undefined, "aria-disabled": readOnly || undefined, children: _jsx("span", { id: `${id}-value`, className: "flood-select__value", "data-placeholder": !selected || undefined, children: selected?.label ?? placeholder }) }) });
|
|
109
|
+
}, children: _jsx(ScrollArea, { orientation: "vertical", className: "flood-tree-select__scroll", tabIndex: -1, children: _jsx(Tree, { ref: node => { initialFocus.current = node?.querySelector('[role="treeitem"][tabindex="0"], [role="tree"][tabindex="0"]') ?? null; }, label: label, hideLabel: true, items: items, value: committed, onValueChange: choose, controlSize: controlSize, emptyMessage: emptyMessage, expandedValues: expandedValues ?? expanded, onExpandedValuesChange: next => { if (expandedValues === undefined)
|
|
110
|
+
setExpanded(next); onExpandedValuesChange?.(next); }, onClick: event => { const target = event.target.closest('[role="treeitem"]'); if (target?.dataset.treeValue === committed && !event.target.closest('[data-tree-toggle="true"]'))
|
|
111
|
+
requestOpen(false); }, onKeyDown: event => { if ((event.key === "Enter" || event.key === " ") && event.target.dataset.treeValue === committed)
|
|
112
|
+
requestOpen(false); } }) }) }), _jsxs("select", { ref: native, name: name, form: form, value: committed, disabled: disabled, required: required && !readOnly, hidden: true, "aria-hidden": "true", tabIndex: -1, onChange: () => { }, onInvalid: event => { event.preventDefault(); setValidation(event.currentTarget.validationMessage); trigger.current?.focus(); }, children: [_jsx("option", { value: "" }), [...entries.values()].filter(({ item }) => item.selectable !== false).map(({ item }) => _jsx("option", { value: item.value, children: item.label }, item.value))] }), _jsx("span", { hidden: true, id: `${id}-readonly`, children: "Selection is read only." }), _jsx("span", { hidden: true, id: `${id}-required`, children: "Required." }), validation && _jsx("span", { id: `${id}-validation`, className: "flood-tree-select__validation", role: "alert", children: validation })] });
|
|
113
|
+
});
|
package/dist/tree.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { TreeProps } from "./index.js";
|
|
2
|
+
/** Application selection, not navigation or a form field; expansion and selection are independent. */
|
|
3
|
+
export declare const SelectionTree: import("react").ForwardRefExoticComponent<TreeProps & import("react").RefAttributes<HTMLDivElement>>;
|