@uniflowed/ui 0.0.0-alpha.10
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 +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// What a reader can reach, in the order they reach it.
|
|
4
|
+
//
|
|
5
|
+
// One list, asked for by two components that want opposite things from it.
|
|
6
|
+
// `Dialog.Body` uses it to keep focus *in*: the first and last entries are
|
|
7
|
+
// where `Tab` and `Shift+Tab` wrap. `Popover.Body` uses it to put focus in
|
|
8
|
+
// once and then leaves it alone, because tabbing out of a popover is how a
|
|
9
|
+
// reader leaves one. The list has to be the same list for those two to be
|
|
10
|
+
// describable as different policies over the same fact rather than as two
|
|
11
|
+
// components that disagree about what focusable means.
|
|
12
|
+
//
|
|
13
|
+
// # The selector is the browser's rule, written down
|
|
14
|
+
//
|
|
15
|
+
// A disabled control is out because the browser will not focus one, and
|
|
16
|
+
// `tabindex="-1"` is out because it means "focusable by script, not by Tab" —
|
|
17
|
+
// which is what every roving tab stop in this package uses, so a menu inside a
|
|
18
|
+
// dialog would otherwise report thirty items as focus stops and the trap would
|
|
19
|
+
// wrap between two of them instead of at the dialog's edges.
|
|
20
|
+
//
|
|
21
|
+
// The three ancestor checks are the ones a selector cannot make. `hidden`,
|
|
22
|
+
// `inert` and `aria-hidden="true"` each hide a whole subtree, and reading them
|
|
23
|
+
// off the element alone returned a button inside `<div aria-hidden="true">` as
|
|
24
|
+
// a focus stop — after which the trap moved focus to a control no screen
|
|
25
|
+
// reader exposes and the reader was somewhere they could not be told about.
|
|
26
|
+
//
|
|
27
|
+
// # Why this is `internal/` and not a subpath
|
|
28
|
+
//
|
|
29
|
+
// The same reason `merge-props.js` gives. A public `focusable()` is a
|
|
30
|
+
// general-purpose DOM utility, and a second, weaker copy of one is how two
|
|
31
|
+
// parts of a package come to disagree about which elements exist. What is
|
|
32
|
+
// shipped here is narrower: the definition this package's focus behaviour is
|
|
33
|
+
// written against.
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The elements a browser will move focus to with `Tab`.
|
|
37
|
+
*
|
|
38
|
+
* Exported as the selector as well as through `focusable`, because one
|
|
39
|
+
* question is asked about a single element rather than about a subtree: a
|
|
40
|
+
* tooltip's trigger has to *be* one of these or the tooltip is one only a mouse
|
|
41
|
+
* can reach, and `element.matches(FOCUS_STOPS)` is that question.
|
|
42
|
+
*/
|
|
43
|
+
export const FOCUS_STOPS: string =
|
|
44
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The focus stops inside an element, in document order.
|
|
48
|
+
*
|
|
49
|
+
* Document order rather than mount order, for the reason
|
|
50
|
+
* `internal/roving-focus.js` gives about items: the two stop agreeing the first
|
|
51
|
+
* time something is rendered conditionally, and the reader's `Tab` follows the
|
|
52
|
+
* document.
|
|
53
|
+
*/
|
|
54
|
+
export function focusable(root: HTMLElement): Array<HTMLElement> {
|
|
55
|
+
return Array.from(root.querySelectorAll(FOCUS_STOPS)).filter(
|
|
56
|
+
(element: $FlowFixMe) =>
|
|
57
|
+
// All three hide a whole subtree, so all three are asked of the
|
|
58
|
+
// ancestors; see the module header for what reading them off the element
|
|
59
|
+
// alone let through.
|
|
60
|
+
element.closest("[hidden]") == null &&
|
|
61
|
+
element.closest("[inert]") == null &&
|
|
62
|
+
element.closest('[aria-hidden="true"]') == null,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// What a `<form>` submits for a control the browser has never heard of.
|
|
4
|
+
//
|
|
5
|
+
// Every widget in this package is a `button` or a `div` wearing an ARIA role,
|
|
6
|
+
// which is what makes it styleable and what makes it invisible to form
|
|
7
|
+
// submission: `new FormData(form)` collects the form's *listed* elements, and a
|
|
8
|
+
// `<div role="listbox">` is not one. So a Select inside a form submitted
|
|
9
|
+
// nothing at all, and a Combobox submitted `Combobox.Input`'s value — which is
|
|
10
|
+
// the label the reader sees and not the value the application meant. A country
|
|
11
|
+
// picker posted "United Kingdom" where the server was waiting for `GB`.
|
|
12
|
+
//
|
|
13
|
+
// The fix is one hidden `<input>` carrying the real value, rendered only when
|
|
14
|
+
// the caller asked for one by giving a `name`. No `name`, no control: a Select
|
|
15
|
+
// used to drive a filter has nothing to submit, and a form field the caller
|
|
16
|
+
// never named is not one this package should invent.
|
|
17
|
+
//
|
|
18
|
+
// # Why a hidden input and not a hidden `<select>`
|
|
19
|
+
//
|
|
20
|
+
// Rendering a real, visually hidden `<select>` is the other answer, and it buys
|
|
21
|
+
// two things: the browser autofills it, and `required` gets native constraint
|
|
22
|
+
// validation. Both were tried and neither survives contact with the
|
|
23
|
+
// accessibility tree.
|
|
24
|
+
//
|
|
25
|
+
// A `<select>` is focusable, so it is announced. A reader who tabs into the
|
|
26
|
+
// widget hears the styled combobox and then a second, invisible combobox with
|
|
27
|
+
// the same options — the duplicate-announcement bug that makes people describe
|
|
28
|
+
// a component library as "noisy". Taking it out of the tree means
|
|
29
|
+
// `aria-hidden="true"`, and `aria-hidden` on a focusable element is itself the
|
|
30
|
+
// violation: it hides the element from a screen reader while leaving it in the
|
|
31
|
+
// tab order, so the reader lands on something their software says is not there.
|
|
32
|
+
// `tabindex="-1"` plus `aria-hidden` closes that hole and gives up the tab
|
|
33
|
+
// order, which is the autofill affordance the native control was for.
|
|
34
|
+
//
|
|
35
|
+
// And native validation cannot work here either. The browser reports a
|
|
36
|
+
// constraint failure by focusing the invalid control and drawing a bubble at
|
|
37
|
+
// it; on a control with no box, Chrome logs "An invalid form control with
|
|
38
|
+
// name='country' is not focusable" and refuses to submit the form at all, with
|
|
39
|
+
// nothing shown to the reader. A headless select's `required` therefore belongs
|
|
40
|
+
// to `@uniflowed/form` and `@uniflowed/validator`, which is where uf already
|
|
41
|
+
// put every other rule, and `Field.Error` is where the message goes.
|
|
42
|
+
//
|
|
43
|
+
// An `<input type="hidden">` is none of those things: never focusable, never in
|
|
44
|
+
// the accessibility tree, never validated, and always submitted. What it costs
|
|
45
|
+
// is autofill, which is a real loss and is written down rather than hidden —
|
|
46
|
+
// a browser will not fill a hidden input the way it fills `<select
|
|
47
|
+
// name="country">`.
|
|
48
|
+
//
|
|
49
|
+
// # This is not how `@uniflowed/form` reads a value
|
|
50
|
+
//
|
|
51
|
+
// Worth stating because the two look like they overlap and do not.
|
|
52
|
+
// `@uniflowed/form` holds values in its own store and calls `preventDefault()`
|
|
53
|
+
// on submit, so it never builds a `FormData` and never sees this element. A
|
|
54
|
+
// Select bound to that library is bound through `useController` — `field.value`
|
|
55
|
+
// into `value`, `field.onChange` into `onValueChange` — and needs no `name`
|
|
56
|
+
// here at all. This element is for the other kind of form: a plain `<form
|
|
57
|
+
// action={…}>`, a Server Action, or anything else that submits the document.
|
|
58
|
+
//
|
|
59
|
+
// # Why this is `internal/` and not a subpath
|
|
60
|
+
//
|
|
61
|
+
// It is one sentence about what this package promises a form, and the failure
|
|
62
|
+
// mode of writing it twice is that Select and Combobox disagree about what a
|
|
63
|
+
// disabled control submits. Exported, it would be a `<HiddenInput>` a consumer
|
|
64
|
+
// could reach for in a component that had not thought about any of the above.
|
|
65
|
+
|
|
66
|
+
"use client";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The control a form actually reads.
|
|
70
|
+
*
|
|
71
|
+
* `value` is the widget's value, not its label. `null` renders an empty string
|
|
72
|
+
* rather than omitting the control, so a form that submits a Select the reader
|
|
73
|
+
* left alone still carries the field — a key missing from the payload and a key
|
|
74
|
+
* present and empty are different questions to a server, and "the reader saw
|
|
75
|
+
* this field and chose nothing" is the second one.
|
|
76
|
+
*
|
|
77
|
+
* `disabled` is passed through rather than interpreted: a disabled control is
|
|
78
|
+
* omitted from the submission by the browser, which is the behaviour a native
|
|
79
|
+
* `<select disabled>` has and the one a caller who disabled the widget expects.
|
|
80
|
+
*/
|
|
81
|
+
export component FormValue(name: string, value: string | null, disabled?: boolean = false) {
|
|
82
|
+
return <input disabled={disabled} name={name} type="hidden" value={value ?? ""} />;
|
|
83
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// What WCAG requires of anything that appears because a pointer or focus
|
|
4
|
+
// arrived, which is a tooltip and a hover card and nothing else in this
|
|
5
|
+
// package.
|
|
6
|
+
//
|
|
7
|
+
// SC 1.4.13, *Content on Hover or Focus*, is three clauses, and a hand-written
|
|
8
|
+
// tooltip fails all three. They are not a matter of taste and they are not
|
|
9
|
+
// separable, so they live in one module:
|
|
10
|
+
//
|
|
11
|
+
// * **Dismissible** — `Escape` removes it without moving the pointer. The
|
|
12
|
+
// content never holds focus, so the key never reaches it: the listener has
|
|
13
|
+
// to be on the document, which is `useDismissOnEscape` below.
|
|
14
|
+
// * **Hoverable** — the pointer can travel from the trigger onto the content
|
|
15
|
+
// without it vanishing on the way. That is why leaving schedules a close
|
|
16
|
+
// rather than performing one, and why arriving anywhere cancels it. A
|
|
17
|
+
// component that closes on `pointerleave` snatches the content away from a
|
|
18
|
+
// reader who was moving towards it — including every reader who magnifies
|
|
19
|
+
// the screen, for whom the trip is long.
|
|
20
|
+
// * **Persistent** — it stays until it is dismissed or the pointer and focus
|
|
21
|
+
// have both left. A timer that closes it on its own is out.
|
|
22
|
+
//
|
|
23
|
+
// The opening delay is not one of the three clauses; it is what makes the
|
|
24
|
+
// component bearable. A pointer crossing a toolbar enters six triggers on its
|
|
25
|
+
// way somewhere else, and a tooltip that opened on each would be six
|
|
26
|
+
// interruptions. **A delay belongs to the pointer and not to focus**: a reader
|
|
27
|
+
// who tabbed to a control has already said what they want, and making them wait
|
|
28
|
+
// for it is a delay with nothing to prevent.
|
|
29
|
+
//
|
|
30
|
+
// # The clock is a ref, and `useTimeout` is deliberately not used
|
|
31
|
+
//
|
|
32
|
+
// `@uniflowed/hooks/timing` has the hook this looks like it wants, and its
|
|
33
|
+
// contract is not this one: `useTimeout(body, millis)` sets its timer in an
|
|
34
|
+
// effect keyed on `millis`, so asking again for the *same* delay does not
|
|
35
|
+
// restart it. Every interesting sequence here asks twice — enter, leave,
|
|
36
|
+
// enter — and with an open delay equal to the close delay the second request
|
|
37
|
+
// would inherit the first request's deadline and fire early. What is wanted is
|
|
38
|
+
// "restart the clock", which is a command rather than a state, so it is written
|
|
39
|
+
// as one.
|
|
40
|
+
//
|
|
41
|
+
// # Why this is `internal/` and not a subpath
|
|
42
|
+
//
|
|
43
|
+
// It is not a `useHoverIntent` for anybody to build a tooltip with; it is the
|
|
44
|
+
// half of `tooltip.js` and `hover-card.js` that has to be the same in both. A
|
|
45
|
+
// consumer given a copy could build the component that closes on
|
|
46
|
+
// `pointerleave`, which is the failure this exists to prevent.
|
|
47
|
+
|
|
48
|
+
import { useEffect, useMemo, useRef } from "@uniflowed/react";
|
|
49
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
50
|
+
|
|
51
|
+
import { FOCUS_STOPS } from "./focus.js";
|
|
52
|
+
|
|
53
|
+
/** How long a pointer must rest on a trigger before its tooltip opens. */
|
|
54
|
+
export const DEFAULT_OPEN_DELAY: number = 700;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How long the content stays after the pointer leaves.
|
|
58
|
+
*
|
|
59
|
+
* This is the hoverable clause's whole implementation: the gap between a
|
|
60
|
+
* trigger and its overlay takes a moment to cross, and a reader who is crossing
|
|
61
|
+
* it has not left.
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_CLOSE_DELAY: number = 300;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How long after one tooltip closes the next one opens with no delay.
|
|
67
|
+
*
|
|
68
|
+
* A reader who has waited out the delay once has established that they are
|
|
69
|
+
* reading tooltips; the second icon in a toolbar should answer immediately.
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_SKIP_DELAY: number = 300;
|
|
72
|
+
|
|
73
|
+
/** Opening and closing, on a clock that can be restarted or called off. */
|
|
74
|
+
export type HoverIntent = {|
|
|
75
|
+
/** Open after `millis`, or in this tick when that is nought. */
|
|
76
|
+
readonly openAfter: (millis: number) => void,
|
|
77
|
+
/** Close after `millis`, or in this tick when that is nought. */
|
|
78
|
+
readonly closeAfter: (millis: number) => void,
|
|
79
|
+
/** Forget whatever was scheduled. Arriving anywhere calls this first. */
|
|
80
|
+
readonly cancel: () => void,
|
|
81
|
+
|};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A group of tooltips that share one clock.
|
|
85
|
+
*
|
|
86
|
+
* `Tooltip.Provider` holds it; `Tooltip.Root` asks it what delay to use. A
|
|
87
|
+
* tooltip outside a provider never sees one and uses its own delay, which is
|
|
88
|
+
* the behaviour a tooltip on its own has always had.
|
|
89
|
+
*/
|
|
90
|
+
export type DelayGroup = {|
|
|
91
|
+
/** `own`, or nought while the group is inside its skip window. */
|
|
92
|
+
readonly delayFor: (own: number) => number,
|
|
93
|
+
/** Told that a tooltip in the group has opened. */
|
|
94
|
+
readonly opened: () => void,
|
|
95
|
+
/** Told that one has closed, which is what starts the window. */
|
|
96
|
+
readonly closed: () => void,
|
|
97
|
+
|};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A pending open or close, restartable, and cancelled when the component goes.
|
|
101
|
+
*
|
|
102
|
+
* `setOpen` is called with the answer rather than with a toggle, so a schedule
|
|
103
|
+
* that is overtaken by a second one does not leave the component holding the
|
|
104
|
+
* first one's opinion.
|
|
105
|
+
*/
|
|
106
|
+
export hook useHoverIntent(setOpen: (open: boolean) => void): HoverIntent {
|
|
107
|
+
const timer = useRef<TimeoutID | null>(null);
|
|
108
|
+
const change = useStableCallback(setOpen);
|
|
109
|
+
|
|
110
|
+
const cancel = useStableCallback(() => {
|
|
111
|
+
if (timer.current != null) {
|
|
112
|
+
clearTimeout(timer.current);
|
|
113
|
+
timer.current = null;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const schedule = useStableCallback((open: boolean, millis: number) => {
|
|
118
|
+
cancel();
|
|
119
|
+
if (millis <= 0) {
|
|
120
|
+
// In this tick, not in a zero-millisecond timeout. A tooltip that opens
|
|
121
|
+
// on focus, and the second tooltip in a toolbar, both have to be open by
|
|
122
|
+
// the time the event handler returns — a test that has to advance a clock
|
|
123
|
+
// to see them is describing a wait the reader would also have had.
|
|
124
|
+
change(open);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
timer.current = setTimeout(() => {
|
|
128
|
+
timer.current = null;
|
|
129
|
+
change(open);
|
|
130
|
+
}, millis);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// The component can be taken away while a tooltip is waiting to open, and a
|
|
134
|
+
// timer that outlives it sets state on something that is gone.
|
|
135
|
+
useEffect(() => cancel, [cancel]);
|
|
136
|
+
|
|
137
|
+
return useMemo(
|
|
138
|
+
() => ({
|
|
139
|
+
cancel,
|
|
140
|
+
closeAfter: (millis: number) => schedule(false, millis),
|
|
141
|
+
openAfter: (millis: number) => schedule(true, millis),
|
|
142
|
+
}),
|
|
143
|
+
[cancel, schedule],
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The shared clock behind `Tooltip.Provider`.
|
|
149
|
+
*
|
|
150
|
+
* Refs rather than state, and that is the whole design: nothing here is
|
|
151
|
+
* rendered. A group that held "are we skipping" in state would re-render every
|
|
152
|
+
* tooltip in a toolbar twice for each one the pointer passed over, to change
|
|
153
|
+
* nothing anybody can see.
|
|
154
|
+
*
|
|
155
|
+
* The window is open while a tooltip in the group is showing — moving along a
|
|
156
|
+
* toolbar with one already open is the case that must not stutter — and for
|
|
157
|
+
* `skipDelay` after the last one closes.
|
|
158
|
+
*/
|
|
159
|
+
export hook useDelayGroup(skipDelay: number): DelayGroup {
|
|
160
|
+
const skipping = useRef(false);
|
|
161
|
+
const timer = useRef<TimeoutID | null>(null);
|
|
162
|
+
|
|
163
|
+
const stop = useStableCallback(() => {
|
|
164
|
+
if (timer.current != null) {
|
|
165
|
+
clearTimeout(timer.current);
|
|
166
|
+
timer.current = null;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
useEffect(() => stop, [stop]);
|
|
171
|
+
|
|
172
|
+
return useMemo(
|
|
173
|
+
() => ({
|
|
174
|
+
closed: () => {
|
|
175
|
+
stop();
|
|
176
|
+
if (skipDelay <= 0) {
|
|
177
|
+
skipping.current = false;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
skipping.current = true;
|
|
181
|
+
timer.current = setTimeout(() => {
|
|
182
|
+
timer.current = null;
|
|
183
|
+
skipping.current = false;
|
|
184
|
+
}, skipDelay);
|
|
185
|
+
},
|
|
186
|
+
delayFor: (own: number) => (skipping.current ? 0 : own),
|
|
187
|
+
opened: () => {
|
|
188
|
+
// While one is open the group is answering instantly, and the window
|
|
189
|
+
// does not start counting down until it closes.
|
|
190
|
+
stop();
|
|
191
|
+
skipping.current = true;
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
[skipDelay, stop],
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Close on `Escape`, from wherever focus happens to be.
|
|
200
|
+
*
|
|
201
|
+
* On the document, because the content shown on hover holds no focus and a
|
|
202
|
+
* handler on it would never be reached — which is exactly why the hand-written
|
|
203
|
+
* version fails the dismissible clause rather than implementing it wrongly.
|
|
204
|
+
*
|
|
205
|
+
* Capture, and `stopPropagation`, so one `Escape` is one dismissal: a tooltip
|
|
206
|
+
* inside a dialog answers the key itself rather than leaving the reader with a
|
|
207
|
+
* dialog that closed because a tooltip was showing.
|
|
208
|
+
*/
|
|
209
|
+
export hook useDismissOnEscape(
|
|
210
|
+
open: boolean,
|
|
211
|
+
ref: { current: HTMLElement | null },
|
|
212
|
+
onDismiss: () => void,
|
|
213
|
+
): void {
|
|
214
|
+
const dismiss = useStableCallback(onDismiss);
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const document = ref.current?.ownerDocument;
|
|
218
|
+
if (!open || document == null) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const onKeyDown = (event: $FlowFixMe) => {
|
|
222
|
+
if (event.key !== "Escape") {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
event.stopPropagation();
|
|
226
|
+
dismiss();
|
|
227
|
+
};
|
|
228
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
229
|
+
return () => document.removeEventListener("keydown", onKeyDown, true);
|
|
230
|
+
}, [open, ref, dismiss]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Refuse a trigger the keyboard cannot reach.
|
|
235
|
+
*
|
|
236
|
+
* A tooltip on a `<span>` is a tooltip only a mouse can find, and it looks
|
|
237
|
+
* perfect: the markup is right, the styles are right, and a reader who never
|
|
238
|
+
* touches a mouse is told nothing at all. It is the failure this package exists
|
|
239
|
+
* to make loud, so it is an error rather than a warning — the same answer
|
|
240
|
+
* `useDialog` gives to a part outside its root.
|
|
241
|
+
*
|
|
242
|
+
* Checked in an effect because it is a question about an element, and the
|
|
243
|
+
* element does not exist until one has been committed.
|
|
244
|
+
*/
|
|
245
|
+
export hook useFocusableTrigger(ref: { current: HTMLElement | null }, part: string): void {
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const element = ref.current;
|
|
248
|
+
if (element == null || element.matches(FOCUS_STOPS)) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
throw new Error(
|
|
252
|
+
`${part} must be something the keyboard can reach: it was rendered onto ` +
|
|
253
|
+
`<${element.tagName.toLowerCase()}>, which is not focusable, so the ` +
|
|
254
|
+
`content would only ever appear for a pointer. Render a button or a ` +
|
|
255
|
+
`link, or give the element a tabindex of 0. A disabled control is not ` +
|
|
256
|
+
`focusable either: use aria-disabled and keep it in the tab order.`,
|
|
257
|
+
);
|
|
258
|
+
}, [ref, part]);
|
|
259
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// One rule about prop order, stated once.
|
|
4
|
+
//
|
|
5
|
+
// `<div {...rest} role="dialog">` and `<div role="dialog" {...rest}>` are
|
|
6
|
+
// different components. The second lets a caller pass `role="button"` and get
|
|
7
|
+
// it; the first does not. That sounds like a matter of taste until you notice
|
|
8
|
+
// what else arrives in `rest`:
|
|
9
|
+
//
|
|
10
|
+
// * A caller `ref` replaced the ref the dialog uses to find its focus stops,
|
|
11
|
+
// so `bodyRef.current` stayed null, the Tab handler returned early, and the
|
|
12
|
+
// focus trap was *silently off* while the dialog still announced
|
|
13
|
+
// `aria-modal="true"`.
|
|
14
|
+
// * A caller `onClick` replaced a tab's selection handler, so clicking a tab
|
|
15
|
+
// did nothing.
|
|
16
|
+
// * A caller `onKeyDown` replaced the dialog's, so Escape stopped closing it.
|
|
17
|
+
//
|
|
18
|
+
// None of those fail loudly. So the rule is: the caller's props go on first and
|
|
19
|
+
// the component's own semantics go on last, and for the two kinds of prop where
|
|
20
|
+
// a caller legitimately wants *both* — event handlers and refs — they are
|
|
21
|
+
// composed rather than one replacing the other.
|
|
22
|
+
//
|
|
23
|
+
// # Why this is `internal/` and not a subpath
|
|
24
|
+
//
|
|
25
|
+
// It is not a "props utils" module and there is nothing else in it. It is the
|
|
26
|
+
// one policy every part of this package applies, extracted so that a new
|
|
27
|
+
// primitive cannot quietly apply a different one. Exporting it would invite a
|
|
28
|
+
// consumer to build a part that spreads `rest` last, which is the failure this
|
|
29
|
+
// exists to prevent — so it stays unreachable from outside the package.
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Props on their way onto an element: what a caller hands a part, and what
|
|
33
|
+
* `Field.Control` hands back for a caller to spread.
|
|
34
|
+
*
|
|
35
|
+
* `key` is named out of the indexer rather than left to it, and that one
|
|
36
|
+
* property is the whole subtlety of this type. React takes `key` off the
|
|
37
|
+
* attributes before a component is called, so a part's props never contain
|
|
38
|
+
* one — but an indexer does not know that, and `{ readonly [string]: mixed }`
|
|
39
|
+
* answers `mixed` for every name, `key` included. React's `key` is
|
|
40
|
+
* `string | number`, so every intrinsic this package rendered was rejected for
|
|
41
|
+
* a property that cannot be there:
|
|
42
|
+
*
|
|
43
|
+
* error[incompatible-type]: Cannot create button element because in
|
|
44
|
+
* property key: Either unknown is incompatible with string. Or unknown is
|
|
45
|
+
* incompatible with number.
|
|
46
|
+
*
|
|
47
|
+
* thirty-two times, one per element, which was 32 of `@uniflowed/ui`'s 73 type
|
|
48
|
+
* errors. `key?: empty` states what React already guarantees, and the errors
|
|
49
|
+
* are the checker agreeing.
|
|
50
|
+
*
|
|
51
|
+
* # Two answers that look better than they are
|
|
52
|
+
*
|
|
53
|
+
* **`readonly key?: string | number`** — React's own type for the property —
|
|
54
|
+
* also silences the error, and is a lie in the shape of a fix. It says a
|
|
55
|
+
* caller may pass a `key` here; a part would then spread it onto its element,
|
|
56
|
+
* which is the "spreading a key into JSX" mistake React 19 added a warning
|
|
57
|
+
* for. `empty` is the same repair and a true sentence. It reads oddly for
|
|
58
|
+
* about a second and then reads as exactly what it is: there is no value you
|
|
59
|
+
* can pass under this name.
|
|
60
|
+
*
|
|
61
|
+
* **`React.PropsOf<"button">`** — the props of the element actually being
|
|
62
|
+
* rendered, which is what this type would like to say — cannot be written
|
|
63
|
+
* here. uf does not merge Flow's `jsx.js` environment, deliberately and for
|
|
64
|
+
* reasons `crates/uf_check/src/upstream/environments.rs` gives, so
|
|
65
|
+
* `$JSXIntrinsics` is the bare-bones table in `lib/react.js`, every
|
|
66
|
+
* intrinsic's `props` is `any`, and `React.PropsOf` itself reads as an
|
|
67
|
+
* any-typed value. Nothing about an element is checked here except its `key`:
|
|
68
|
+
* `<button className={5} nonsenseAttr={{}} />` is not an error today. A named
|
|
69
|
+
* type per element would therefore not be React's contract but a hand-written
|
|
70
|
+
* copy of `jsx.js` living in a UI package, drifting from the DOM on its own
|
|
71
|
+
* schedule — and it would still need an indexer for `data-*` and `aria-*`,
|
|
72
|
+
* which is where this started. So it stays one `Rest`, and the day
|
|
73
|
+
* `$JSXIntrinsics` is real is the day this becomes `React.PropsOf` and the
|
|
74
|
+
* parts say which element they render.
|
|
75
|
+
*/
|
|
76
|
+
export type Rest = { readonly key?: empty, readonly [string]: mixed };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A caller's props on their way to another *part of this package*, rather than
|
|
80
|
+
* onto an intrinsic element.
|
|
81
|
+
*
|
|
82
|
+
* `Rest` names `key` out of its indexer and types it `empty`, which is a true
|
|
83
|
+
* sentence and is what stopped thirty-two intrinsics being rejected for a
|
|
84
|
+
* property that cannot be there. It has a second consequence, and it only shows
|
|
85
|
+
* up the first time one part of this package renders another —
|
|
86
|
+
* `ToggleGroup.Root` rendering a `RadioGroup.Root`, which is how `single` mode
|
|
87
|
+
* avoids being a second copy of the radio group. Creating
|
|
88
|
+
* `<RadioGroup.Root {...rest} />` has Flow check the props object against that
|
|
89
|
+
* component's own `...rest: Rest`, `key` included, and the indexer answers
|
|
90
|
+
* `mixed` for it rather than the named `empty`:
|
|
91
|
+
*
|
|
92
|
+
* error[incompatible-type]: Cannot create RadioGroupRoot element because in
|
|
93
|
+
* property key: unknown is incompatible with empty.
|
|
94
|
+
*
|
|
95
|
+
* So a part is spreadable onto a `<div>` and not onto a sibling part. That is a
|
|
96
|
+
* hole in the type rather than a fact about the props, and this is the one
|
|
97
|
+
* place it is papered over — a named function rather than an `as $FlowFixMe` at
|
|
98
|
+
* the call site, so there is somewhere to say what is and is not lost.
|
|
99
|
+
*
|
|
100
|
+
* What is lost is nothing that was ever checked. Every element this package
|
|
101
|
+
* renders has `any`-typed props today, for the reason `Rest` gives above: uf
|
|
102
|
+
* does not merge Flow's `jsx.js` environment, so `$JSXIntrinsics` is the
|
|
103
|
+
* bare-bones table in `lib/react.js` and `key` is the only property of an
|
|
104
|
+
* element anything verifies. On the day that changes and `Rest` becomes
|
|
105
|
+
* `React.PropsOf`, this function is what gets deleted.
|
|
106
|
+
*/
|
|
107
|
+
export function forwarded(rest: Rest): $FlowFixMe {
|
|
108
|
+
return rest;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Call the caller's handler and then the component's.
|
|
113
|
+
*
|
|
114
|
+
* The caller's runs first so it can inspect the event before the component acts
|
|
115
|
+
* on it, and the component's runs unless the caller stopped the event —
|
|
116
|
+
* `defaultPrevented` is the caller's way of saying "I handled this", which is
|
|
117
|
+
* the same contract the DOM uses.
|
|
118
|
+
*/
|
|
119
|
+
export function composeHandlers<TEvent extends { readonly defaultPrevented?: boolean }>(
|
|
120
|
+
theirs: mixed,
|
|
121
|
+
ours: (event: TEvent) => mixed,
|
|
122
|
+
): (event: TEvent) => mixed {
|
|
123
|
+
if (typeof theirs !== "function") {
|
|
124
|
+
return ours;
|
|
125
|
+
}
|
|
126
|
+
return (event: TEvent) => {
|
|
127
|
+
(theirs as $FlowFixMe)(event);
|
|
128
|
+
if (event.defaultPrevented !== true) {
|
|
129
|
+
ours(event);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Set both refs, whichever kinds they are. */
|
|
135
|
+
export function composeRefs<T>(
|
|
136
|
+
theirs: mixed,
|
|
137
|
+
ours: (value: T | null) => mixed,
|
|
138
|
+
): (value: T | null) => void {
|
|
139
|
+
return (value: T | null) => {
|
|
140
|
+
ours(value);
|
|
141
|
+
if (typeof theirs === "function") {
|
|
142
|
+
(theirs as $FlowFixMe)(value);
|
|
143
|
+
} else if (theirs != null && typeof theirs === "object") {
|
|
144
|
+
(theirs as $FlowFixMe).current = value;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Two sets of props, the component's on top.
|
|
151
|
+
*
|
|
152
|
+
* The same rule as everywhere else in this package, applied where the element
|
|
153
|
+
* is the *caller's* rather than the component's: `Tooltip.Trigger` and
|
|
154
|
+
* `HoverCard.Trigger` hand their attributes to a render function so a caller
|
|
155
|
+
* can put them on a link or a menu item of their own, and the attributes that
|
|
156
|
+
* make the trigger work — the `aria-describedby` naming the content, the ref
|
|
157
|
+
* the content is measured against — have to survive whatever the caller passed
|
|
158
|
+
* alongside them.
|
|
159
|
+
*
|
|
160
|
+
* A spread would say this in one line and cannot be written: Flow declines to
|
|
161
|
+
* compute a type for `{ ...base, name: value }` when `base` has an indexer,
|
|
162
|
+
* because the indexer may overwrite the named key in a way it cannot track.
|
|
163
|
+
* The loop is that spread, with `key` dropped for the reason `withoutComposed`
|
|
164
|
+
* gives.
|
|
165
|
+
*/
|
|
166
|
+
export function withProps(base: Rest, ours: Rest): Rest {
|
|
167
|
+
const merged: { key?: empty, [string]: mixed } = {};
|
|
168
|
+
for (const name of Object.keys(base)) {
|
|
169
|
+
if (name !== "key") {
|
|
170
|
+
merged[name] = base[name];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const name of Object.keys(ours)) {
|
|
174
|
+
if (name !== "key") {
|
|
175
|
+
merged[name] = ours[name];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return merged;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A caller's props with the handlers and ref removed.
|
|
183
|
+
*
|
|
184
|
+
* They are pulled out because they have to be composed rather than spread, and
|
|
185
|
+
* leaving them in would put the caller's copy back on top of the composed one.
|
|
186
|
+
*/
|
|
187
|
+
export function withoutComposed(rest: Rest, names: $ReadOnlyArray<string>): Rest {
|
|
188
|
+
const kept: { key?: empty, [string]: mixed } = {};
|
|
189
|
+
for (const name of Object.keys(rest)) {
|
|
190
|
+
// `key` is dropped whatever the caller asked to compose, because it is the
|
|
191
|
+
// one name the indexer does not speak for: writing `rest[name]` under it
|
|
192
|
+
// would put a `mixed` back where `Rest` promises nothing can be, and Flow
|
|
193
|
+
// says so. Nothing is lost — React removed the `key` long before this ran,
|
|
194
|
+
// so this is the type-level statement made at runtime rather than a filter
|
|
195
|
+
// that ever has work to do.
|
|
196
|
+
if (name !== "key" && !names.includes(name)) {
|
|
197
|
+
kept[name] = rest[name];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return kept;
|
|
201
|
+
}
|