@uniflowed/ui 0.0.0-alpha.9 → 0.2.0

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.
Files changed (65) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +587 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +243 -178
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1177 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +562 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +317 -0
  35. package/internal/selection.js +171 -0
  36. package/internal/visually-hidden-style.js +41 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -28
  42. package/pagination.js +34 -22
  43. package/popover.js +116 -75
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +79 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +100 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +48 -55
  64. package/tree.js +8 -0
  65. package/visually-hidden.js +259 -0
@@ -0,0 +1,317 @@
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
+ import { visuallyHiddenStyle } from "./visually-hidden-style.js";
13
+
14
+ type Segment = "year" | "month" | "day" | "hour" | "minute" | "second" | "dayPeriod";
15
+ type Fields = { [string]: string };
16
+ export type DateFieldProps = {
17
+ value?: string | null,
18
+ defaultValue?: string | null,
19
+ onValueChange?: (value: string | null) => void,
20
+ minValue?: string,
21
+ isDateUnavailable?: (value: string) => boolean,
22
+ maxValue?: string,
23
+ disabled?: boolean,
24
+ readOnly?: boolean,
25
+ required?: boolean,
26
+ granularity?: "minute" | "second",
27
+ hourCycle?: "h12" | "h23",
28
+ segmentLabels?: { [string]: string },
29
+ onValidationChange?: (invalid: boolean) => void,
30
+ render?: RenderProp,
31
+ readonly key?: empty,
32
+ readonly [string]: mixed,
33
+ };
34
+ function names(time: boolean, seconds: boolean): Array<Segment> {
35
+ return time
36
+ ? seconds
37
+ ? ["hour", "minute", "second"]
38
+ : ["hour", "minute"]
39
+ : ["year", "month", "day"];
40
+ }
41
+ function fieldsFor(value: string | null, time: boolean, seconds: boolean): Fields {
42
+ if (value == null) return {};
43
+ const parsed = time ? Temporal.PlainTime.from(value) : Temporal.PlainDate.from(value);
44
+ const fields: Fields = {};
45
+ for (const name of names(time, seconds)) fields[name] = String((parsed as $FlowFixMe)[name]);
46
+ return fields;
47
+ }
48
+ function serialize(fields: Fields, time: boolean, seconds: boolean): string | null {
49
+ const parts = names(time, seconds);
50
+ if (parts.some((part) => !/^\d+$/.test(fields[part] ?? ""))) return null;
51
+ try {
52
+ if (time) {
53
+ const value = `${fields.hour.padStart(2, "0")}:${fields.minute.padStart(2, "0")}:${seconds ? fields.second.padStart(2, "0") : "00"}`;
54
+ const parsed = Temporal.PlainTime.from(value);
55
+ return parsed.toString().slice(0, seconds ? 8 : 5);
56
+ }
57
+ const year = Number(fields.year),
58
+ month = Number(fields.month),
59
+ day = Number(fields.day);
60
+ if (year < 1 || year > 9999) return null;
61
+ const parsed = Temporal.PlainDate.from({ year, month, day });
62
+ if (parsed.year !== year || parsed.month !== month || parsed.day !== day) return null;
63
+ return parsed.toString();
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ function maximum(name: Segment, fields: Fields): number {
69
+ if (name === "year") return 9999;
70
+ if (name === "month") return 12;
71
+ if (name === "hour") return 23;
72
+ if (name !== "day") return 59;
73
+ try {
74
+ return Temporal.PlainDate.from({
75
+ year: Number(fields.year) || 2000,
76
+ month: Number(fields.month) || 1,
77
+ day: 1,
78
+ }).daysInMonth;
79
+ } catch {
80
+ return 31;
81
+ }
82
+ }
83
+
84
+ /** Segment order, digits and labels come from the locale; the stored value is ISO. */
85
+ export component SegmentedField(time: boolean, options: DateFieldProps) {
86
+ const {
87
+ value,
88
+ defaultValue = null,
89
+ onValueChange,
90
+ minValue,
91
+ isDateUnavailable,
92
+ maxValue,
93
+ disabled = false,
94
+ readOnly = false,
95
+ required = false,
96
+ granularity = "minute",
97
+ hourCycle,
98
+ segmentLabels,
99
+ onValidationChange,
100
+ render,
101
+ ...rest
102
+ } = options;
103
+ const { locale } = useLocale();
104
+ const seconds = granularity === "second";
105
+ const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
106
+ const [draft, setDraft] = useState<Fields | null>(null);
107
+ const [announcement, announce] = useState("");
108
+ const fields = draft ?? fieldsFor(current, time, seconds);
109
+ const empty = names(time, seconds).every((part) => !fields[part]);
110
+ const serialized = serialize(fields, time, seconds);
111
+ const minimum =
112
+ minValue == null ? null : serialize(fieldsFor(minValue, time, seconds), time, seconds);
113
+ const maximumValue =
114
+ maxValue == null ? null : serialize(fieldsFor(maxValue, time, seconds), time, seconds);
115
+ if (minimum != null && maximumValue != null && minimum > maximumValue)
116
+ throw new RangeError("DateField minimum exceeds maximum");
117
+ const invalid =
118
+ (empty ? required : serialized == null) ||
119
+ (serialized != null &&
120
+ ((minimum != null && serialized < minimum) ||
121
+ (maximumValue != null && serialized > maximumValue) ||
122
+ isDateUnavailable?.(serialized) === true));
123
+ const digits = useMemo(() => new Intl.NumberFormat(locale, { useGrouping: false }), [locale]);
124
+ const labels: $FlowFixMe = useMemo(
125
+ () => new (Intl as $FlowFixMe).DisplayNames(locale, { type: "dateTimeField" }),
126
+ [locale],
127
+ );
128
+ const parts: Array<{ type: string, value: string }> = useMemo(() => {
129
+ const formatter: $FlowFixMe = new Intl.DateTimeFormat(
130
+ locale,
131
+ time
132
+ ? {
133
+ hour: "2-digit",
134
+ minute: "2-digit",
135
+ second: seconds ? "2-digit" : undefined,
136
+ hourCycle,
137
+ timeZone: "UTC",
138
+ }
139
+ : {
140
+ year: "numeric",
141
+ month: "2-digit",
142
+ day: "2-digit",
143
+ calendar: "gregory",
144
+ timeZone: "UTC",
145
+ },
146
+ );
147
+ return formatter.formatToParts(new Date("2000-01-02T12:34:56Z"));
148
+ }, [locale, time, seconds, hourCycle]);
149
+ const hour12 = time && parts.some((part) => part.type === "dayPeriod");
150
+ const periodNames: Array<string> = useMemo(() => {
151
+ const formatter: $FlowFixMe = new Intl.DateTimeFormat(locale, {
152
+ hour: "numeric",
153
+ hour12: true,
154
+ timeZone: "UTC",
155
+ });
156
+ return [0, 12].map(
157
+ (hour) =>
158
+ formatter
159
+ .formatToParts(new Date(Date.UTC(2000, 0, 1, hour)))
160
+ .find((part) => part.type === "dayPeriod")?.value ?? (hour === 0 ? "AM" : "PM"),
161
+ );
162
+ }, [locale]);
163
+ const encode = (text: string): string =>
164
+ Array.from(text)
165
+ .map((digit) => digits.format(Number(digit)))
166
+ .join("");
167
+ const decode = (text: string): string => {
168
+ let result = text;
169
+ for (let digit = 0; digit < 10; digit += 1)
170
+ result = result.split(digits.format(digit)).join(String(digit));
171
+ return result.replace(/[^0-9]/g, "");
172
+ };
173
+ const commit = useStableCallback(() => {
174
+ if (disabled || readOnly) return;
175
+ onValidationChange?.(invalid);
176
+ if (invalid) {
177
+ announce("Enter a valid value within the allowed range");
178
+ return;
179
+ }
180
+ setCurrent(empty ? null : serialized);
181
+ setDraft(null);
182
+ announce(serialized ?? "Cleared");
183
+ });
184
+ const edit = useStableCallback((part: Segment, text: string) => {
185
+ if (!disabled && !readOnly) setDraft({ ...fields, [part]: text });
186
+ });
187
+ const keyboard = useStableCallback((event: $FlowFixMe, part: Segment) => {
188
+ if (disabled || readOnly) return;
189
+ const key = event.key;
190
+ if (key === "Enter") {
191
+ event.preventDefault();
192
+ commit();
193
+ return;
194
+ }
195
+ if (key === "ArrowLeft" || key === "ArrowRight") {
196
+ event.preventDefault();
197
+ const root = event.currentTarget.closest("[data-segmented]");
198
+ const segments = Array.from(root.querySelectorAll("[data-segment]"));
199
+ const at = segments.indexOf(event.currentTarget);
200
+ const forward =
201
+ key === (directionOf(event.currentTarget) === "rtl" ? "ArrowLeft" : "ArrowRight");
202
+ (segments[at + (forward ? 1 : -1)] as $FlowFixMe)?.focus();
203
+ return;
204
+ }
205
+ if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(key)) return;
206
+ event.preventDefault();
207
+ const min = time && !(part === "hour" && hour12) ? 0 : 1,
208
+ max = part === "hour" && hour12 ? 12 : maximum(part, fields);
209
+ const raw = Number(fields[part] || min);
210
+ const current = part === "hour" && hour12 ? ((raw + 11) % 12) + 1 : raw;
211
+ const next =
212
+ key === "Home"
213
+ ? min
214
+ : key === "End"
215
+ ? max
216
+ : current +
217
+ (key === "ArrowUp" ? 1 : key === "ArrowDown" ? -1 : key === "PageUp" ? 10 : -10);
218
+ const bounded = next > max ? min : next < min ? max : next;
219
+ const adjusted =
220
+ part === "hour" && hour12 ? (bounded % 12) + (Number(fields.hour) >= 12 ? 12 : 0) : bounded;
221
+ const nextFields = { ...fields, [part]: String(adjusted) };
222
+ if (!time && (part === "month" || part === "year") && nextFields.day)
223
+ nextFields.day = String(Math.min(Number(nextFields.day), maximum("day", nextFields)));
224
+ setDraft(nextFields);
225
+ });
226
+ const segments = parts.map((part, index) => {
227
+ if (part.type === "dayPeriod") {
228
+ const pm = Number(fields.hour) >= 12;
229
+ const toggle = () => edit("hour", String((Number(fields.hour) || 0) + (pm ? -12 : 12)));
230
+ return (
231
+ <span
232
+ key="period"
233
+ role="spinbutton"
234
+ tabIndex={disabled ? -1 : 0}
235
+ data-segment="dayPeriod"
236
+ aria-label={segmentLabels?.dayPeriod ?? labels.of("dayPeriod")}
237
+ aria-valuemin={0}
238
+ aria-valuemax={1}
239
+ aria-valuenow={pm ? 1 : 0}
240
+ aria-valuetext={periodNames[pm ? 1 : 0]}
241
+ aria-disabled={disabled || undefined}
242
+ aria-readonly={readOnly || undefined}
243
+ onClick={toggle}
244
+ onKeyDown={(event) => {
245
+ if (disabled || readOnly) return;
246
+ if (["ArrowUp", "ArrowDown", "Home", "End", " "].includes(event.key)) {
247
+ event.preventDefault();
248
+ if ((event.key !== "Home" || pm) && (event.key !== "End" || !pm)) toggle();
249
+ } else if (["ArrowLeft", "ArrowRight", "Enter"].includes(event.key))
250
+ keyboard(event, "dayPeriod");
251
+ }}
252
+ >
253
+ {periodNames[pm ? 1 : 0]}
254
+ </span>
255
+ );
256
+ }
257
+
258
+ if (!names(time, seconds).includes(part.type as $FlowFixMe))
259
+ return (
260
+ <span key={index} aria-hidden="true">
261
+ {part.value}
262
+ </span>
263
+ );
264
+ const name: Segment = part.type as $FlowFixMe;
265
+ const raw = fields[name] ?? "";
266
+ const text =
267
+ name === "hour" && hour12 && raw !== "" ? String(((Number(raw) + 11) % 12) + 1) : raw;
268
+ return (
269
+ <input
270
+ key={name}
271
+ type="text"
272
+ role="spinbutton"
273
+ inputMode="numeric"
274
+ data-segment={name}
275
+ aria-label={segmentLabels?.[name] ?? labels.of(name)}
276
+ aria-valuemin={time && !(name === "hour" && hour12) ? 0 : 1}
277
+ aria-valuemax={name === "hour" && hour12 ? 12 : maximum(name, fields)}
278
+ aria-valuenow={text ? Number(text) : undefined}
279
+ aria-valuetext={text ? encode(text) : undefined}
280
+ aria-invalid={invalid || undefined}
281
+ disabled={disabled}
282
+ readOnly={readOnly}
283
+ required={required}
284
+ maxLength={name === "year" ? 4 : 2}
285
+ placeholder={name === "year" ? "yyyy" : "--"}
286
+ value={text ? encode(text) : ""}
287
+ onChange={(event) => {
288
+ const text = decode(event.currentTarget.value);
289
+ edit(
290
+ name,
291
+ name === "hour" && hour12 && text && Number(text) <= 12
292
+ ? String((Number(text) % 12) + (Number(fields.hour) >= 12 ? 12 : 0))
293
+ : text,
294
+ );
295
+ }}
296
+ onKeyDown={(event) => keyboard(event, name)}
297
+ />
298
+ );
299
+ });
300
+ const props = withProps(rest, {
301
+ role: "group",
302
+ "data-segmented": time ? "time" : "date",
303
+ "aria-invalid": invalid || rest["aria-invalid"],
304
+ onBlur: composeHandlers(rest.onBlur, (event: $FlowFixMe) => {
305
+ if (!event.currentTarget.contains(event.relatedTarget)) commit();
306
+ }),
307
+ children: (
308
+ <>
309
+ {segments}
310
+ <span role="status" aria-live="polite" style={visuallyHiddenStyle}>
311
+ {announcement}
312
+ </span>
313
+ </>
314
+ ),
315
+ });
316
+ return render != null ? render(props) : <div {...props} />;
317
+ }
@@ -0,0 +1,171 @@
1
+ // @flow
2
+ //
3
+ // What a collection's selection becomes after one gesture, as pure functions.
4
+ //
5
+ // `ListBox`, `GridList`, `Tree` and `TagGroup` all ask the same four questions
6
+ // — toggle this key, make it the only one, extend to it, take everything — and
7
+ // the answers depend on three settings rather than on which collection is
8
+ // asking. Keeping them here, apart from React, is what lets every rule be
9
+ // tested without rendering and lets all four collections agree by construction.
10
+ //
11
+ // The rules are React Aria's selection manager (`@react-stately/selection`),
12
+ // which is itself the WAI-ARIA APG's listbox and grid selection models made
13
+ // precise:
14
+ //
15
+ // * `mode` is what may be chosen: nothing, one key, or any number.
16
+ // * `behavior` is what a plain gesture does. `"toggle"` flips the key — the
17
+ // checkbox model, right for touch and for lists whose rows carry a
18
+ // checkbox. `"replace"` makes the key the whole selection — the file
19
+ // manager model, where Ctrl/Cmd adds one and Shift adds a range.
20
+ // * `disallowEmpty` refuses the gesture that would leave nothing selected,
21
+ // rather than accepting it and then reselecting.
22
+ //
23
+ // Every function returns the *same* set it was given when nothing changes, so a
24
+ // caller can compare by identity and skip `onSelectionChange` for a gesture
25
+ // that did nothing — a controlled parent is not told about a change that is
26
+ // not one.
27
+ //
28
+ // # Why this is `internal/`
29
+ //
30
+ // A public selection manager would be a second way to build a collection, and
31
+ // the point of this package is that there is one. The collections are the
32
+ // public surface; `packages/ui/index.js` says why the internals stay internal.
33
+
34
+ export type SelectionMode = "none" | "single" | "multiple";
35
+ export type SelectionBehavior = "toggle" | "replace";
36
+
37
+ /** The three settings every answer below depends on. */
38
+ export type SelectionPolicy = {|
39
+ readonly mode: SelectionMode,
40
+ readonly behavior: SelectionBehavior,
41
+ readonly disallowEmpty: boolean,
42
+ |};
43
+
44
+ /**
45
+ * The keys a range covers, in collection order, inclusive at both ends.
46
+ *
47
+ * `order` is the selectable keys only — disabled rows are not in it — so a range
48
+ * dragged across a disabled row does not pick it up, which is what React Aria
49
+ * and every native list do. An end that is not in `order` (a key filtered out
50
+ * since it was chosen) contributes nothing rather than stretching the range to
51
+ * the start of the list.
52
+ */
53
+ export function keyRange(
54
+ order: $ReadOnlyArray<string>,
55
+ from: string,
56
+ to: string,
57
+ ): $ReadOnlyArray<string> {
58
+ const start = order.indexOf(from);
59
+ const end = order.indexOf(to);
60
+ if (start < 0 || end < 0) return end < 0 ? [] : [to];
61
+ return order.slice(Math.min(start, end), Math.max(start, end) + 1);
62
+ }
63
+
64
+ /** Flip one key, unless that would empty a selection that must not be empty. */
65
+ export function toggleKey(
66
+ policy: SelectionPolicy,
67
+ selected: $ReadOnlySet<string>,
68
+ key: string,
69
+ ): $ReadOnlySet<string> {
70
+ if (policy.mode === "none") return selected;
71
+ if (selected.has(key)) {
72
+ if (policy.disallowEmpty && selected.size === 1) return selected;
73
+ if (policy.mode === "single") return new Set();
74
+ const next = new Set(selected);
75
+ next.delete(key);
76
+ return next;
77
+ }
78
+ if (policy.mode === "single") return new Set([key]);
79
+ const next = new Set(selected);
80
+ next.add(key);
81
+ return next;
82
+ }
83
+
84
+ /** Make one key the whole selection. */
85
+ export function replaceWith(
86
+ policy: SelectionPolicy,
87
+ selected: $ReadOnlySet<string>,
88
+ key: string,
89
+ ): $ReadOnlySet<string> {
90
+ if (policy.mode === "none") return selected;
91
+ if (selected.size === 1 && selected.has(key)) return selected;
92
+ return new Set([key]);
93
+ }
94
+
95
+ /**
96
+ * Extend from `anchor` to `key`, replacing the range the previous extension made.
97
+ *
98
+ * `lead` is where the last extension ended. Shift+Down, Shift+Down, Shift+Up
99
+ * must end with two rows selected rather than three, so the old range
100
+ * (`anchor`…`lead`) comes out before the new one (`anchor`…`key`) goes in —
101
+ * while anything selected outside both ranges, by an earlier Ctrl/Cmd+click,
102
+ * stays. With no anchor the extension starts at `key`.
103
+ */
104
+ export function extendTo(
105
+ policy: SelectionPolicy,
106
+ selected: $ReadOnlySet<string>,
107
+ order: $ReadOnlyArray<string>,
108
+ anchor: string | null,
109
+ lead: string | null,
110
+ key: string,
111
+ ): $ReadOnlySet<string> {
112
+ if (policy.mode === "none") return selected;
113
+ if (policy.mode === "single") return replaceWith(policy, selected, key);
114
+ const from = anchor ?? key;
115
+ const next = new Set(selected);
116
+ if (lead != null) for (const each of keyRange(order, from, lead)) next.delete(each);
117
+ for (const each of keyRange(order, from, key)) next.add(each);
118
+ if (next.size === 0 && policy.disallowEmpty) return selected;
119
+ return sameKeys(next, selected) ? selected : next;
120
+ }
121
+
122
+ /** Every selectable key, when more than one may be chosen. */
123
+ export function selectAll(
124
+ policy: SelectionPolicy,
125
+ selected: $ReadOnlySet<string>,
126
+ order: $ReadOnlyArray<string>,
127
+ ): $ReadOnlySet<string> {
128
+ if (policy.mode !== "multiple" || order.length === 0) return selected;
129
+ const next = new Set(selected);
130
+ for (const key of order) next.add(key);
131
+ return sameKeys(next, selected) ? selected : next;
132
+ }
133
+
134
+ /** Nothing, unless nothing is not allowed. */
135
+ export function clearAll(
136
+ policy: SelectionPolicy,
137
+ selected: $ReadOnlySet<string>,
138
+ ): $ReadOnlySet<string> {
139
+ if (policy.mode === "none" || policy.disallowEmpty || selected.size === 0) return selected;
140
+ return new Set();
141
+ }
142
+
143
+ /**
144
+ * A selection as the array `onSelectionChange` reports: collection order first,
145
+ * then any key the collection is not showing, in the order it was chosen.
146
+ *
147
+ * Keys the collection is not showing are real — a child of a collapsed tree
148
+ * row, a row a filter hid — and dropping them because they are out of sight
149
+ * would deselect something the reader chose and cannot see go.
150
+ */
151
+ export function orderedKeys(
152
+ selected: $ReadOnlySet<string>,
153
+ order: $ReadOnlyArray<string>,
154
+ ): $ReadOnlyArray<string> {
155
+ const result = [];
156
+ const placed = new Set<string>();
157
+ for (const key of order) {
158
+ if (selected.has(key)) {
159
+ result.push(key);
160
+ placed.add(key);
161
+ }
162
+ }
163
+ for (const key of selected) if (!placed.has(key)) result.push(key);
164
+ return result;
165
+ }
166
+
167
+ function sameKeys(a: $ReadOnlySet<string>, b: $ReadOnlySet<string>): boolean {
168
+ if (a.size !== b.size) return false;
169
+ for (const key of a) if (!b.has(key)) return false;
170
+ return true;
171
+ }
@@ -0,0 +1,41 @@
1
+ // @flow
2
+ //
3
+ // The one inline style for "hidden from sight, present for a screen reader".
4
+ //
5
+ // Its own module, with no `"use client"`, so a part that renders on the server
6
+ // can import it without importing `visually-hidden.js` — a client module whose
7
+ // every export is a client reference to a Server Component. Internal for the
8
+ // reason `packages/ui/index.js` gives about every module here: the public
9
+ // answer is `VisuallyHidden`.
10
+
11
+ /**
12
+ * The style that hides an element from sight and from nothing else.
13
+ *
14
+ * Shared by `VisuallyHidden` and by the parts that keep a live region of their
15
+ * own beside what they render — the table's sort announcement, the
16
+ * collections', the calendar's and the range calendar's, and the segmented
17
+ * fields'. Frozen, because every one of them holds the same object.
18
+ */
19
+ export const visuallyHiddenStyle: {|
20
+ readonly border: number,
21
+ readonly clip: string,
22
+ readonly clipPath: string,
23
+ readonly height: number,
24
+ readonly margin: number,
25
+ readonly overflow: string,
26
+ readonly padding: number,
27
+ readonly position: string,
28
+ readonly whiteSpace: string,
29
+ readonly width: number,
30
+ |} = Object.freeze({
31
+ border: 0,
32
+ clip: "rect(0, 0, 0, 0)",
33
+ clipPath: "inset(50%)",
34
+ height: 1,
35
+ margin: -1,
36
+ overflow: "hidden",
37
+ padding: 0,
38
+ position: "absolute",
39
+ whiteSpace: "nowrap",
40
+ width: 1,
41
+ });
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";