@uniflowed/ui 0.0.0-alpha.40 → 0.0.0-alpha.42
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 +2 -0
- package/alert-dialog.js +2 -0
- package/avatar.js +4 -0
- package/calendar.js +8 -8
- package/collapsible.js +2 -0
- package/combobox.js +26 -7
- package/context-menu.js +12 -3
- package/date-picker.js +11 -0
- package/dialog.js +6 -0
- package/drawer.js +38 -24
- package/hover-card.js +26 -22
- package/interactions.js +4 -0
- package/internal/menu-tree.js +3 -3
- package/internal/roving-focus.js +2 -0
- package/menu.js +39 -6
- package/menubar.js +8 -1
- package/package.json +5 -5
- package/popover.js +23 -0
- package/radio-group.js +2 -0
- package/resizable.js +6 -0
- package/scroll-area.js +13 -13
- package/select.js +23 -10
- package/sidebar.js +9 -2
- package/skeleton.js +4 -0
- package/slider.js +6 -0
- package/toast.js +13 -11
- package/toggle-group.js +2 -0
- package/tooltip.js +19 -15
package/accordion.js
CHANGED
|
@@ -347,6 +347,8 @@ export component AccordionContent(children: React.Node, render?: RenderProp, ...
|
|
|
347
347
|
children,
|
|
348
348
|
hidden: !item.open,
|
|
349
349
|
id: item.contentId,
|
|
350
|
+
// React calls callback refs during commit; this node is only read by effects.
|
|
351
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
350
352
|
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
351
353
|
contentRef.current = element;
|
|
352
354
|
}),
|
package/alert-dialog.js
CHANGED
|
@@ -227,6 +227,8 @@ export component AlertDialogDescription(children: React.Node, render?: RenderPro
|
|
|
227
227
|
const describedBy = alert.describedBy;
|
|
228
228
|
|
|
229
229
|
useEffect(() => {
|
|
230
|
+
// This mount counter is a ref because it is read by Dialog.Body after commit.
|
|
231
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
230
232
|
describedBy.current += 1;
|
|
231
233
|
return () => {
|
|
232
234
|
describedBy.current -= 1;
|
package/avatar.js
CHANGED
|
@@ -223,6 +223,8 @@ export component AvatarImage(
|
|
|
223
223
|
alt,
|
|
224
224
|
onError: composeHandlers(rest.onError, () => report(source, "error")),
|
|
225
225
|
onLoad: composeHandlers(rest.onLoad, () => report(source, "loaded")),
|
|
226
|
+
// React calls callback refs during commit; the load effect reads it later.
|
|
227
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
226
228
|
ref: composeRefs(rest.ref, (node: HTMLImageElement | null) => {
|
|
227
229
|
element.current = node;
|
|
228
230
|
}),
|
|
@@ -261,6 +263,8 @@ export component AvatarFallback(
|
|
|
261
263
|
|
|
262
264
|
useEffect(() => {
|
|
263
265
|
if (!waiting) {
|
|
266
|
+
// The fallback delay resets after the image leaves its loading window.
|
|
267
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
264
268
|
setElapsed(false);
|
|
265
269
|
}
|
|
266
270
|
}, [waiting]);
|
package/calendar.js
CHANGED
|
@@ -138,7 +138,7 @@ type CalendarState = {|
|
|
|
138
138
|
* November — a cell that is rendered for the first time by the same update
|
|
139
139
|
* that asked for it, so nothing could have focused it when the key arrived.
|
|
140
140
|
*/
|
|
141
|
-
readonly
|
|
141
|
+
readonly pendingFocusRef: { current: string | null },
|
|
142
142
|
readonly select: (date: PlainDate) => void,
|
|
143
143
|
readonly selected: PlainDate | null,
|
|
144
144
|
readonly today: PlainDate,
|
|
@@ -190,7 +190,7 @@ export component CalendarRoot(
|
|
|
190
190
|
...rest: Rest
|
|
191
191
|
) {
|
|
192
192
|
const base = useId();
|
|
193
|
-
const
|
|
193
|
+
const pendingFocusRef = useRef<string | null>(null);
|
|
194
194
|
// One is always allocated, because a hook may not be called conditionally;
|
|
195
195
|
// the caller's is used when there is one.
|
|
196
196
|
const ownDayRef = useRef<HTMLElement | null>(null);
|
|
@@ -238,7 +238,7 @@ export component CalendarRoot(
|
|
|
238
238
|
if (viaKeyboard) {
|
|
239
239
|
// Only for the keyboard. A press already put focus on the cell it landed
|
|
240
240
|
// on, and asking for it again would fight a caller who moved it.
|
|
241
|
-
|
|
241
|
+
pendingFocusRef.current = date.toString();
|
|
242
242
|
}
|
|
243
243
|
// Compared rather than assigned, because `Calendar.Day` reports the focus
|
|
244
244
|
// that this very call produced: a key moves the tab stop, the effect focuses
|
|
@@ -286,7 +286,7 @@ export component CalendarRoot(
|
|
|
286
286
|
isDisabled,
|
|
287
287
|
locale,
|
|
288
288
|
moveFocus,
|
|
289
|
-
|
|
289
|
+
pendingFocusRef,
|
|
290
290
|
select,
|
|
291
291
|
selected,
|
|
292
292
|
today: currentDate,
|
|
@@ -336,7 +336,7 @@ export component CalendarRoot(
|
|
|
336
336
|
export component CalendarMonth(children?: (date: PlainDate) => renders CalendarDay, ...rest: Rest) {
|
|
337
337
|
const calendar = useCalendar("Calendar.Month");
|
|
338
338
|
const gridRef = useRef<HTMLElement | null>(null);
|
|
339
|
-
const { focused, moveFocus,
|
|
339
|
+
const { focused, focusedDayRef, moveFocus, pendingFocusRef, weekStartsOn } = calendar;
|
|
340
340
|
|
|
341
341
|
const weeks = useMemo(
|
|
342
342
|
() => weeksOf(focused.year, focused.month, weekStartsOn),
|
|
@@ -355,15 +355,15 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
|
|
|
355
355
|
}
|
|
356
356
|
const at = focused.toString();
|
|
357
357
|
const cell: HTMLElement | null = (grid.querySelector(`[data-date="${at}"]`): $FlowFixMe);
|
|
358
|
-
|
|
358
|
+
focusedDayRef.current = cell;
|
|
359
359
|
// Only when a key asked for it, and only once the render it asked for has
|
|
360
360
|
// happened: `ArrowRight` off the end of October sets this to the 1st of
|
|
361
361
|
// November, and the cell exists for the first time in the commit this
|
|
362
362
|
// effect belongs to.
|
|
363
|
-
if (
|
|
363
|
+
if (pendingFocusRef.current !== at) {
|
|
364
364
|
return;
|
|
365
365
|
}
|
|
366
|
-
|
|
366
|
+
pendingFocusRef.current = null;
|
|
367
367
|
cell?.focus();
|
|
368
368
|
});
|
|
369
369
|
|
package/collapsible.js
CHANGED
|
@@ -157,6 +157,8 @@ export component CollapsibleContent(children: React.Node, render?: RenderProp, .
|
|
|
157
157
|
children,
|
|
158
158
|
hidden: !collapsible.open,
|
|
159
159
|
id: collapsible.contentId,
|
|
160
|
+
// React calls callback refs during commit; this node is only read by effects.
|
|
161
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
160
162
|
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
161
163
|
contentRef.current = element;
|
|
162
164
|
}),
|
package/combobox.js
CHANGED
|
@@ -165,7 +165,7 @@ type ComboboxState = {|
|
|
|
165
165
|
* and the list does not exist to be measured until the next commit. A ref
|
|
166
166
|
* rather than state because nothing renders it.
|
|
167
167
|
*/
|
|
168
|
-
readonly
|
|
168
|
+
readonly pendingActiveRef: { current: "first" | "last" | null },
|
|
169
169
|
readonly inputRef: { current: HTMLElement | null },
|
|
170
170
|
readonly listRef: { current: HTMLElement | null },
|
|
171
171
|
/** How many options are in the list, for the live region. */
|
|
@@ -231,7 +231,7 @@ export component ComboboxRoot(
|
|
|
231
231
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
232
232
|
const [count, setCount] = useState(0);
|
|
233
233
|
const [labelled, setLabelled] = useState(false);
|
|
234
|
-
const
|
|
234
|
+
const pendingActiveRef = useRef<"first" | "last" | null>(null);
|
|
235
235
|
const inputRef = useRef<HTMLElement | null>(null);
|
|
236
236
|
const listRef = useRef<HTMLElement | null>(null);
|
|
237
237
|
|
|
@@ -265,7 +265,7 @@ export component ComboboxRoot(
|
|
|
265
265
|
clear,
|
|
266
266
|
activeId,
|
|
267
267
|
setActiveId,
|
|
268
|
-
|
|
268
|
+
pendingActiveRef,
|
|
269
269
|
inputRef,
|
|
270
270
|
listRef,
|
|
271
271
|
count,
|
|
@@ -312,6 +312,8 @@ export component ComboboxLabel(children: React.Node, ...rest: Rest) {
|
|
|
312
312
|
/** The text field, and every key the pattern defines. */
|
|
313
313
|
export component ComboboxInput(...rest: Rest) {
|
|
314
314
|
const combobox = useCombobox("Combobox.Input");
|
|
315
|
+
// `rest` filtering is render-time props work; ref objects are only passed through later.
|
|
316
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
315
317
|
const passed = withoutComposed(rest, ["onChange", "onKeyDown", "ref"]);
|
|
316
318
|
|
|
317
319
|
/** The options in the document right now, in document order. */
|
|
@@ -325,7 +327,8 @@ export component ComboboxInput(...rest: Rest) {
|
|
|
325
327
|
if (items.length === 0) {
|
|
326
328
|
// The list is not in the document yet, so leave an instruction for the
|
|
327
329
|
// commit that puts it there.
|
|
328
|
-
|
|
330
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
331
|
+
combobox.pendingActiveRef.current = movement === "next" ? "first" : "last";
|
|
329
332
|
return;
|
|
330
333
|
}
|
|
331
334
|
const at = items.findIndex((item) => item.id === combobox.activeId);
|
|
@@ -358,6 +361,8 @@ export component ComboboxInput(...rest: Rest) {
|
|
|
358
361
|
// The browser's own dropdown would sit on top of this one.
|
|
359
362
|
autoComplete="off"
|
|
360
363
|
id={`${combobox.base}-input`}
|
|
364
|
+
// Input events read list refs and update the virtual active descendant.
|
|
365
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
361
366
|
onChange={composeHandlers(rest.onChange, (event: $FlowFixMe) => {
|
|
362
367
|
combobox.setText(event.target.value);
|
|
363
368
|
combobox.setOpen(true);
|
|
@@ -366,6 +371,8 @@ export component ComboboxInput(...rest: Rest) {
|
|
|
366
371
|
// Enter takes something the reader can no longer see.
|
|
367
372
|
combobox.setActiveId(null);
|
|
368
373
|
})}
|
|
374
|
+
// Key events read list refs and update the virtual active descendant.
|
|
375
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
369
376
|
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
370
377
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
371
378
|
event.preventDefault();
|
|
@@ -411,7 +418,10 @@ export component ComboboxInput(...rest: Rest) {
|
|
|
411
418
|
combobox.setActiveId(null);
|
|
412
419
|
}
|
|
413
420
|
})}
|
|
421
|
+
// React calls callback refs during commit; keyboard handlers read the input later.
|
|
422
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
414
423
|
ref={composeRefs(rest.ref, (element) => {
|
|
424
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
415
425
|
combobox.inputRef.current = element;
|
|
416
426
|
})}
|
|
417
427
|
role="combobox"
|
|
@@ -443,7 +453,7 @@ export component ComboboxList(
|
|
|
443
453
|
...rest: Rest
|
|
444
454
|
) {
|
|
445
455
|
const combobox = useCombobox("Combobox.List");
|
|
446
|
-
const { activeId, count, listRef, inputRef,
|
|
456
|
+
const { activeId, count, listRef, inputRef, pendingActiveRef, setActiveId, setCount } = combobox;
|
|
447
457
|
const close = useStableCallback(() => {
|
|
448
458
|
combobox.setOpen(false);
|
|
449
459
|
combobox.setActiveId(null);
|
|
@@ -453,13 +463,19 @@ export component ComboboxList(
|
|
|
453
463
|
// `align="start"` because a list of options belongs under the edge the text
|
|
454
464
|
// starts at, and `--uf-anchor-trigger-width` is what a stylesheet reads to
|
|
455
465
|
// make it exactly as wide as the field.
|
|
466
|
+
// useAnchor accepts ref objects and reads them from layout/effects.
|
|
467
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
456
468
|
const anchored = useAnchor({
|
|
457
469
|
align,
|
|
458
470
|
alignOffset,
|
|
471
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
459
472
|
anchorRef: inputRef,
|
|
460
473
|
avoidCollisions,
|
|
461
474
|
collisionPadding,
|
|
475
|
+
// `open` is combobox metadata; no ref value is read during render.
|
|
476
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
462
477
|
open: combobox.open,
|
|
478
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
463
479
|
overlayRef: listRef,
|
|
464
480
|
side,
|
|
465
481
|
sideOffset,
|
|
@@ -470,6 +486,8 @@ export component ComboboxList(
|
|
|
470
486
|
// `children` that no dependency list can describe. Every write below is
|
|
471
487
|
// guarded by a comparison, so the effect settles after one extra pass rather
|
|
472
488
|
// than looping.
|
|
489
|
+
// This effect measures caller-rendered options after commit.
|
|
490
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
473
491
|
useEffect(() => {
|
|
474
492
|
const list = listRef.current;
|
|
475
493
|
if (list == null) {
|
|
@@ -485,9 +503,10 @@ export component ComboboxList(
|
|
|
485
503
|
setCount(items.length);
|
|
486
504
|
}
|
|
487
505
|
|
|
488
|
-
const wanted =
|
|
506
|
+
const wanted = pendingActiveRef.current;
|
|
489
507
|
if (wanted != null) {
|
|
490
|
-
|
|
508
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
509
|
+
pendingActiveRef.current = null;
|
|
491
510
|
setActiveId(moveTo(items, -1, wanted, false)?.id ?? null);
|
|
492
511
|
return;
|
|
493
512
|
}
|
package/context-menu.js
CHANGED
|
@@ -141,7 +141,9 @@ export component ContextMenuTrigger(children: React.Node, render?: RenderProp, .
|
|
|
141
141
|
// No point: the menu goes against the element, which is where the reader's
|
|
142
142
|
// focus already is.
|
|
143
143
|
openAt(null);
|
|
144
|
-
menu
|
|
144
|
+
// This is an instruction for the menu body after the opening commit.
|
|
145
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
146
|
+
menu.pendingFocusRef.current = "first";
|
|
145
147
|
menu.setOpen(true);
|
|
146
148
|
}, [menu, openAt]);
|
|
147
149
|
|
|
@@ -150,7 +152,9 @@ export component ContextMenuTrigger(children: React.Node, render?: RenderProp, .
|
|
|
150
152
|
useLongPress(triggerRef, (event: Event) => {
|
|
151
153
|
const pointer: $FlowFixMe = event;
|
|
152
154
|
openAt(pointAt(pointer.clientX ?? 0, pointer.clientY ?? 0));
|
|
153
|
-
menu
|
|
155
|
+
// This is an instruction for the menu body after the opening commit.
|
|
156
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
157
|
+
menu.pendingFocusRef.current = "first";
|
|
154
158
|
menu.setOpen(true);
|
|
155
159
|
});
|
|
156
160
|
|
|
@@ -173,7 +177,9 @@ export component ContextMenuTrigger(children: React.Node, render?: RenderProp, .
|
|
|
173
177
|
// commands the page has for what they pressed on.
|
|
174
178
|
press.preventDefault();
|
|
175
179
|
openAt(pointAt(press.clientX ?? 0, press.clientY ?? 0));
|
|
176
|
-
menu
|
|
180
|
+
// This is an instruction for the menu body after the opening commit.
|
|
181
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
182
|
+
menu.pendingFocusRef.current = "first";
|
|
177
183
|
menu.setOpen(true);
|
|
178
184
|
}),
|
|
179
185
|
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
@@ -188,10 +194,13 @@ export component ContextMenuTrigger(children: React.Node, render?: RenderProp, .
|
|
|
188
194
|
event.preventDefault();
|
|
189
195
|
openHere();
|
|
190
196
|
}),
|
|
197
|
+
// React calls callback refs during commit; focus restoration reads these later.
|
|
198
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
191
199
|
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
192
200
|
triggerRef.current = element;
|
|
193
201
|
// What focus goes back to when the menu closes. It is deliberately not
|
|
194
202
|
// registered as the menu's *name*; see the module header.
|
|
203
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
195
204
|
menu.triggerRef.current = element;
|
|
196
205
|
}),
|
|
197
206
|
},
|
package/date-picker.js
CHANGED
|
@@ -253,18 +253,26 @@ const CalendarSettings: React.Context<CalendarSettingsValue> = createContext({
|
|
|
253
253
|
*/
|
|
254
254
|
export component DatePickerInput(...rest: Rest) {
|
|
255
255
|
const picker = useDatePicker("DatePicker.Input");
|
|
256
|
+
// `rest` filtering is render-time props work; ref objects are only passed through later.
|
|
257
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
256
258
|
const passed = withoutComposed(rest, ["onBlur", "onChange", "onKeyDown", "ref"]);
|
|
257
259
|
|
|
258
260
|
return (
|
|
259
261
|
<input
|
|
260
262
|
{...passed}
|
|
261
263
|
aria-invalid={picker.invalid ? "true" : undefined}
|
|
264
|
+
// Input events commit typed text while focus stays on the field ref.
|
|
265
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
262
266
|
onBlur={composeHandlers(rest.onBlur, (event) => {
|
|
263
267
|
picker.commit((event.currentTarget: $FlowFixMe).value);
|
|
264
268
|
})}
|
|
269
|
+
// Input events commit typed text while focus stays on the field ref.
|
|
270
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
265
271
|
onChange={composeHandlers(rest.onChange, (event) => {
|
|
266
272
|
picker.setDraft((event.currentTarget: $FlowFixMe).value);
|
|
267
273
|
})}
|
|
274
|
+
// Key events commit typed text while focus stays on the field ref.
|
|
275
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
268
276
|
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
269
277
|
if (event.key !== "Enter") {
|
|
270
278
|
return;
|
|
@@ -274,7 +282,10 @@ export component DatePickerInput(...rest: Rest) {
|
|
|
274
282
|
event.preventDefault();
|
|
275
283
|
picker.commit((event.currentTarget: $FlowFixMe).value);
|
|
276
284
|
})}
|
|
285
|
+
// React calls callback refs during commit; the calendar and picker read the field later.
|
|
286
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
277
287
|
ref={composeRefs(rest.ref, (element) => {
|
|
288
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
278
289
|
picker.fieldRef.current = element;
|
|
279
290
|
})}
|
|
280
291
|
type="text"
|
package/dialog.js
CHANGED
|
@@ -182,6 +182,8 @@ export component DialogTrigger(children: React.Node, render?: RenderProp, ...res
|
|
|
182
182
|
children,
|
|
183
183
|
onClick: composeHandlers(rest.onClick, () => dialog.setOpen(true)),
|
|
184
184
|
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
185
|
+
// React calls callback refs during commit; focus restoration reads it later.
|
|
186
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
185
187
|
dialog.triggerRef.current = element;
|
|
186
188
|
}),
|
|
187
189
|
});
|
|
@@ -315,6 +317,8 @@ export component DialogBody(
|
|
|
315
317
|
"aria-modal": "true",
|
|
316
318
|
children,
|
|
317
319
|
id: `${dialog.base}-body`,
|
|
320
|
+
// Key handling closes the mounted dialog and restores focus after events.
|
|
321
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
318
322
|
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
319
323
|
if (event.key === "Escape") {
|
|
320
324
|
event.preventDefault();
|
|
@@ -353,6 +357,8 @@ export component DialogBody(
|
|
|
353
357
|
first.focus();
|
|
354
358
|
}
|
|
355
359
|
}),
|
|
360
|
+
// React calls callback refs during commit; focus effects and traps read it later.
|
|
361
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
356
362
|
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
357
363
|
bodyRef.current = element;
|
|
358
364
|
}),
|
package/drawer.js
CHANGED
|
@@ -102,8 +102,8 @@ type DrawerState = {|
|
|
|
102
102
|
* child's effect runs before its parent's, so `Drawer.Body` can ask about
|
|
103
103
|
* both on the commit that mounted them, and nothing renders either number.
|
|
104
104
|
*/
|
|
105
|
-
readonly
|
|
106
|
-
readonly
|
|
105
|
+
readonly closeCountRef: { current: number },
|
|
106
|
+
readonly handleCountRef: { current: number },
|
|
107
107
|
|};
|
|
108
108
|
|
|
109
109
|
const DrawerContext: React.Context<DrawerState | null> = createContext(null);
|
|
@@ -146,15 +146,15 @@ export component DrawerRoot(
|
|
|
146
146
|
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
147
147
|
const [snapIndex, setSnapIndex] = useControlled(snapPoint, defaultSnapPoint, onSnapPointChange);
|
|
148
148
|
const bodyRef = useRef<HTMLElement | null>(null);
|
|
149
|
-
const
|
|
150
|
-
const
|
|
149
|
+
const closeCountRef = useRef(0);
|
|
150
|
+
const handleCountRef = useRef(0);
|
|
151
151
|
|
|
152
152
|
const state = useMemo(
|
|
153
153
|
() => ({
|
|
154
154
|
bodyRef,
|
|
155
155
|
close: () => setOpen(false),
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
closeCountRef,
|
|
157
|
+
handleCountRef,
|
|
158
158
|
setSnapIndex,
|
|
159
159
|
side,
|
|
160
160
|
snapIndex,
|
|
@@ -243,10 +243,10 @@ export component DrawerBody(children: React.Node, render?: RenderProp, ...rest:
|
|
|
243
243
|
*/
|
|
244
244
|
component RequireCloseForTheDrag() {
|
|
245
245
|
const drawer = useDrawer("Drawer.Body");
|
|
246
|
-
const {
|
|
246
|
+
const { closeCountRef, handleCountRef } = drawer;
|
|
247
247
|
|
|
248
248
|
useEffect(() => {
|
|
249
|
-
if (
|
|
249
|
+
if (handleCountRef.current > 0 && closeCountRef.current === 0) {
|
|
250
250
|
throw new Error(
|
|
251
251
|
"Drawer.Body has a Drawer.Handle and no Drawer.Close: WCAG 2.2 SC 2.5.7 " +
|
|
252
252
|
"requires anything achievable by dragging to be achievable without a " +
|
|
@@ -254,7 +254,7 @@ component RequireCloseForTheDrag() {
|
|
|
254
254
|
"replacement for one.",
|
|
255
255
|
);
|
|
256
256
|
}
|
|
257
|
-
}, [
|
|
257
|
+
}, [closeCountRef, handleCountRef]);
|
|
258
258
|
|
|
259
259
|
return null;
|
|
260
260
|
}
|
|
@@ -303,14 +303,19 @@ export component DrawerDescription(children: React.Node, render?: RenderProp, ..
|
|
|
303
303
|
*/
|
|
304
304
|
export component DrawerClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
305
305
|
const drawer = useDrawer("Drawer.Close");
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
useEffect(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
306
|
+
const closeCountRef = drawer.closeCountRef;
|
|
307
|
+
|
|
308
|
+
useEffect(
|
|
309
|
+
() => {
|
|
310
|
+
closeCountRef.current += 1;
|
|
311
|
+
return () => {
|
|
312
|
+
closeCountRef.current -= 1;
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
// The context ref object is stable; the effect registers this part's mount.
|
|
316
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
317
|
+
[closeCountRef],
|
|
318
|
+
);
|
|
314
319
|
|
|
315
320
|
return (
|
|
316
321
|
<SheetClose {...forwarded(rest)} render={render}>
|
|
@@ -337,7 +342,7 @@ export component DrawerHandle(
|
|
|
337
342
|
...rest: Rest
|
|
338
343
|
) {
|
|
339
344
|
const drawer = useDrawer("Drawer.Handle");
|
|
340
|
-
const { bodyRef, close,
|
|
345
|
+
const { bodyRef, close, handleCountRef, setSnapIndex, side, snapIndex, snapPoints } = drawer;
|
|
341
346
|
const passed = withoutComposed(rest, [
|
|
342
347
|
"onKeyDown",
|
|
343
348
|
"onPointerDown",
|
|
@@ -354,12 +359,17 @@ export component DrawerHandle(
|
|
|
354
359
|
// So `Drawer.Body` knows there is a drag to provide an alternative to. A
|
|
355
360
|
// drawer with no handle has no gesture, and requiring a close button of one
|
|
356
361
|
// would be this component inventing a rule WCAG did not write.
|
|
357
|
-
useEffect(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
362
|
+
useEffect(
|
|
363
|
+
() => {
|
|
364
|
+
handleCountRef.current += 1;
|
|
365
|
+
return () => {
|
|
366
|
+
handleCountRef.current -= 1;
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
// The context ref object is stable; the effect registers this part's mount.
|
|
370
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
371
|
+
[handleCountRef],
|
|
372
|
+
);
|
|
363
373
|
|
|
364
374
|
/** Move by one snap point, or close when there is no smaller one. */
|
|
365
375
|
const step = (towardsOpen: boolean) => {
|
|
@@ -420,6 +430,8 @@ export component DrawerHandle(
|
|
|
420
430
|
step(false);
|
|
421
431
|
}
|
|
422
432
|
}),
|
|
433
|
+
// Pointer handlers keep drag coordinates in refs between events.
|
|
434
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
423
435
|
onPointerDown: composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
|
|
424
436
|
dragFrom.current = vertical ? event.clientY : event.clientX;
|
|
425
437
|
setDragging(true);
|
|
@@ -427,6 +439,7 @@ export component DrawerHandle(
|
|
|
427
439
|
// immediately: the handle moves with the drawer.
|
|
428
440
|
event.currentTarget?.setPointerCapture?.(event.pointerId);
|
|
429
441
|
}),
|
|
442
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
430
443
|
onPointerMove: composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
|
|
431
444
|
const body = bodyRef.current;
|
|
432
445
|
if (dragFrom.current == null || body == null) {
|
|
@@ -436,6 +449,7 @@ export component DrawerHandle(
|
|
|
436
449
|
// otherwise lift it off the edge it is attached to.
|
|
437
450
|
body.style.setProperty("--uf-drawer-drag", `${String(Math.max(0, travelled(event)))}px`);
|
|
438
451
|
}),
|
|
452
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
439
453
|
onPointerUp: composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
|
|
440
454
|
const body = bodyRef.current;
|
|
441
455
|
const moved = travelled(event);
|
package/hover-card.js
CHANGED
|
@@ -68,7 +68,7 @@ type HoverCardState = {|
|
|
|
68
68
|
readonly openDelay: number,
|
|
69
69
|
readonly closeDelay: number,
|
|
70
70
|
/** Whether `Escape` has dismissed it; see `tooltip.js`, which shares the rule. */
|
|
71
|
-
readonly
|
|
71
|
+
readonly dismissedRef: { current: boolean },
|
|
72
72
|
|};
|
|
73
73
|
|
|
74
74
|
const HoverCardContext: React.Context<HoverCardState | null> = createContext(null);
|
|
@@ -99,14 +99,14 @@ export component HoverCardRoot(
|
|
|
99
99
|
const base = useId();
|
|
100
100
|
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
101
101
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
102
|
-
const
|
|
102
|
+
const dismissedRef = useRef(false);
|
|
103
103
|
const intent = useHoverIntent(setOpen);
|
|
104
104
|
|
|
105
105
|
const state = useMemo(
|
|
106
106
|
() => ({
|
|
107
107
|
base,
|
|
108
108
|
closeDelay,
|
|
109
|
-
|
|
109
|
+
dismissedRef,
|
|
110
110
|
intent,
|
|
111
111
|
open: isOpen,
|
|
112
112
|
openDelay,
|
|
@@ -129,36 +129,36 @@ export component HoverCardRoot(
|
|
|
129
129
|
*/
|
|
130
130
|
export component HoverCardTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
131
131
|
const card = useHoverCard("HoverCard.Trigger");
|
|
132
|
-
const { closeDelay,
|
|
132
|
+
const { closeDelay, dismissedRef, intent, openDelay, triggerRef } = card;
|
|
133
133
|
useFocusableTrigger(triggerRef, "HoverCard.Trigger");
|
|
134
134
|
|
|
135
135
|
// A press focuses the trigger, and a card opening under the reader's own
|
|
136
136
|
// click would cover what they just went to. See `tooltip.js`.
|
|
137
|
-
const
|
|
137
|
+
const pressedRef = useRef(false);
|
|
138
138
|
|
|
139
139
|
useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
|
|
140
|
-
if (event.pointerType === "touch" ||
|
|
140
|
+
if (event.pointerType === "touch" || dismissedRef.current) {
|
|
141
141
|
return;
|
|
142
142
|
}
|
|
143
143
|
intent.openAfter(openDelay);
|
|
144
144
|
});
|
|
145
145
|
useEventListener(triggerRef, "pointerleave", () => {
|
|
146
|
-
|
|
146
|
+
dismissedRef.current = false;
|
|
147
147
|
intent.closeAfter(closeDelay);
|
|
148
148
|
});
|
|
149
149
|
useEventListener(triggerRef, "pointerdown", () => {
|
|
150
|
-
|
|
150
|
+
pressedRef.current = true;
|
|
151
151
|
intent.cancel();
|
|
152
152
|
});
|
|
153
153
|
useEventListener(triggerRef, "focusin", () => {
|
|
154
|
-
if (
|
|
155
|
-
|
|
154
|
+
if (pressedRef.current) {
|
|
155
|
+
pressedRef.current = false;
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
158
158
|
// The focus a dismissed card hands *back* to this trigger must not reopen
|
|
159
159
|
// it, which is the whole reason the flag exists: without it `Escape` closes
|
|
160
160
|
// the card, focus returns here, and the card comes straight back.
|
|
161
|
-
if (
|
|
161
|
+
if (dismissedRef.current) {
|
|
162
162
|
return;
|
|
163
163
|
}
|
|
164
164
|
// A reader who tabbed here has said what they want; only the pointer is
|
|
@@ -166,8 +166,8 @@ export component HoverCardTrigger(children?: React.Node, render?: RenderProp, ..
|
|
|
166
166
|
intent.openAfter(0);
|
|
167
167
|
});
|
|
168
168
|
useEventListener(triggerRef, "focusout", () => {
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
pressedRef.current = false;
|
|
170
|
+
dismissedRef.current = false;
|
|
171
171
|
// `closeAfter` rather than a close, and this is where the delay earns its
|
|
172
172
|
// keep a second time: `Tab` from the trigger *into* the card is a leave
|
|
173
173
|
// followed immediately by an arrival, and the card's own `focusin` calls
|
|
@@ -179,6 +179,8 @@ export component HoverCardTrigger(children?: React.Node, render?: RenderProp, ..
|
|
|
179
179
|
|
|
180
180
|
// Annotated because this one is not written inside a `ref={...}`, and there
|
|
181
181
|
// is nothing else here for Flow to infer the element's type from.
|
|
182
|
+
// React calls callback refs during commit; pointer and focus handlers read it later.
|
|
183
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
182
184
|
const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
183
185
|
triggerRef.current = element;
|
|
184
186
|
});
|
|
@@ -212,16 +214,16 @@ export component HoverCardBody(
|
|
|
212
214
|
...rest: Rest
|
|
213
215
|
) {
|
|
214
216
|
const card = useHoverCard("HoverCard.Body");
|
|
215
|
-
const { closeDelay, intent, open, triggerRef } = card;
|
|
217
|
+
const { closeDelay, dismissedRef, intent, open, triggerRef } = card;
|
|
216
218
|
const bodyRef = useRef<HTMLElement | null>(null);
|
|
217
219
|
// Whether the reader is *in* the card, as opposed to over it. It decides one
|
|
218
220
|
// thing and it cannot be asked afterwards: a card closed while it held focus
|
|
219
221
|
// has to hand focus back, and by the time the effect below is cleaned up the
|
|
220
222
|
// element is gone from the document and `activeElement` has already fallen to
|
|
221
223
|
// `<body>` — so the answer is kept while it is still true.
|
|
222
|
-
const
|
|
224
|
+
const heldRef = useRef(false);
|
|
223
225
|
const close = useStableCallback(() => {
|
|
224
|
-
|
|
226
|
+
dismissedRef.current = true;
|
|
225
227
|
intent.cancel();
|
|
226
228
|
card.setOpen(false);
|
|
227
229
|
});
|
|
@@ -250,11 +252,11 @@ export component HoverCardBody(
|
|
|
250
252
|
const stay = () => intent.cancel();
|
|
251
253
|
const go = () => intent.closeAfter(closeDelay);
|
|
252
254
|
const arrived = () => {
|
|
253
|
-
|
|
255
|
+
heldRef.current = true;
|
|
254
256
|
stay();
|
|
255
257
|
};
|
|
256
258
|
const gone = () => {
|
|
257
|
-
|
|
259
|
+
heldRef.current = false;
|
|
258
260
|
go();
|
|
259
261
|
};
|
|
260
262
|
body.addEventListener("pointerenter", stay);
|
|
@@ -289,8 +291,8 @@ export component HoverCardBody(
|
|
|
289
291
|
if (open) {
|
|
290
292
|
return;
|
|
291
293
|
}
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
+
if (heldRef.current) {
|
|
295
|
+
heldRef.current = false;
|
|
294
296
|
triggerRef.current?.focus?.();
|
|
295
297
|
}
|
|
296
298
|
}, [open, triggerRef]);
|
|
@@ -299,8 +301,8 @@ export component HoverCardBody(
|
|
|
299
301
|
// the reader is inside it leaves focus on a node that is gone.
|
|
300
302
|
useEffect(
|
|
301
303
|
() => () => {
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
+
if (heldRef.current) {
|
|
305
|
+
heldRef.current = false;
|
|
304
306
|
triggerRef.current?.focus?.();
|
|
305
307
|
}
|
|
306
308
|
},
|
|
@@ -317,6 +319,8 @@ export component HoverCardBody(
|
|
|
317
319
|
"data-side": anchored.side,
|
|
318
320
|
"data-state": "open",
|
|
319
321
|
id: `${card.base}-body`,
|
|
322
|
+
// React calls callback refs during commit; placement effects read it later.
|
|
323
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
320
324
|
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
321
325
|
bodyRef.current = element;
|
|
322
326
|
}),
|
package/interactions.js
CHANGED
|
@@ -1776,6 +1776,8 @@ hook useDescription(text: string | void): string | void {
|
|
|
1776
1776
|
const host: $FlowFixMe = globalThis;
|
|
1777
1777
|
const body: HTMLElement | null = host.document?.body ?? null;
|
|
1778
1778
|
if (text == null || text === "" || body == null) {
|
|
1779
|
+
// The id is state because it must render only after the shared DOM node exists.
|
|
1780
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
1779
1781
|
setId(undefined);
|
|
1780
1782
|
return;
|
|
1781
1783
|
}
|
|
@@ -1795,6 +1797,8 @@ hook useDescription(text: string | void): string | void {
|
|
|
1795
1797
|
}
|
|
1796
1798
|
const entry: SharedDescription = shared;
|
|
1797
1799
|
entry.users += 1;
|
|
1800
|
+
// The id is state because it must render only after the shared DOM node exists.
|
|
1801
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
1798
1802
|
setId(entry.node.id);
|
|
1799
1803
|
return () => {
|
|
1800
1804
|
entry.users -= 1;
|