@uniflowed/ui 0.0.0-alpha.43 → 0.0.0-alpha.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,316 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { useMemo, useState } from "@uniflowed/react";
5
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
6
+ import { Temporal } from "@uniflowed/core/temporal";
7
+ import { useControlled } from "./controlled-state.js";
8
+ import { composeHandlers, withProps } from "./merge-props.js";
9
+ import type { RenderProp, Rest } from "./merge-props.js";
10
+ import { directionOf } from "./roving-focus.js";
11
+ import { useLocale } from "../i18n-provider.js";
12
+
13
+ type Segment = "year" | "month" | "day" | "hour" | "minute" | "second" | "dayPeriod";
14
+ type Fields = { [string]: string };
15
+ export type DateFieldProps = {
16
+ value?: string | null,
17
+ defaultValue?: string | null,
18
+ onValueChange?: (value: string | null) => void,
19
+ minValue?: string,
20
+ isDateUnavailable?: (value: string) => boolean,
21
+ maxValue?: string,
22
+ disabled?: boolean,
23
+ readOnly?: boolean,
24
+ required?: boolean,
25
+ granularity?: "minute" | "second",
26
+ hourCycle?: "h12" | "h23",
27
+ segmentLabels?: { [string]: string },
28
+ onValidationChange?: (invalid: boolean) => void,
29
+ render?: RenderProp,
30
+ readonly key?: empty,
31
+ readonly [string]: mixed,
32
+ };
33
+ function names(time: boolean, seconds: boolean): Array<Segment> {
34
+ return time
35
+ ? seconds
36
+ ? ["hour", "minute", "second"]
37
+ : ["hour", "minute"]
38
+ : ["year", "month", "day"];
39
+ }
40
+ function fieldsFor(value: string | null, time: boolean, seconds: boolean): Fields {
41
+ if (value == null) return {};
42
+ const parsed = time ? Temporal.PlainTime.from(value) : Temporal.PlainDate.from(value);
43
+ const fields: Fields = {};
44
+ for (const name of names(time, seconds)) fields[name] = String((parsed as $FlowFixMe)[name]);
45
+ return fields;
46
+ }
47
+ function serialize(fields: Fields, time: boolean, seconds: boolean): string | null {
48
+ const parts = names(time, seconds);
49
+ if (parts.some((part) => !/^\d+$/.test(fields[part] ?? ""))) return null;
50
+ try {
51
+ if (time) {
52
+ const value = `${fields.hour.padStart(2, "0")}:${fields.minute.padStart(2, "0")}:${seconds ? fields.second.padStart(2, "0") : "00"}`;
53
+ const parsed = Temporal.PlainTime.from(value);
54
+ return parsed.toString().slice(0, seconds ? 8 : 5);
55
+ }
56
+ const year = Number(fields.year),
57
+ month = Number(fields.month),
58
+ day = Number(fields.day);
59
+ if (year < 1 || year > 9999) return null;
60
+ const parsed = Temporal.PlainDate.from({ year, month, day });
61
+ if (parsed.year !== year || parsed.month !== month || parsed.day !== day) return null;
62
+ return parsed.toString();
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ function maximum(name: Segment, fields: Fields): number {
68
+ if (name === "year") return 9999;
69
+ if (name === "month") return 12;
70
+ if (name === "hour") return 23;
71
+ if (name !== "day") return 59;
72
+ try {
73
+ return Temporal.PlainDate.from({
74
+ year: Number(fields.year) || 2000,
75
+ month: Number(fields.month) || 1,
76
+ day: 1,
77
+ }).daysInMonth;
78
+ } catch {
79
+ return 31;
80
+ }
81
+ }
82
+
83
+ /** Segment order, digits and labels come from the locale; the stored value is ISO. */
84
+ export component SegmentedField(time: boolean, options: DateFieldProps) {
85
+ const {
86
+ value,
87
+ defaultValue = null,
88
+ onValueChange,
89
+ minValue,
90
+ isDateUnavailable,
91
+ maxValue,
92
+ disabled = false,
93
+ readOnly = false,
94
+ required = false,
95
+ granularity = "minute",
96
+ hourCycle,
97
+ segmentLabels,
98
+ onValidationChange,
99
+ render,
100
+ ...rest
101
+ } = options;
102
+ const { locale } = useLocale();
103
+ const seconds = granularity === "second";
104
+ const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
105
+ const [draft, setDraft] = useState<Fields | null>(null);
106
+ const [announcement, announce] = useState("");
107
+ const fields = draft ?? fieldsFor(current, time, seconds);
108
+ const empty = names(time, seconds).every((part) => !fields[part]);
109
+ const serialized = serialize(fields, time, seconds);
110
+ const minimum =
111
+ minValue == null ? null : serialize(fieldsFor(minValue, time, seconds), time, seconds);
112
+ const maximumValue =
113
+ maxValue == null ? null : serialize(fieldsFor(maxValue, time, seconds), time, seconds);
114
+ if (minimum != null && maximumValue != null && minimum > maximumValue)
115
+ throw new RangeError("DateField minimum exceeds maximum");
116
+ const invalid =
117
+ (empty ? required : serialized == null) ||
118
+ (serialized != null &&
119
+ ((minimum != null && serialized < minimum) ||
120
+ (maximumValue != null && serialized > maximumValue) ||
121
+ isDateUnavailable?.(serialized) === true));
122
+ const digits = useMemo(() => new Intl.NumberFormat(locale, { useGrouping: false }), [locale]);
123
+ const labels: $FlowFixMe = useMemo(
124
+ () => new (Intl as $FlowFixMe).DisplayNames(locale, { type: "dateTimeField" }),
125
+ [locale],
126
+ );
127
+ const parts: Array<{ type: string, value: string }> = useMemo(() => {
128
+ const formatter: $FlowFixMe = new Intl.DateTimeFormat(
129
+ locale,
130
+ time
131
+ ? {
132
+ hour: "2-digit",
133
+ minute: "2-digit",
134
+ second: seconds ? "2-digit" : undefined,
135
+ hourCycle,
136
+ timeZone: "UTC",
137
+ }
138
+ : {
139
+ year: "numeric",
140
+ month: "2-digit",
141
+ day: "2-digit",
142
+ calendar: "gregory",
143
+ timeZone: "UTC",
144
+ },
145
+ );
146
+ return formatter.formatToParts(new Date("2000-01-02T12:34:56Z"));
147
+ }, [locale, time, seconds, hourCycle]);
148
+ const hour12 = time && parts.some((part) => part.type === "dayPeriod");
149
+ const periodNames: Array<string> = useMemo(() => {
150
+ const formatter: $FlowFixMe = new Intl.DateTimeFormat(locale, {
151
+ hour: "numeric",
152
+ hour12: true,
153
+ timeZone: "UTC",
154
+ });
155
+ return [0, 12].map(
156
+ (hour) =>
157
+ formatter
158
+ .formatToParts(new Date(Date.UTC(2000, 0, 1, hour)))
159
+ .find((part) => part.type === "dayPeriod")?.value ?? (hour === 0 ? "AM" : "PM"),
160
+ );
161
+ }, [locale]);
162
+ const encode = (text: string): string =>
163
+ Array.from(text)
164
+ .map((digit) => digits.format(Number(digit)))
165
+ .join("");
166
+ const decode = (text: string): string => {
167
+ let result = text;
168
+ for (let digit = 0; digit < 10; digit += 1)
169
+ result = result.split(digits.format(digit)).join(String(digit));
170
+ return result.replace(/[^0-9]/g, "");
171
+ };
172
+ const commit = useStableCallback(() => {
173
+ if (disabled || readOnly) return;
174
+ onValidationChange?.(invalid);
175
+ if (invalid) {
176
+ announce("Enter a valid value within the allowed range");
177
+ return;
178
+ }
179
+ setCurrent(empty ? null : serialized);
180
+ setDraft(null);
181
+ announce(serialized ?? "Cleared");
182
+ });
183
+ const edit = useStableCallback((part: Segment, text: string) => {
184
+ if (!disabled && !readOnly) setDraft({ ...fields, [part]: text });
185
+ });
186
+ const keyboard = useStableCallback((event: $FlowFixMe, part: Segment) => {
187
+ if (disabled || readOnly) return;
188
+ const key = event.key;
189
+ if (key === "Enter") {
190
+ event.preventDefault();
191
+ commit();
192
+ return;
193
+ }
194
+ if (key === "ArrowLeft" || key === "ArrowRight") {
195
+ event.preventDefault();
196
+ const root = event.currentTarget.closest("[data-segmented]");
197
+ const segments = Array.from(root.querySelectorAll("[data-segment]"));
198
+ const at = segments.indexOf(event.currentTarget);
199
+ const forward =
200
+ key === (directionOf(event.currentTarget) === "rtl" ? "ArrowLeft" : "ArrowRight");
201
+ (segments[at + (forward ? 1 : -1)] as $FlowFixMe)?.focus();
202
+ return;
203
+ }
204
+ if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(key)) return;
205
+ event.preventDefault();
206
+ const min = time && !(part === "hour" && hour12) ? 0 : 1,
207
+ max = part === "hour" && hour12 ? 12 : maximum(part, fields);
208
+ const raw = Number(fields[part] || min);
209
+ const current = part === "hour" && hour12 ? ((raw + 11) % 12) + 1 : raw;
210
+ const next =
211
+ key === "Home"
212
+ ? min
213
+ : key === "End"
214
+ ? max
215
+ : current +
216
+ (key === "ArrowUp" ? 1 : key === "ArrowDown" ? -1 : key === "PageUp" ? 10 : -10);
217
+ const bounded = next > max ? min : next < min ? max : next;
218
+ const adjusted =
219
+ part === "hour" && hour12 ? (bounded % 12) + (Number(fields.hour) >= 12 ? 12 : 0) : bounded;
220
+ const nextFields = { ...fields, [part]: String(adjusted) };
221
+ if (!time && (part === "month" || part === "year") && nextFields.day)
222
+ nextFields.day = String(Math.min(Number(nextFields.day), maximum("day", nextFields)));
223
+ setDraft(nextFields);
224
+ });
225
+ const segments = parts.map((part, index) => {
226
+ if (part.type === "dayPeriod") {
227
+ const pm = Number(fields.hour) >= 12;
228
+ const toggle = () => edit("hour", String((Number(fields.hour) || 0) + (pm ? -12 : 12)));
229
+ return (
230
+ <span
231
+ key="period"
232
+ role="spinbutton"
233
+ tabIndex={disabled ? -1 : 0}
234
+ data-segment="dayPeriod"
235
+ aria-label={segmentLabels?.dayPeriod ?? labels.of("dayPeriod")}
236
+ aria-valuemin={0}
237
+ aria-valuemax={1}
238
+ aria-valuenow={pm ? 1 : 0}
239
+ aria-valuetext={periodNames[pm ? 1 : 0]}
240
+ aria-disabled={disabled || undefined}
241
+ aria-readonly={readOnly || undefined}
242
+ onClick={toggle}
243
+ onKeyDown={(event) => {
244
+ if (disabled || readOnly) return;
245
+ if (["ArrowUp", "ArrowDown", "Home", "End", " "].includes(event.key)) {
246
+ event.preventDefault();
247
+ if ((event.key !== "Home" || pm) && (event.key !== "End" || !pm)) toggle();
248
+ } else if (["ArrowLeft", "ArrowRight", "Enter"].includes(event.key))
249
+ keyboard(event, "dayPeriod");
250
+ }}
251
+ >
252
+ {periodNames[pm ? 1 : 0]}
253
+ </span>
254
+ );
255
+ }
256
+
257
+ if (!names(time, seconds).includes(part.type as $FlowFixMe))
258
+ return (
259
+ <span key={index} aria-hidden="true">
260
+ {part.value}
261
+ </span>
262
+ );
263
+ const name: Segment = part.type as $FlowFixMe;
264
+ const raw = fields[name] ?? "";
265
+ const text =
266
+ name === "hour" && hour12 && raw !== "" ? String(((Number(raw) + 11) % 12) + 1) : raw;
267
+ return (
268
+ <input
269
+ key={name}
270
+ type="text"
271
+ role="spinbutton"
272
+ inputMode="numeric"
273
+ data-segment={name}
274
+ aria-label={segmentLabels?.[name] ?? labels.of(name)}
275
+ aria-valuemin={time && !(name === "hour" && hour12) ? 0 : 1}
276
+ aria-valuemax={name === "hour" && hour12 ? 12 : maximum(name, fields)}
277
+ aria-valuenow={text ? Number(text) : undefined}
278
+ aria-valuetext={text ? encode(text) : undefined}
279
+ aria-invalid={invalid || undefined}
280
+ disabled={disabled}
281
+ readOnly={readOnly}
282
+ required={required}
283
+ maxLength={name === "year" ? 4 : 2}
284
+ placeholder={name === "year" ? "yyyy" : "--"}
285
+ value={text ? encode(text) : ""}
286
+ onChange={(event) => {
287
+ const text = decode(event.currentTarget.value);
288
+ edit(
289
+ name,
290
+ name === "hour" && hour12 && text && Number(text) <= 12
291
+ ? String((Number(text) % 12) + (Number(fields.hour) >= 12 ? 12 : 0))
292
+ : text,
293
+ );
294
+ }}
295
+ onKeyDown={(event) => keyboard(event, name)}
296
+ />
297
+ );
298
+ });
299
+ const props = withProps(rest, {
300
+ role: "group",
301
+ "data-segmented": time ? "time" : "date",
302
+ "aria-invalid": invalid || rest["aria-invalid"],
303
+ onBlur: composeHandlers(rest.onBlur, (event: $FlowFixMe) => {
304
+ if (!event.currentTarget.contains(event.relatedTarget)) commit();
305
+ }),
306
+ children: (
307
+ <>
308
+ {segments}
309
+ <span role="status" aria-live="polite">
310
+ {announcement}
311
+ </span>
312
+ </>
313
+ ),
314
+ });
315
+ return render != null ? render(props) : <div {...props} />;
316
+ }
package/list-box.js ADDED
@@ -0,0 +1,13 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { CollectionRoot } from "./internal/collection.js";
5
+ import type { CollectionProps } from "./internal/collection.js";
6
+ export component ListBox(...props: CollectionProps) {
7
+ return <CollectionRoot options={props} kind="listbox" />;
8
+ }
9
+ export type {
10
+ CollectionItem,
11
+ CollectionItemState,
12
+ CollectionProps,
13
+ } from "./internal/collection.js";
@@ -0,0 +1,263 @@
1
+ // @flow
2
+ "use client";
3
+
4
+ import * as React from "@uniflowed/react";
5
+ import { createContext, useContext, useMemo, useState } from "@uniflowed/react";
6
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
7
+ import { useControlled } from "./internal/controlled-state.js";
8
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
9
+ import { composeHandlers, withProps } from "./internal/merge-props.js";
10
+ import { useLocale } from "./i18n-provider.js";
11
+
12
+ type NumberFormatter = {
13
+ format: (value: number) => string,
14
+ formatToParts: (value: number) => Array<{ type: string, value: string }>,
15
+ resolvedOptions: () => { locale: string, style: string, ... },
16
+ };
17
+ export type NumberFormatOptions = {
18
+ style?: "decimal" | "percent" | "currency" | "unit",
19
+ currency?: string,
20
+ currencyDisplay?: "symbol" | "narrowSymbol" | "code" | "name",
21
+ currencySign?: "standard" | "accounting",
22
+ unit?: string,
23
+ unitDisplay?: "long" | "short" | "narrow",
24
+ useGrouping?: boolean,
25
+ minimumFractionDigits?: number,
26
+ maximumFractionDigits?: number,
27
+ minimumIntegerDigits?: number,
28
+ numberingSystem?: string,
29
+ };
30
+ // Flow's bundled Intl declarations predate formatToParts and unit formatting.
31
+ function numberFormatter(locale: string, options?: NumberFormatOptions): NumberFormatter {
32
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: 20, ...options }) as $FlowFixMe;
33
+ }
34
+
35
+ /** Parse only the symbols the formatter emits, never arbitrary trailing text. */
36
+ export function parseNumber(text: string, formatter: NumberFormatter): number | null {
37
+ if (text.trim() === "") return null;
38
+ const options = formatter.resolvedOptions();
39
+ const digits = new Intl.NumberFormat(options.locale, { useGrouping: false });
40
+ let clean = text.trim();
41
+ for (let digit = 0; digit < 10; digit += 1)
42
+ clean = clean.split(digits.format(digit)).join(String(digit));
43
+ const parts = formatter.formatToParts(-12345.6);
44
+ const decimal =
45
+ numberFormatter(options.locale)
46
+ .formatToParts(1.1)
47
+ .find((part) => part.type === "decimal")?.value ?? ".";
48
+ const accounting = clean.startsWith("(") && clean.endsWith(")");
49
+ if (accounting) clean = clean.slice(1, -1);
50
+ for (const part of parts) {
51
+ if (["group", "currency", "unit", "percentSign", "literal"].includes(part.type))
52
+ clean = clean.split(part.value).join("");
53
+ if (part.type === "minusSign") clean = clean.split(part.value).join("-");
54
+ }
55
+ clean = clean
56
+ .split(decimal)
57
+ .join(".")
58
+ .replace(/[\s\u200e\u200f\u061c]/g, "");
59
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(clean)) return NaN;
60
+ const value = (Number(clean) * (accounting ? -1 : 1)) / (options.style === "percent" ? 100 : 1);
61
+ return Number.isFinite(value) ? value : NaN;
62
+ }
63
+
64
+ type NumberState = {
65
+ value: number | null,
66
+ text: string,
67
+ invalid: boolean,
68
+ disabled: boolean,
69
+ readOnly: boolean,
70
+ min: number | void,
71
+ max: number | void,
72
+ formatter: NumberFormatter,
73
+ edit: (text: string) => void,
74
+ commit: () => void,
75
+ stepBy: (direction: number) => void,
76
+ boundary: (value: number | void) => void,
77
+ };
78
+ const NumberContext: React.Context<NumberState | null> = createContext(null);
79
+ hook useNumber(): NumberState {
80
+ const state = useContext(NumberContext);
81
+ if (state == null) throw new Error("NumberField parts must be inside NumberField.Root");
82
+ return state;
83
+ }
84
+
85
+ export component NumberFieldRoot(
86
+ children: React.Node,
87
+ value?: number | null,
88
+ defaultValue?: number | null = null,
89
+ onValueChange?: (value: number | null) => void,
90
+ onValidationChange?: (invalid: boolean) => void,
91
+ min?: number,
92
+ max?: number,
93
+ step?: number,
94
+ formatOptions?: NumberFormatOptions,
95
+ disabled?: boolean = false,
96
+ readOnly?: boolean = false,
97
+ render?: RenderProp,
98
+ ...rest: Rest
99
+ ) {
100
+ const increment = step ?? (formatOptions?.style === "percent" ? 0.01 : 1);
101
+ if (
102
+ !Number.isFinite(increment) ||
103
+ increment <= 0 ||
104
+ (min != null && !Number.isFinite(min)) ||
105
+ (max != null && !Number.isFinite(max)) ||
106
+ (min != null && max != null && min > max)
107
+ ) {
108
+ throw new RangeError("NumberField requires finite bounds and a positive increment");
109
+ }
110
+ const upperBound =
111
+ max == null
112
+ ? undefined
113
+ : Number(
114
+ ((min ?? 0) + Math.floor((max - (min ?? 0)) / increment + 1e-9) * increment).toPrecision(
115
+ 15,
116
+ ),
117
+ );
118
+ const { locale } = useLocale();
119
+ const formatter = useMemo(() => numberFormatter(locale, formatOptions), [locale, formatOptions]);
120
+ const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
121
+ const [draft, setDraft] = useState<string | null>(null);
122
+ const text = draft ?? (current == null ? "" : formatter.format(current));
123
+ const parsed = parseNumber(text, formatter);
124
+ const invalid =
125
+ parsed != null &&
126
+ (!Number.isFinite(parsed) ||
127
+ (min != null && parsed < min) ||
128
+ (max != null && parsed > max) ||
129
+ Math.abs((parsed - (min ?? 0)) / increment - Math.round((parsed - (min ?? 0)) / increment)) >
130
+ 1e-7);
131
+ const assign = useStableCallback((next: number | null) => {
132
+ if (disabled || readOnly) return;
133
+ setDraft(null);
134
+ setCurrent(next);
135
+ onValidationChange?.(false);
136
+ });
137
+ const commit = useStableCallback(() => {
138
+ if (disabled || readOnly) return;
139
+ onValidationChange?.(invalid);
140
+ if (!invalid) assign(parsed);
141
+ });
142
+ const stepBy = useStableCallback((direction: number) => {
143
+ const start = parsed != null && Number.isFinite(parsed) ? parsed : (current ?? min ?? 0);
144
+ const base = min ?? 0;
145
+ const position = (start - base) / increment;
146
+ const index =
147
+ direction > 0
148
+ ? Math.floor(position + 1e-9) + direction
149
+ : Math.ceil(position - 1e-9) + direction;
150
+ const next = Number((base + index * increment).toPrecision(15));
151
+ const ceiling =
152
+ max == null
153
+ ? Infinity
154
+ : Number((base + Math.floor((max - base) / increment + 1e-9) * increment).toPrecision(15));
155
+ assign(Math.max(min ?? -Infinity, Math.min(ceiling, next)));
156
+ });
157
+ const state = {
158
+ value: current,
159
+ text,
160
+ invalid,
161
+ disabled,
162
+ readOnly,
163
+ min,
164
+ max: upperBound,
165
+ formatter,
166
+ edit: (next: string) => {
167
+ if (!disabled && !readOnly) setDraft(next);
168
+ },
169
+ commit,
170
+ stepBy,
171
+ boundary: (next: number | void) => {
172
+ if (next != null)
173
+ assign(
174
+ Number(
175
+ (
176
+ (min ?? 0) +
177
+ Math.floor((next - (min ?? 0)) / increment + 1e-9) * increment
178
+ ).toPrecision(15),
179
+ ),
180
+ );
181
+ },
182
+ };
183
+ const props = withProps(rest, { children, "data-invalid": invalid ? "" : undefined });
184
+ return (
185
+ <NumberContext.Provider value={state}>
186
+ {render != null ? render(props) : <div {...props} />}
187
+ </NumberContext.Provider>
188
+ );
189
+ }
190
+
191
+ /** Field.Control may render this input to supply the label, description and error ids. */
192
+ export component NumberFieldInput(render?: RenderProp, ...rest: Rest) {
193
+ const state = useNumber();
194
+ const props = withProps(rest, {
195
+ type: "text",
196
+ role: "spinbutton",
197
+ inputMode: "decimal",
198
+ value: state.text,
199
+ disabled: state.disabled,
200
+ readOnly: state.readOnly,
201
+ "aria-valuenow": state.value == null || !Number.isFinite(state.value) ? undefined : state.value,
202
+ "aria-valuetext": state.text || undefined,
203
+ "aria-valuemin": state.min,
204
+ "aria-valuemax": state.max,
205
+ "aria-invalid": state.invalid ? "true" : rest["aria-invalid"],
206
+ onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
207
+ state.edit(event.currentTarget.value),
208
+ ),
209
+ onBlur: composeHandlers(rest.onBlur, state.commit),
210
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
211
+ if (state.disabled || state.readOnly) return;
212
+ const key = event.key;
213
+ if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", "Enter"].includes(key)) {
214
+ event.preventDefault();
215
+ if (key === "Home") state.boundary(state.min);
216
+ else if (key === "End") state.boundary(state.max);
217
+ else if (key === "Enter") state.commit();
218
+ else
219
+ state.stepBy(
220
+ key === "ArrowUp" ? 1 : key === "ArrowDown" ? -1 : key === "PageUp" ? 10 : -10,
221
+ );
222
+ }
223
+ }),
224
+ });
225
+ return render != null ? render(props) : <input {...props} />;
226
+ }
227
+
228
+ export component NumberFieldIncrement(
229
+ children?: React.Node = "+",
230
+ render?: RenderProp,
231
+ ...rest: Rest
232
+ ) {
233
+ const state = useNumber();
234
+ const props = withProps(rest, {
235
+ children,
236
+ type: "button",
237
+ "aria-label": rest["aria-label"] ?? "Increase",
238
+ disabled:
239
+ state.disabled ||
240
+ state.readOnly ||
241
+ (state.max != null && state.value != null && state.value >= state.max),
242
+ onClick: composeHandlers(rest.onClick, () => state.stepBy(1)),
243
+ });
244
+ return render != null ? render(props) : <button {...props} />;
245
+ }
246
+ export component NumberFieldDecrement(
247
+ children?: React.Node = "−",
248
+ render?: RenderProp,
249
+ ...rest: Rest
250
+ ) {
251
+ const state = useNumber();
252
+ const props = withProps(rest, {
253
+ children,
254
+ type: "button",
255
+ "aria-label": rest["aria-label"] ?? "Decrease",
256
+ disabled:
257
+ state.disabled ||
258
+ state.readOnly ||
259
+ (state.min != null && state.value != null && state.value <= state.min),
260
+ onClick: composeHandlers(rest.onClick, () => state.stepBy(-1)),
261
+ });
262
+ return render != null ? render(props) : <button {...props} />;
263
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/ui",
3
- "version": "0.0.0-alpha.43",
3
+ "version": "0.0.0-alpha.44",
4
4
  "description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,10 +19,10 @@
19
19
  "!*.test.js"
20
20
  ],
