@uniflowed/ui 0.0.0-alpha.42 → 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 CHANGED
@@ -67,6 +67,7 @@ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
67
67
  import type { DateTimeFormatOptions, PlainDate } from "@uniflowed/core/temporal";
68
68
  import { Temporal } from "@uniflowed/core/temporal";
69
69
 
70
+ import { useLocale } from "./i18n-provider.js";
70
71
  import { useControlled } from "./internal/controlled-state.js";
71
72
  import type { Rest } from "./internal/merge-props.js";
72
73
  import {
@@ -127,6 +128,7 @@ type CalendarState = {|
127
128
  */
128
129
  readonly focusedDayRef: { current: HTMLElement | null },
129
130
  readonly isDisabled: (date: PlainDate) => boolean,
131
+ readonly isDateSelected: ((date: PlainDate) => boolean) | void,
130
132
  readonly locale: string | void,
131
133
  readonly moveFocus: (date: PlainDate, viaKeyboard: boolean) => void,
132
134
  /**
@@ -180,6 +182,7 @@ export component CalendarRoot(
180
182
  focusedDayRef?: { current: HTMLElement | null },
181
183
  /** Whether a date may be chosen. A rejected date stays reachable; see the header. */
182
184
  isDateDisabled?: (date: PlainDate) => boolean,
185
+ isDateSelected?: (date: PlainDate) => boolean,
183
186
  locale?: string,
184
187
  onMonthChange?: (firstOfMonth: PlainDate) => mixed,
185
188
  onValueChange?: (value: PlainDate) => mixed,
@@ -189,6 +192,8 @@ export component CalendarRoot(
189
192
  weekStartsOn?: number,
190
193
  ...rest: Rest
191
194
  ) {
195
+ const inherited = useLocale();
196
+ const resolvedLocale = locale ?? inherited.locale;
192
197
  const base = useId();
193
198
  const pendingFocusRef = useRef<string | null>(null);
194
199
  // One is always allocated, because a hook may not be called conditionally;
@@ -228,8 +233,8 @@ export component CalendarRoot(
228
233
  });
229
234
 
230
235
  const weekStart = useMemo(
231
- () => (weekStartsOn == null ? firstDayOfWeekFor(locale) : weekStartsOn),
232
- [locale, weekStartsOn],
236
+ () => (weekStartsOn == null ? firstDayOfWeekFor(resolvedLocale) : weekStartsOn),
237
+ [resolvedLocale, weekStartsOn],
233
238
  );
234
239
 
235
240
  const isDisabled = useStableCallback((date: PlainDate) => isDateDisabled?.(date) === true);
@@ -256,7 +261,10 @@ export component CalendarRoot(
256
261
  setFocused((current) => (current.equals(date) ? current : date));
257
262
  });
258
263
 
259
- const caption = useMemo(() => focused.toLocaleString(locale, CAPTION_FORMAT), [focused, locale]);
264
+ const caption = useMemo(
265
+ () => focused.toLocaleString(resolvedLocale, CAPTION_FORMAT),
266
+ [focused, resolvedLocale],
267
+ );
260
268
 
261
269
  const [announcement, setAnnouncement] = useState("");
262
270
  const shown = useRef(`${focused.year}-${focused.month}`);
@@ -284,7 +292,8 @@ export component CalendarRoot(
284
292
  focused,
285
293
  focusedDayRef: dayRef,
286
294
  isDisabled,
287
- locale,
295
+ isDateSelected,
296
+ locale: resolvedLocale,
288
297
  moveFocus,
289
298
  pendingFocusRef,
290
299
  select,
@@ -299,7 +308,8 @@ export component CalendarRoot(
299
308
  dayRef,
300
309
  focused,
301
310
  isDisabled,
302
- locale,
311
+ isDateSelected,
312
+ resolvedLocale,
303
313
  moveFocus,
304
314
  select,
305
315
  selected,
@@ -354,7 +364,7 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
354
364
  return;
355
365
  }
356
366
  const at = focused.toString();
357
- const cell: HTMLElement | null = (grid.querySelector(`[data-date="${at}"]`): $FlowFixMe);
367
+ const cell: HTMLElement | null = grid.querySelector(`[data-date="${at}"]`) as $FlowFixMe;
358
368
  focusedDayRef.current = cell;
359
369
  // Only when a key asked for it, and only once the render it asked for has
360
370
  // happened: `ArrowRight` off the end of October sets this to the 1st of
@@ -379,6 +389,7 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
379
389
  // attribute points at the caption that is there either way, so the two
380
390
  // answers cannot disagree.
381
391
  aria-labelledby={`${calendar.base}-caption`}
392
+ aria-multiselectable={calendar.isDateSelected != null || undefined}
382
393
  onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
383
394
  const grid: $FlowFixMe = event.currentTarget;
384
395
  const movement = movementForDateKey(event, directionOf(grid));
@@ -452,7 +463,9 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
452
463
  export component CalendarDay(date: PlainDate, children?: React.Node, ...rest: Rest) {
453
464
  const calendar = useCalendar("Calendar.Day");
454
465
  const disabled = calendar.isDisabled(date);
455
- const chosen = calendar.selected != null && calendar.selected.equals(date);
466
+ const chosen =
467
+ calendar.isDateSelected?.(date) ??
468
+ (calendar.selected != null && calendar.selected.equals(date));
456
469
  const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown"]);
457
470
 
458
471
  return (
@@ -0,0 +1,172 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { createContext, useContext, useState } from "@uniflowed/react";
5
+ import { useControlled } from "./internal/controlled-state.js";
6
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
7
+ import { composeHandlers, withProps } from "./internal/merge-props.js";
8
+ import { directionOf } from "./internal/roving-focus.js";
9
+
10
+ /** Canonical #rrggbb or #rrggbbaa, with CSS's short hex forms accepted at the boundary. */
11
+ export function parseColor(value: string): string | null {
12
+ let hex = value.trim().toLowerCase();
13
+ if (!/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(hex)) return null;
14
+ if (hex.length <= 5)
15
+ hex =
16
+ "#" +
17
+ Array.from(hex.slice(1))
18
+ .map((char) => char + char)
19
+ .join("");
20
+ return hex;
21
+ }
22
+ type ColorState = { value: string, disabled: boolean, set: (value: string) => void };
23
+ const ColorContext: React.Context<ColorState | null> = createContext(null);
24
+ hook useColor(): ColorState {
25
+ const color = useContext(ColorContext);
26
+ if (color == null) throw new Error("ColorPicker parts must be inside ColorPicker.Root");
27
+ return color;
28
+ }
29
+ export component ColorPickerRoot(
30
+ children: React.Node,
31
+ value?: string,
32
+ defaultValue?: string = "#000000",
33
+ onValueChange?: (value: string) => void,
34
+ disabled?: boolean = false,
35
+ render?: RenderProp,
36
+ ...rest: Rest
37
+ ) {
38
+ const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
39
+ const normalized = parseColor(current);
40
+ if (normalized == null) throw new RangeError("ColorPicker value must be a CSS hex color");
41
+ const state = {
42
+ value: normalized,
43
+ disabled,
44
+ set: (next: string) => {
45
+ const color = parseColor(next);
46
+ if (!disabled && color != null) setCurrent(color);
47
+ },
48
+ };
49
+ const props = withProps(rest, { children });
50
+ return (
51
+ <ColorContext.Provider value={state}>
52
+ {render != null ? render(props) : <div {...props} />}
53
+ </ColorContext.Provider>
54
+ );
55
+ }
56
+ export component ColorPickerInput(render?: RenderProp, ...rest: Rest) {
57
+ const color = useColor();
58
+ const props = withProps(rest, {
59
+ type: "color",
60
+ value: color.value.slice(0, 7),
61
+ disabled: color.disabled,
62
+ onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
63
+ color.set(event.currentTarget.value + color.value.slice(7)),
64
+ ),
65
+ });
66
+ return render != null ? render(props) : <input {...props} />;
67
+ }
68
+ export component ColorPickerField(render?: RenderProp, ...rest: Rest) {
69
+ const color = useColor();
70
+ const [draft, setDraft] = useState<string | null>(null);
71
+ const text = draft ?? color.value;
72
+ const invalid = parseColor(text) == null;
73
+ const commit = () => {
74
+ if (!invalid) {
75
+ color.set(text);
76
+ setDraft(null);
77
+ }
78
+ };
79
+ const props = withProps(rest, {
80
+ type: "text",
81
+ value: text,
82
+ disabled: color.disabled,
83
+ "aria-invalid": invalid ? "true" : rest["aria-invalid"],
84
+ onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
85
+ setDraft(event.currentTarget.value),
86
+ ),
87
+ onBlur: composeHandlers(rest.onBlur, commit),
88
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
89
+ if (event.key === "Enter") {
90
+ event.preventDefault();
91
+ commit();
92
+ }
93
+ }),
94
+ });
95
+ return render != null ? render(props) : <input {...props} />;
96
+ }
97
+ export component ColorPickerChannel(
98
+ channel: "red" | "green" | "blue" | "alpha",
99
+ render?: RenderProp,
100
+ ...rest: Rest
101
+ ) {
102
+ const color = useColor();
103
+ const at = ["red", "green", "blue", "alpha"].indexOf(channel);
104
+ const value = parseInt(
105
+ (color.value + (color.value.length === 7 ? "ff" : "")).slice(1 + at * 2, 3 + at * 2),
106
+ 16,
107
+ );
108
+ const change = (next: number) => {
109
+ const hex = color.value.slice(1).padEnd(8, "f");
110
+ const byte = Math.max(0, Math.min(255, Math.round(next)))
111
+ .toString(16)
112
+ .padStart(2, "0");
113
+ const full = hex.slice(0, at * 2) + byte + hex.slice(at * 2 + 2);
114
+ color.set("#" + (at === 3 || color.value.length === 9 ? full : full.slice(0, 6)));
115
+ };
116
+ const props = withProps(rest, {
117
+ type: "range",
118
+ min: 0,
119
+ max: 255,
120
+ step: 1,
121
+ value,
122
+ disabled: color.disabled,
123
+ "aria-label": rest["aria-label"] ?? channel,
124
+ "aria-valuetext": channel === "alpha" ? `${Math.round((value / 255) * 100)}%` : String(value),
125
+ onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
126
+ change(Number(event.currentTarget.value)),
127
+ ),
128
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
129
+ if (color.disabled) return;
130
+ const key = event.key;
131
+ if (
132
+ ![
133
+ "ArrowLeft",
134
+ "ArrowRight",
135
+ "ArrowUp",
136
+ "ArrowDown",
137
+ "PageUp",
138
+ "PageDown",
139
+ "Home",
140
+ "End",
141
+ ].includes(key)
142
+ )
143
+ return;
144
+ event.preventDefault();
145
+ const rtl = directionOf(event.currentTarget) === "rtl";
146
+ change(
147
+ key === "Home"
148
+ ? 0
149
+ : key === "End"
150
+ ? 255
151
+ : value +
152
+ (key === "PageUp"
153
+ ? 10
154
+ : key === "PageDown"
155
+ ? -10
156
+ : key === "ArrowUp" || key === (rtl ? "ArrowLeft" : "ArrowRight")
157
+ ? 1
158
+ : -1),
159
+ );
160
+ }),
161
+ });
162
+ return render != null ? render(props) : <input {...props} />;
163
+ }
164
+ export component ColorPickerSwatch(render?: RenderProp, ...rest: Rest) {
165
+ const color = useColor();
166
+ const props = withProps(rest, {
167
+ "aria-hidden": "true",
168
+ "data-color": color.value,
169
+ style: { ...(rest.style as $FlowFixMe), backgroundColor: color.value },
170
+ });
171
+ return render != null ? render(props) : <span {...props} />;
172
+ }
package/date-field.js ADDED
@@ -0,0 +1,9 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { SegmentedField } from "./internal/segmented-field.js";
5
+ import type { DateFieldProps } from "./internal/segmented-field.js";
6
+ export component DateField(...props: DateFieldProps) {
7
+ return <SegmentedField options={props} time={false} />;
8
+ }
9
+ export type { DateFieldProps } from "./internal/segmented-field.js";
@@ -0,0 +1,120 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { createContext, useContext, useRef } 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 { forwarded } from "./internal/merge-props.js";
9
+ import type { DateRange } from "./internal/date-range.js";
10
+ import { validateRange, unavailableInRange } from "./internal/date-range.js";
11
+ import { CalendarMonth } from "./calendar.js";
12
+ import { RangeCalendarRoot } from "./range-calendar.js";
13
+ import { DateField } from "./date-field.js";
14
+ import { PopoverRoot, PopoverBody, PopoverTrigger } from "./popover.js";
15
+
16
+ type Picker = {
17
+ range: DateRange | null,
18
+ set: (range: DateRange | null) => void,
19
+ close: () => void,
20
+ minValue: string | void,
21
+ maxValue: string | void,
22
+ isDateDisabled: ((date: PlainDate) => boolean) | void,
23
+ };
24
+ const PickerContext: React.Context<Picker | null> = createContext(null);
25
+ hook usePicker(): Picker {
26
+ const picker = useContext(PickerContext);
27
+ if (picker == null) throw new Error("DateRangePicker parts must be inside DateRangePicker.Root");
28
+ return picker;
29
+ }
30
+ export component DateRangePickerRoot(
31
+ children: React.Node,
32
+ value?: DateRange | null,
33
+ defaultValue?: DateRange | null = null,
34
+ onValueChange?: (range: DateRange | null) => void,
35
+ open?: boolean,
36
+ defaultOpen?: boolean = false,
37
+ onOpenChange?: (open: boolean) => void,
38
+ minValue?: string,
39
+ maxValue?: string,
40
+ isDateDisabled?: (date: PlainDate) => boolean,
41
+ ) {
42
+ const [range, setRange] = useControlled(value, defaultValue, onValueChange);
43
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
44
+ validateRange(range);
45
+ const state = {
46
+ range,
47
+ set: setRange,
48
+ close: () => setOpen(false),
49
+ minValue,
50
+ maxValue,
51
+ isDateDisabled,
52
+ };
53
+ return (
54
+ <PickerContext.Provider value={state}>
55
+ <PopoverRoot open={isOpen} onOpenChange={setOpen}>
56
+ {children}
57
+ </PopoverRoot>
58
+ </PickerContext.Provider>
59
+ );
60
+ }
61
+ export component DateRangePickerStartField(...rest: Rest) {
62
+ const picker = usePicker();
63
+ return (
64
+ <DateField
65
+ {...forwarded(rest)}
66
+ value={picker.range?.start ?? null}
67
+ minValue={picker.minValue}
68
+ maxValue={picker.range?.end ?? picker.maxValue}
69
+ isDateUnavailable={(start) =>
70
+ unavailableInRange(start, picker.range?.end ?? start, picker.isDateDisabled)
71
+ }
72
+ onValueChange={(start) => {
73
+ if (start == null) picker.set(null);
74
+ else picker.set({ start, end: picker.range?.end ?? start });
75
+ }}
76
+ />
77
+ );
78
+ }
79
+ export component DateRangePickerEndField(...rest: Rest) {
80
+ const picker = usePicker();
81
+ return (
82
+ <DateField
83
+ {...forwarded(rest)}
84
+ value={picker.range?.end ?? null}
85
+ minValue={picker.range?.start ?? picker.minValue}
86
+ maxValue={picker.maxValue}
87
+ isDateUnavailable={(end) =>
88
+ unavailableInRange(picker.range?.start ?? end, end, picker.isDateDisabled)
89
+ }
90
+ onValueChange={(end) => {
91
+ if (end == null) picker.set(null);
92
+ else picker.set({ start: picker.range?.start ?? end, end });
93
+ }}
94
+ />
95
+ );
96
+ }
97
+ export component DateRangePickerTrigger(children: React.Node, ...rest: Rest) {
98
+ return <PopoverTrigger {...forwarded(rest)}>{children}</PopoverTrigger>;
99
+ }
100
+ export component DateRangePickerCalendar(children?: React.Node = <CalendarMonth />, ...rest: Rest) {
101
+ const picker = usePicker();
102
+ const dayRef = useRef<HTMLElement | null>(null);
103
+ return (
104
+ <PopoverBody {...forwarded(rest)} initialFocus={dayRef}>
105
+ <RangeCalendarRoot
106
+ value={picker.range}
107
+ minValue={picker.minValue}
108
+ maxValue={picker.maxValue}
109
+ isDateDisabled={picker.isDateDisabled}
110
+ focusedDayRef={dayRef}
111
+ onValueChange={(range) => {
112
+ picker.set(range);
113
+ picker.close();
114
+ }}
115
+ >
116
+ {children}
117
+ </RangeCalendarRoot>
118
+ </PopoverBody>
119
+ );
120
+ }
package/drag-drop.js ADDED
@@ -0,0 +1,125 @@
1
+ // @flow
2
+ "use client";
3
+ import { useState } from "@uniflowed/react";
4
+
5
+ export type Drop = {| readonly keys: $ReadOnlyArray<string>, readonly target: string |};
6
+ export type DragAndDrop = {
7
+ dragging: boolean,
8
+ announcement: string,
9
+ start: (key: string, label: string) => void,
10
+ drop: (target: string, incoming?: $ReadOnlyArray<string>, label?: string) => void,
11
+ cancel: () => void,
12
+ getDragProps: (
13
+ key: string,
14
+ label: string,
15
+ ) => {
16
+ draggable: boolean,
17
+ onDragStart: (event: $FlowFixMe) => void,
18
+ onDragEnd: () => void,
19
+ onKeyDown: (event: $FlowFixMe) => void,
20
+ },
21
+ getDropProps: (
22
+ target: string,
23
+ label?: string,
24
+ ) => {
25
+ onDragOver: (event: $FlowFixMe) => void,
26
+ onDrop: (event: $FlowFixMe) => void,
27
+ onKeyDown: (event: $FlowFixMe) => void,
28
+ },
29
+ };
30
+ const MIME = "application/x-uf-collection";
31
+
32
+ /** Pointer drag data and keyboard lift/drop share one validated payload. */
33
+ export hook useDragAndDrop(options: {
34
+ onDrop: (drop: Drop) => void,
35
+ disabled?: boolean,
36
+ }): DragAndDrop {
37
+ const [keys, setKeys] = useState<$ReadOnlyArray<string>>([]);
38
+ const [announcement, announce] = useState("");
39
+ const cancel = () => {
40
+ setKeys([]);
41
+ announce("Drag cancelled");
42
+ };
43
+ const start = (key: string, label: string) => {
44
+ if (options.disabled) return;
45
+ setKeys([key]);
46
+ announce(`Picked up ${label}. Move to a drop target and press Enter. Escape cancels.`);
47
+ };
48
+ const drop = (
49
+ target: string,
50
+ incoming: $ReadOnlyArray<string> = keys,
51
+ label: string = target,
52
+ ) => {
53
+ if (options.disabled || incoming.length === 0) return;
54
+ options.onDrop({ keys: incoming, target });
55
+ setKeys([]);
56
+ announce(`Dropped on ${label}`);
57
+ };
58
+ return {
59
+ dragging: keys.length > 0,
60
+ announcement,
61
+ start,
62
+ drop,
63
+ cancel,
64
+ getDragProps: (key: string, label: string) => ({
65
+ draggable: !options.disabled,
66
+ onDragStart: (event: $FlowFixMe) => {
67
+ if (options.disabled) {
68
+ event.preventDefault();
69
+ return;
70
+ }
71
+ start(key, label);
72
+ event.dataTransfer.setData(MIME, JSON.stringify([key]));
73
+ event.dataTransfer.setData("text/plain", label);
74
+ event.dataTransfer.effectAllowed = "move";
75
+ },
76
+ onDragEnd: () => setKeys([]),
77
+ onKeyDown: (event: $FlowFixMe) => {
78
+ if (event.key === "Escape" && keys.length > 0) {
79
+ event.preventDefault();
80
+ cancel();
81
+ } else if (event.key === " " && !keys.length) {
82
+ event.preventDefault();
83
+ start(key, label);
84
+ }
85
+ },
86
+ }),
87
+ getDropProps: (target: string, label: string = target) => ({
88
+ onDragOver: (event: $FlowFixMe) => {
89
+ if (!options.disabled && Array.from(event.dataTransfer.types).includes(MIME)) {
90
+ event.preventDefault();
91
+ event.dataTransfer.dropEffect = "move";
92
+ }
93
+ },
94
+ onDrop: (event: $FlowFixMe) => {
95
+ if (options.disabled) return;
96
+ const raw = event.dataTransfer.getData(MIME);
97
+ if (raw.length > 64000) return;
98
+ let incoming;
99
+ try {
100
+ incoming = JSON.parse(raw);
101
+ } catch {
102
+ return;
103
+ }
104
+ if (
105
+ !Array.isArray(incoming) ||
106
+ incoming.length === 0 ||
107
+ !incoming.every((key) => typeof key === "string")
108
+ )
109
+ return;
110
+ event.preventDefault();
111
+ drop(target, incoming, label);
112
+ },
113
+ onKeyDown: (event: $FlowFixMe) => {
114
+ if (keys.length === 0) return;
115
+ if (event.key === "Enter") {
116
+ event.preventDefault();
117
+ drop(target, keys, label);
118
+ } else if (event.key === "Escape") {
119
+ event.preventDefault();
120
+ cancel();
121
+ }
122
+ },
123
+ }),
124
+ };
125
+ }
package/grid-list.js ADDED
@@ -0,0 +1,8 @@
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 GridList(...props: CollectionProps) {
7
+ return <CollectionRoot options={props} kind="grid" />;
8
+ }
@@ -0,0 +1,89 @@
1
+ // @flow
2
+ "use client";
3
+
4
+ import * as React from "@uniflowed/react";
5
+ import { createContext, useContext, useMemo } from "@uniflowed/react";
6
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
7
+ import { withProps } from "./internal/merge-props.js";
8
+
9
+ export type Locale = {| readonly locale: string, readonly direction: "ltr" | "rtl" |};
10
+ const LocaleContext: React.Context<Locale> = createContext({ locale: "en-US", direction: "ltr" });
11
+
12
+ /** An explicit locale keeps server and client output identical. Nested providers form islands. */
13
+ export component I18nProvider(
14
+ children: React.Node,
15
+ locale?: string,
16
+ direction?: "ltr" | "rtl",
17
+ render?: RenderProp,
18
+ ...rest: Rest
19
+ ) {
20
+ const parent = useLocale();
21
+ const state = useMemo((): Locale => {
22
+ const resolved = new Intl.Locale(locale ?? parent.locale);
23
+ const script = resolved.maximize().script;
24
+ const rtl = ["Arab", "Hebr", "Thaa", "Nkoo", "Adlm", "Rohg"].includes(script ?? "");
25
+ return {
26
+ locale: resolved.toString(),
27
+ direction: direction ?? (locale == null ? parent.direction : rtl ? "rtl" : "ltr"),
28
+ };
29
+ }, [locale, direction, parent]);
30
+ const props = withProps(rest, { children, lang: state.locale, dir: state.direction });
31
+ return (
32
+ <LocaleContext.Provider value={state}>
33
+ {render != null ? render(props) : <div {...props} />}
34
+ </LocaleContext.Provider>
35
+ );
36
+ }
37
+
38
+ export hook useLocale(): Locale {
39
+ return useContext(LocaleContext);
40
+ }
41
+
42
+ /** Share Intl's language-specific ordering with caller-owned collections. */
43
+ export hook useCollator(options?: Intl$CollatorOptions): Intl$Collator {
44
+ const { locale } = useLocale();
45
+ return useMemo(() => new Intl.Collator(locale, options), [locale, options]);
46
+ }
47
+
48
+ // Bound the cache when a long-lived server serves many requested locales.
49
+ const searchCollators = new Map<string, Intl$Collator>();
50
+ function searchCollator(locale: string): Intl$Collator {
51
+ const cached = searchCollators.get(locale);
52
+ if (cached != null) return cached;
53
+ const collator = new Intl.Collator(locale, { usage: "search", sensitivity: "base" });
54
+ if (searchCollators.size >= 32) searchCollators.clear();
55
+ searchCollators.set(locale, collator);
56
+ return collator;
57
+ }
58
+
59
+ export function startsWithLocale(text: string, query: string, locale: string): boolean {
60
+ const value = text.normalize("NFC");
61
+ const needle = query.normalize("NFC");
62
+ return (
63
+ searchCollator(locale).compare(
64
+ Array.from(value).slice(0, Array.from(needle).length).join(""),
65
+ needle,
66
+ ) === 0
67
+ );
68
+ }
69
+
70
+ /** Filtering uses the same collation as typeahead; consumers own the result list. */
71
+ export hook useFilter(): {
72
+ startsWith: (text: string, query: string) => boolean,
73
+ contains: (text: string, query: string) => boolean,
74
+ } {
75
+ const { locale } = useLocale();
76
+ return useMemo(
77
+ () => ({
78
+ startsWith: (text, query) => startsWithLocale(text, query, locale),
79
+ contains: (text, query) => {
80
+ const chars = Array.from(text.normalize("NFC"));
81
+ return (
82
+ query === "" ||
83
+ chars.some((_, index) => startsWithLocale(chars.slice(index).join(""), query, locale))
84
+ );
85
+ },
86
+ }),
87
+ [locale],
88
+ );
89
+ }