@uniflowed/ui 0.0.0-alpha.8 → 0.1.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 +560 -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 +235 -198
- 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 +334 -0
- package/i18n-provider.js +89 -0
- package/index.js +1254 -32
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +565 -0
- package/internal/collection.js +395 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/focus.js +64 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +117 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +316 -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 -25
- package/pagination.js +34 -22
- package/popover.js +367 -0
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +78 -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 +112 -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 +404 -0
- package/tree.js +8 -0
package/scroll-area.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A scroll area: a scrollbar you drew yourself, and the keyboard you took away
|
|
4
|
+
// when you did.
|
|
5
|
+
//
|
|
6
|
+
// # What it gives that `overflow: auto` does not
|
|
7
|
+
//
|
|
8
|
+
// A `<div style="overflow: auto">` scrolls with the wheel, with a trackpad, and
|
|
9
|
+
// with a finger. What it does not reliably do is scroll from the keyboard,
|
|
10
|
+
// because it may not be focusable: Firefox makes a scrollable region focusable,
|
|
11
|
+
// Chromium historically does not, and Safari's answer depends on the setting
|
|
12
|
+
// that controls whether `Tab` reaches anything but form controls. So a region
|
|
13
|
+
// that must be scrolled to be read is, in most browsers, a region a keyboard
|
|
14
|
+
// reader can see the top of and nothing else. That is WCAG 2.1.1, and it is the
|
|
15
|
+
// entire reason to have this component rather than the `div`:
|
|
16
|
+
//
|
|
17
|
+
// * **`tabindex="0"`, `role="region"` and a name.** The tab stop is what
|
|
18
|
+
// makes the arrow keys and `PageDown` work; the role and the name are what
|
|
19
|
+
// keep a tab stop from being a mystery — a focusable `div` with no name is
|
|
20
|
+
// announced as nothing at all, which is a worse place to land than the
|
|
21
|
+
// `div` was.
|
|
22
|
+
// * **Nothing is intercepted.** No `onKeyDown`, no `onWheel`, no
|
|
23
|
+
// `scroll-behavior` written from JavaScript. Every key that scrolls a
|
|
24
|
+
// native overflow container scrolls this one, because this one *is* a
|
|
25
|
+
// native overflow container and the component's whole contribution is not
|
|
26
|
+
// getting in its way. A scroll area that reimplemented `PageDown` would
|
|
27
|
+
// have to reimplement `Home`, `End`, the space bar, caret browsing and
|
|
28
|
+
// whatever the reader's own software sends, and would get one of them
|
|
29
|
+
// wrong.
|
|
30
|
+
// * **`scrollIntoView({ block: "nearest" })` still works.** `combobox.js` and
|
|
31
|
+
// `select.js` both call it to keep the active option visible, so a
|
|
32
|
+
// `Combobox.List` inside a `ScrollArea` is a case that has to work. It does
|
|
33
|
+
// because the viewport is a plain scroll container and nothing here
|
|
34
|
+
// overrides `scrollTop`; the one time this module writes it is described
|
|
35
|
+
// below, and it is exactly the case where the browser has already thrown
|
|
36
|
+
// the position away.
|
|
37
|
+
// * **The position survives a re-render.** Replacing the content of a scroll
|
|
38
|
+
// container — a filtered list, a new page of results — makes the browser
|
|
39
|
+
// clamp `scrollTop` to a shorter document and it does not put it back. The
|
|
40
|
+
// viewport remembers where the reader actually scrolled to, from the
|
|
41
|
+
// `scroll` event, and restores it after a commit that lost it. A reader who
|
|
42
|
+
// scrolls to the top themselves fires a `scroll` event, so the remembered
|
|
43
|
+
// position is theirs and this never fights them.
|
|
44
|
+
//
|
|
45
|
+
// # The scrollbar is a picture
|
|
46
|
+
//
|
|
47
|
+
// `ScrollArea.Scrollbar` is `aria-hidden` and holds no controls. That is the
|
|
48
|
+
// decision the component is made of: the *region* is the thing that scrolls and
|
|
49
|
+
// the keyboard is how it is operated, so a drawn scrollbar has nothing it must
|
|
50
|
+
// be able to do — which means it never has to answer WCAG 2.5.7's question
|
|
51
|
+
// about dragging, because nothing here is achievable only by dragging. It
|
|
52
|
+
// reports where the content is as two custom properties and stays out of the
|
|
53
|
+
// accessibility tree, where a second, mouse-only copy of the scroll position
|
|
54
|
+
// would be noise.
|
|
55
|
+
|
|
56
|
+
"use client";
|
|
57
|
+
|
|
58
|
+
import * as React from "@uniflowed/react";
|
|
59
|
+
import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
|
|
60
|
+
import { useEventListener } from "@uniflowed/hooks/dom";
|
|
61
|
+
|
|
62
|
+
import type { Orientation } from "./internal/roving-focus.js";
|
|
63
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
64
|
+
import { composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
65
|
+
|
|
66
|
+
export type { Orientation } from "./internal/roving-focus.js";
|
|
67
|
+
|
|
68
|
+
/** Where the reader last actually was, per axis. */
|
|
69
|
+
type Offset = {| x: number, y: number |};
|
|
70
|
+
|
|
71
|
+
type ScrollAreaState = {|
|
|
72
|
+
readonly base: string,
|
|
73
|
+
readonly label: string,
|
|
74
|
+
readonly viewportRef: { current: HTMLElement | null },
|
|
75
|
+
readonly rememberedRef: { current: Offset },
|
|
76
|
+
/** Written by the viewport, read by every scrollbar. */
|
|
77
|
+
readonly report: () => void,
|
|
78
|
+
readonly scrollbarsRef: { current: Array<HTMLElement> },
|
|
79
|
+
|};
|
|
80
|
+
|
|
81
|
+
const ScrollAreaContext: React.Context<ScrollAreaState | null> = createContext(null);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The scroll area a part belongs to.
|
|
85
|
+
*
|
|
86
|
+
* Raising rather than returning null, for the reason `useDialog` gives: a
|
|
87
|
+
* `ScrollArea.Scrollbar` outside a root would draw a thumb for a viewport it
|
|
88
|
+
* has never measured, and it would look correct until the content moved.
|
|
89
|
+
*/
|
|
90
|
+
hook useScrollArea(part: string): ScrollAreaState {
|
|
91
|
+
const state = useContext(ScrollAreaContext);
|
|
92
|
+
if (state == null) {
|
|
93
|
+
throw new Error(`${part} must be rendered inside a ScrollArea.Root`);
|
|
94
|
+
}
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The box the viewport and the scrollbars sit in.
|
|
100
|
+
*
|
|
101
|
+
* `label` is required and lives here rather than on the viewport, because the
|
|
102
|
+
* name belongs to the whole component: it is what a reader hears when `Tab`
|
|
103
|
+
* lands them in it, and a scroll area that has to be scrolled to be read and is
|
|
104
|
+
* announced as "region" has told them nothing.
|
|
105
|
+
*/
|
|
106
|
+
export component ScrollAreaRoot(children: React.Node, label: string, ...rest: Rest) {
|
|
107
|
+
const base = useId();
|
|
108
|
+
const viewportRef = useRef<HTMLElement | null>(null);
|
|
109
|
+
const rememberedRef = useRef<Offset>({ x: 0, y: 0 });
|
|
110
|
+
const scrollbarsRef = useRef<Array<HTMLElement>>([]);
|
|
111
|
+
|
|
112
|
+
const state = useMemo(
|
|
113
|
+
() => ({
|
|
114
|
+
base,
|
|
115
|
+
label,
|
|
116
|
+
rememberedRef,
|
|
117
|
+
report: () => {
|
|
118
|
+
const viewport = viewportRef.current;
|
|
119
|
+
if (viewport == null) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const scrollbar of scrollbarsRef.current) {
|
|
123
|
+
write(scrollbar, viewport);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
scrollbarsRef,
|
|
127
|
+
viewportRef,
|
|
128
|
+
}),
|
|
129
|
+
[base, label],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<ScrollAreaContext.Provider value={state}>
|
|
134
|
+
<div {...rest}>{children}</div>
|
|
135
|
+
</ScrollAreaContext.Provider>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The element that actually scrolls: a named region, in the tab sequence.
|
|
141
|
+
*
|
|
142
|
+
* It carries no key handling at all. See the module header — every key that
|
|
143
|
+
* scrolls a native overflow container scrolls this one because it is one, and
|
|
144
|
+
* the component's contribution is the tab stop that lets those keys arrive.
|
|
145
|
+
*/
|
|
146
|
+
export component ScrollAreaViewport(children: React.Node, ...rest: Rest) {
|
|
147
|
+
const area = useScrollArea("ScrollArea.Viewport");
|
|
148
|
+
const { rememberedRef, report, viewportRef } = area;
|
|
149
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
150
|
+
|
|
151
|
+
useEventListener(viewportRef, "scroll", () => {
|
|
152
|
+
const viewport = viewportRef.current;
|
|
153
|
+
if (viewport == null) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// The reader's own position, including a deliberate scroll back to the
|
|
157
|
+
// top — which is why the restore below never fights them.
|
|
158
|
+
rememberedRef.current = { x: viewport.scrollLeft, y: viewport.scrollTop };
|
|
159
|
+
report();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// After every commit, because a commit is what replaces the content: a
|
|
163
|
+
// shorter document makes the browser clamp the offset to fit and it does not
|
|
164
|
+
// put it back when the content grows again.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const viewport = viewportRef.current;
|
|
167
|
+
if (viewport == null) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const { x, y } = rememberedRef.current;
|
|
171
|
+
if (y !== 0 && viewport.scrollTop === 0) {
|
|
172
|
+
viewport.scrollTop = y;
|
|
173
|
+
}
|
|
174
|
+
if (x !== 0 && viewport.scrollLeft === 0) {
|
|
175
|
+
viewport.scrollLeft = x;
|
|
176
|
+
}
|
|
177
|
+
report();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
{...passed}
|
|
183
|
+
aria-label={area.label}
|
|
184
|
+
id={`${area.base}-viewport`}
|
|
185
|
+
ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
186
|
+
viewportRef.current = element;
|
|
187
|
+
})}
|
|
188
|
+
// A named region, which is what makes the tab stop below explicable
|
|
189
|
+
// rather than a place a reader lands and cannot account for.
|
|
190
|
+
role="region"
|
|
191
|
+
// The whole component. Without it the arrow keys and `PageDown` never
|
|
192
|
+
// arrive, and the bottom of this box is unreachable from a keyboard in
|
|
193
|
+
// every browser that does not make scroll containers focusable.
|
|
194
|
+
tabIndex={0}
|
|
195
|
+
>
|
|
196
|
+
{children}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The drawn scrollbar: two numbers and no semantics.
|
|
203
|
+
*
|
|
204
|
+
* `--uf-scroll-thumb-size` is the thumb's length as a fraction of the track and
|
|
205
|
+
* `--uf-scroll-thumb-offset` is where along it the thumb sits, both between 0
|
|
206
|
+
* and 1, so a stylesheet can draw one with a `scale` and a `translate` and
|
|
207
|
+
* measure nothing. `aria-hidden`, because the region it belongs to is already
|
|
208
|
+
* the thing a reader operates.
|
|
209
|
+
*/
|
|
210
|
+
export component ScrollAreaScrollbar(
|
|
211
|
+
children?: React.Node,
|
|
212
|
+
orientation?: Orientation = "vertical",
|
|
213
|
+
...rest: Rest
|
|
214
|
+
) {
|
|
215
|
+
const area = useScrollArea("ScrollArea.Scrollbar");
|
|
216
|
+
const { report, scrollbarsRef } = area;
|
|
217
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div
|
|
221
|
+
{...passed}
|
|
222
|
+
// A picture of the scroll position is not something a screen reader has
|
|
223
|
+
// any use for: it cannot be operated, and the region it describes
|
|
224
|
+
// announces itself.
|
|
225
|
+
aria-hidden="true"
|
|
226
|
+
data-orientation={orientation}
|
|
227
|
+
ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
228
|
+
const kept = scrollbarsRef.current.filter((each) => each !== element);
|
|
229
|
+
scrollbarsRef.current = element == null ? kept : [...kept, element];
|
|
230
|
+
report();
|
|
231
|
+
})}
|
|
232
|
+
>
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Write where the content is onto a scrollbar.
|
|
240
|
+
*
|
|
241
|
+
* Imperatively, and only these two properties, for the reason
|
|
242
|
+
* `internal/anchor.js` gives about a placement: they change on every scroll
|
|
243
|
+
* frame, and re-rendering the scroll area and everything in it sixty times a
|
|
244
|
+
* second to move a thumb is the cost this package does not pay. React sets
|
|
245
|
+
* neither property, so a caller's `style` keeps everything in it.
|
|
246
|
+
*
|
|
247
|
+
* Both axes are written on every scrollbar rather than the one its
|
|
248
|
+
* `data-orientation` names, because a stylesheet reads the pair it wants and a
|
|
249
|
+
* branch here would be a second place the orientation is decided.
|
|
250
|
+
*/
|
|
251
|
+
function write(scrollbar: HTMLElement, viewport: HTMLElement): void {
|
|
252
|
+
const style = scrollbar.style;
|
|
253
|
+
style.setProperty(
|
|
254
|
+
"--uf-scroll-thumb-size",
|
|
255
|
+
String(fraction(viewport.clientHeight, viewport.scrollHeight)),
|
|
256
|
+
);
|
|
257
|
+
style.setProperty(
|
|
258
|
+
"--uf-scroll-thumb-offset",
|
|
259
|
+
String(fraction(viewport.scrollTop, viewport.scrollHeight - viewport.clientHeight)),
|
|
260
|
+
);
|
|
261
|
+
style.setProperty(
|
|
262
|
+
"--uf-scroll-thumb-size-x",
|
|
263
|
+
String(fraction(viewport.clientWidth, viewport.scrollWidth)),
|
|
264
|
+
);
|
|
265
|
+
style.setProperty(
|
|
266
|
+
"--uf-scroll-thumb-offset-x",
|
|
267
|
+
String(fraction(viewport.scrollLeft, viewport.scrollWidth - viewport.clientWidth)),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `part / whole`, clamped, and 1 when there is no whole.
|
|
273
|
+
*
|
|
274
|
+
* A document that computes no layout reports every measurement as zero, and
|
|
275
|
+
* `0 / 0` is `NaN` — which a stylesheet reading the custom property renders as
|
|
276
|
+
* a thumb of no size at all rather than as a full-length one.
|
|
277
|
+
*/
|
|
278
|
+
function fraction(part: number, whole: number): number {
|
|
279
|
+
if (!(whole > 0)) {
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
return Math.min(1, Math.max(0, part / whole));
|
|
283
|
+
}
|
package/select.js
CHANGED
|
@@ -146,6 +146,10 @@ import {
|
|
|
146
146
|
} from "@uniflowed/react";
|
|
147
147
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
148
148
|
|
|
149
|
+
import { useInteractOutside } from "./interactions.js";
|
|
150
|
+
|
|
151
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
152
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
149
153
|
import type { Rest } from "./internal/merge-props.js";
|
|
150
154
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
151
155
|
import type { Movement } from "./internal/roving-focus.js";
|
|
@@ -153,6 +157,8 @@ import { isTypeaheadKey, itemsOf, moveTo, useTypeahead } from "./internal/roving
|
|
|
153
157
|
import { useControlled } from "./internal/controlled-state.js";
|
|
154
158
|
import { FormValue } from "./internal/form-value.js";
|
|
155
159
|
|
|
160
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
161
|
+
|
|
156
162
|
const OPTION_SELECTOR = '[role="option"]';
|
|
157
163
|
const LISTBOX_SELECTOR = '[role="listbox"]';
|
|
158
164
|
|
|
@@ -186,7 +192,7 @@ type SelectState = {|
|
|
|
186
192
|
/** The id of the option `aria-activedescendant` names, if any. */
|
|
187
193
|
readonly activeId: string | null,
|
|
188
194
|
readonly setActiveId: (id: string | null) => void,
|
|
189
|
-
readonly
|
|
195
|
+
readonly pendingLandingRef: { current: Landing | null },
|
|
190
196
|
readonly triggerRef: { current: HTMLElement | null },
|
|
191
197
|
readonly listRef: { current: HTMLElement | null },
|
|
192
198
|
/**
|
|
@@ -264,7 +270,7 @@ export component SelectRoot(
|
|
|
264
270
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
265
271
|
const [labels, setLabels] = useState<{ readonly [string]: string }>({});
|
|
266
272
|
const [labelled, setLabelled] = useState(false);
|
|
267
|
-
const
|
|
273
|
+
const pendingLandingRef = useRef<Landing | null>(null);
|
|
268
274
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
269
275
|
const listRef = useRef<HTMLElement | null>(null);
|
|
270
276
|
const typeahead = useTypeahead();
|
|
@@ -307,7 +313,7 @@ export component SelectRoot(
|
|
|
307
313
|
choose,
|
|
308
314
|
activeId,
|
|
309
315
|
setActiveId,
|
|
310
|
-
|
|
316
|
+
pendingLandingRef,
|
|
311
317
|
triggerRef,
|
|
312
318
|
listRef,
|
|
313
319
|
labels,
|
|
@@ -409,7 +415,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
409
415
|
/** Move the cursor within an open list, or open with an instruction. */
|
|
410
416
|
const move = (end: Movement, preferSelected: boolean) => {
|
|
411
417
|
if (!select.open) {
|
|
412
|
-
|
|
418
|
+
// This is an instruction for the list after the opening commit.
|
|
419
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
420
|
+
select.pendingLandingRef.current = { kind: "end", end, preferSelected };
|
|
413
421
|
select.setOpen(true);
|
|
414
422
|
return;
|
|
415
423
|
}
|
|
@@ -452,7 +460,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
452
460
|
select.setActiveId(null);
|
|
453
461
|
return;
|
|
454
462
|
}
|
|
455
|
-
|
|
463
|
+
// This is an instruction for the list after the opening commit.
|
|
464
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
465
|
+
select.pendingLandingRef.current = { kind: "end", end: "first", preferSelected: true };
|
|
456
466
|
select.setOpen(true);
|
|
457
467
|
})}
|
|
458
468
|
onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
|
|
@@ -494,7 +504,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
494
504
|
// again as a click.
|
|
495
505
|
event.preventDefault();
|
|
496
506
|
if (!select.open) {
|
|
497
|
-
|
|
507
|
+
// This is an instruction for the list after the opening commit.
|
|
508
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
509
|
+
select.pendingLandingRef.current = { kind: "end", end: "first", preferSelected: true };
|
|
498
510
|
select.setOpen(true);
|
|
499
511
|
return;
|
|
500
512
|
}
|
|
@@ -538,7 +550,8 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
538
550
|
event.preventDefault();
|
|
539
551
|
// The options are not in the document yet, so the keystroke travels
|
|
540
552
|
// to the commit that renders them.
|
|
541
|
-
|
|
553
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
554
|
+
select.pendingLandingRef.current = { kind: "typed", key: event.key };
|
|
542
555
|
select.setOpen(true);
|
|
543
556
|
return;
|
|
544
557
|
}
|
|
@@ -554,6 +567,8 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
554
567
|
}
|
|
555
568
|
})}
|
|
556
569
|
ref={composeRefs(rest.ref, (element) => {
|
|
570
|
+
// React calls callback refs during commit; the list reads the trigger later.
|
|
571
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
557
572
|
select.triggerRef.current = element;
|
|
558
573
|
})}
|
|
559
574
|
role="combobox"
|
|
@@ -613,20 +628,46 @@ export component SelectValue(children?: React.Node, placeholder?: React.Node, ..
|
|
|
613
628
|
*/
|
|
614
629
|
export component SelectList(
|
|
615
630
|
children: renders* (SelectOption | SelectGroup | SelectSeparator),
|
|
631
|
+
align?: Align = "start",
|
|
632
|
+
alignOffset?: number = 0,
|
|
633
|
+
avoidCollisions?: boolean = true,
|
|
634
|
+
collisionPadding?: number = 0,
|
|
635
|
+
side?: LogicalSide = "bottom",
|
|
636
|
+
sideOffset?: number = 0,
|
|
616
637
|
...rest: Rest
|
|
617
638
|
) {
|
|
618
639
|
const select = useSelect("Select.List");
|
|
619
|
-
const { activeId, listRef,
|
|
640
|
+
const { activeId, listRef, pendingLandingRef, setActiveId, triggerRef, typeahead, value } =
|
|
641
|
+
select;
|
|
620
642
|
const close = useStableCallback(() => {
|
|
621
643
|
select.setOpen(false);
|
|
622
644
|
select.setActiveId(null);
|
|
623
645
|
});
|
|
624
646
|
|
|
647
|
+
// The popup a select opens is the one case where the trigger's *width* is
|
|
648
|
+
// part of the design rather than a detail: a list narrower than the button it
|
|
649
|
+
// came out of reads as a different control. `--uf-anchor-trigger-width` is
|
|
650
|
+
// written on this element for a stylesheet to use, which is why the
|
|
651
|
+
// measurement is here and not in the caller.
|
|
652
|
+
const anchored = useAnchor({
|
|
653
|
+
align,
|
|
654
|
+
alignOffset,
|
|
655
|
+
anchorRef: triggerRef,
|
|
656
|
+
avoidCollisions,
|
|
657
|
+
collisionPadding,
|
|
658
|
+
open: select.open,
|
|
659
|
+
overlayRef: listRef,
|
|
660
|
+
side,
|
|
661
|
+
sideOffset,
|
|
662
|
+
});
|
|
663
|
+
|
|
625
664
|
// No dependency list, for the reason `combobox.js` gives: what this reads is
|
|
626
665
|
// the *rendered* options, and a caller may render different ones on any
|
|
627
666
|
// render — a change to `children` that no dependency list can describe. Every
|
|
628
667
|
// write is guarded by a comparison, so it settles after one extra pass rather
|
|
629
668
|
// than looping.
|
|
669
|
+
// This effect measures caller-rendered options after commit.
|
|
670
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
630
671
|
useEffect(() => {
|
|
631
672
|
const list = listRef.current;
|
|
632
673
|
if (list == null) {
|
|
@@ -634,9 +675,10 @@ export component SelectList(
|
|
|
634
675
|
}
|
|
635
676
|
const items = itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
|
|
636
677
|
|
|
637
|
-
const wanted =
|
|
678
|
+
const wanted = pendingLandingRef.current;
|
|
638
679
|
if (wanted != null) {
|
|
639
|
-
|
|
680
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
681
|
+
pendingLandingRef.current = null;
|
|
640
682
|
// An `if` rather than a `match` on `wanted.kind`, because matching on a
|
|
641
683
|
// property does not refine the object that property came from: inside
|
|
642
684
|
// `match (wanted.kind)` both arms still see the whole union, and `uf
|
|
@@ -664,32 +706,16 @@ export component SelectList(
|
|
|
664
706
|
}
|
|
665
707
|
});
|
|
666
708
|
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
const onOutsidePress = (event: Event) => {
|
|
678
|
-
const target: $FlowFixMe = event.target;
|
|
679
|
-
if (target == null || list.contains(target)) {
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
// The trigger is not "outside": closing here and letting its own click
|
|
683
|
-
// reopen the list makes a press on the trigger a no-op that flickers.
|
|
684
|
-
const trigger = triggerRef.current;
|
|
685
|
-
if (trigger != null && trigger.contains(target)) {
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
close();
|
|
689
|
-
};
|
|
690
|
-
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
691
|
-
return () => document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
692
|
-
}, [select.open, close, listRef, triggerRef]);
|
|
709
|
+
// The refs are read when a press arrives rather than when the listener is
|
|
710
|
+
// attached, which is what makes `select.open` the only thing this depends on:
|
|
711
|
+
// this component is mounted the whole time and only *renders* while the list
|
|
712
|
+
// is open, and a listener attached on the commit where `listRef.current` was
|
|
713
|
+
// still null used to be one that never worked.
|
|
714
|
+
useInteractOutside({
|
|
715
|
+
isDisabled: !select.open,
|
|
716
|
+
onInteractOutside: () => close(),
|
|
717
|
+
refs: [listRef, triggerRef],
|
|
718
|
+
});
|
|
693
719
|
|
|
694
720
|
if (!select.open) {
|
|
695
721
|
return null;
|
|
@@ -701,6 +727,8 @@ export component SelectList(
|
|
|
701
727
|
<div
|
|
702
728
|
{...passed}
|
|
703
729
|
aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
|
|
730
|
+
data-align={anchored.align}
|
|
731
|
+
data-side={anchored.side}
|
|
704
732
|
id={`${select.base}-list`}
|
|
705
733
|
ref={composeRefs(rest.ref, (element) => {
|
|
706
734
|
listRef.current = element;
|
|
@@ -793,8 +821,26 @@ export component SelectOption(
|
|
|
793
821
|
* `Select.GroupLabel` is rendered — the same rule, and the same reason, as
|
|
794
822
|
* `Menu.Group`. The arrow keys pass over the label without stopping on it,
|
|
795
823
|
* because they only ever look for `role="option"`.
|
|
824
|
+
*
|
|
825
|
+
* `children` is `renders* (SelectOption | SelectGroupLabel)`, which is what a
|
|
826
|
+
* `group` inside a `listbox` may hold: options, and the heading that names
|
|
827
|
+
* them. It took `React.Node` until ubugeeei-prod/uf#562, so a `<div>` in a
|
|
828
|
+
* group was a runtime surprise — an element with no role between two options,
|
|
829
|
+
* which the arrow keys walk straight past and a screen reader reads as a stray
|
|
830
|
+
* line — rather than a type error. `Combobox.Group` has stated the constraint
|
|
831
|
+
* since #558 and this is the same listbox.
|
|
832
|
+
*
|
|
833
|
+
* No `Select.Separator`, and that is deliberate rather than an omission: a rule
|
|
834
|
+
* separates *groups*, so it belongs between them in `Select.List` — which does
|
|
835
|
+
* admit one. A separator inside a group is a rule with nothing on one side of
|
|
836
|
+
* it.
|
|
837
|
+
*
|
|
838
|
+
* **Breaking.** A caller passing anything else — a `<div>` wrapper, a fragment
|
|
839
|
+
* of their own, a component that returns options — now fails `uf check`. The
|
|
840
|
+
* fix is to hand the options to the group directly; a wrapper had no effect on
|
|
841
|
+
* what this renders, because the group's element is the one below.
|
|
796
842
|
*/
|
|
797
|
-
export component SelectGroup(children:
|
|
843
|
+
export component SelectGroup(children: renders* (SelectOption | SelectGroupLabel), ...rest: Rest) {
|
|
798
844
|
const base = useId();
|
|
799
845
|
const [labelled, setLabelled] = useState(false);
|
|
800
846
|
|
package/separator.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A rule, and the one decision in it: whether anybody is told it is there.
|
|
4
|
+
//
|
|
5
|
+
// Two lines of markup, and it belongs in this package rather than in the preset
|
|
6
|
+
// for the same reason `Progress` does — the component *is* a conditional about
|
|
7
|
+
// what a reader hears:
|
|
8
|
+
//
|
|
9
|
+
// * A separator **between groups of content** is `role="separator"` with an
|
|
10
|
+
// `aria-orientation`. A reader moving down the page is told the subject
|
|
11
|
+
// changed, which is the information the line was drawn to give and the only
|
|
12
|
+
// way they get it.
|
|
13
|
+
// * A **decorative** rule — the line under a heading, the hairline between a
|
|
14
|
+
// card's padding and its footer — is `aria-hidden="true"` and announced to
|
|
15
|
+
// nobody. It is a border that happens to be an element.
|
|
16
|
+
//
|
|
17
|
+
// Getting it backwards is silent in both directions: a decorative rule with the
|
|
18
|
+
// role adds a "separator" to every reading of the page, and a real boundary
|
|
19
|
+
// without it takes the boundary away from everyone who is not looking at it.
|
|
20
|
+
//
|
|
21
|
+
// The default is the semantic one, because the two mistakes do not cost the
|
|
22
|
+
// same. A rule wrongly announced is noise a reader can hear and skip; a
|
|
23
|
+
// boundary wrongly silent is information that is simply not there, and nobody
|
|
24
|
+
// finds out. `progress.js` makes the same trade in its own sentence: the safe
|
|
25
|
+
// answer has to be the honest one.
|
|
26
|
+
//
|
|
27
|
+
// # Why this is a `<div>` and not an `<hr>`
|
|
28
|
+
//
|
|
29
|
+
// An `<hr>` already carries `role="separator"`, so for a horizontal rule
|
|
30
|
+
// between two blocks of prose it is the better answer and a caller who can use
|
|
31
|
+
// one should. This exists for what it cannot do.
|
|
32
|
+
//
|
|
33
|
+
// It comes with a border and a margin from the browser's own stylesheet, and a
|
|
34
|
+
// package that ships no styles cannot ship a visible line — every consumer
|
|
35
|
+
// would begin by turning it off. It is horizontal by definition, so a vertical
|
|
36
|
+
// rule between two things in a row is a rotated element rather than a described
|
|
37
|
+
// one. And it is a paragraph-level break in the flow, which is not what a
|
|
38
|
+
// hairline inside a toolbar is.
|
|
39
|
+
//
|
|
40
|
+
// # The two separators that are not this one
|
|
41
|
+
//
|
|
42
|
+
// `Menu.Separator` is the rule between groups of menu items, and belongs to the
|
|
43
|
+
// menu because it has to be skipped by the arrow keys that walk it.
|
|
44
|
+
// `Resizable.Handle` announces itself as a separator too, and is a *control* —
|
|
45
|
+
// the APG window splitter, a separator that behaves like a slider. Neither is
|
|
46
|
+
// this, and reaching for this one in either place loses the behaviour that made
|
|
47
|
+
// them their own components.
|
|
48
|
+
//
|
|
49
|
+
// # No `"use client"`
|
|
50
|
+
//
|
|
51
|
+
// One element, two attributes, nothing to remember. It renders on a server.
|
|
52
|
+
|
|
53
|
+
import type { Orientation } from "./internal/roving-focus.js";
|
|
54
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
55
|
+
import { withProps } from "./internal/merge-props.js";
|
|
56
|
+
|
|
57
|
+
export type { Orientation } from "./internal/roving-focus.js";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A rule between two things, or a line that is only a line.
|
|
61
|
+
*
|
|
62
|
+
* `decorative` is the whole component. Without it the element is a
|
|
63
|
+
* `role="separator"` a reader is told about; with it the element is hidden from
|
|
64
|
+
* the accessibility tree entirely.
|
|
65
|
+
*
|
|
66
|
+
* <Separator />
|
|
67
|
+
* <Separator orientation="vertical" />
|
|
68
|
+
* <Separator decorative />
|
|
69
|
+
*
|
|
70
|
+
* The decorative one gets `aria-hidden` and no role, rather than
|
|
71
|
+
* `role="presentation"` as well: a `<div>` has nothing to hide behind a
|
|
72
|
+
* presentation role, and one attribute that removes the element from the tree
|
|
73
|
+
* says the whole thing. `Breadcrumb.Separator` carries both because it is an
|
|
74
|
+
* `<li>`, whose `listitem` role would otherwise be counted.
|
|
75
|
+
*
|
|
76
|
+
* `aria-orientation` is written out even for the horizontal case, where ARIA
|
|
77
|
+
* would default to it. It is the attribute a reader of this markup is looking
|
|
78
|
+
* for, and a default that is left implicit is a default somebody has to know.
|
|
79
|
+
*
|
|
80
|
+
* `render` changes the element carrying that decision, not the decision
|
|
81
|
+
* itself: decorative rules stay hidden, semantic rules keep the separator
|
|
82
|
+
* role and orientation.
|
|
83
|
+
*/
|
|
84
|
+
export component Separator(
|
|
85
|
+
decorative?: boolean = false,
|
|
86
|
+
orientation?: Orientation = "horizontal",
|
|
87
|
+
render?: RenderProp,
|
|
88
|
+
...rest: Rest
|
|
89
|
+
) {
|
|
90
|
+
const props = decorative
|
|
91
|
+
? withProps(rest, { "aria-hidden": "true" })
|
|
92
|
+
: withProps(rest, { "aria-orientation": orientation, role: "separator" });
|
|
93
|
+
if (render != null) {
|
|
94
|
+
return render(props);
|
|
95
|
+
}
|
|
96
|
+
return <div {...props} />;
|
|
97
|
+
}
|