@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.
- package/accordion.js +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +587 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +243 -178
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +100 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +48 -55
- package/tree.js +8 -0
- 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";
|