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

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/index.js CHANGED
@@ -1781,3 +1781,94 @@ export const Skeleton = {
1781
1781
  Root: SkeletonRoot,
1782
1782
  Box: SkeletonBox,
1783
1783
  };
1784
+
1785
+ export { I18nProvider, useLocale, useCollator, useFilter } from "./i18n-provider.js";
1786
+ import {
1787
+ NumberFieldRoot,
1788
+ NumberFieldInput,
1789
+ NumberFieldIncrement,
1790
+ NumberFieldDecrement,
1791
+ } from "./number-field.js";
1792
+ export const NumberField = {
1793
+ Root: NumberFieldRoot,
1794
+ Input: NumberFieldInput,
1795
+ Increment: NumberFieldIncrement,
1796
+ Decrement: NumberFieldDecrement,
1797
+ };
1798
+
1799
+ export { ListBox } from "./list-box.js";
1800
+ export { GridList } from "./grid-list.js";
1801
+ export { Tree } from "./tree.js";
1802
+ export { TagGroup } from "./tag-group.js";
1803
+ export type { CollectionItem, CollectionItemState, CollectionProps } from "./list-box.js";
1804
+
1805
+ export type { Drop, DragAndDrop } from "./drag-drop.js";
1806
+ export { useDragAndDrop } from "./drag-drop.js";
1807
+ import {
1808
+ ColorPickerRoot,
1809
+ ColorPickerInput,
1810
+ ColorPickerField,
1811
+ ColorPickerChannel,
1812
+ ColorPickerSwatch,
1813
+ } from "./color-picker.js";
1814
+ export const ColorPicker = {
1815
+ Root: ColorPickerRoot,
1816
+ Input: ColorPickerInput,
1817
+ Field: ColorPickerField,
1818
+ Channel: ColorPickerChannel,
1819
+ Swatch: ColorPickerSwatch,
1820
+ };
1821
+
1822
+ export { DateField } from "./date-field.js";
1823
+ export { TimeField } from "./time-field.js";
1824
+ export type { DateFieldProps } from "./date-field.js";
1825
+ import { RangeCalendarRoot } from "./range-calendar.js";
1826
+ import {
1827
+ DateRangePickerRoot,
1828
+ DateRangePickerStartField,
1829
+ DateRangePickerEndField,
1830
+ DateRangePickerTrigger,
1831
+ DateRangePickerCalendar,
1832
+ } from "./date-range-picker.js";
1833
+ export type { DateRange } from "./range-calendar.js";
1834
+ export const RangeCalendar = {
1835
+ Root: RangeCalendarRoot,
1836
+ Month: CalendarMonth,
1837
+ Day: CalendarDay,
1838
+ Previous: CalendarPrevious,
1839
+ Next: CalendarNext,
1840
+ };
1841
+ export const DateRangePicker = {
1842
+ Root: DateRangePickerRoot,
1843
+ StartField: DateRangePickerStartField,
1844
+ EndField: DateRangePickerEndField,
1845
+ Trigger: DateRangePickerTrigger,
1846
+ Calendar: DateRangePickerCalendar,
1847
+ };
1848
+
1849
+ export {
1850
+ NumberFieldRoot,
1851
+ NumberFieldInput,
1852
+ NumberFieldIncrement,
1853
+ NumberFieldDecrement,
1854
+ parseNumber,
1855
+ } from "./number-field.js";
1856
+ export type { NumberFormatOptions } from "./number-field.js";
1857
+ export {
1858
+ ColorPickerRoot,
1859
+ ColorPickerInput,
1860
+ ColorPickerField,
1861
+ ColorPickerChannel,
1862
+ ColorPickerSwatch,
1863
+ parseColor,
1864
+ } from "./color-picker.js";
1865
+ export { RangeCalendarRoot } from "./range-calendar.js";
1866
+ export {
1867
+ DateRangePickerRoot,
1868
+ DateRangePickerStartField,
1869
+ DateRangePickerEndField,
1870
+ DateRangePickerTrigger,
1871
+ DateRangePickerCalendar,
1872
+ } from "./date-range-picker.js";
1873
+ export type { Locale } from "./i18n-provider.js";
1874
+ export { startsWithLocale } from "./i18n-provider.js";
@@ -0,0 +1,395 @@
1
+ // @flow
2
+ "use client";
3
+
4
+ import * as React from "@uniflowed/react";
5
+ import { useId, useRef, useState } from "@uniflowed/react";
6
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
7
+ import { useDragAndDrop } from "../drag-drop.js";
8
+ import { useControlled } from "./controlled-state.js";
9
+ import { composeHandlers, composeRefs, withProps } from "./merge-props.js";
10
+ import type { RenderProp, Rest } from "./merge-props.js";
11
+ import { directionOf } from "./roving-focus.js";
12
+ import { startsWithLocale, useLocale } from "../i18n-provider.js";
13
+
14
+ export type CollectionItem = {
15
+ readonly key: string,
16
+ readonly textValue: string,
17
+ readonly disabled?: boolean,
18
+ readonly children?: $ReadOnlyArray<CollectionItem>,
19
+ };
20
+ export type CollectionItemState = {
21
+ readonly selected: boolean,
22
+ readonly active: boolean,
23
+ readonly disabled: boolean,
24
+ readonly level: number,
25
+ };
26
+ type Entry = {
27
+ item: CollectionItem,
28
+ level: number,
29
+ parent: string | null,
30
+ position: number,
31
+ count: number,
32
+ };
33
+ type Kind = "listbox" | "grid" | "tree" | "tags";
34
+
35
+ function entries(
36
+ items: $ReadOnlyArray<CollectionItem>,
37
+ expanded: $ReadOnlyArray<string>,
38
+ tree: boolean,
39
+ ): Array<Entry> {
40
+ const result = [];
41
+ const seen = new Set<string>();
42
+ const visit = (
43
+ siblings: $ReadOnlyArray<CollectionItem>,
44
+ level: number,
45
+ parent: string | null,
46
+ ): void => {
47
+ siblings.forEach((item, index) => {
48
+ if (seen.has(item.key)) throw new Error(`Duplicate collection key: ${item.key}`);
49
+ seen.add(item.key);
50
+ result.push({ item, level, parent, position: index + 1, count: siblings.length });
51
+ const children = item.children;
52
+ if (tree && children != null && expanded.includes(item.key))
53
+ visit(children, level + 1, item.key);
54
+ });
55
+ };
56
+ visit(items, 1, null);
57
+ return result;
58
+ }
59
+
60
+ /** Data owns order and identity. Only visible tree descendants and virtual rows enter the DOM. */
61
+ export component CollectionRoot(kind: Kind, options: CollectionProps) {
62
+ const {
63
+ items,
64
+ children,
65
+ selectionMode: requestedSelectionMode = "single",
66
+ selectedKeys,
67
+ defaultSelectedKeys = [],
68
+ onSelectionChange,
69
+ disabledKeys = [],
70
+ expandedKeys,
71
+ defaultExpandedKeys = [],
72
+ onExpandedChange,
73
+ onRemove,
74
+ onReorder,
75
+ loading = false,
76
+ onLoadMore,
77
+ virtualized = false,
78
+ height = 300,
79
+ rowHeight = 30,
80
+ render,
81
+ ...rest
82
+ } = options;
83
+ const selectionMode = kind === "tags" ? "none" : requestedSelectionMode;
84
+ if (height <= 0 || rowHeight <= 0 || !Number.isFinite(height) || !Number.isFinite(rowHeight))
85
+ throw new RangeError("Collection dimensions must be positive finite numbers");
86
+ const { locale } = useLocale();
87
+ const id = useId();
88
+ const root = useRef<HTMLElement | null>(null);
89
+ const anchor = useRef<string | null>(null);
90
+ const buffer = useRef({ text: "", time: 0 });
91
+ const [selected, setSelected] = useControlled(
92
+ selectedKeys,
93
+ defaultSelectedKeys,
94
+ onSelectionChange,
95
+ );
96
+ const [expanded, setExpanded] = useControlled(
97
+ expandedKeys,
98
+ defaultExpandedKeys,
99
+ onExpandedChange,
100
+ );
101
+ const [active, setActive] = useState<string | null>(null);
102
+ const [scrollTop, setScrollTop] = useState(0);
103
+ const [announcement, announce] = useState("");
104
+ const drag = useDragAndDrop({
105
+ disabled: onReorder == null,
106
+ onDrop: ({ keys, target }) => {
107
+ const moving = keys[0];
108
+ if (
109
+ [moving, target].some(
110
+ (key) => disabledKeys.includes(key) || items.find((item) => item.key === key)?.disabled,
111
+ )
112
+ )
113
+ return;
114
+ if (
115
+ moving === target ||
116
+ !items.some((item) => item.key === moving) ||
117
+ !items.some((item) => item.key === target)
118
+ )
119
+ return;
120
+ const ordered = items.filter((item) => item.key !== moving).map((item) => item.key);
121
+ ordered.splice(ordered.indexOf(target), 0, moving);
122
+ onReorder?.(ordered);
123
+ },
124
+ });
125
+ const rows = entries(items, expanded, kind === "tree");
126
+ const disabled = (item: CollectionItem) =>
127
+ item.disabled === true || disabledKeys.includes(item.key);
128
+ const enabled = rows.filter((row) => !disabled(row.item));
129
+ const focused =
130
+ enabled.find((row) => row.item.key === active) ??
131
+ enabled.find((row) => selected.includes(row.item.key)) ??
132
+ enabled[0];
133
+ const activeKey = focused?.item.key;
134
+ const choose = (key: string, range: boolean, toggle: boolean) => {
135
+ const item = rows.find((row) => row.item.key === key)?.item;
136
+ if (item == null || disabled(item) || selectionMode === "none") return;
137
+ let next;
138
+ if (selectionMode === "single") next = [key];
139
+ else if (range && anchor.current != null) {
140
+ const from = enabled.findIndex((row) => row.item.key === anchor.current);
141
+ const to = enabled.findIndex((row) => row.item.key === key);
142
+ next = enabled
143
+ .slice(Math.min(Math.max(from, 0), to), Math.max(from, to) + 1)
144
+ .map((row) => row.item.key);
145
+ } else {
146
+ next = toggle
147
+ ? selected.includes(key)
148
+ ? selected.filter((each) => each !== key)
149
+ : [...selected, key]
150
+ : [key];
151
+ anchor.current = key;
152
+ }
153
+ setSelected(next);
154
+ announce(`${next.length} selected`);
155
+ };
156
+ const land = (row: Entry, shift: boolean) => {
157
+ setActive(row.item.key);
158
+ const container = root.current;
159
+ if (virtualized && container != null) {
160
+ const top = rows.indexOf(row) * rowHeight;
161
+ if (top < container.scrollTop || top + rowHeight > container.scrollTop + height) {
162
+ container.scrollTop = top;
163
+ setScrollTop(top);
164
+ }
165
+ }
166
+ if (shift) choose(row.item.key, true, false);
167
+ };
168
+ const remove = (key: string) => {
169
+ const at = enabled.findIndex((row) => row.item.key === key);
170
+ if (at < 0 || onRemove == null) return;
171
+ onRemove(key);
172
+ setActive(enabled[at + 1]?.item.key ?? enabled[at - 1]?.item.key ?? null);
173
+ announce(`Removed ${enabled[at].item.textValue}`);
174
+ };
175
+ const keydown = useStableCallback((event: $FlowFixMe) => {
176
+ if (
177
+ event.target !== event.currentTarget &&
178
+ event.target.closest("button,input,textarea,select")
179
+ )
180
+ return;
181
+ if (drag.dragging && event.key === "Escape") {
182
+ event.preventDefault();
183
+ drag.cancel();
184
+ return;
185
+ }
186
+ if (drag.dragging && event.key === "Enter" && activeKey != null) {
187
+ event.preventDefault();
188
+ drag.drop(activeKey, undefined, focused?.item.textValue);
189
+ return;
190
+ }
191
+ if (
192
+ onReorder != null &&
193
+ event.key === " " &&
194
+ (event.ctrlKey || event.metaKey) &&
195
+ focused != null
196
+ ) {
197
+ event.preventDefault();
198
+ drag.start(focused.item.key, focused.item.textValue);
199
+ return;
200
+ }
201
+ const at = enabled.findIndex((row) => row.item.key === activeKey);
202
+ let next = null;
203
+ if (event.key === "ArrowDown") next = enabled[Math.min(at + 1, enabled.length - 1)];
204
+ else if (event.key === "ArrowUp") next = enabled[Math.max(at - 1, 0)];
205
+ else if (event.key === "Home") next = enabled[0];
206
+ else if (event.key === "End") {
207
+ next = enabled[enabled.length - 1];
208
+ if (!loading) onLoadMore?.();
209
+ } else if (
210
+ kind === "tree" &&
211
+ focused != null &&
212
+ ["ArrowLeft", "ArrowRight"].includes(event.key)
213
+ ) {
214
+ const open =
215
+ event.key === (directionOf(event.currentTarget) === "rtl" ? "ArrowLeft" : "ArrowRight");
216
+ const key = focused.item.key;
217
+ if (open && focused.item.children?.length) {
218
+ if (!expanded.includes(key)) setExpanded([...expanded, key]);
219
+ else next = enabled[at + 1];
220
+ } else if (!open && expanded.includes(key))
221
+ setExpanded(expanded.filter((each) => each !== key));
222
+ else if (!open) next = enabled.find((row) => row.item.key === focused.parent);
223
+ } else if (
224
+ (event.ctrlKey || event.metaKey) &&
225
+ event.key === "a" &&
226
+ selectionMode === "multiple"
227
+ ) {
228
+ setSelected(enabled.map((row) => row.item.key));
229
+ announce(`${enabled.length} selected`);
230
+ } else if ((event.key === " " || event.key === "Enter") && activeKey != null)
231
+ choose(activeKey, event.shiftKey, true);
232
+ else if (
233
+ (event.key === "Delete" || event.key === "Backspace") &&
234
+ kind === "tags" &&
235
+ activeKey != null
236
+ )
237
+ remove(activeKey);
238
+ else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
239
+ const time = Date.now();
240
+ const text = time - buffer.current.time > 500 ? event.key : buffer.current.text + event.key;
241
+ buffer.current = { text, time };
242
+ const repeated = Array.from(text).every((char) => char === event.key);
243
+ const query = repeated ? event.key : text;
244
+ const start = repeated || text.length === 1 ? at + 1 : Math.max(at, 0);
245
+ for (let offset = 0; offset < enabled.length; offset += 1) {
246
+ const candidate = enabled[(start + offset) % enabled.length];
247
+ if (startsWithLocale(candidate.item.textValue, query, locale)) {
248
+ next = candidate;
249
+ break;
250
+ }
251
+ }
252
+ } else return;
253
+ event.preventDefault();
254
+ if (next != null) land(next, event.shiftKey);
255
+ });
256
+ const start = virtualized ? Math.max(0, Math.floor(scrollTop / rowHeight) - 2) : 0;
257
+ const end = virtualized
258
+ ? Math.min(rows.length, start + Math.ceil(height / rowHeight) + 4)
259
+ : rows.length;
260
+ // aria-activedescendant must always name a mounted row, even after a pointer scroll.
261
+ const indices = new Set(rows.slice(start, end).map((_, index) => start + index));
262
+ const activeIndex = rows.findIndex((row) => row.item.key === activeKey);
263
+ if (activeIndex >= 0) indices.add(activeIndex);
264
+ const rowNodes = Array.from(indices)
265
+ .sort((a, b) => a - b)
266
+ .map((index) => {
267
+ const row = rows[index];
268
+ const { item } = row;
269
+ const chosen = selected.includes(item.key);
270
+ const grid = kind === "grid" || kind === "tags";
271
+ const content =
272
+ children?.(item, {
273
+ selected: chosen,
274
+ active: activeKey === item.key,
275
+ disabled: disabled(item),
276
+ level: row.level,
277
+ }) ?? item.textValue;
278
+ const props = {
279
+ ...drag.getDragProps(item.key, item.textValue),
280
+ ...drag.getDropProps(item.key, item.textValue),
281
+ onKeyDown: undefined,
282
+ draggable: onReorder != null && !disabled(item),
283
+ id: `${id}-${index}`,
284
+ role: grid ? "row" : kind === "tree" ? "treeitem" : "option",
285
+ "aria-selected": selectionMode === "none" ? undefined : chosen,
286
+ "aria-disabled": disabled(item) || undefined,
287
+ "aria-expanded":
288
+ kind === "tree" && item.children?.length ? expanded.includes(item.key) : undefined,
289
+ "aria-level": kind === "tree" ? row.level : undefined,
290
+ "aria-posinset": grid ? undefined : kind === "tree" ? row.position : index + 1,
291
+ "aria-setsize": grid ? undefined : kind === "tree" ? row.count : rows.length,
292
+ "aria-rowindex": grid ? index + 1 : undefined,
293
+ "data-key": item.key,
294
+ "data-active": activeKey === item.key || undefined,
295
+ style: virtualized
296
+ ? { position: "absolute", top: index * rowHeight, height: rowHeight, width: "100%" }
297
+ : undefined,
298
+ onClick: (event: $FlowFixMe) => {
299
+ if (disabled(item)) return;
300
+ root.current?.focus();
301
+ setActive(item.key);
302
+ choose(item.key, event.shiftKey, selectionMode === "multiple");
303
+ },
304
+ };
305
+ return (
306
+ <div key={item.key} {...props}>
307
+ {grid ? (
308
+ <div role="gridcell">
309
+ {content}
310
+ {kind === "tags" && onRemove != null ? (
311
+ <button
312
+ type="button"
313
+ tabIndex={-1}
314
+ disabled={disabled(item)}
315
+ aria-label={`Remove ${item.textValue}`}
316
+ onClick={(event) => {
317
+ event.stopPropagation();
318
+ root.current?.focus();
319
+ remove(item.key);
320
+ }}
321
+ >
322
+ ×
323
+ </button>
324
+ ) : null}
325
+ </div>
326
+ ) : (
327
+ content
328
+ )}
329
+ </div>
330
+ );
331
+ });
332
+ const setRef = useStableCallback((element: HTMLElement | null) => {
333
+ root.current = element;
334
+ });
335
+ const props = withProps(rest, {
336
+ ref: composeRefs(rest.ref, setRef),
337
+ role: kind === "tags" ? "grid" : kind,
338
+ tabIndex: 0,
339
+ "aria-activedescendant": activeIndex < 0 ? undefined : `${id}-${activeIndex}`,
340
+ "aria-multiselectable": selectionMode === "multiple" || undefined,
341
+ "aria-busy": loading || undefined,
342
+ "aria-rowcount": kind === "grid" || kind === "tags" ? rows.length : undefined,
343
+ onKeyDown: composeHandlers(rest.onKeyDown, keydown),
344
+ onScroll: composeHandlers(rest.onScroll, (event: $FlowFixMe) => {
345
+ if (virtualized) setScrollTop(event.currentTarget.scrollTop);
346
+ if (
347
+ !loading &&
348
+ event.currentTarget.scrollTop + event.currentTarget.clientHeight >=
349
+ event.currentTarget.scrollHeight
350
+ )
351
+ onLoadMore?.();
352
+ }),
353
+ style: virtualized
354
+ ? { ...(rest.style as $FlowFixMe), height, overflow: "auto", position: "relative" }
355
+ : rest.style,
356
+ children: virtualized ? (
357
+ <div role="presentation" style={{ height: rows.length * rowHeight, position: "relative" }}>
358
+ {rowNodes}
359
+ </div>
360
+ ) : (
361
+ rowNodes
362
+ ),
363
+ });
364
+ return (
365
+ <>
366
+ {render != null ? render(props) : <div {...props} />}
367
+ <span role="status" aria-live="polite">
368
+ {drag.announcement || announcement}
369
+ </span>
370
+ </>
371
+ );
372
+ }
373
+
374
+ export type CollectionProps = {
375
+ items: $ReadOnlyArray<CollectionItem>,
376
+ children?: (item: CollectionItem, state: CollectionItemState) => React.Node,
377
+ selectionMode?: "single" | "multiple" | "none",
378
+ selectedKeys?: $ReadOnlyArray<string>,
379
+ defaultSelectedKeys?: $ReadOnlyArray<string>,
380
+ onSelectionChange?: (keys: $ReadOnlyArray<string>) => void,
381
+ disabledKeys?: $ReadOnlyArray<string>,
382
+ expandedKeys?: $ReadOnlyArray<string>,
383
+ defaultExpandedKeys?: $ReadOnlyArray<string>,
384
+ onExpandedChange?: (keys: $ReadOnlyArray<string>) => void,
385
+ onRemove?: (key: string) => void,
386
+ onReorder?: (keys: $ReadOnlyArray<string>) => void,
387
+ loading?: boolean,
388
+ onLoadMore?: () => void,
389
+ virtualized?: boolean,
390
+ height?: number,
391
+ rowHeight?: number,
392
+ render?: RenderProp,
393
+ readonly key?: empty,
394
+ readonly [string]: mixed,
395
+ };
@@ -0,0 +1,26 @@
1
+ // @flow
2
+ import type { PlainDate } from "@uniflowed/core/temporal";
3
+ import { Temporal } from "@uniflowed/core/temporal";
4
+ export type DateRange = {| readonly start: string, readonly end: string |};
5
+ export function validateRange(range: DateRange | null): void {
6
+ if (range == null) return;
7
+ const start = Temporal.PlainDate.from(range.start).toString();
8
+ const end = Temporal.PlainDate.from(range.end).toString();
9
+ if (start > end) throw new RangeError("A date range must start on or before its end");
10
+ }
11
+
12
+ export function unavailableInRange(
13
+ start: string,
14
+ end: string,
15
+ unavailable: ((date: PlainDate) => boolean) | void,
16
+ ): boolean {
17
+ if (unavailable == null) return false;
18
+ for (
19
+ let date = Temporal.PlainDate.from(start);
20
+ date.toString() <= end;
21
+ date = date.add({ days: 1 })
22
+ ) {
23
+ if (unavailable(date)) return true;
24
+ }
25
+ return false;
26
+ }
@@ -45,6 +45,7 @@
45
45
  // one the components make.
