@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.
- package/calendar.js +20 -7
- package/color-picker.js +172 -0
- package/date-field.js +9 -0
- package/date-range-picker.js +120 -0
- package/drag-drop.js +125 -0
- package/grid-list.js +8 -0
- package/i18n-provider.js +89 -0
- package/index.js +91 -0
- package/internal/collection.js +395 -0
- package/internal/date-range.js +26 -0
- package/internal/roving-focus.js +13 -4
- package/internal/segmented-field.js +316 -0
- package/list-box.js +13 -0
- package/number-field.js +263 -0
- package/package.json +5 -5
- package/range-calendar.js +78 -0
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/tree.js +8 -0
|
@@ -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";
|
package/number-field.js
ADDED
|
@@ -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.
|
|
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.
|
|
23
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
24
|
-
"@uniflowed/react": "0.0.0-alpha.
|
|
25
|
-
"@uniflowed/state": "0.0.0-alpha.
|
|
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
|
+
}
|