21
21
  "dependencies": {
22
- "@uniflowed/core": "0.0.0-alpha.43",
23
- "@uniflowed/hooks": "0.0.0-alpha.43",
24
- "@uniflowed/react": "0.0.0-alpha.43",
25
- "@uniflowed/state": "0.0.0-alpha.43"
22
+ "@uniflowed/core": "0.0.0-alpha.44",
23
+ "@uniflowed/hooks": "0.0.0-alpha.44",
24
+ "@uniflowed/react": "0.0.0-alpha.44",
25
+ "@uniflowed/state": "0.0.0-alpha.44"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=19"
@@ -0,0 +1,78 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { useState } from "@uniflowed/react";
5
+ import type { PlainDate } from "@uniflowed/core/temporal";
6
+ import { useControlled } from "./internal/controlled-state.js";
7
+ import type { Rest } from "./internal/merge-props.js";
8
+ import type { DateRange } from "./internal/date-range.js";
9
+ import { validateRange, unavailableInRange } from "./internal/date-range.js";
10
+ import { CalendarRoot, CalendarMonth } from "./calendar.js";
11
+ export type { DateRange } from "./internal/date-range.js";
12
+
13
+ export component RangeCalendarRoot(
14
+ children?: React.Node = <CalendarMonth />,
15
+ value?: DateRange | null,
16
+ defaultValue?: DateRange | null = null,
17
+ onValueChange?: (value: DateRange | null) => void,
18
+ minValue?: string,
19
+ maxValue?: string,
20
+ isDateDisabled?: (date: PlainDate) => boolean,
21
+ defaultFocused?: string,
22
+ today?: string,
23
+ locale?: string,
24
+ focusedDayRef?: { current: HTMLElement | null },
25
+ ...rest: Rest
26
+ ) {
27
+ const [range, setRange] = useControlled(value, defaultValue, onValueChange);
28
+ const [anchor, setAnchor] = useState<string | null>(null);
29
+ const [announcement, announce] = useState("");
30
+ validateRange(range);
31
+ const disabled = (date: PlainDate) =>
32
+ (minValue != null && date.toString() < minValue) ||
33
+ (maxValue != null && date.toString() > maxValue) ||
34
+ isDateDisabled?.(date) === true;
35
+ const choose = (date: PlainDate) => {
36
+ const iso = date.toString();
37
+ if (disabled(date)) return;
38
+ if (anchor == null) {
39
+ setAnchor(iso);
40
+ announce(`Start ${iso}. Choose an end date.`);
41
+ return;
42
+ }
43
+ const start = anchor < iso ? anchor : iso,
44
+ end = anchor < iso ? iso : anchor;
45
+ if (unavailableInRange(start, end, isDateDisabled)) {
46
+ announce("The range contains an unavailable date");
47
+ return;
48
+ }
49
+ setRange({ start, end });
50
+ setAnchor(null);
51
+ announce(`Selected ${start} to ${end}`);
52
+ };
53
+ const selected = (date: PlainDate) => {
54
+ const iso = date.toString();
55
+ return anchor != null
56
+ ? iso === anchor
57
+ : range != null && iso >= range.start && iso <= range.end;
58
+ };
59
+ return (
60
+ <div {...rest}>
61
+ <CalendarRoot
62
+ value={anchor ?? range?.start ?? null}
63
+ defaultFocused={defaultFocused}
64
+ today={today}
65
+ locale={locale}
66
+ focusedDayRef={focusedDayRef}
67
+ isDateDisabled={disabled}
68
+ isDateSelected={selected}
69
+ onValueChange={choose}
70
+ >
71
+ {children}
72
+ </CalendarRoot>
73
+ <span role="status" aria-live="polite">
74
+ {announcement}
75
+ </span>
76
+ </div>
77
+ );
78
+ }