@flood-md/react 0.2.6 → 0.2.9
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 +45 -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 +401 -3
- 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 +17 -6
- 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,172 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { resolveFieldStatus, mergeDescriptions } from "./field-state.js";
|
|
4
|
+
import { canonicalDate, dateInRange, daysInMonth, emptyDateParts, isEmptyDate, parseDate, stepDatePart } from "./date-value.js";
|
|
5
|
+
const orders = { "month-day-year": ["month", "day", "year"], "day-month-year": ["day", "month", "year"], "year-month-day": ["year", "month", "day"] };
|
|
6
|
+
const cx = (...values) => values.filter(Boolean).join(" ");
|
|
7
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
8
|
+
export const SegmentedDateField = /* @__PURE__ */ forwardRef(function SegmentedDateField({ label, hideLabel = false, value, defaultValue = "", onValueChange, min, max, required = false, readOnly = false, disabled = false, name, form, controlSize = "md", invalid = false, status, description, order = "month-day-year", segmentLabels, messages, validationMessage, end, id: explicitId, className, onBlur, onKeyDown, "aria-describedby": describedBy, "aria-invalid": ariaInvalid, ...props }, forwardedRef) {
|
|
9
|
+
if (!label.trim())
|
|
10
|
+
throw new Error("Flood DateField requires a nonempty label");
|
|
11
|
+
for (const [key, entry] of [["value", value], ["defaultValue", defaultValue], ["min", min], ["max", max]]) {
|
|
12
|
+
if (entry !== undefined && entry !== "" && !parseDate(entry))
|
|
13
|
+
throw new Error(`Flood DateField ${key} must be an ISO Gregorian date from 0001 to 9999`);
|
|
14
|
+
}
|
|
15
|
+
if (min === "" || max === "" || (min && max && min > max))
|
|
16
|
+
throw new Error("Flood DateField requires an ordered nonempty date range");
|
|
17
|
+
const ordered = orders[order];
|
|
18
|
+
if (!ordered)
|
|
19
|
+
throw new Error("Flood DateField order is unsupported");
|
|
20
|
+
const labels = { year: segmentLabels?.year ?? "Year", month: segmentLabels?.month ?? "Month", day: segmentLabels?.day ?? "Day" };
|
|
21
|
+
if (!Object.values(labels).every(text => text.trim()))
|
|
22
|
+
throw new Error("Flood DateField segment labels must be nonempty");
|
|
23
|
+
if (messages && Object.values(messages).some(text => text !== undefined && !text.trim()))
|
|
24
|
+
throw new Error("Flood DateField validation messages must be nonempty");
|
|
25
|
+
const generated = useId(), id = explicitId ?? `flood-date-${generated}`;
|
|
26
|
+
const root = useRef(null), bridge = useRef(null);
|
|
27
|
+
const inputs = useRef({});
|
|
28
|
+
const lastFocused = useRef(undefined);
|
|
29
|
+
useImperativeHandle(forwardedRef, () => root.current, []);
|
|
30
|
+
const initial = useRef(defaultValue), [local, setLocal] = useState(defaultValue);
|
|
31
|
+
const committed = value ?? local;
|
|
32
|
+
const [parts, setParts] = useState(() => parseDate(committed) ?? emptyDateParts());
|
|
33
|
+
const [touched, setTouched] = useState(false), [inheritedDisabled, setInheritedDisabled] = useState(false);
|
|
34
|
+
const effectiveDisabled = disabled || inheritedDisabled;
|
|
35
|
+
const composing = useRef(undefined), compositionSnapshot = useRef(parts), lastPublished = useRef(committed);
|
|
36
|
+
const settledParts = composing.current ? compositionSnapshot.current : parts;
|
|
37
|
+
const canonical = canonicalDate(settledParts), empty = isEmptyDate(settledParts);
|
|
38
|
+
const inRange = !!canonical && dateInRange(canonical, min, max);
|
|
39
|
+
const candidate = empty ? "" : inRange ? canonical : undefined;
|
|
40
|
+
const pendingAcceptance = value !== undefined && candidate !== undefined && candidate !== value;
|
|
41
|
+
const ownMessage = pendingAcceptance ? messages?.invalid ?? "Enter a valid date." : empty ? (required ? messages?.required ?? "Enter a date." : "") : !canonical ? messages?.invalid ?? "Enter a valid date." : !inRange ? messages?.outOfRange ?? "Enter a date within the allowed range." : "";
|
|
42
|
+
const message = ownMessage || (validationMessage?.trim() ? validationMessage : "");
|
|
43
|
+
const current = useRef({ value, committed, onValueChange, parts });
|
|
44
|
+
current.current = { value, committed, onValueChange, parts };
|
|
45
|
+
useBrowserLayoutEffect(() => {
|
|
46
|
+
if (value !== undefined && value !== lastPublished.current)
|
|
47
|
+
setParts(parseDate(value) ?? emptyDateParts());
|
|
48
|
+
if (value !== undefined)
|
|
49
|
+
lastPublished.current = value;
|
|
50
|
+
}, [value]);
|
|
51
|
+
useBrowserLayoutEffect(() => {
|
|
52
|
+
for (const part of ordered)
|
|
53
|
+
inputs.current[part]?.setCustomValidity(part === ordered[0] && !effectiveDisabled && !readOnly ? message : "");
|
|
54
|
+
}, [message, effectiveDisabled, readOnly, order]);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const node = inputs.current[ordered[0]];
|
|
57
|
+
if (!node)
|
|
58
|
+
return;
|
|
59
|
+
const update = () => setInheritedDisabled(node.matches(":disabled"));
|
|
60
|
+
update();
|
|
61
|
+
const observer = new node.ownerDocument.defaultView.MutationObserver(update);
|
|
62
|
+
let ancestor = node.parentElement;
|
|
63
|
+
while (ancestor) {
|
|
64
|
+
if (ancestor.localName === "fieldset")
|
|
65
|
+
observer.observe(ancestor, { attributes: true, attributeFilter: ["disabled"] });
|
|
66
|
+
ancestor = ancestor.parentElement;
|
|
67
|
+
}
|
|
68
|
+
return () => observer.disconnect();
|
|
69
|
+
}, [disabled, order]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const owner = bridge.current?.form;
|
|
72
|
+
if (!owner)
|
|
73
|
+
return;
|
|
74
|
+
const timers = new Set();
|
|
75
|
+
const reset = (event) => {
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
timers.delete(timer);
|
|
78
|
+
if (event.defaultPrevented)
|
|
79
|
+
return;
|
|
80
|
+
const next = current.current.value === undefined ? initial.current : current.current.committed;
|
|
81
|
+
if (current.current.value === undefined)
|
|
82
|
+
setLocal(next);
|
|
83
|
+
setParts(parseDate(next) ?? emptyDateParts());
|
|
84
|
+
setTouched(false);
|
|
85
|
+
lastPublished.current = next;
|
|
86
|
+
}, 0);
|
|
87
|
+
timers.add(timer);
|
|
88
|
+
};
|
|
89
|
+
owner.addEventListener("reset", reset);
|
|
90
|
+
return () => { owner.removeEventListener("reset", reset); for (const timer of timers)
|
|
91
|
+
clearTimeout(timer); };
|
|
92
|
+
}, [form]);
|
|
93
|
+
const select = (part) => { const node = inputs.current[part]; node?.focus(); node?.select(); };
|
|
94
|
+
const change = (next) => {
|
|
95
|
+
if (readOnly || effectiveDisabled || inputs.current[ordered[0]]?.matches(":disabled"))
|
|
96
|
+
return;
|
|
97
|
+
setParts(next);
|
|
98
|
+
const date = canonicalDate(next);
|
|
99
|
+
const nextValue = date && dateInRange(date, min, max) ? date : "";
|
|
100
|
+
if (value === undefined)
|
|
101
|
+
setLocal(nextValue);
|
|
102
|
+
if (lastPublished.current !== nextValue) {
|
|
103
|
+
lastPublished.current = nextValue;
|
|
104
|
+
onValueChange?.(nextValue);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const edit = (part, raw) => {
|
|
108
|
+
if (!/^\d*$/.test(raw))
|
|
109
|
+
return;
|
|
110
|
+
const limit = part === "year" ? 4 : 2;
|
|
111
|
+
const text = raw.slice(0, limit);
|
|
112
|
+
change({ ...current.current.parts, [part]: text });
|
|
113
|
+
if (text.length === limit && ordered.indexOf(part) < 2)
|
|
114
|
+
select(ordered[ordered.indexOf(part) + 1]);
|
|
115
|
+
};
|
|
116
|
+
const errorShown = touched && !effectiveDisabled && !readOnly && !!message;
|
|
117
|
+
const resolvedStatus = resolveFieldStatus(status, invalid || errorShown, ariaInvalid);
|
|
118
|
+
const descriptionId = description ? `${id}-description` : undefined;
|
|
119
|
+
const errorId = errorShown ? `${id}-error` : undefined;
|
|
120
|
+
return _jsxs("div", { ...props, id: id, ref: root, className: cx("flood-date-field", className), "data-disabled": effectiveDisabled || undefined, onBlur: event => {
|
|
121
|
+
onBlur?.(event);
|
|
122
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
123
|
+
setTouched(true);
|
|
124
|
+
const date = canonicalDate(current.current.parts);
|
|
125
|
+
const draft = isEmptyDate(current.current.parts) ? "" : date && dateInRange(date, min, max) ? date : undefined;
|
|
126
|
+
if (value !== undefined && draft !== undefined && draft !== value) {
|
|
127
|
+
setParts(parseDate(value) ?? emptyDateParts());
|
|
128
|
+
lastPublished.current = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}, onKeyDown: event => {
|
|
132
|
+
onKeyDown?.(event);
|
|
133
|
+
if (event.defaultPrevented || event.nativeEvent.isComposing || composing.current || event.keyCode === 229 || event.altKey || event.ctrlKey || event.metaKey)
|
|
134
|
+
return;
|
|
135
|
+
const target = event.target;
|
|
136
|
+
const part = target.getAttribute("data-segment");
|
|
137
|
+
if (!part || inputs.current[part] !== target)
|
|
138
|
+
return;
|
|
139
|
+
const index = ordered.indexOf(part);
|
|
140
|
+
const rtl = root.current && root.current.ownerDocument.defaultView.getComputedStyle(root.current).direction === "rtl";
|
|
141
|
+
const offset = (event.key === "ArrowLeft" ? -1 : 1) * (rtl ? -1 : 1);
|
|
142
|
+
if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && index + offset >= 0 && index + offset <= 2) {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
select(ordered[index + offset]);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (readOnly || effectiveDisabled || target.matches(":disabled"))
|
|
148
|
+
return;
|
|
149
|
+
if (["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
change(stepDatePart(current.current.parts, part, event.key === "ArrowDown" ? -1 : 1, event.key === "Home" ? "start" : event.key === "End" ? "end" : undefined));
|
|
152
|
+
target.select();
|
|
153
|
+
}
|
|
154
|
+
if (event.key === "Backspace" && !parts[part] && index > 0) {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
select(ordered[index - 1]);
|
|
157
|
+
}
|
|
158
|
+
}, children: [_jsx("label", { className: cx("flood-label", hideLabel && "flood-visually-hidden"), id: `${id}-label`, htmlFor: `${id}-${ordered[0]}`, children: label }), _jsxs("div", { className: cx("flood-date-field__control", `flood-control--${controlSize}`), role: "group", "aria-labelledby": `${id}-label`, "aria-describedby": mergeDescriptions(describedBy, descriptionId, errorId), "data-size": controlSize, "data-status": resolvedStatus, "data-readonly": readOnly || undefined, "data-disabled": effectiveDisabled || undefined, onClick: event => {
|
|
159
|
+
if (event.defaultPrevented || effectiveDisabled || event.target.closest("input,button,a,.flood-date-field__end"))
|
|
160
|
+
return;
|
|
161
|
+
const part = ordered.find(part => !parts[part]) ?? lastFocused.current ?? ordered[0];
|
|
162
|
+
if (!inputs.current[part]?.matches(":disabled"))
|
|
163
|
+
select(part);
|
|
164
|
+
}, children: [ordered.map((part, index) => _jsxs("span", { className: "flood-date-field__part", children: [index > 0 && _jsx("span", { className: "flood-date-field__separator", "aria-hidden": "true", children: "/" }), _jsx("input", { id: `${id}-${part}`, ref: node => { inputs.current[part] = node; }, className: "flood-date-field__segment", "data-segment": part, type: "text", role: "spinbutton", inputMode: "numeric", autoComplete: "off", spellCheck: false, form: form, "aria-label": `${label}, ${labels[part]}`, "aria-valuemin": 1, "aria-valuemax": part === "year" ? 9999 : part === "month" ? 12 : daysInMonth(Number(parts.year) || 2000, Number(parts.month) || 1), "aria-valuenow": parts[part] && Number(parts[part]) >= 1 && Number(parts[part]) <= (part === "year" ? 9999 : part === "month" ? 12 : daysInMonth(Number(parts.year) || 2000, Number(parts.month) || 1)) ? Number(parts[part]) : undefined, "aria-valuetext": parts[part] || undefined, "aria-invalid": resolvedStatus === "error" || undefined, "aria-required": required || undefined, "aria-describedby": mergeDescriptions(describedBy, descriptionId, errorId), placeholder: part === "year" ? "YYYY" : part === "month" ? "MM" : "DD", value: parts[part], disabled: disabled, readOnly: readOnly, onFocus: event => { lastFocused.current = part; event.currentTarget.select(); }, onClick: event => event.currentTarget.select(), onChange: event => { if (composing.current === part)
|
|
165
|
+
setParts(previous => ({ ...previous, [part]: event.target.value }));
|
|
166
|
+
else
|
|
167
|
+
edit(part, event.target.value); }, onCompositionStart: () => { compositionSnapshot.current = current.current.parts; composing.current = part; }, onCompositionEnd: event => { composing.current = undefined; edit(part, event.currentTarget.value); }, onPaste: event => { if (readOnly || effectiveDisabled)
|
|
168
|
+
return; const parsed = parseDate(event.clipboardData.getData("text/plain").trim()); if (parsed) {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
change(parsed);
|
|
171
|
+
} }, onInvalid: () => setTouched(true) })] }, part)), end != null && _jsx("div", { className: "flood-date-field__end", children: end })] }), _jsx("input", { ref: bridge, type: "hidden", name: name, form: form, value: canonical && (readOnly || inRange && !message) && (value === undefined || canonical === value) ? canonical : "", disabled: disabled }), description && _jsx("div", { id: descriptionId, className: "flood-date-field__description", children: description }), errorShown && _jsx("div", { id: errorId, className: "flood-date-field__error", children: message })] });
|
|
172
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { DatePickerProps } from "./index.js";
|
|
2
|
+
/** Calendar selection and keyboard date entry share one DateField value/form bridge. */
|
|
3
|
+
export declare const CalendarDatePicker: import("react").ForwardRefExoticComponent<DatePickerProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { Calendar, DateField, IconButton, Popover } from "./index.js";
|
|
4
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
5
|
+
const triggerSizes = { sm: "xs", md: "sm", lg: "md", xl: "lg" };
|
|
6
|
+
/** Calendar selection and keyboard date entry share one DateField value/form bridge. */
|
|
7
|
+
export const CalendarDatePicker = /* @__PURE__ */ forwardRef(function CalendarDatePicker({ value, defaultValue = "", onValueChange, open: controlledOpen, defaultOpen = false, onOpenChange, calendarLabel, openCalendarLabel, locale, weekStartsOn, today, previousMonthLabel, nextMonthLabel, label, disabled = false, readOnly = false, controlSize = "md", min, max, form, className, onKeyDown, ...props }, forwardedRef) {
|
|
8
|
+
const panelLabel = calendarLabel ?? `${label} calendar`;
|
|
9
|
+
const triggerLabel = openCalendarLabel ?? `Open calendar: ${label}`;
|
|
10
|
+
if (!panelLabel.trim() || !triggerLabel.trim())
|
|
11
|
+
throw new Error("Flood DatePicker calendar labels must be nonempty");
|
|
12
|
+
const root = useRef(null);
|
|
13
|
+
const positionReference = useRef(null);
|
|
14
|
+
const [fieldWidth, setFieldWidth] = useState();
|
|
15
|
+
useImperativeHandle(forwardedRef, () => root.current, []);
|
|
16
|
+
const initial = useRef(defaultValue), [localValue, setLocalValue] = useState(defaultValue);
|
|
17
|
+
const [localOpen, setLocalOpen] = useState(defaultOpen), [inheritedDisabled, setInheritedDisabled] = useState(false);
|
|
18
|
+
const committed = value ?? localValue, requestedOpen = controlledOpen ?? localOpen;
|
|
19
|
+
const blocked = disabled || readOnly || inheritedDisabled;
|
|
20
|
+
const current = useRef({ value, controlledOpen, onValueChange, onOpenChange, requestedOpen });
|
|
21
|
+
current.current = { value, controlledOpen, onValueChange, onOpenChange, requestedOpen };
|
|
22
|
+
const requestOpen = (next) => {
|
|
23
|
+
if (current.current.controlledOpen === undefined)
|
|
24
|
+
setLocalOpen(next);
|
|
25
|
+
current.current.onOpenChange?.(next);
|
|
26
|
+
};
|
|
27
|
+
const nativeDisabled = () => root.current?.querySelector("input[data-segment]")?.matches(":disabled") ?? false;
|
|
28
|
+
const changeDate = (next) => {
|
|
29
|
+
if (blocked || nativeDisabled())
|
|
30
|
+
return;
|
|
31
|
+
if (current.current.value === undefined)
|
|
32
|
+
setLocalValue(next);
|
|
33
|
+
current.current.onValueChange?.(next);
|
|
34
|
+
};
|
|
35
|
+
useBrowserLayoutEffect(() => {
|
|
36
|
+
const control = root.current?.querySelector(".flood-date-field__control");
|
|
37
|
+
if (!control)
|
|
38
|
+
return;
|
|
39
|
+
positionReference.current = control;
|
|
40
|
+
const update = () => { const width = control.getBoundingClientRect().width; if (width > 0)
|
|
41
|
+
setFieldWidth(previous => previous === width ? previous : width); };
|
|
42
|
+
update();
|
|
43
|
+
const view = control.ownerDocument.defaultView;
|
|
44
|
+
const observer = view?.ResizeObserver ? new view.ResizeObserver(update) : undefined;
|
|
45
|
+
observer?.observe(control);
|
|
46
|
+
return () => { observer?.disconnect(); if (positionReference.current === control)
|
|
47
|
+
positionReference.current = null; };
|
|
48
|
+
}, []);
|
|
49
|
+
useBrowserLayoutEffect(() => {
|
|
50
|
+
const node = root.current;
|
|
51
|
+
if (!node)
|
|
52
|
+
return;
|
|
53
|
+
const update = () => setInheritedDisabled(nativeDisabled());
|
|
54
|
+
update();
|
|
55
|
+
const observer = new node.ownerDocument.defaultView.MutationObserver(update);
|
|
56
|
+
let ancestor = node.parentElement;
|
|
57
|
+
while (ancestor) {
|
|
58
|
+
if (ancestor.localName === "fieldset")
|
|
59
|
+
observer.observe(ancestor, { attributes: true, attributeFilter: ["disabled"] });
|
|
60
|
+
ancestor = ancestor.parentElement;
|
|
61
|
+
}
|
|
62
|
+
return () => observer.disconnect();
|
|
63
|
+
}, [disabled]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (blocked && current.current.requestedOpen) {
|
|
66
|
+
if (current.current.controlledOpen === undefined)
|
|
67
|
+
setLocalOpen(false);
|
|
68
|
+
current.current.onOpenChange?.(false);
|
|
69
|
+
}
|
|
70
|
+
}, [blocked, requestedOpen]);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const owner = root.current?.querySelector('input[type="hidden"]')?.form;
|
|
73
|
+
if (!owner)
|
|
74
|
+
return;
|
|
75
|
+
const timers = new Set();
|
|
76
|
+
const reset = (event) => {
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
timers.delete(timer);
|
|
79
|
+
if (event.defaultPrevented)
|
|
80
|
+
return;
|
|
81
|
+
if (current.current.value === undefined)
|
|
82
|
+
setLocalValue(initial.current);
|
|
83
|
+
if (current.current.requestedOpen) {
|
|
84
|
+
if (current.current.controlledOpen === undefined)
|
|
85
|
+
setLocalOpen(false);
|
|
86
|
+
current.current.onOpenChange?.(false);
|
|
87
|
+
}
|
|
88
|
+
}, 0);
|
|
89
|
+
timers.add(timer);
|
|
90
|
+
};
|
|
91
|
+
owner.addEventListener("reset", reset);
|
|
92
|
+
return () => { owner.removeEventListener("reset", reset); for (const timer of timers)
|
|
93
|
+
clearTimeout(timer); };
|
|
94
|
+
}, [form]);
|
|
95
|
+
return _jsx(DateField, { ...props, ref: root, label: label, value: committed, onValueChange: changeDate, disabled: disabled, readOnly: readOnly, controlSize: controlSize, min: min, max: max, form: form, className: ["flood-date-picker", className].filter(Boolean).join(" "), onKeyDown: event => {
|
|
96
|
+
onKeyDown?.(event);
|
|
97
|
+
if (!event.defaultPrevented && !event.nativeEvent.isComposing && event.keyCode !== 229 &&
|
|
98
|
+
!blocked && !nativeDisabled() && event.altKey && !event.ctrlKey && !event.metaKey && event.key === "ArrowDown" &&
|
|
99
|
+
event.target.matches("input[data-segment]")) {
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
requestOpen(true);
|
|
102
|
+
}
|
|
103
|
+
}, end: _jsx(Popover, { title: panelLabel, className: "flood-date-picker__panel", align: "end", positionReference: positionReference, style: { "--flood-date-picker-width": fieldWidth === undefined ? undefined : `${fieldWidth}px` }, open: !blocked && requestedOpen, onOpenChange: requestOpen, disabled: blocked, trigger: trigger => _jsx(IconButton, { ...trigger, label: triggerLabel, variant: "ghost", size: triggerSizes[controlSize], children: _jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "3", y: "5", width: "18", height: "16", rx: "2" }), _jsx("path", { d: "M16 3v4M8 3v4M3 11h18" })] }) }), children: _jsx(Calendar, { label: panelLabel, value: committed, min: min, max: max, locale: locale, weekStartsOn: weekStartsOn, today: today, previousMonthLabel: previousMonthLabel, nextMonthLabel: nextMonthLabel, autoFocus: true, onValueChange: changeDate, onDateSelect: () => {
|
|
104
|
+
if (blocked || nativeDisabled())
|
|
105
|
+
return;
|
|
106
|
+
requestOpen(false);
|
|
107
|
+
} }) }) });
|
|
108
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { DateRangeFieldProps } from "./index.js";
|
|
2
|
+
/** Two original DateFields share one atomic range contract and two canonical form bridges. */
|
|
3
|
+
export declare const PairedDateRangeField: import("react").ForwardRefExoticComponent<DateRangeFieldProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { DateField } from "./index.js";
|
|
4
|
+
import { mergeDescriptions } from "./field-state.js";
|
|
5
|
+
import { canonicalDate, isEmptyDate } from "./date-value.js";
|
|
6
|
+
import { availableDateRange, checkDateRange, completeDateRange, emptyDateRange, emptyRange, sameDateRange } from "./date-range-value.js";
|
|
7
|
+
const cx = (...values) => values.filter(Boolean).join(" ");
|
|
8
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
9
|
+
/** Two original DateFields share one atomic range contract and two canonical form bridges. */
|
|
10
|
+
export const PairedDateRangeField = /* @__PURE__ */ forwardRef(function PairedDateRangeField({ label, hideLabel = false, value, defaultValue = emptyDateRange(), onValueChange, startName, endName, startLabel = "Start date", endLabel = "End date", min, max, required = false, disabled = false, readOnly = false, isDateDisabled, isRangeDisabled, rangeMessages, validationMessage, status, invalid, description, order = "month-day-year", controlSize = "md", segmentLabels, messages, end, form, id: explicitId, className, onBlur, onKeyDown, onKeyDownCapture, "aria-describedby": describedBy, ...props }, forwardedRef) {
|
|
11
|
+
if (!label.trim() || !startLabel.trim() || !endLabel.trim())
|
|
12
|
+
throw new Error("Flood DateRangeField requires nonempty range and endpoint labels");
|
|
13
|
+
checkDateRange(defaultValue, "defaultValue");
|
|
14
|
+
if (value !== undefined)
|
|
15
|
+
checkDateRange(value);
|
|
16
|
+
const initial = useRef({ ...defaultValue }), [local, setLocal] = useState(() => ({ ...defaultValue })), committed = value ?? local;
|
|
17
|
+
const [draft, setDraft] = useState(() => ({ ...committed }));
|
|
18
|
+
const root = useRef(null), startBridge = useRef(null), endBridge = useRef(null);
|
|
19
|
+
const generated = useId(), id = explicitId ?? `flood-date-range-${generated}`;
|
|
20
|
+
useImperativeHandle(forwardedRef, () => root.current, []);
|
|
21
|
+
const lastPublished = useRef({ ...committed }), current = useRef({ value, committed, draft });
|
|
22
|
+
current.current = { value, committed, draft };
|
|
23
|
+
const labels = { incomplete: rangeMessages?.incomplete ?? "Enter both dates.", reversed: rangeMessages?.reversed ?? "The end date must not precede the start date.", unavailable: rangeMessages?.unavailable ?? "This date range is unavailable." };
|
|
24
|
+
if (!Object.values(labels).every(text => text.trim()))
|
|
25
|
+
throw new Error("Flood DateRangeField relationship messages must be nonempty");
|
|
26
|
+
const available = availableDateRange(draft, min, max, isDateDisabled, isRangeDisabled), empty = emptyRange(draft);
|
|
27
|
+
const pending = value !== undefined && (empty || available) && !sameDateRange(draft, value);
|
|
28
|
+
const relationship = pending ? messages?.invalid ?? "Enter a valid date range." : empty ? (required ? labels.incomplete : "") : !draft.start || !draft.end ? labels.incomplete : draft.start > draft.end ? labels.reversed : !available ? labels.unavailable : "";
|
|
29
|
+
const constraint = relationship || (validationMessage?.trim() ? validationMessage : "");
|
|
30
|
+
const errorEndpoint = !draft.start || !!draft.start && !!isDateDisabled?.(draft.start) ? "start" : "end";
|
|
31
|
+
const descriptionId = description ? `${id}-description` : undefined;
|
|
32
|
+
useBrowserLayoutEffect(() => {
|
|
33
|
+
if (value !== undefined && !sameDateRange(value, lastPublished.current))
|
|
34
|
+
setDraft({ ...value });
|
|
35
|
+
if (value !== undefined)
|
|
36
|
+
lastPublished.current = { ...value };
|
|
37
|
+
}, [value?.start, value?.end]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const owner = startBridge.current?.form;
|
|
40
|
+
if (!owner)
|
|
41
|
+
return;
|
|
42
|
+
const timers = new Set();
|
|
43
|
+
const reset = (event) => { const timer = setTimeout(() => { timers.delete(timer); if (event.defaultPrevented)
|
|
44
|
+
return; const next = current.current.value === undefined ? initial.current : current.current.committed; if (current.current.value === undefined)
|
|
45
|
+
setLocal({ ...next }); setDraft({ ...next }); lastPublished.current = { ...next }; }, 0); timers.add(timer); };
|
|
46
|
+
owner.addEventListener("reset", reset);
|
|
47
|
+
return () => { owner.removeEventListener("reset", reset); for (const timer of timers)
|
|
48
|
+
clearTimeout(timer); };
|
|
49
|
+
}, [form]);
|
|
50
|
+
const change = (part, date) => {
|
|
51
|
+
if (disabled || readOnly || root.current?.querySelector("input[data-segment]")?.matches(":disabled"))
|
|
52
|
+
return;
|
|
53
|
+
const next = { ...current.current.draft, [part]: date };
|
|
54
|
+
current.current.draft = next;
|
|
55
|
+
setDraft(next);
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
setLocal(next);
|
|
58
|
+
if (!sameDateRange(next, lastPublished.current)) {
|
|
59
|
+
lastPublished.current = { ...next };
|
|
60
|
+
onValueChange?.(next);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const readEndpoint = (part) => {
|
|
64
|
+
const parts = { year: "", month: "", day: "" };
|
|
65
|
+
root.current?.querySelectorAll(`[data-range-endpoint="${part}"] input[data-segment]`).forEach(node => { parts[node.dataset.segment] = node.value; });
|
|
66
|
+
return isEmptyDate(parts) ? "" : canonicalDate(parts);
|
|
67
|
+
};
|
|
68
|
+
const accepted = (readOnly ? completeDateRange(draft) || empty : (available || empty) && !constraint) && (value === undefined || sameDateRange(draft, value));
|
|
69
|
+
const firstPart = order.split("-")[0];
|
|
70
|
+
return _jsxs("div", { ...props, id: id, ref: root, role: "group", "aria-labelledby": `${id}-label`, "aria-describedby": mergeDescriptions(describedBy, descriptionId), className: cx("flood-date-range-field", className), onKeyDownCapture: event => { onKeyDownCapture?.(event); onKeyDown?.(event); }, onBlur: event => {
|
|
71
|
+
onBlur?.(event);
|
|
72
|
+
if (event.currentTarget.contains(event.relatedTarget))
|
|
73
|
+
return;
|
|
74
|
+
const start = readEndpoint("start"), finish = readEndpoint("end");
|
|
75
|
+
const next = start !== undefined && finish !== undefined ? { start, end: finish } : undefined;
|
|
76
|
+
if (value !== undefined && next && (emptyRange(next) || availableDateRange(next, min, max, isDateDisabled, isRangeDisabled)) && !sameDateRange(next, value)) {
|
|
77
|
+
setDraft({ ...value });
|
|
78
|
+
lastPublished.current = { ...value };
|
|
79
|
+
}
|
|
80
|
+
else if (constraint)
|
|
81
|
+
queueMicrotask(() => { const anchor = root.current?.querySelector(`[data-range-endpoint="${errorEndpoint}"] input[data-segment]`); if (anchor && !anchor.matches(":disabled"))
|
|
82
|
+
anchor.checkValidity(); });
|
|
83
|
+
}, children: [_jsx("label", { id: `${id}-label`, htmlFor: `${id}-start-${firstPart}`, className: cx("flood-label", hideLabel && "flood-visually-hidden"), children: label }), _jsxs("div", { className: cx("flood-date-range-field__fields", `flood-control--${controlSize}`), children: [_jsx("div", { "data-range-endpoint": "start", children: _jsx(DateField, { id: `${id}-start`, label: `${label}, ${startLabel}`, hideLabel: true, value: draft.start, onValueChange: date => change("start", date), min: min, max: max, required: required, disabled: disabled, readOnly: readOnly, controlSize: controlSize, order: order, segmentLabels: segmentLabels, messages: messages, form: form, status: status, invalid: invalid, "aria-invalid": props["aria-invalid"], "aria-describedby": mergeDescriptions(describedBy, descriptionId), validationMessage: errorEndpoint === "start" ? constraint : undefined }) }), _jsx("span", { className: "flood-date-range-field__separator", "aria-hidden": "true", children: "\u2013" }), _jsx("div", { "data-range-endpoint": "end", children: _jsx(DateField, { id: `${id}-end`, label: `${label}, ${endLabel}`, hideLabel: true, value: draft.end, onValueChange: date => change("end", date), min: min, max: max, required: required, disabled: disabled, readOnly: readOnly, controlSize: controlSize, order: order, segmentLabels: segmentLabels, messages: messages, form: form, status: status, invalid: invalid, "aria-invalid": props["aria-invalid"], "aria-describedby": mergeDescriptions(describedBy, descriptionId), validationMessage: errorEndpoint === "end" ? constraint : undefined, end: end }) })] }), _jsx("input", { ref: startBridge, type: "hidden", name: startName, form: form, value: accepted ? draft.start : "", disabled: disabled }), _jsx("input", { ref: endBridge, type: "hidden", name: endName, form: form, value: accepted ? draft.end : "", disabled: disabled }), description && _jsx("div", { id: descriptionId, className: "flood-date-range-field__description", children: description })] });
|
|
84
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { DateRangeField, IconButton, Popover, RangeCalendar } from "./index.js";
|
|
4
|
+
import { emptyDateRange } from "./date-range-value.js";
|
|
5
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
6
|
+
const triggerSizes = { sm: "xs", md: "sm", lg: "md", xl: "lg" };
|
|
7
|
+
export const CalendarDateRangePicker = /* @__PURE__ */ forwardRef(function CalendarDateRangePicker({ value, defaultValue = emptyDateRange(), onValueChange, open: controlledOpen, defaultOpen = false, onOpenChange, label, calendarLabel, openCalendarLabel, locale, weekStartsOn, today, previousMonthLabel, nextMonthLabel, rangeLabels, disabled = false, readOnly = false, controlSize = "md", min, max, isDateDisabled, isRangeDisabled, form, className, onKeyDown, ...props }, forwardedRef) {
|
|
8
|
+
const panelLabel = calendarLabel ?? `${label} calendar`, triggerLabel = openCalendarLabel ?? `Open calendar: ${label}`;
|
|
9
|
+
if (!panelLabel.trim() || !triggerLabel.trim())
|
|
10
|
+
throw new Error("Flood DateRangePicker requires nonempty calendar and trigger labels");
|
|
11
|
+
const root = useRef(null), positionReference = useRef(null);
|
|
12
|
+
useImperativeHandle(forwardedRef, () => root.current, []);
|
|
13
|
+
const initial = useRef({ ...defaultValue }), [localValue, setLocalValue] = useState(() => ({ ...defaultValue }));
|
|
14
|
+
const [localOpen, setLocalOpen] = useState(defaultOpen), [inheritedDisabled, setInheritedDisabled] = useState(false), [fieldWidth, setFieldWidth] = useState();
|
|
15
|
+
const committed = value ?? localValue, requestedOpen = controlledOpen ?? localOpen, blocked = disabled || readOnly || inheritedDisabled;
|
|
16
|
+
const current = useRef({ value, committed, controlledOpen, requestedOpen });
|
|
17
|
+
current.current = { value, committed, controlledOpen, requestedOpen };
|
|
18
|
+
const nativeDisabled = () => root.current?.querySelector("input[data-segment]")?.matches(":disabled") ?? false;
|
|
19
|
+
const requestOpen = (next) => { if (current.current.controlledOpen === undefined)
|
|
20
|
+
setLocalOpen(next); onOpenChange?.(next); };
|
|
21
|
+
const change = (next) => { if (blocked || nativeDisabled())
|
|
22
|
+
return; if (value === undefined)
|
|
23
|
+
setLocalValue({ ...next }); onValueChange?.(next); };
|
|
24
|
+
useBrowserLayoutEffect(() => {
|
|
25
|
+
const fields = root.current?.querySelector(".flood-date-range-field__fields");
|
|
26
|
+
if (!fields)
|
|
27
|
+
return;
|
|
28
|
+
positionReference.current = fields;
|
|
29
|
+
const update = () => { const width = fields.getBoundingClientRect().width; if (width > 0)
|
|
30
|
+
setFieldWidth(previous => previous === width ? previous : width); };
|
|
31
|
+
update();
|
|
32
|
+
const view = fields.ownerDocument.defaultView, observer = view?.ResizeObserver ? new view.ResizeObserver(update) : undefined;
|
|
33
|
+
observer?.observe(fields);
|
|
34
|
+
return () => { observer?.disconnect(); if (positionReference.current === fields)
|
|
35
|
+
positionReference.current = null; };
|
|
36
|
+
}, []);
|
|
37
|
+
useBrowserLayoutEffect(() => {
|
|
38
|
+
const node = root.current;
|
|
39
|
+
if (!node)
|
|
40
|
+
return;
|
|
41
|
+
const update = () => setInheritedDisabled(nativeDisabled());
|
|
42
|
+
update();
|
|
43
|
+
const observer = new node.ownerDocument.defaultView.MutationObserver(update);
|
|
44
|
+
let ancestor = node.parentElement;
|
|
45
|
+
while (ancestor) {
|
|
46
|
+
if (ancestor.localName === "fieldset")
|
|
47
|
+
observer.observe(ancestor, { attributes: true, attributeFilter: ["disabled"] });
|
|
48
|
+
ancestor = ancestor.parentElement;
|
|
49
|
+
}
|
|
50
|
+
return () => observer.disconnect();
|
|
51
|
+
}, [disabled]);
|
|
52
|
+
useEffect(() => { if (blocked && requestedOpen) {
|
|
53
|
+
if (controlledOpen === undefined)
|
|
54
|
+
setLocalOpen(false);
|
|
55
|
+
onOpenChange?.(false);
|
|
56
|
+
} }, [blocked, requestedOpen]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const owner = root.current?.querySelector('input[type="hidden"]')?.form;
|
|
59
|
+
if (!owner)
|
|
60
|
+
return;
|
|
61
|
+
const timers = new Set();
|
|
62
|
+
const reset = (event) => { const timer = setTimeout(() => { timers.delete(timer); if (event.defaultPrevented)
|
|
63
|
+
return; if (current.current.value === undefined)
|
|
64
|
+
setLocalValue({ ...initial.current }); if (current.current.requestedOpen) {
|
|
65
|
+
if (current.current.controlledOpen === undefined)
|
|
66
|
+
setLocalOpen(false);
|
|
67
|
+
onOpenChange?.(false);
|
|
68
|
+
} }, 0); timers.add(timer); };
|
|
69
|
+
owner.addEventListener("reset", reset);
|
|
70
|
+
return () => { owner.removeEventListener("reset", reset); for (const timer of timers)
|
|
71
|
+
clearTimeout(timer); };
|
|
72
|
+
}, [form]);
|
|
73
|
+
return _jsx(DateRangeField, { ...props, ref: root, label: label, value: committed, onValueChange: change, disabled: disabled, readOnly: readOnly, controlSize: controlSize, min: min, max: max, isDateDisabled: isDateDisabled, isRangeDisabled: isRangeDisabled, form: form, className: ["flood-date-range-picker", className].filter(Boolean).join(" "), onKeyDown: event => {
|
|
74
|
+
onKeyDown?.(event);
|
|
75
|
+
if (!event.defaultPrevented && !event.nativeEvent.isComposing && event.keyCode !== 229 && !blocked && !nativeDisabled() && event.altKey && !event.ctrlKey && !event.metaKey && event.key === "ArrowDown" && event.target.matches("input[data-segment]")) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
requestOpen(true);
|
|
78
|
+
}
|
|
79
|
+
}, end: _jsx(Popover, { title: panelLabel, className: "flood-date-range-picker__panel", align: "end", positionReference: positionReference, style: { "--flood-date-range-picker-width": fieldWidth ? `${fieldWidth}px` : undefined }, open: !blocked && requestedOpen, onOpenChange: requestOpen, disabled: blocked, trigger: trigger => _jsx(IconButton, { ...trigger, label: triggerLabel, variant: "ghost", size: triggerSizes[controlSize], children: _jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "3", y: "5", width: "18", height: "16", rx: "2" }), _jsx("path", { d: "M16 3v4M8 3v4M3 11h18" })] }) }), children: _jsx(RangeCalendar, { label: panelLabel, value: committed, min: min, max: max, isDateDisabled: isDateDisabled, isRangeDisabled: isRangeDisabled, locale: locale, weekStartsOn: weekStartsOn, today: today, previousMonthLabel: previousMonthLabel, nextMonthLabel: nextMonthLabel, rangeLabels: rangeLabels, autoFocus: true, onValueChange: change, onRangeSelect: () => { if (!blocked && !nativeDisabled())
|
|
80
|
+
requestOpen(false); } }) }) });
|
|
81
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DateRangeValue } from "./index.js";
|
|
2
|
+
export declare const emptyDateRange: () => DateRangeValue;
|
|
3
|
+
export declare const sameDateRange: (a: DateRangeValue, b: DateRangeValue) => boolean;
|
|
4
|
+
export declare function checkDateRange(range: DateRangeValue, name?: string): void;
|
|
5
|
+
export declare const completeDateRange: (range: DateRangeValue) => boolean;
|
|
6
|
+
export declare const orderedDateRange: (a: string, b: string) => DateRangeValue;
|
|
7
|
+
export declare const emptyRange: (range: DateRangeValue) => boolean;
|
|
8
|
+
export declare function availableDateRange(range: DateRangeValue, min?: string, max?: string, isDateDisabled?: (date: string) => boolean, isRangeDisabled?: (range: DateRangeValue) => boolean): boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { dateInRange, parseDate } from "./date-value.js";
|
|
2
|
+
export const emptyDateRange = () => ({ start: "", end: "" });
|
|
3
|
+
export const sameDateRange = (a, b) => a.start === b.start && a.end === b.end;
|
|
4
|
+
export function checkDateRange(range, name = "value") {
|
|
5
|
+
if (!range || typeof range.start !== "string" || typeof range.end !== "string" || [range.start, range.end].some(date => date !== "" && !parseDate(date)))
|
|
6
|
+
throw new Error(`Flood date range ${name} requires ISO Gregorian start/end dates or empty strings`);
|
|
7
|
+
}
|
|
8
|
+
export const completeDateRange = (range) => !!range.start && !!range.end && range.start <= range.end;
|
|
9
|
+
export const orderedDateRange = (a, b) => a <= b ? { start: a, end: b } : { start: b, end: a };
|
|
10
|
+
export const emptyRange = (range) => !range.start && !range.end;
|
|
11
|
+
export function availableDateRange(range, min, max, isDateDisabled, isRangeDisabled) {
|
|
12
|
+
return completeDateRange(range) && dateInRange(range.start, min, max) && dateInRange(range.end, min, max) && !isDateDisabled?.(range.start) && !isDateDisabled?.(range.end) && !isRangeDisabled?.(range);
|
|
13
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type DateTimePickerProps } from "./index.js";
|
|
2
|
+
/** A civil date and wall-clock time. The host owns timezone conversion. */
|
|
3
|
+
export declare const CivilDateTimePicker: import("react").ForwardRefExoticComponent<DateTimePickerProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
3
|
+
import { DatePicker, TimePicker, Fieldset } from "./index.js";
|
|
4
|
+
/** A civil date and wall-clock time. The host owns timezone conversion. */
|
|
5
|
+
export const CivilDateTimePicker = /* @__PURE__ */ forwardRef(function CivilDateTimePicker({ label, validationMessage, timeInterval, hideLabel = false, hideFieldLabels = false, value, defaultValue = { date: "", time: "" }, onValueChange, dateLabel = "Date", timeLabel = "Time", dateName, timeName, form, required = false, disabled = false, readOnly = false, controlSize = "md", precision = "minute", order = "month-day-year", locale = "en-US", minDate, maxDate, minTime, maxTime, step, status, description, className, ...props }, ref) {
|
|
6
|
+
const [local, setLocal] = useState(defaultValue);
|
|
7
|
+
const root = useRef(null), initial = useRef(defaultValue);
|
|
8
|
+
useImperativeHandle(ref, () => root.current, []);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const owner = root.current?.querySelector("input")?.form;
|
|
11
|
+
if (!owner)
|
|
12
|
+
return;
|
|
13
|
+
const timers = new Set();
|
|
14
|
+
const reset = (event) => { const timer = setTimeout(() => { timers.delete(timer); if (!event.defaultPrevented)
|
|
15
|
+
setLocal(initial.current); }, 0); timers.add(timer); };
|
|
16
|
+
owner.addEventListener("reset", reset);
|
|
17
|
+
return () => { owner.removeEventListener("reset", reset); timers.forEach(clearTimeout); };
|
|
18
|
+
}, [form]);
|
|
19
|
+
const selected = value ?? local;
|
|
20
|
+
const change = (part, next) => {
|
|
21
|
+
if (disabled || readOnly)
|
|
22
|
+
return;
|
|
23
|
+
const updated = { ...selected, [part]: next };
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
setLocal(updated);
|
|
26
|
+
onValueChange?.(updated);
|
|
27
|
+
};
|
|
28
|
+
// Each field owns native validity, reset and submission. Values are deliberately
|
|
29
|
+
// separate: neither a timezone nor an instant is inferred from this pair.
|
|
30
|
+
return _jsx("div", { ...props, ref: root, className: ["flood-date-time-picker", className].filter(Boolean).join(" "), children: _jsx(Fieldset, { legend: label, hideLabel: hideLabel, hint: description, disabled: disabled, children: _jsxs("div", { className: "flood-date-time-picker__fields", children: [_jsx(DatePicker, { label: dateLabel, hideLabel: hideFieldLabels, value: value === undefined ? undefined : selected.date, defaultValue: defaultValue.date, onValueChange: next => change("date", next), name: dateName, form: form, required: required, disabled: disabled, readOnly: readOnly, controlSize: controlSize, order: order, locale: locale, min: minDate, max: maxDate, status: status, validationMessage: validationMessage }), _jsx(TimePicker, { label: timeLabel, hideLabel: hideFieldLabels, value: value === undefined ? undefined : selected.time, defaultValue: defaultValue.time, onValueChange: next => change("time", next), name: timeName, form: form, required: required, disabled: disabled, readOnly: readOnly, controlSize: controlSize, precision: precision, min: minTime, max: maxTime, step: step, interval: timeInterval, status: status })] }) }) });
|
|
31
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
3
|
+
import { Fieldset, Stack, DateTimePicker } from "./index.js";
|
|
4
|
+
import { parseDate, dateInRange } from "./date-value.js";
|
|
5
|
+
import { parseTime, timeOnStep } from "./time-value.js";
|
|
6
|
+
const empty = () => ({ start: { date: "", time: "" }, end: { date: "", time: "" } });
|
|
7
|
+
const copy = (value) => ({ start: { ...value.start }, end: { ...value.end } });
|
|
8
|
+
const signature = (value) => [value.start.date, value.start.time, value.end.date, value.end.time].join("|");
|
|
9
|
+
export const CivilDateTimeRangePicker = /* @__PURE__ */ forwardRef(function CivilDateTimeRangePicker({ label, hideLabel = false, value, defaultValue = empty(), onValueChange, startDateName, startTimeName, endDateName, endTimeName, startLabel = "Start", endLabel = "End", startDateLabel = "Start date", startTimeLabel = "Start time", endDateLabel = "End date", endTimeLabel = "End time", form, required = false, disabled = false, readOnly = false, controlSize = "md", precision = "minute", order = "month-day-year", locale = "en-US", minDate, maxDate, step: explicitStep, interval, status, description, incompleteMessage = "Complete both dates and times.", reversedMessage = "End must not precede start.", className, onBlur, ...props }, ref) {
|
|
10
|
+
if (![label, startLabel, endLabel, startDateLabel, startTimeLabel, endDateLabel, endTimeLabel, incompleteMessage, reversedMessage].every(text => text.trim()))
|
|
11
|
+
throw new Error("Flood DateTimeRangePicker requires nonempty labels and messages");
|
|
12
|
+
const initial = useRef(copy(defaultValue)), [local, setLocal] = useState(() => copy(defaultValue)), [proposal, setProposal] = useState();
|
|
13
|
+
const selected = value ?? local, sourceSignature = signature(selected), requested = proposal && signature(proposal) !== sourceSignature;
|
|
14
|
+
const root = useRef(null);
|
|
15
|
+
useImperativeHandle(ref, () => root.current, []);
|
|
16
|
+
useEffect(() => { setProposal(undefined); }, [sourceSignature]);
|
|
17
|
+
const complete = Boolean(parseDate(selected.start.date) && parseDate(selected.end.date) && parseTime(selected.start.time, precision) && parseTime(selected.end.time, precision));
|
|
18
|
+
const blank = Object.values(selected).every(endpoint => !endpoint.date && !endpoint.time);
|
|
19
|
+
const ordered = complete && `${selected.start.date}T${selected.start.time}` <= `${selected.end.date}T${selected.end.time}`;
|
|
20
|
+
const step = explicitStep ?? (precision === "minute" ? 60 : 1);
|
|
21
|
+
const available = complete && (readOnly || [selected.start, selected.end].every(endpoint => dateInRange(endpoint.date, minDate, maxDate) && timeOnStep(parseTime(endpoint.time, precision), step, undefined, precision)));
|
|
22
|
+
const accepted = Boolean(complete && ordered && available && !requested);
|
|
23
|
+
const constraint = requested ? incompleteMessage : !complete ? (blank && !required ? "" : incompleteMessage) : !ordered ? reversedMessage : "";
|
|
24
|
+
const change = (part, next) => {
|
|
25
|
+
if (disabled || readOnly || root.current?.querySelector("input")?.matches(":disabled"))
|
|
26
|
+
return;
|
|
27
|
+
const update = { ...selected, [part]: next };
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
setLocal(update);
|
|
30
|
+
else
|
|
31
|
+
setProposal(update);
|
|
32
|
+
onValueChange?.(update);
|
|
33
|
+
};
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const owner = root.current?.querySelector("input")?.form;
|
|
36
|
+
if (!owner)
|
|
37
|
+
return;
|
|
38
|
+
const timers = new Set();
|
|
39
|
+
const reset = (event) => { const timer = setTimeout(() => { timers.delete(timer); if (event.defaultPrevented)
|
|
40
|
+
return; if (value === undefined)
|
|
41
|
+
setLocal(copy(initial.current)); setProposal(undefined); }, 0); timers.add(timer); };
|
|
42
|
+
owner.addEventListener("reset", reset);
|
|
43
|
+
return () => { owner.removeEventListener("reset", reset); timers.forEach(clearTimeout); };
|
|
44
|
+
}, [form, value === undefined]);
|
|
45
|
+
const shared = { form, required, disabled, readOnly, controlSize, precision, order, locale, minDate, maxDate, step, timeInterval: interval, status, hideFieldLabels: true };
|
|
46
|
+
return _jsxs("div", { ...props, ref: root, className: ["flood-date-time-range-picker", className].filter(Boolean).join(" "), onBlur: event => {
|
|
47
|
+
onBlur?.(event);
|
|
48
|
+
if (!event.defaultPrevented && !event.currentTarget.contains(event.relatedTarget))
|
|
49
|
+
setProposal(undefined);
|
|
50
|
+
}, children: [_jsx(Fieldset, { legend: label, hideLabel: hideLabel, hint: description, disabled: disabled, children: _jsxs(Stack, { gap: "4", children: [_jsx(DateTimePicker, { ...shared, label: startLabel, dateLabel: startDateLabel, timeLabel: startTimeLabel, value: selected.start, defaultValue: initial.current.start, onValueChange: next => change("start", next), validationMessage: constraint }), _jsx(DateTimePicker, { ...shared, label: endLabel, dateLabel: endDateLabel, timeLabel: endTimeLabel, value: selected.end, defaultValue: initial.current.end, onValueChange: next => change("end", next) })] }) }), [[startDateName, selected.start.date], [startTimeName, selected.start.time], [endDateName, selected.end.date], [endTimeName, selected.end.time]].map(([name, part], index) => name && _jsx("input", { type: "hidden", name: name, form: form, value: accepted ? part : "", disabled: disabled }, index))] });
|
|
51
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Gregorian civil dates, deliberately independent of Date, time zones and calendars. */
|
|
2
|
+
export type DateSegment = "year" | "month" | "day";
|
|
3
|
+
export type DateParts = Record<DateSegment, string>;
|
|
4
|
+
export declare const emptyDateParts: () => DateParts;
|
|
5
|
+
export declare function daysInMonth(year: number, month: number): number;
|
|
6
|
+
export declare function canonicalDate(parts: DateParts): string | undefined;
|
|
7
|
+
export declare function parseDate(value: string): DateParts | undefined;
|
|
8
|
+
export declare const dateInRange: (value: string, min?: string, max?: string) => boolean;
|
|
9
|
+
export declare const isEmptyDate: (parts: DateParts) => boolean;
|
|
10
|
+
export declare function stepDatePart(parts: DateParts, part: DateSegment, direction: number, edge?: "start" | "end"): DateParts;
|