@uniflowed/ui 0.9.0 → 0.10.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 +8 -5
- package/collapsible.js +21 -10
- package/combobox.js +7 -2
- package/dialog.js +29 -5
- package/hover-card.js +25 -8
- package/internal/anchor.js +10 -1
- package/internal/collection.js +52 -11
- package/internal/disclosure.js +84 -22
- package/internal/presence.js +249 -0
- package/internal/segmented-field.js +40 -16
- package/menu.js +9 -6
- package/navigation-menu.js +22 -6
- package/number-field.js +40 -10
- package/package.json +5 -5
- package/popover.js +10 -9
- package/select.js +7 -2
- package/tabs.js +126 -5
- package/toast.js +136 -7
- package/tooltip.js +7 -3
package/select.js
CHANGED
|
@@ -156,6 +156,7 @@ import type { Movement } from "./internal/roving-focus.js";
|
|
|
156
156
|
import { isTypeaheadKey, itemsOf, moveTo, useTypeahead } from "./internal/roving-focus.js";
|
|
157
157
|
import { useControlled } from "./internal/controlled-state.js";
|
|
158
158
|
import { FormValue } from "./internal/form-value.js";
|
|
159
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
159
160
|
|
|
160
161
|
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
161
162
|
|
|
@@ -642,6 +643,9 @@ component SelectList(
|
|
|
642
643
|
select.setOpen(false);
|
|
643
644
|
select.setActiveId(null);
|
|
644
645
|
});
|
|
646
|
+
// On the page while its exit runs. The trigger's `aria-controls` and
|
|
647
|
+
// `aria-activedescendant`, and the outside press, stay keyed on `open`.
|
|
648
|
+
const presence = usePresence(select.open, listRef);
|
|
645
649
|
|
|
646
650
|
// The popup a select opens is the one case where the trigger's *width* is
|
|
647
651
|
// part of the design rather than a detail: a list narrower than the button it
|
|
@@ -654,7 +658,7 @@ component SelectList(
|
|
|
654
658
|
anchorRef: triggerRef,
|
|
655
659
|
avoidCollisions,
|
|
656
660
|
collisionPadding,
|
|
657
|
-
open:
|
|
661
|
+
open: presence.present,
|
|
658
662
|
overlayRef: listRef,
|
|
659
663
|
side,
|
|
660
664
|
sideOffset,
|
|
@@ -713,7 +717,7 @@ component SelectList(
|
|
|
713
717
|
refs: [listRef, triggerRef],
|
|
714
718
|
});
|
|
715
719
|
|
|
716
|
-
if (!
|
|
720
|
+
if (!presence.present) {
|
|
717
721
|
return null;
|
|
718
722
|
}
|
|
719
723
|
|
|
@@ -722,6 +726,7 @@ component SelectList(
|
|
|
722
726
|
return (
|
|
723
727
|
<div
|
|
724
728
|
{...passed}
|
|
729
|
+
{...presenceProps(presence)}
|
|
725
730
|
aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
|
|
726
731
|
data-align={anchored.align}
|
|
727
732
|
data-side={anchored.side}
|
package/tabs.js
CHANGED
|
@@ -30,6 +30,27 @@
|
|
|
30
30
|
// component that swallows it has taken scrolling away from every reader who
|
|
31
31
|
// uses the keyboard to read.
|
|
32
32
|
//
|
|
33
|
+
// # Where the selected tab is, for an indicator that slides
|
|
34
|
+
//
|
|
35
|
+
// An underline that moves from one tab to the next, rather than one that goes
|
|
36
|
+
// out under the old tab and comes on under the new, needs one number no
|
|
37
|
+
// stylesheet can work out: where the selected tab is inside the list. So
|
|
38
|
+
// `Tabs.List` measures it and writes four custom properties on itself:
|
|
39
|
+
//
|
|
40
|
+
// --uf-tabs-indicator-left --uf-tabs-indicator-top
|
|
41
|
+
// --uf-tabs-indicator-width --uf-tabs-indicator-height
|
|
42
|
+
//
|
|
43
|
+
// The selected tab's box, relative to the list's padding box — the box an
|
|
44
|
+
// absolutely positioned child of the list is placed in — in CSS pixels. They
|
|
45
|
+
// are **numbers without a unit**, which is the one form a stylesheet can use
|
|
46
|
+
// both ways: `calc(var(--uf-tabs-indicator-left) * 1px)` is a length, and
|
|
47
|
+
// `scaleX(var(--uf-tabs-indicator-width))` stretches a one-pixel bar to the
|
|
48
|
+
// tab's width, which moves the indicator with `transform` alone and lays
|
|
49
|
+
// nothing out while it travels. They are measured again whenever the list
|
|
50
|
+
// renders and whenever the list or a tab changes size, and removed when no tab
|
|
51
|
+
// is selected. As everywhere in this package, the numbers are the component's
|
|
52
|
+
// and the look is the stylesheet's: nothing is drawn here.
|
|
53
|
+
//
|
|
33
54
|
// # Composition is type-checked
|
|
34
55
|
//
|
|
35
56
|
// `Tabs.List` takes `renders* TabsTab`, so putting a `<button>` in the list is
|
|
@@ -47,12 +68,18 @@ import {
|
|
|
47
68
|
useEffect,
|
|
48
69
|
useId,
|
|
49
70
|
useMemo,
|
|
71
|
+
useRef,
|
|
50
72
|
useState,
|
|
51
73
|
} from "@uniflowed/react";
|
|
52
74
|
|
|
53
75
|
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
54
|
-
import {
|
|
55
|
-
|
|
76
|
+
import {
|
|
77
|
+
composeHandlers,
|
|
78
|
+
composeRefs,
|
|
79
|
+
withProps,
|
|
80
|
+
withoutComposed,
|
|
81
|
+
} from "./internal/merge-props.js";
|
|
82
|
+
import { itemsOf, moveOnKey } from "./internal/roving-focus.js";
|
|
56
83
|
import { useControlled } from "./internal/controlled-state.js";
|
|
57
84
|
import type { Orientation } from "./internal/roving-focus.js";
|
|
58
85
|
|
|
@@ -147,7 +174,45 @@ component TabsRoot(
|
|
|
147
174
|
*/
|
|
148
175
|
component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Rest) {
|
|
149
176
|
const tabs = useTabs("Tabs.List");
|
|
150
|
-
const
|
|
177
|
+
const listRef = useRef<HTMLElement | null>(null);
|
|
178
|
+
const selected = tabs.selected;
|
|
179
|
+
|
|
180
|
+
// Every render, with no dependency list, for the reason
|
|
181
|
+
// `internal/disclosure.js` gives for its height: the tabs a caller renders
|
|
182
|
+
// can change on any render, and what the indicator follows is where the
|
|
183
|
+
// selected one ended up. Every write is compared first, so a render that
|
|
184
|
+
// moved nothing writes nothing.
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const list = listRef.current;
|
|
187
|
+
if (list != null) {
|
|
188
|
+
placeIndicator(list);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// A tab can move without anything here rendering: a web font arriving, a
|
|
193
|
+
// label a sibling component translated, the window narrowing a list that
|
|
194
|
+
// wraps. Keyed on `selected` so the observer is watching the tabs there are
|
|
195
|
+
// now, which is when a new one may have arrived.
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
const list = listRef.current;
|
|
198
|
+
const view = list?.ownerDocument?.defaultView;
|
|
199
|
+
if (list == null || view == null) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Read off the window, for the reason `internal/anchor.js` gives.
|
|
203
|
+
const host: $FlowFixMe = view;
|
|
204
|
+
if (typeof host.ResizeObserver !== "function") {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const sizes = new host.ResizeObserver(() => placeIndicator(list));
|
|
208
|
+
sizes.observe(list);
|
|
209
|
+
for (const tab of itemsOf(list, TAB, TAB_LIST)) {
|
|
210
|
+
sizes.observe(tab);
|
|
211
|
+
}
|
|
212
|
+
return () => sizes.disconnect();
|
|
213
|
+
}, [selected]);
|
|
214
|
+
|
|
215
|
+
const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
|
|
151
216
|
// A screen reader announces the axis, and it is also what tells a reader
|
|
152
217
|
// which arrow keys to try.
|
|
153
218
|
"aria-orientation": tabs.orientation,
|
|
@@ -159,8 +224,8 @@ component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Res
|
|
|
159
224
|
// set inside somebody else's `dir="rtl"` walks the right way without
|
|
160
225
|
// the caller having had to know it needed to say so.
|
|
161
226
|
const next = moveOnKey(event, list, {
|
|
162
|
-
item:
|
|
163
|
-
owner:
|
|
227
|
+
item: TAB,
|
|
228
|
+
owner: TAB_LIST,
|
|
164
229
|
orientation: tabs.orientation,
|
|
165
230
|
wrap: true,
|
|
166
231
|
skipDisabled: true,
|
|
@@ -169,6 +234,11 @@ component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Res
|
|
|
169
234
|
tabs.select(next.getAttribute("data-value") ?? "");
|
|
170
235
|
}
|
|
171
236
|
}),
|
|
237
|
+
// React calls callback refs during commit; the indicator effects read it later.
|
|
238
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
239
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
240
|
+
listRef.current = element;
|
|
241
|
+
}),
|
|
172
242
|
role: "tablist",
|
|
173
243
|
});
|
|
174
244
|
|
|
@@ -178,6 +248,57 @@ component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Res
|
|
|
178
248
|
return <div {...props} />;
|
|
179
249
|
}
|
|
180
250
|
|
|
251
|
+
/** A tab, and the list that owns it, as the arrow keys and the indicator find them. */
|
|
252
|
+
const TAB = '[role="tab"]';
|
|
253
|
+
const TAB_LIST = '[role="tablist"]';
|
|
254
|
+
|
|
255
|
+
/** The four properties the module header describes, in the order they are written. */
|
|
256
|
+
const INDICATOR = [
|
|
257
|
+
"--uf-tabs-indicator-left",
|
|
258
|
+
"--uf-tabs-indicator-top",
|
|
259
|
+
"--uf-tabs-indicator-width",
|
|
260
|
+
"--uf-tabs-indicator-height",
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Write the selected tab's box on `list`, or take it away when none is.
|
|
265
|
+
*
|
|
266
|
+
* Measured from the two bounding boxes rather than from `offsetLeft`, which is
|
|
267
|
+
* relative to the nearest *positioned* ancestor — the list only if a
|
|
268
|
+
* stylesheet happened to position it. The list's border is taken off and its
|
|
269
|
+
* scroll added back, so the numbers are in the padding box an absolutely
|
|
270
|
+
* positioned indicator is placed in, and a tab scrolled into view in a long
|
|
271
|
+
* list is still underlined where it is.
|
|
272
|
+
*/
|
|
273
|
+
function placeIndicator(list: HTMLElement): void {
|
|
274
|
+
const tab = itemsOf(list, TAB, TAB_LIST).find(
|
|
275
|
+
(each) => each.getAttribute("aria-selected") === "true",
|
|
276
|
+
);
|
|
277
|
+
const style = list.style;
|
|
278
|
+
if (tab == null) {
|
|
279
|
+
for (const name of INDICATOR) {
|
|
280
|
+
style.removeProperty(name);
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const outer = list.getBoundingClientRect();
|
|
285
|
+
const inner = tab.getBoundingClientRect();
|
|
286
|
+
const values = [
|
|
287
|
+
inner.left - outer.left - list.clientLeft + list.scrollLeft,
|
|
288
|
+
inner.top - outer.top - list.clientTop + list.scrollTop,
|
|
289
|
+
inner.width,
|
|
290
|
+
inner.height,
|
|
291
|
+
];
|
|
292
|
+
INDICATOR.forEach((name, index) => {
|
|
293
|
+
// Two decimal places: enough for a device pixel at any zoom, and a number
|
|
294
|
+
// that does not change in its fifteenth digit between two renders.
|
|
295
|
+
const value = String(Math.round(values[index] * 100) / 100);
|
|
296
|
+
if (style.getPropertyValue(name) !== value) {
|
|
297
|
+
style.setProperty(name, value);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
181
302
|
/**
|
|
182
303
|
* One tab. Exactly one of them is in the page's tab order.
|
|
183
304
|
*
|
package/toast.js
CHANGED
|
@@ -88,6 +88,16 @@
|
|
|
88
88
|
// A notification given `duration: null` never expires at all, which is what
|
|
89
89
|
// anything carrying an action should be.
|
|
90
90
|
//
|
|
91
|
+
// # Leaving
|
|
92
|
+
//
|
|
93
|
+
// A notification is taken out of the queue the moment it is dismissed, and it
|
|
94
|
+
// is not taken off the screen at that moment. The region remembers it and
|
|
95
|
+
// keeps rendering it where it was, with `data-state="closed"` and `inert`, for
|
|
96
|
+
// as long as its exit transition runs; `internal/presence.js` decides when
|
|
97
|
+
// that is. `inert` is what keeps this honest: a leaving notification is out of
|
|
98
|
+
// the accessibility tree and out of the tab order at once, so a reader is
|
|
99
|
+
// never told about, or tabbed into, something that has already gone.
|
|
100
|
+
//
|
|
91
101
|
// # The queue lives outside React
|
|
92
102
|
//
|
|
93
103
|
// `toast("Saved")` is called from an event handler, from a `catch`, from a
|
|
@@ -141,6 +151,7 @@ import { useTimeout } from "@uniflowed/hooks/timing";
|
|
|
141
151
|
|
|
142
152
|
import type { Rest } from "./internal/merge-props.js";
|
|
143
153
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
154
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
144
155
|
|
|
145
156
|
/** How loudly a notification interrupts. */
|
|
146
157
|
export type Urgency = "polite" | "assertive";
|
|
@@ -319,6 +330,74 @@ function readQueue(): $ReadOnlyArray<Notification> {
|
|
|
319
330
|
|
|
320
331
|
const NotificationContext: React.Context<Notification | null> = createContext(null);
|
|
321
332
|
|
|
333
|
+
/** A notification the region is rendering, and whether it is still in the queue. */
|
|
334
|
+
type Shown = {|
|
|
335
|
+
readonly notification: Notification,
|
|
336
|
+
/** False once it has been dismissed, while its exit plays. */
|
|
337
|
+
readonly open: boolean,
|
|
338
|
+
|};
|
|
339
|
+
|
|
340
|
+
/** What a `Toast.Root` is told about its own life by the region. */
|
|
341
|
+
type ToastLife = {|
|
|
342
|
+
readonly open: boolean,
|
|
343
|
+
/** Called once a dismissed notification's exit has finished. */
|
|
344
|
+
readonly gone: () => void,
|
|
345
|
+
|};
|
|
346
|
+
|
|
347
|
+
const ToastLifeContext: React.Context<ToastLife | null> = createContext(null);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* What the region renders next, given what it rendered and what is queued now.
|
|
351
|
+
*
|
|
352
|
+
* Everything queued is open, in queue order. Everything the region was showing
|
|
353
|
+
* that is no longer queued stays, closed, where it was, so a notification
|
|
354
|
+
* leaving from the middle of the stack does not jump to the end of it first.
|
|
355
|
+
* A notification that arrives is placed after the last one before it in the
|
|
356
|
+
* queue, which for a queue that only ever appends is the end.
|
|
357
|
+
*/
|
|
358
|
+
function nextShown(
|
|
359
|
+
previous: $ReadOnlyArray<Shown>,
|
|
360
|
+
queued: $ReadOnlyArray<Notification>,
|
|
361
|
+
): $ReadOnlyArray<Shown> {
|
|
362
|
+
const byId = new Map<string, Notification>();
|
|
363
|
+
for (const notification of queued) {
|
|
364
|
+
byId.set(notification.id, notification);
|
|
365
|
+
}
|
|
366
|
+
const placed = new Set<string>();
|
|
367
|
+
const next: Array<Shown> = [];
|
|
368
|
+
let at = 0;
|
|
369
|
+
for (const entry of previous) {
|
|
370
|
+
const current = byId.get(entry.notification.id);
|
|
371
|
+
if (current == null) {
|
|
372
|
+
next.push(entry.open ? { notification: entry.notification, open: false } : entry);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
// Whatever arrived in the queue before this one goes in front of it.
|
|
376
|
+
while (at < queued.length && queued[at].id !== current.id) {
|
|
377
|
+
const arrived = queued[at];
|
|
378
|
+
if (
|
|
379
|
+
!placed.has(arrived.id) &&
|
|
380
|
+
!previous.some((each) => each.notification.id === arrived.id)
|
|
381
|
+
) {
|
|
382
|
+
next.push({ notification: arrived, open: true });
|
|
383
|
+
placed.add(arrived.id);
|
|
384
|
+
}
|
|
385
|
+
at += 1;
|
|
386
|
+
}
|
|
387
|
+
next.push({ notification: current, open: true });
|
|
388
|
+
placed.add(current.id);
|
|
389
|
+
at += 1;
|
|
390
|
+
}
|
|
391
|
+
for (; at < queued.length; at += 1) {
|
|
392
|
+
const arrived = queued[at];
|
|
393
|
+
if (!placed.has(arrived.id)) {
|
|
394
|
+
next.push({ notification: arrived, open: true });
|
|
395
|
+
placed.add(arrived.id);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return next;
|
|
399
|
+
}
|
|
400
|
+
|
|
322
401
|
hook useNotification(part: string): Notification {
|
|
323
402
|
const notification = useContext(NotificationContext);
|
|
324
403
|
if (notification == null) {
|
|
@@ -387,12 +466,37 @@ component ToastRegion(
|
|
|
387
466
|
// pile that hides what arrived first. What is over the limit is not rendered
|
|
388
467
|
// at all, which is also what keeps its countdown from running before anyone
|
|
389
468
|
// has seen it.
|
|
390
|
-
const
|
|
469
|
+
const visible = queued.slice(0, limit);
|
|
470
|
+
|
|
471
|
+
// What was queued at the last render, and what the region is rendering: the
|
|
472
|
+
// visible notifications, and the dismissed ones still playing their exit.
|
|
473
|
+
// Brought up to date during render, the pattern React documents for
|
|
474
|
+
// "storing information from previous renders", so a dismissal never commits
|
|
475
|
+
// a frame without the notification that is leaving.
|
|
476
|
+
const [seen, setSeen] = useState<$ReadOnlyArray<Notification>>(visible);
|
|
477
|
+
const [shown, setShown] = useState<$ReadOnlyArray<Shown>>(() =>
|
|
478
|
+
visible.map((notification) => ({ notification, open: true })),
|
|
479
|
+
);
|
|
480
|
+
if (!sameNotifications(seen, visible)) {
|
|
481
|
+
setSeen(visible);
|
|
482
|
+
setShown(nextShown(shown, visible));
|
|
483
|
+
}
|
|
391
484
|
|
|
392
|
-
const place = (
|
|
393
|
-
<
|
|
394
|
-
{
|
|
395
|
-
|
|
485
|
+
const place = (entry: Shown) => (
|
|
486
|
+
<ToastLifeContext.Provider
|
|
487
|
+
key={entry.notification.id}
|
|
488
|
+
value={{
|
|
489
|
+
open: entry.open,
|
|
490
|
+
gone: () =>
|
|
491
|
+
setShown((current) =>
|
|
492
|
+
current.filter((each) => each.open || each.notification.id !== entry.notification.id),
|
|
493
|
+
),
|
|
494
|
+
}}
|
|
495
|
+
>
|
|
496
|
+
<NotificationContext.Provider value={entry.notification}>
|
|
497
|
+
{children(entry.notification)}
|
|
498
|
+
</NotificationContext.Provider>
|
|
499
|
+
</ToastLifeContext.Provider>
|
|
396
500
|
);
|
|
397
501
|
|
|
398
502
|
return (
|
|
@@ -408,15 +512,23 @@ component ToastRegion(
|
|
|
408
512
|
tabIndex={-1}
|
|
409
513
|
>
|
|
410
514
|
<div aria-atomic="true" aria-live="polite" role="status">
|
|
411
|
-
{shown.filter((each) => each.urgency === "polite").map(place)}
|
|
515
|
+
{shown.filter((each) => each.notification.urgency === "polite").map(place)}
|
|
412
516
|
</div>
|
|
413
517
|
<div aria-atomic="true" aria-live="assertive" role="alert">
|
|
414
|
-
{shown.filter((each) => each.urgency === "assertive").map(place)}
|
|
518
|
+
{shown.filter((each) => each.notification.urgency === "assertive").map(place)}
|
|
415
519
|
</div>
|
|
416
520
|
</div>
|
|
417
521
|
);
|
|
418
522
|
}
|
|
419
523
|
|
|
524
|
+
/** Whether two lists hold the same notifications, in the same order. */
|
|
525
|
+
function sameNotifications(
|
|
526
|
+
left: $ReadOnlyArray<Notification>,
|
|
527
|
+
right: $ReadOnlyArray<Notification>,
|
|
528
|
+
): boolean {
|
|
529
|
+
return left.length === right.length && left.every((each, index) => each === right[index]);
|
|
530
|
+
}
|
|
531
|
+
|
|
420
532
|
/**
|
|
421
533
|
* One notification, and the countdown that stops.
|
|
422
534
|
*
|
|
@@ -434,6 +546,18 @@ component ToastRoot(children: React.Node, ...rest: Rest) {
|
|
|
434
546
|
const [titled, setTitled] = useState(false);
|
|
435
547
|
const [described, setDescribed] = useState(false);
|
|
436
548
|
const passed = withoutComposed(rest, ["ref"]);
|
|
549
|
+
// Outside a `Toast.Region` — a notification rendered on its own, in a story
|
|
550
|
+
// or a test — there is no queue to leave, so it is simply open.
|
|
551
|
+
const life = useContext(ToastLifeContext);
|
|
552
|
+
const presence = usePresence(life?.open ?? true, elementRef);
|
|
553
|
+
const gone = life?.gone;
|
|
554
|
+
const present = presence.present;
|
|
555
|
+
|
|
556
|
+
useEffect(() => {
|
|
557
|
+
if (!present) {
|
|
558
|
+
gone?.();
|
|
559
|
+
}
|
|
560
|
+
}, [present, gone]);
|
|
437
561
|
|
|
438
562
|
const hovered = useHover(elementRef);
|
|
439
563
|
const focusInside = useFocusWithin(elementRef);
|
|
@@ -481,10 +605,15 @@ component ToastRoot(children: React.Node, ...rest: Rest) {
|
|
|
481
605
|
[base],
|
|
482
606
|
);
|
|
483
607
|
|
|
608
|
+
if (!present) {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
|
|
484
612
|
return (
|
|
485
613
|
<ToastPartsContext.Provider value={parts}>
|
|
486
614
|
<div
|
|
487
615
|
{...passed}
|
|
616
|
+
{...presenceProps(presence)}
|
|
488
617
|
aria-describedby={described ? parts.descriptionId : undefined}
|
|
489
618
|
aria-labelledby={titled ? parts.titleId : undefined}
|
|
490
619
|
ref={composeRefs(rest.ref, (node) => {
|
package/tooltip.js
CHANGED
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
} from "./internal/hover-intent.js";
|
|
89
89
|
import { useAnchor } from "./internal/anchor.js";
|
|
90
90
|
import { useControlled } from "./internal/controlled-state.js";
|
|
91
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
91
92
|
|
|
92
93
|
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
93
94
|
|
|
@@ -343,6 +344,9 @@ component TooltipBody(
|
|
|
343
344
|
intent.cancel();
|
|
344
345
|
tooltip.setOpen(false);
|
|
345
346
|
});
|
|
347
|
+
// On the page while its exit runs. The trigger's `aria-describedby` and
|
|
348
|
+
// everything below stay keyed on `open`.
|
|
349
|
+
const presence = usePresence(open, bodyRef);
|
|
346
350
|
|
|
347
351
|
const anchored = useAnchor({
|
|
348
352
|
align,
|
|
@@ -350,7 +354,7 @@ component TooltipBody(
|
|
|
350
354
|
anchorRef: triggerRef,
|
|
351
355
|
avoidCollisions,
|
|
352
356
|
collisionPadding,
|
|
353
|
-
open,
|
|
357
|
+
open: presence.present,
|
|
354
358
|
overlayRef: bodyRef,
|
|
355
359
|
side,
|
|
356
360
|
sideOffset,
|
|
@@ -377,7 +381,7 @@ component TooltipBody(
|
|
|
377
381
|
};
|
|
378
382
|
}, [open, intent, closeDelay]);
|
|
379
383
|
|
|
380
|
-
if (!
|
|
384
|
+
if (!presence.present) {
|
|
381
385
|
return null;
|
|
382
386
|
}
|
|
383
387
|
|
|
@@ -385,7 +389,7 @@ component TooltipBody(
|
|
|
385
389
|
children,
|
|
386
390
|
"data-align": anchored.align,
|
|
387
391
|
"data-side": anchored.side,
|
|
388
|
-
|
|
392
|
+
...presenceProps(presence),
|
|
389
393
|
id: `${tooltip.base}-body`,
|
|
390
394
|
// React calls callback refs during commit; placement effects read it later.
|
|
391
395
|
// uf-lint-disable-next-line react-compiler/refs
|