46
46
 
47
47
  import { useCallback, useEffect, useRef, useState } from "@uniflowed/react";
48
+ import { useLocale, startsWithLocale } from "../i18n-provider.js";
48
49
 
49
50
  /** Which way a key asks the focus to move within a set. */
50
51
  export type Movement = "previous" | "next" | "first" | "last";
@@ -375,6 +376,7 @@ export hook useTypeahead(): (
375
376
  from: number,
376
377
  key: string,
377
378
  ) => HTMLElement | null {
379
+ const { locale } = useLocale();
378
380
  const buffer = useRef<{| text: string, at: number |}>({ text: "", at: 0 });
379
381
 
380
382
  return useCallback(
@@ -384,7 +386,7 @@ export hook useTypeahead(): (
384
386
  buffer.current = { text, at: now };
385
387
 
386
388
  const repeated = text.length > 1 && text.split("").every((each) => each === text[0]);
387
- const needle = (repeated ? text[0] : text).toLowerCase();
389
+ const needle = repeated ? text[0] : text;
388
390
  // A single character — or the same one again — moves on from where we
389
391
  // are. A longer buffer starts *at* the current item, so typing "sa" after
390
392
  // "s" can keep the item "s" already found.
@@ -392,13 +394,20 @@ export hook useTypeahead(): (
392
394
 
393
395
  for (let tried = 0; tried < items.length; tried += 1) {
394
396
  const candidate = items[(((start + tried) % items.length) + items.length) % items.length];
395
- if (isEnabled(candidate) && labelOf(candidate).startsWith(needle)) {
397
+ if (
398
+ isEnabled(candidate) &&
399
+ startsWithLocale(
400
+ labelOf(candidate),
401
+ needle,
402
+ candidate.closest("[lang]")?.getAttribute("lang") || locale,
403
+ )
404
+ ) {
396
405
  return candidate;
397
406
  }
398
407
  }
399
408
  return null;
400
409
  },
401
- [],
410
+ [locale],
402
411
  );
403
412
  }
404
413
 
@@ -428,5 +437,5 @@ export function isTypeaheadKey(event: {
428
437
  /** What a reader hears for this item, lower-cased for matching. */
429
438
  function labelOf(element: HTMLElement): string {
430
439
  const spoken = element.getAttribute("aria-label") ?? element.textContent ?? "";
431
- return spoken.replace(/\s+/g, " ").trim().toLowerCase();
440
+ return spoken.replace(/\s+/g, " ").trim();
432
441
  }