@uniflowed/ui 0.1.0 → 0.3.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 +22 -9
- package/alert-dialog.js +41 -19
- package/alert.js +14 -8
- package/avatar.js +14 -8
- package/breadcrumb.js +24 -6
- package/calendar.js +54 -10
- package/carousel.js +24 -6
- package/collapsible.js +14 -3
- package/color-picker.js +22 -5
- package/combobox.js +30 -21
- package/context-menu.js +31 -2
- package/date-picker.js +37 -11
- package/date-range-picker.js +25 -8
- package/dialog.js +39 -11
- package/drawer.js +47 -34
- package/field.js +24 -6
- package/hover-card.js +14 -3
- package/index.js +107 -740
- package/input-otp.js +20 -4
- package/interactions.js +0 -1
- package/internal/collection.js +262 -95
- package/internal/focus.js +13 -4
- package/internal/roving-focus.js +12 -3
- package/internal/segmented-field.js +2 -1
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/menu.js +36 -13
- package/menubar.js +32 -5
- package/navigation-menu.js +24 -6
- package/number-field.js +20 -12
- package/package.json +5 -5
- package/pagination.js +22 -5
- package/popover.js +14 -4
- package/radio-group.js +15 -4
- package/range-calendar.js +22 -3
- package/resizable.js +14 -3
- package/scroll-area.js +14 -3
- package/select.js +30 -13
- package/sheet.js +39 -18
- package/sidebar.js +31 -16
- package/skeleton.js +13 -3
- package/slider.js +15 -4
- package/table.js +34 -24
- package/tabs.js +15 -9
- package/toast.js +24 -6
- package/toggle-group.js +14 -3
- package/tooltip.js +21 -5
- package/visually-hidden.js +259 -0
package/table.js
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
withoutComposed,
|
|
76
76
|
} from "./internal/merge-props.js";
|
|
77
77
|
import { useControlled } from "./internal/controlled-state.js";
|
|
78
|
+
import { visuallyHiddenStyle } from "./internal/visually-hidden-style.js";
|
|
78
79
|
|
|
79
80
|
/** Which column a table is sorted by, and which way. */
|
|
80
81
|
export type Sort = {|
|
|
@@ -104,19 +105,6 @@ const TableContext: React.Context<TableState | null> = createContext(null);
|
|
|
104
105
|
/** Whether the rows below are header rows, which decides `th` versus `td`. */
|
|
105
106
|
const HeaderContext: React.Context<boolean> = createContext(false);
|
|
106
107
|
|
|
107
|
-
const VISUALLY_HIDDEN_STYLE = Object.freeze({
|
|
108
|
-
border: 0,
|
|
109
|
-
clip: "rect(0, 0, 0, 0)",
|
|
110
|
-
clipPath: "inset(50%)",
|
|
111
|
-
height: 1,
|
|
112
|
-
margin: -1,
|
|
113
|
-
overflow: "hidden",
|
|
114
|
-
padding: 0,
|
|
115
|
-
position: "absolute",
|
|
116
|
-
whiteSpace: "nowrap",
|
|
117
|
-
width: 1,
|
|
118
|
-
});
|
|
119
|
-
|
|
120
108
|
hook useTable(part: string): TableState {
|
|
121
109
|
const state = useContext(TableContext);
|
|
122
110
|
if (state == null) {
|
|
@@ -139,7 +127,7 @@ hook useTable(part: string): TableState {
|
|
|
139
127
|
* `announceSort` is the wording of the announcement, for an application with a
|
|
140
128
|
* translation table. The default is English.
|
|
141
129
|
*/
|
|
142
|
-
|
|
130
|
+
component TableRoot(
|
|
143
131
|
children: React.Node,
|
|
144
132
|
sort?: Sort | null,
|
|
145
133
|
defaultSort?: Sort | null = null,
|
|
@@ -222,7 +210,7 @@ export component TableRoot(
|
|
|
222
210
|
aria-live="polite"
|
|
223
211
|
data-uf-table-status=""
|
|
224
212
|
role="status"
|
|
225
|
-
style={
|
|
213
|
+
style={visuallyHiddenStyle}
|
|
226
214
|
>
|
|
227
215
|
{message}
|
|
228
216
|
</div>
|
|
@@ -237,7 +225,7 @@ export component TableRoot(
|
|
|
237
225
|
* what a screen reader reads when a reader lands on it. A heading above the
|
|
238
226
|
* table looks the same and is not the table's name.
|
|
239
227
|
*/
|
|
240
|
-
|
|
228
|
+
component TableCaption(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
241
229
|
const table = useTable("Table.Caption");
|
|
242
230
|
const register = table.registerCaption;
|
|
243
231
|
|
|
@@ -259,7 +247,7 @@ export component TableCaption(children: React.Node, render?: RenderProp, ...rest
|
|
|
259
247
|
* It tells the root that it exists, because `aria-rowcount` and every row's
|
|
260
248
|
* `aria-rowindex` count header rows and a table without one counts differently.
|
|
261
249
|
*/
|
|
262
|
-
|
|
250
|
+
component TableHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
263
251
|
const table = useTable("Table.Header");
|
|
264
252
|
const register = table.registerHeader;
|
|
265
253
|
|
|
@@ -278,7 +266,7 @@ export component TableHeader(children: React.Node, render?: RenderProp, ...rest:
|
|
|
278
266
|
}
|
|
279
267
|
|
|
280
268
|
/** The data rows. */
|
|
281
|
-
|
|
269
|
+
component TableBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
282
270
|
const props = withProps(rest, { children });
|
|
283
271
|
return (
|
|
284
272
|
<HeaderContext.Provider value={false}>
|
|
@@ -303,7 +291,7 @@ export component TableBody(children: React.Node, render?: RenderProp, ...rest: R
|
|
|
303
291
|
* adding them anyway is a second source of truth that can disagree with the
|
|
304
292
|
* document.
|
|
305
293
|
*/
|
|
306
|
-
|
|
294
|
+
component TableRow(
|
|
307
295
|
children: React.Node,
|
|
308
296
|
index?: number | null = null,
|
|
309
297
|
render?: RenderProp,
|
|
@@ -344,7 +332,7 @@ export component TableRow(
|
|
|
344
332
|
* Not `"none"` on the others: eleven headers each announcing "not sorted" is
|
|
345
333
|
* eleven announcements of nothing, on every pass through the table.
|
|
346
334
|
*/
|
|
347
|
-
|
|
335
|
+
component TableHead(
|
|
348
336
|
children: React.Node,
|
|
349
337
|
column?: string | null = null,
|
|
350
338
|
render?: RenderProp,
|
|
@@ -407,7 +395,7 @@ export component TableHead(
|
|
|
407
395
|
}
|
|
408
396
|
|
|
409
397
|
/** One cell. */
|
|
410
|
-
|
|
398
|
+
component TableCell(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
411
399
|
const props = withProps(rest, { children });
|
|
412
400
|
if (render != null) {
|
|
413
401
|
return render(withProps(props, { role: "cell" }));
|
|
@@ -423,7 +411,7 @@ export component TableCell(children: React.Node, render?: RenderProp, ...rest: R
|
|
|
423
411
|
* moves down the year column. A table of records usually has one and almost
|
|
424
412
|
* never marks it.
|
|
425
413
|
*/
|
|
426
|
-
|
|
414
|
+
component TableRowHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
427
415
|
const props = withProps(rest, { children, scope: "row" });
|
|
428
416
|
if (render != null) {
|
|
429
417
|
return render(withProps(props, { role: "rowheader" }));
|
|
@@ -461,7 +449,7 @@ export component TableRowHeader(children: React.Node, render?: RenderProp, ...re
|
|
|
461
449
|
* wants a `Checkbox` of their own, which they should write — this part exists
|
|
462
450
|
* for the type of `checked`, not for the markup.
|
|
463
451
|
*/
|
|
464
|
-
|
|
452
|
+
component TableSelectAll(
|
|
465
453
|
checked: boolean | "mixed",
|
|
466
454
|
onCheckedChange: (checked: boolean) => void,
|
|
467
455
|
label?: string = "Select all rows",
|
|
@@ -494,7 +482,7 @@ export component TableSelectAll(
|
|
|
494
482
|
* Named props rather than `...rest: Rest`, for the reason `Table.SelectAll`
|
|
495
483
|
* gives above.
|
|
496
484
|
*/
|
|
497
|
-
|
|
485
|
+
component TableRowSelect(
|
|
498
486
|
label: string,
|
|
499
487
|
checked: boolean,
|
|
500
488
|
onCheckedChange: (checked: boolean) => void,
|
|
@@ -518,3 +506,25 @@ export component TableRowSelect(
|
|
|
518
506
|
function defaultAnnouncement(column: string, direction: "ascending" | "descending"): string {
|
|
519
507
|
return `Sorted by ${column}, ${direction}.`;
|
|
520
508
|
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The parts, under the names the `Table` namespace gives them.
|
|
512
|
+
*
|
|
513
|
+
* `index.js` re-exports this module whole — `export * as Table from "./table.js"` —
|
|
514
|
+
* so a caller writes `<Table.Root>`, and the namespace is the prefix. Each
|
|
515
|
+
* part is still *declared* as `TableRoot`, so React DevTools, a component
|
|
516
|
+
* stack and an error name the part a reader can find rather than one of forty
|
|
517
|
+
* `Root`s.
|
|
518
|
+
*/
|
|
519
|
+
export {
|
|
520
|
+
TableRoot as Root,
|
|
521
|
+
TableCaption as Caption,
|
|
522
|
+
TableHeader as Header,
|
|
523
|
+
TableBody as Body,
|
|
524
|
+
TableRow as Row,
|
|
525
|
+
TableHead as Head,
|
|
526
|
+
TableRowHeader as RowHeader,
|
|
527
|
+
TableCell as Cell,
|
|
528
|
+
TableSelectAll as SelectAll,
|
|
529
|
+
TableRowSelect as RowSelect,
|
|
530
|
+
};
|
package/tabs.js
CHANGED
|
@@ -87,7 +87,7 @@ hook useTabs(part: string): TabsState {
|
|
|
87
87
|
* distinction every one of these components needs: a form library owns the
|
|
88
88
|
* value, and a page that just wants tabs does not.
|
|
89
89
|
*/
|
|
90
|
-
|
|
90
|
+
component TabsRoot(
|
|
91
91
|
children: React.Node,
|
|
92
92
|
defaultValue: string,
|
|
93
93
|
value?: string,
|
|
@@ -145,7 +145,7 @@ export component TabsRoot(
|
|
|
145
145
|
* tabs push themselves into as they mount answers with mount order, which stops
|
|
146
146
|
* being document order the first time a tab is conditional.
|
|
147
147
|
*/
|
|
148
|
-
|
|
148
|
+
component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Rest) {
|
|
149
149
|
const tabs = useTabs("Tabs.List");
|
|
150
150
|
const props = withProps(withoutComposed(rest, ["onKeyDown"]), {
|
|
151
151
|
// A screen reader announces the axis, and it is also what tells a reader
|
|
@@ -186,7 +186,7 @@ export component TabsList(children: renders* TabsTab, render?: RenderProp, ...re
|
|
|
186
186
|
* the section exists and is unavailable, where a native `disabled` would leave a
|
|
187
187
|
* gap they cannot ask about. The keyboard steps over it either way.
|
|
188
188
|
*/
|
|
189
|
-
|
|
189
|
+
component TabsTab(
|
|
190
190
|
value: string,
|
|
191
191
|
children: React.Node,
|
|
192
192
|
disabled?: boolean = false,
|
|
@@ -249,12 +249,7 @@ export component TabsTab(
|
|
|
249
249
|
* subscription rather than "the selected value equals mine", because a caller
|
|
250
250
|
* may render a subset of panels, or none at all until data arrives.
|
|
251
251
|
*/
|
|
252
|
-
|
|
253
|
-
value: string,
|
|
254
|
-
children: React.Node,
|
|
255
|
-
render?: RenderProp,
|
|
256
|
-
...rest: Rest
|
|
257
|
-
) {
|
|
252
|
+
component TabsPanel(value: string, children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
258
253
|
const tabs = useTabs("Tabs.Panel");
|
|
259
254
|
const register = tabs.registerPanel;
|
|
260
255
|
const selected = tabs.selected === value;
|
|
@@ -287,3 +282,14 @@ export component TabsPanel(
|
|
|
287
282
|
}
|
|
288
283
|
return <div {...props} />;
|
|
289
284
|
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The parts, under the names the `Tabs` namespace gives them.
|
|
288
|
+
*
|
|
289
|
+
* `index.js` re-exports this module whole — `export * as Tabs from "./tabs.js"` —
|
|
290
|
+
* so a caller writes `<Tabs.Root>`, and the namespace is the prefix. Each
|
|
291
|
+
* part is still *declared* as `TabsRoot`, so React DevTools, a component
|
|
292
|
+
* stack and an error name the part a reader can find rather than one of forty
|
|
293
|
+
* `Root`s.
|
|
294
|
+
*/
|
|
295
|
+
export { TabsRoot as Root, TabsList as List, TabsTab as Tab, TabsPanel as Panel };
|
package/toast.js
CHANGED
|
@@ -349,7 +349,7 @@ const ToastPartsContext: React.Context<ToastParts | null> = createContext(null);
|
|
|
349
349
|
* so a `<div>` where a notification belongs is a type error rather than a
|
|
350
350
|
* stack of notifications a reader cannot dismiss.
|
|
351
351
|
*/
|
|
352
|
-
|
|
352
|
+
component ToastRegion(
|
|
353
353
|
children: (notification: Notification) => renders ToastRoot,
|
|
354
354
|
label?: string = "Notifications",
|
|
355
355
|
limit?: number = 3,
|
|
@@ -427,7 +427,7 @@ export component ToastRegion(
|
|
|
427
427
|
* an id that is not in the document makes a screen reader announce nothing at
|
|
428
428
|
* all.
|
|
429
429
|
*/
|
|
430
|
-
|
|
430
|
+
component ToastRoot(children: React.Node, ...rest: Rest) {
|
|
431
431
|
const notification = useNotification("Toast.Root");
|
|
432
432
|
const base = useId();
|
|
433
433
|
const elementRef = useElementRef<HTMLElement>();
|
|
@@ -499,7 +499,7 @@ export component ToastRoot(children: React.Node, ...rest: Rest) {
|
|
|
499
499
|
}
|
|
500
500
|
|
|
501
501
|
/** What the notification is about, and the name of its group. */
|
|
502
|
-
|
|
502
|
+
component ToastTitle(children: React.Node, ...rest: Rest) {
|
|
503
503
|
const parts = useToastParts("Toast.Title");
|
|
504
504
|
useRegistration(parts.registerTitle);
|
|
505
505
|
|
|
@@ -511,7 +511,7 @@ export component ToastTitle(children: React.Node, ...rest: Rest) {
|
|
|
511
511
|
}
|
|
512
512
|
|
|
513
513
|
/** The rest of it, and the group's description. */
|
|
514
|
-
|
|
514
|
+
component ToastDescription(children: React.Node, ...rest: Rest) {
|
|
515
515
|
const parts = useToastParts("Toast.Description");
|
|
516
516
|
useRegistration(parts.registerDescription);
|
|
517
517
|
|
|
@@ -532,7 +532,7 @@ export component ToastDescription(children: React.Node, ...rest: Rest) {
|
|
|
532
532
|
* countdown stopping while focus is inside is what makes the button reachable
|
|
533
533
|
* at all; it is not a promise that four seconds was enough time to decide.
|
|
534
534
|
*/
|
|
535
|
-
|
|
535
|
+
component ToastAction(children: React.Node, ...rest: Rest) {
|
|
536
536
|
const notification = useNotification("Toast.Action");
|
|
537
537
|
const passed = withoutComposed(rest, ["onClick"]);
|
|
538
538
|
|
|
@@ -561,7 +561,7 @@ export component ToastAction(children: React.Node, ...rest: Rest) {
|
|
|
561
561
|
* `aria-label` that says "Dismiss" over a button that says "Close" breaks the
|
|
562
562
|
* speech reader who says "click Close" out loud.
|
|
563
563
|
*/
|
|
564
|
-
|
|
564
|
+
component ToastClose(children?: React.Node, label?: string = "Dismiss", ...rest: Rest) {
|
|
565
565
|
const notification = useNotification("Toast.Close");
|
|
566
566
|
const passed = withoutComposed(rest, ["onClick"]);
|
|
567
567
|
|
|
@@ -592,3 +592,21 @@ hook useRegistration(register: (present: boolean) => void): void {
|
|
|
592
592
|
return () => register(false);
|
|
593
593
|
}, [register]);
|
|
594
594
|
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The parts, under the names the `Toast` namespace gives them.
|
|
598
|
+
*
|
|
599
|
+
* `index.js` re-exports this module whole — `export * as Toast from "./toast.js"` —
|
|
600
|
+
* so a caller writes `<Toast.Region>`, and the namespace is the prefix. Each
|
|
601
|
+
* part is still *declared* as `ToastRegion`, so React DevTools, a component
|
|
602
|
+
* stack and an error name the part a reader can find rather than one of forty
|
|
603
|
+
* `Region`s.
|
|
604
|
+
*/
|
|
605
|
+
export {
|
|
606
|
+
ToastRegion as Region,
|
|
607
|
+
ToastRoot as Root,
|
|
608
|
+
ToastTitle as Title,
|
|
609
|
+
ToastDescription as Description,
|
|
610
|
+
ToastAction as Action,
|
|
611
|
+
ToastClose as Close,
|
|
612
|
+
};
|
package/toggle-group.js
CHANGED
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
} from "./internal/merge-props.js";
|
|
71
71
|
import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
|
|
72
72
|
import type { Orientation, RovingSet } from "./internal/roving-focus.js";
|
|
73
|
-
import { RadioGroupItem, RadioGroupRoot } from "./radio-group.js";
|
|
73
|
+
import { Item as RadioGroupItem, Root as RadioGroupRoot } from "./radio-group.js";
|
|
74
74
|
import { useControlled } from "./internal/controlled-state.js";
|
|
75
75
|
|
|
76
76
|
/** Whether the set holds one answer or any number of them. */
|
|
@@ -128,7 +128,7 @@ hook useToggleGroup(part: string): ToggleGroupState {
|
|
|
128
128
|
* Uncontrolled by default and controlled the moment `value` is passed, like
|
|
129
129
|
* everything else here.
|
|
130
130
|
*/
|
|
131
|
-
|
|
131
|
+
component ToggleGroupRoot(
|
|
132
132
|
children: renders* ToggleGroupItem,
|
|
133
133
|
type?: ToggleGroupType = "multiple",
|
|
134
134
|
defaultValue?: $ReadOnlyArray<string> = NOTHING,
|
|
@@ -224,7 +224,7 @@ export component ToggleGroupRoot(
|
|
|
224
224
|
* a reader is told the set has two members when it has three and cannot ask
|
|
225
225
|
* where the third went. The arrow keys step over it either way.
|
|
226
226
|
*/
|
|
227
|
-
|
|
227
|
+
component ToggleGroupItem(
|
|
228
228
|
value: string,
|
|
229
229
|
children?: React.Node,
|
|
230
230
|
disabled?: boolean = false,
|
|
@@ -282,3 +282,14 @@ export component ToggleGroupItem(
|
|
|
282
282
|
|
|
283
283
|
return <button {...itemProps} type="button" />;
|
|
284
284
|
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The parts, under the names the `ToggleGroup` namespace gives them.
|
|
288
|
+
*
|
|
289
|
+
* `index.js` re-exports this module whole — `export * as ToggleGroup from "./toggle-group.js"` —
|
|
290
|
+
* so a caller writes `<ToggleGroup.Root>`, and the namespace is the prefix. Each
|
|
291
|
+
* part is still *declared* as `ToggleGroupRoot`, so React DevTools, a component
|
|
292
|
+
* stack and an error name the part a reader can find rather than one of forty
|
|
293
|
+
* `Root`s.
|
|
294
|
+
*/
|
|
295
|
+
export { ToggleGroupRoot as Root, ToggleGroupItem as Item };
|
package/tooltip.js
CHANGED
|
@@ -150,7 +150,7 @@ hook useTooltip(part: string): TooltipState {
|
|
|
150
150
|
* Renders no element: it is a context and a clock, and a `<div>` around a
|
|
151
151
|
* toolbar is the caller's business.
|
|
152
152
|
*/
|
|
153
|
-
|
|
153
|
+
component TooltipProvider(
|
|
154
154
|
children: React.Node,
|
|
155
155
|
delayDuration?: number = DEFAULT_OPEN_DELAY,
|
|
156
156
|
skipDelayDuration?: number = DEFAULT_SKIP_DELAY,
|
|
@@ -168,7 +168,7 @@ export component TooltipProvider(
|
|
|
168
168
|
* `delayDuration`, and to 700ms when there is no provider — a tooltip on its
|
|
169
169
|
* own is a complete tooltip and needs nothing around it.
|
|
170
170
|
*/
|
|
171
|
-
|
|
171
|
+
component TooltipRoot(
|
|
172
172
|
children: React.Node,
|
|
173
173
|
closeDelay?: number = DEFAULT_CLOSE_DELAY,
|
|
174
174
|
defaultOpen?: boolean = false,
|
|
@@ -243,7 +243,7 @@ export component TooltipRoot(
|
|
|
243
243
|
* `render` function that forgets to spread something still gets a working
|
|
244
244
|
* tooltip; see the module header.
|
|
245
245
|
*/
|
|
246
|
-
|
|
246
|
+
component TooltipTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
247
247
|
const tooltip = useTooltip("Tooltip.Trigger");
|
|
248
248
|
const { closeDelay, dismissedRef, intent, openDelay, setOpen, triggerRef } = tooltip;
|
|
249
249
|
useFocusableTrigger(triggerRef, "Tooltip.Trigger");
|
|
@@ -320,7 +320,7 @@ export component TooltipTrigger(children?: React.Node, render?: RenderProp, ...r
|
|
|
320
320
|
* takes focus, holds no tab stop, and answers `Escape` from wherever focus
|
|
321
321
|
* happens to be.
|
|
322
322
|
*/
|
|
323
|
-
|
|
323
|
+
component TooltipBody(
|
|
324
324
|
children: React.Node,
|
|
325
325
|
align?: Align = "center",
|
|
326
326
|
alignOffset?: number = 0,
|
|
@@ -389,7 +389,7 @@ export component TooltipBody(
|
|
|
389
389
|
id: `${tooltip.base}-body`,
|
|
390
390
|
// React calls callback refs during commit; placement effects read it later.
|
|
391
391
|
// uf-lint-disable-next-line react-compiler/refs
|
|
392
|
-
ref: composeRefs(rest.ref, (element) => {
|
|
392
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
393
393
|
bodyRef.current = element;
|
|
394
394
|
}),
|
|
395
395
|
role: "tooltip",
|
|
@@ -402,3 +402,19 @@ export component TooltipBody(
|
|
|
402
402
|
}
|
|
403
403
|
return <div {...props} />;
|
|
404
404
|
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The parts, under the names the `Tooltip` namespace gives them.
|
|
408
|
+
*
|
|
409
|
+
* `index.js` re-exports this module whole — `export * as Tooltip from "./tooltip.js"` —
|
|
410
|
+
* so a caller writes `<Tooltip.Provider>`, and the namespace is the prefix. Each
|
|
411
|
+
* part is still *declared* as `TooltipProvider`, so React DevTools, a component
|
|
412
|
+
* stack and an error name the part a reader can find rather than one of forty
|
|
413
|
+
* `Provider`s.
|
|
414
|
+
*/
|
|
415
|
+
export {
|
|
416
|
+
TooltipProvider as Provider,
|
|
417
|
+
TooltipRoot as Root,
|
|
418
|
+
TooltipTrigger as Trigger,
|
|
419
|
+
TooltipBody as Body,
|
|
420
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
//
|
|
4
|
+
// Text for assistive technology and nobody else, and the announcer built on it.
|
|
5
|
+
//
|
|
6
|
+
// # `VisuallyHidden`
|
|
7
|
+
//
|
|
8
|
+
// `display: none` and `visibility: hidden` take an element out of the
|
|
9
|
+
// accessibility tree as well as off the screen, which is the opposite of what a
|
|
10
|
+
// visually hidden label is for. The style in `internal/visually-hidden-style.js`
|
|
11
|
+
// is the one that survives every engine's accessibility mapping: a one-pixel
|
|
12
|
+
// box, clipped twice (`clip` for the engines that predate `clip-path`), and
|
|
13
|
+
// `white-space: nowrap` so a screen reader's virtual cursor does not read a
|
|
14
|
+
// clipped paragraph one word per line.
|
|
15
|
+
//
|
|
16
|
+
// `focusable` is for the skip link: hidden until a keyboard lands on it, then
|
|
17
|
+
// on screen while focus is anywhere inside, because a focus ring around a
|
|
18
|
+
// one-pixel box is a focus ring nobody can see.
|
|
19
|
+
//
|
|
20
|
+
// # `announce`
|
|
21
|
+
//
|
|
22
|
+
// A live region only announces a *change*, and only when the region was in the
|
|
23
|
+
// document before the change — so a region rendered together with its message
|
|
24
|
+
// says nothing, and a component that renders its own region next to itself has
|
|
25
|
+
// to exist, silent, before the thing it wants to say happens. Every component
|
|
26
|
+
// that did that also put an element in the caller's layout, and three of them
|
|
27
|
+
// (the collections, the range calendar and the segmented fields) left theirs
|
|
28
|
+
// visible, so "3 selected" was printed on the page under the list.
|
|
29
|
+
//
|
|
30
|
+
// `announce()` is one pair of regions for the whole document, created on the
|
|
31
|
+
// first call and kept. It follows React Aria's LiveAnnouncer, and not by
|
|
32
|
+
// accident:
|
|
33
|
+
//
|
|
34
|
+
// * one `role="log"` region per politeness, since a region's politeness is
|
|
35
|
+
// read when the region is first seen and cannot be changed on a live one;
|
|
36
|
+
// * each message is a new child node rather than a new text value, so the
|
|
37
|
+
// same message twice is announced twice (`aria-relevant="additions"`);
|
|
38
|
+
// * each message is removed after `timeout`, so a reader who arrives at the
|
|
39
|
+
// end of the document later does not find a transcript there;
|
|
40
|
+
// * the first message waits 100 ms after the regions are created, because a
|
|
41
|
+
// region and its first content inserted together are, again, silent.
|
|
42
|
+
//
|
|
43
|
+
// On the server there is no document and `announce` does nothing; there is
|
|
44
|
+
// nothing to hydrate either, because the regions are never rendered by React.
|
|
45
|
+
|
|
46
|
+
import * as React from "@uniflowed/react";
|
|
47
|
+
import { useState } from "@uniflowed/react";
|
|
48
|
+
import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
49
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
50
|
+
import { visuallyHiddenStyle } from "./internal/visually-hidden-style.js";
|
|
51
|
+
|
|
52
|
+
type FocusEvent = {
|
|
53
|
+
readonly currentTarget: mixed,
|
|
54
|
+
readonly relatedTarget: mixed,
|
|
55
|
+
readonly defaultPrevented: boolean,
|
|
56
|
+
...
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Content a screen reader reads and a sighted reader does not see.
|
|
61
|
+
*
|
|
62
|
+
* <button><Icon name="trash" /><VisuallyHidden>Delete draft</VisuallyHidden></button>
|
|
63
|
+
* <VisuallyHidden focusable render={(props) => <a href="#main" {...props} />}>
|
|
64
|
+
* Skip to content
|
|
65
|
+
* </VisuallyHidden>
|
|
66
|
+
*
|
|
67
|
+
* A caller's `style` is kept, underneath the hiding, so it applies again the
|
|
68
|
+
* moment a `focusable` one is shown.
|
|
69
|
+
*/
|
|
70
|
+
export component VisuallyHidden(
|
|
71
|
+
children?: React.Node,
|
|
72
|
+
/** Show the content while focus is inside it: the skip-link pattern. */
|
|
73
|
+
focusable: boolean = false,
|
|
74
|
+
render?: RenderProp,
|
|
75
|
+
...rest: Rest
|
|
76
|
+
) {
|
|
77
|
+
const [focused, setFocused] = useState(false);
|
|
78
|
+
const style = rest.style;
|
|
79
|
+
const own = typeof style === "object" && style != null ? style : {};
|
|
80
|
+
const hidden = !(focusable && focused);
|
|
81
|
+
const props = withProps(withoutComposed(rest, ["onFocus", "onBlur"]), {
|
|
82
|
+
children,
|
|
83
|
+
style: hidden ? { ...own, ...visuallyHiddenStyle } : style,
|
|
84
|
+
onFocus: composeHandlers(rest.onFocus, () => {
|
|
85
|
+
if (focusable) setFocused(true);
|
|
86
|
+
}),
|
|
87
|
+
onBlur: composeHandlers(rest.onBlur, (event: FocusEvent) => {
|
|
88
|
+
// Moving between two links inside one skip block is not leaving it.
|
|
89
|
+
const { currentTarget, relatedTarget } = event;
|
|
90
|
+
if (
|
|
91
|
+
currentTarget instanceof Node &&
|
|
92
|
+
relatedTarget instanceof Node &&
|
|
93
|
+
currentTarget.contains(relatedTarget)
|
|
94
|
+
)
|
|
95
|
+
return;
|
|
96
|
+
setFocused(false);
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
return render != null ? render(props) : <span {...props} />;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type Politeness = "polite" | "assertive";
|
|
103
|
+
|
|
104
|
+
export type AnnounceOptions = {|
|
|
105
|
+
/**
|
|
106
|
+
* `"polite"` (the default) waits for the reader to finish; `"assertive"`
|
|
107
|
+
* interrupts, and is for what cannot wait — an error that stopped a save.
|
|
108
|
+
*/
|
|
109
|
+
readonly politeness?: Politeness,
|
|
110
|
+
/** Milliseconds before the message leaves the document. 7000 by default. */
|
|
111
|
+
readonly timeout?: number,
|
|
112
|
+
|};
|
|
113
|
+
|
|
114
|
+
type Announcer = {|
|
|
115
|
+
readonly root: HTMLElement,
|
|
116
|
+
readonly polite: HTMLElement,
|
|
117
|
+
readonly assertive: HTMLElement,
|
|
118
|
+
/** When the regions went into the document; the first message waits for them. */
|
|
119
|
+
readonly created: number,
|
|
120
|
+
|};
|
|
121
|
+
|
|
122
|
+
let announcer: Announcer | null = null;
|
|
123
|
+
|
|
124
|
+
/** Wait this long after creating the regions, so their first content is a change. */
|
|
125
|
+
const SETTLE_MS = 100;
|
|
126
|
+
|
|
127
|
+
function region(document: Document, politeness: Politeness): HTMLElement {
|
|
128
|
+
const element = document.createElement("div");
|
|
129
|
+
element.setAttribute("role", "log");
|
|
130
|
+
element.setAttribute("aria-live", politeness);
|
|
131
|
+
element.setAttribute("aria-relevant", "additions");
|
|
132
|
+
return element;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function announcerIn(document: Document): Announcer | null {
|
|
136
|
+
const body = document.body;
|
|
137
|
+
if (body == null) return null;
|
|
138
|
+
// A test's cleanup, or an app that replaces `<body>`, can take the regions
|
|
139
|
+
// out; a region that is not in the document announces nothing.
|
|
140
|
+
if (announcer != null && announcer.root.isConnected && announcer.root.ownerDocument === document)
|
|
141
|
+
return announcer;
|
|
142
|
+
const root = document.createElement("div");
|
|
143
|
+
root.setAttribute("data-uf-live-announcer", "");
|
|
144
|
+
root.style.cssText =
|
|
145
|
+
"position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;" +
|
|
146
|
+
"overflow:hidden;clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap";
|
|
147
|
+
const assertive = region(document, "assertive");
|
|
148
|
+
const polite = region(document, "polite");
|
|
149
|
+
root.append(assertive, polite);
|
|
150
|
+
// A direct child of `<body>`, which is the level a modal's conceal walk
|
|
151
|
+
// reaches last; `dialog.js` skips it by the attribute above.
|
|
152
|
+
body.prepend(root);
|
|
153
|
+
announcer = { root, polite, assertive, created: Date.now() };
|
|
154
|
+
return announcer;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The timers `announce` has scheduled and not yet run, per politeness, so
|
|
159
|
+
* `clearAnnouncements` can take back a message that has not arrived yet as
|
|
160
|
+
* well as one that has.
|
|
161
|
+
*/
|
|
162
|
+
const pending: {| readonly polite: Set<TimeoutID>, readonly assertive: Set<TimeoutID> |} = {
|
|
163
|
+
polite: new Set(),
|
|
164
|
+
assertive: new Set(),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run `work` after `ms`, unless `clearAnnouncements` cancels it first.
|
|
169
|
+
*
|
|
170
|
+
* A timer outlives whatever scheduled it: a test worker restores its globals
|
|
171
|
+
* between files, and an app can unmount, replace `<body>` or tear its window
|
|
172
|
+
* down, all while a message is still waiting to arrive or to leave. So a timer
|
|
173
|
+
* never reads the `document` global — `work` is handed the document and
|
|
174
|
+
* regions captured when the message was announced, and each timer checks they
|
|
175
|
+
* are still there before touching them.
|
|
176
|
+
*/
|
|
177
|
+
function schedule(politeness: Politeness, ms: number, work: () => void): void {
|
|
178
|
+
const timers = pending[politeness];
|
|
179
|
+
const timer: TimeoutID = setTimeout(() => {
|
|
180
|
+
timers.delete(timer);
|
|
181
|
+
work();
|
|
182
|
+
}, ms);
|
|
183
|
+
timers.add(timer);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether `document` is still the document this realm has, and `region` still
|
|
188
|
+
* in it: the one condition under which a timer may write to either.
|
|
189
|
+
*/
|
|
190
|
+
function stillLive(document: Document, region: HTMLElement): boolean {
|
|
191
|
+
return (
|
|
192
|
+
typeof globalThis.document !== "undefined" &&
|
|
193
|
+
globalThis.document === document &&
|
|
194
|
+
region.isConnected &&
|
|
195
|
+
region.ownerDocument === document
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Say something to a screen reader, from anywhere.
|
|
201
|
+
*
|
|
202
|
+
* announce(`${count} results`);
|
|
203
|
+
* announce("Could not save the draft", { politeness: "assertive" });
|
|
204
|
+
*
|
|
205
|
+
* A function rather than a hook, because what needs announcing comes from
|
|
206
|
+
* event handlers, effects and `catch` blocks — `toast()` makes the same choice.
|
|
207
|
+
* An empty message is ignored rather than announced as silence. On the server
|
|
208
|
+
* it returns before scheduling anything.
|
|
209
|
+
*/
|
|
210
|
+
export function announce(message: string, options?: AnnounceOptions): void {
|
|
211
|
+
if (message.trim() === "" || typeof document === "undefined") return;
|
|
212
|
+
// Captured now, and the only document the timers below ever use.
|
|
213
|
+
const doc = document;
|
|
214
|
+
const current = announcerIn(doc);
|
|
215
|
+
if (current == null) return;
|
|
216
|
+
const politeness = options?.politeness ?? "polite";
|
|
217
|
+
const timeout = options?.timeout ?? 7000;
|
|
218
|
+
const target = politeness === "assertive" ? current.assertive : current.polite;
|
|
219
|
+
const insert = () => {
|
|
220
|
+
// The document went away (a worker's teardown, an unmounted frame) or the
|
|
221
|
+
// regions were taken out of it: there is nobody left to tell.
|
|
222
|
+
if (!stillLive(doc, target)) return;
|
|
223
|
+
const node = doc.createElement("div");
|
|
224
|
+
node.textContent = message;
|
|
225
|
+
target.append(node);
|
|
226
|
+
if (timeout > 0 && Number.isFinite(timeout))
|
|
227
|
+
schedule(politeness, timeout, () => {
|
|
228
|
+
// Removing a detached node is harmless; only a live one needs it.
|
|
229
|
+
if (node.isConnected) node.remove();
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
const wait = current.created + SETTLE_MS - Date.now();
|
|
233
|
+
if (wait > 0) schedule(politeness, wait, insert);
|
|
234
|
+
else insert();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Take every message out, for one politeness or both: the ones in the regions
|
|
239
|
+
* and the ones still waiting to arrive, whose timers are cancelled. After it,
|
|
240
|
+
* nothing `announce` scheduled for that politeness runs.
|
|
241
|
+
*/
|
|
242
|
+
export function clearAnnouncements(politeness?: Politeness): void {
|
|
243
|
+
const both: $ReadOnlyArray<Politeness> = ["assertive", "polite"];
|
|
244
|
+
for (const which of both) {
|
|
245
|
+
if (politeness != null && politeness !== which) continue;
|
|
246
|
+
const timers = pending[which];
|
|
247
|
+
for (const timer of timers) clearTimeout(timer);
|
|
248
|
+
timers.clear();
|
|
249
|
+
}
|
|
250
|
+
const current = announcer;
|
|
251
|
+
if (current == null) return;
|
|
252
|
+
// Regions a teardown took out are not reused, and need not be kept alive.
|
|
253
|
+
if (!current.root.isConnected) {
|
|
254
|
+
announcer = null;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (politeness !== "polite") current.assertive.replaceChildren();
|
|
258
|
+
if (politeness !== "assertive") current.polite.replaceChildren();
|
|
259
|
+
}
|