@uniflowed/ui 0.8.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/accordion.js
CHANGED
|
@@ -99,7 +99,8 @@ import {
|
|
|
99
99
|
} from "./internal/merge-props.js";
|
|
100
100
|
import { moveOnKey } from "./internal/roving-focus.js";
|
|
101
101
|
import type { RovingSet } from "./internal/roving-focus.js";
|
|
102
|
-
import {
|
|
102
|
+
import { useDisclosurePanel, useRegistered } from "./internal/disclosure.js";
|
|
103
|
+
import { presenceProps } from "./internal/presence.js";
|
|
103
104
|
import { useControlled } from "./internal/controlled-state.js";
|
|
104
105
|
|
|
105
106
|
/** Whether one section is open at a time, or any number of them. */
|
|
@@ -333,15 +334,17 @@ component AccordionContent(children: React.Node, render?: RenderProp, ...rest: R
|
|
|
333
334
|
const accordion = useAccordion("Accordion.Content");
|
|
334
335
|
const item = useAccordionItem("Accordion.Content");
|
|
335
336
|
const contentRef = useRef<HTMLElement | null>(null);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
useMeasuredHeight(contentRef, accordion.measure);
|
|
337
|
+
useRegistered(item.registerContent);
|
|
338
|
+
const presence = useDisclosurePanel(contentRef, item.open, accordion.measure);
|
|
339
339
|
|
|
340
340
|
const props = withProps(withoutComposed(rest, ["ref"]), {
|
|
341
|
+
...presenceProps(presence),
|
|
341
342
|
// The name a reader hears for this landmark is the header they pressed.
|
|
342
343
|
"aria-labelledby": item.triggerId,
|
|
343
344
|
children,
|
|
344
|
-
|
|
345
|
+
// After the panel's closing transition, not at the moment it closes; see
|
|
346
|
+
// `collapsible.js`'s header.
|
|
347
|
+
hidden: !presence.present,
|
|
345
348
|
id: item.contentId,
|
|
346
349
|
// React calls callback refs during commit; this node is only read by effects.
|
|
347
350
|
// uf-lint-disable-next-line react-compiler/refs
|
package/collapsible.js
CHANGED
|
@@ -35,12 +35,21 @@
|
|
|
35
35
|
//
|
|
36
36
|
// [data-collapsible-content] {
|
|
37
37
|
// overflow: hidden;
|
|
38
|
-
// transition: height 150ms;
|
|
39
|
-
// height: 0;
|
|
40
|
-
// }
|
|
41
|
-
// [data-collapsible-content]:not([hidden]) {
|
|
42
38
|
// height: var(--uf-collapsible-height);
|
|
39
|
+
// transition: height 200ms;
|
|
40
|
+
// }
|
|
41
|
+
// @starting-style {
|
|
42
|
+
// [data-collapsible-content] { height: 0; }
|
|
43
43
|
// }
|
|
44
|
+
// [data-collapsible-content][data-state=closed] {
|
|
45
|
+
// height: 0;
|
|
46
|
+
// transition-duration: 150ms;
|
|
47
|
+
// }
|
|
48
|
+
//
|
|
49
|
+
// It opens from the `@starting-style` and closes on `[data-state=closed]`.
|
|
50
|
+
// A closing panel is not `hidden` yet: it stays on screen, closed and `inert`,
|
|
51
|
+
// until that transition has finished, and becomes `hidden` (and findable) only
|
|
52
|
+
// then. With no transition to wait for it is hidden at once.
|
|
44
53
|
//
|
|
45
54
|
// The selector is the caller's — a class, a `data-*` of their own, whatever
|
|
46
55
|
// they already style with. This package emits the number and no styles at all,
|
|
@@ -59,7 +68,8 @@ import {
|
|
|
59
68
|
withProps,
|
|
60
69
|
withoutComposed,
|
|
61
70
|
} from "./internal/merge-props.js";
|
|
62
|
-
import {
|
|
71
|
+
import { useDisclosurePanel, useRegistered } from "./internal/disclosure.js";
|
|
72
|
+
import { presenceProps } from "./internal/presence.js";
|
|
63
73
|
import { useControlled } from "./internal/controlled-state.js";
|
|
64
74
|
|
|
65
75
|
type CollapsibleState = {|
|
|
@@ -144,18 +154,19 @@ component CollapsibleTrigger(
|
|
|
144
154
|
*
|
|
145
155
|
* It is always rendered and `hidden` while closed, rather than removed — see
|
|
146
156
|
* the module header, and `internal/disclosure.js` for what `hidden` is upgraded
|
|
147
|
-
* to and why that takes an effect.
|
|
157
|
+
* to and why that takes an effect. `data-state` says whether it is open or
|
|
158
|
+
* closing, and `hidden` waits for a closing transition to finish.
|
|
148
159
|
*/
|
|
149
160
|
component CollapsibleContent(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
150
161
|
const collapsible = useCollapsible("Collapsible.Content");
|
|
151
162
|
const contentRef = useRef<HTMLElement | null>(null);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
useMeasuredHeight(contentRef, collapsible.measure);
|
|
163
|
+
useRegistered(collapsible.registerContent);
|
|
164
|
+
const presence = useDisclosurePanel(contentRef, collapsible.open, collapsible.measure);
|
|
155
165
|
|
|
156
166
|
const props = withProps(withoutComposed(rest, ["ref"]), {
|
|
167
|
+
...presenceProps(presence),
|
|
157
168
|
children,
|
|
158
|
-
hidden: !
|
|
169
|
+
hidden: !presence.present,
|
|
159
170
|
id: collapsible.contentId,
|
|
160
171
|
// React calls callback refs during commit; this node is only read by effects.
|
|
161
172
|
// uf-lint-disable-next-line react-compiler/refs
|
package/combobox.js
CHANGED
|
@@ -136,6 +136,7 @@ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-
|
|
|
136
136
|
import { itemsOf, moveTo } from "./internal/roving-focus.js";
|
|
137
137
|
import { useControlled } from "./internal/controlled-state.js";
|
|
138
138
|
import { FormValue } from "./internal/form-value.js";
|
|
139
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
139
140
|
|
|
140
141
|
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
141
142
|
|
|
@@ -458,6 +459,9 @@ component ComboboxList(
|
|
|
458
459
|
combobox.setOpen(false);
|
|
459
460
|
combobox.setActiveId(null);
|
|
460
461
|
});
|
|
462
|
+
// On the page while its exit runs. The field's `aria-controls` and
|
|
463
|
+
// `aria-activedescendant`, and the outside press, stay keyed on `open`.
|
|
464
|
+
const presence = usePresence(combobox.open, listRef);
|
|
461
465
|
|
|
462
466
|
// Anchored to the *field*, not to a wrapper the caller may not have written.
|
|
463
467
|
// `align="start"` because a list of options belongs under the edge the text
|
|
@@ -469,7 +473,7 @@ component ComboboxList(
|
|
|
469
473
|
anchorRef: inputRef,
|
|
470
474
|
avoidCollisions,
|
|
471
475
|
collisionPadding,
|
|
472
|
-
open:
|
|
476
|
+
open: presence.present,
|
|
473
477
|
overlayRef: listRef,
|
|
474
478
|
side,
|
|
475
479
|
sideOffset,
|
|
@@ -521,7 +525,7 @@ component ComboboxList(
|
|
|
521
525
|
refs: [listRef, inputRef],
|
|
522
526
|
});
|
|
523
527
|
|
|
524
|
-
if (!
|
|
528
|
+
if (!presence.present) {
|
|
525
529
|
return null;
|
|
526
530
|
}
|
|
527
531
|
|
|
@@ -530,6 +534,7 @@ component ComboboxList(
|
|
|
530
534
|
return (
|
|
531
535
|
<div
|
|
532
536
|
{...passed}
|
|
537
|
+
{...presenceProps(presence)}
|
|
533
538
|
aria-labelledby={combobox.labelled ? `${combobox.base}-label` : undefined}
|
|
534
539
|
data-align={anchored.align}
|
|
535
540
|
data-side={anchored.side}
|
package/dialog.js
CHANGED
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
} from "./internal/merge-props.js";
|
|
90
90
|
import { focusable } from "./internal/focus.js";
|
|
91
91
|
import { useControlled } from "./internal/controlled-state.js";
|
|
92
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
95
|
* What a screen reader is told the dialog is.
|
|
@@ -205,10 +206,22 @@ component DialogTrigger(children: React.Node, render?: RenderProp, ...rest: Rest
|
|
|
205
206
|
*/
|
|
206
207
|
component DialogOverlay(render?: RenderProp, ...rest: Rest) {
|
|
207
208
|
const dialog = useDialog("Dialog.Overlay");
|
|
208
|
-
|
|
209
|
+
const overlayRef = useRef<HTMLElement | null>(null);
|
|
210
|
+
// It fades out with the panel rather than vanishing under it, and it is
|
|
211
|
+
// `inert` while it does, so a press on the fading scrim reaches the page.
|
|
212
|
+
const presence = usePresence(dialog.open, overlayRef);
|
|
213
|
+
if (!presence.present) {
|
|
209
214
|
return null;
|
|
210
215
|
}
|
|
211
|
-
const props = withProps(rest,
|
|
216
|
+
const props = withProps(withoutComposed(rest, ["ref"]), {
|
|
217
|
+
...presenceProps(presence),
|
|
218
|
+
"aria-hidden": "true",
|
|
219
|
+
// React calls callback refs during commit; the presence hook reads it later.
|
|
220
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
221
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
222
|
+
overlayRef.current = element;
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
212
225
|
if (render != null) {
|
|
213
226
|
return render(props);
|
|
214
227
|
}
|
|
@@ -251,6 +264,10 @@ component DialogBody(
|
|
|
251
264
|
// shared one is the one that also pads out the scrollbar's width — a page
|
|
252
265
|
// that jumps sideways when a dialog opens is this component's doing.
|
|
253
266
|
useScrollLock(dialog.open);
|
|
267
|
+
// On the page for as long as its exit runs. Everything above and below is
|
|
268
|
+
// keyed on `dialog.open` instead, so the lock lifts, the page comes back and
|
|
269
|
+
// focus returns at the moment of closing, not when the panel has faded.
|
|
270
|
+
const presence = usePresence(dialog.open, bodyRef);
|
|
254
271
|
|
|
255
272
|
useEffect(() => {
|
|
256
273
|
const body = bodyRef.current;
|
|
@@ -299,7 +316,7 @@ component DialogBody(
|
|
|
299
316
|
refs: [bodyRef, dialog.triggerRef],
|
|
300
317
|
});
|
|
301
318
|
|
|
302
|
-
if (!
|
|
319
|
+
if (!presence.present) {
|
|
303
320
|
return null;
|
|
304
321
|
}
|
|
305
322
|
|
|
@@ -314,8 +331,11 @@ component DialogBody(
|
|
|
314
331
|
// the caller passed instead.
|
|
315
332
|
"aria-describedby": dialog.described ? `${dialog.base}-description` : undefined,
|
|
316
333
|
"aria-labelledby": dialog.titled ? `${dialog.base}-title` : undefined,
|
|
317
|
-
|
|
334
|
+
// Only while open: a closing panel is `inert`, and a modal a reader cannot
|
|
335
|
+
// reach must not go on telling them the rest of the page is unavailable.
|
|
336
|
+
"aria-modal": dialog.open ? "true" : undefined,
|
|
318
337
|
children,
|
|
338
|
+
...presenceProps(presence),
|
|
319
339
|
id: `${dialog.base}-body`,
|
|
320
340
|
// Key handling closes the mounted dialog and restores focus after events.
|
|
321
341
|
// uf-lint-disable-next-line react-compiler/refs
|
|
@@ -519,7 +539,11 @@ function concealOutside(element: HTMLElement): () => void {
|
|
|
519
539
|
} else {
|
|
520
540
|
entry.element.setAttribute("aria-hidden", entry.hidden);
|
|
521
541
|
}
|
|
522
|
-
|
|
542
|
+
// Nor a part that is on its way out. A scrim beside the panel was
|
|
543
|
+
// concealed with the rest of the page, and is now fading out `inert` of
|
|
544
|
+
// its own accord (`internal/presence.js`); taking that away would leave
|
|
545
|
+
// an invisible layer over the page that swallows the next press.
|
|
546
|
+
if (!entry.inert && entry.element.getAttribute("data-state") !== "closed") {
|
|
523
547
|
entry.element.removeAttribute("inert");
|
|
524
548
|
}
|
|
525
549
|
}
|
package/hover-card.js
CHANGED
|
@@ -39,7 +39,15 @@
|
|
|
39
39
|
"use client";
|
|
40
40
|
|
|
41
41
|
import * as React from "@uniflowed/react";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
createContext,
|
|
44
|
+
useContext,
|
|
45
|
+
useEffect,
|
|
46
|
+
useId,
|
|
47
|
+
useLayoutEffect,
|
|
48
|
+
useMemo,
|
|
49
|
+
useRef,
|
|
50
|
+
} from "@uniflowed/react";
|
|
43
51
|
import { useEventListener } from "@uniflowed/hooks/dom";
|
|
44
52
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
45
53
|
|
|
@@ -56,6 +64,7 @@ import {
|
|
|
56
64
|
} from "./internal/hover-intent.js";
|
|
57
65
|
import { useAnchor } from "./internal/anchor.js";
|
|
58
66
|
import { useControlled } from "./internal/controlled-state.js";
|
|
67
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
59
68
|
|
|
60
69
|
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
61
70
|
|
|
@@ -218,15 +227,17 @@ component HoverCardBody(
|
|
|
218
227
|
const bodyRef = useRef<HTMLElement | null>(null);
|
|
219
228
|
// Whether the reader is *in* the card, as opposed to over it. It decides one
|
|
220
229
|
// thing and it cannot be asked afterwards: a card closed while it held focus
|
|
221
|
-
// has to hand focus back, and by the time the
|
|
222
|
-
//
|
|
223
|
-
//
|
|
230
|
+
// has to hand focus back, and by the time the card has closed the element is
|
|
231
|
+
// `inert` or gone and `activeElement` may already have fallen to `<body>` —
|
|
232
|
+
// so the answer is kept while it is still true.
|
|
224
233
|
const heldRef = useRef(false);
|
|
225
234
|
const close = useStableCallback(() => {
|
|
226
235
|
dismissedRef.current = true;
|
|
227
236
|
intent.cancel();
|
|
228
237
|
card.setOpen(false);
|
|
229
238
|
});
|
|
239
|
+
// On the page while its exit runs; everything else stays keyed on `open`.
|
|
240
|
+
const presence = usePresence(open, bodyRef);
|
|
230
241
|
|
|
231
242
|
const anchored = useAnchor({
|
|
232
243
|
align,
|
|
@@ -234,7 +245,7 @@ component HoverCardBody(
|
|
|
234
245
|
anchorRef: triggerRef,
|
|
235
246
|
avoidCollisions,
|
|
236
247
|
collisionPadding,
|
|
237
|
-
open,
|
|
248
|
+
open: presence.present,
|
|
238
249
|
overlayRef: bodyRef,
|
|
239
250
|
side,
|
|
240
251
|
sideOffset,
|
|
@@ -287,7 +298,13 @@ component HoverCardBody(
|
|
|
287
298
|
// dependencies change — and `closeDelay` is a caller's prop. A caller
|
|
288
299
|
// changing it while the card was open with focus inside pulled focus off the
|
|
289
300
|
// link the reader was on, and the effect then re-attached with `held` reset.
|
|
290
|
-
|
|
301
|
+
//
|
|
302
|
+
// A layout effect, so it runs in the commit that closed the card. The card
|
|
303
|
+
// stays on the page, `inert`, while its exit plays, and a browser moves focus
|
|
304
|
+
// out of an inert element when it next renders. That move fires `focusout`,
|
|
305
|
+
// which would say the reader had left before this could ask whether they were
|
|
306
|
+
// inside.
|
|
307
|
+
useLayoutEffect(() => {
|
|
291
308
|
if (open) {
|
|
292
309
|
return;
|
|
293
310
|
}
|
|
@@ -309,7 +326,7 @@ component HoverCardBody(
|
|
|
309
326
|
[triggerRef],
|
|
310
327
|
);
|
|
311
328
|
|
|
312
|
-
if (!
|
|
329
|
+
if (!presence.present) {
|
|
313
330
|
return null;
|
|
314
331
|
}
|
|
315
332
|
|
|
@@ -317,7 +334,7 @@ component HoverCardBody(
|
|
|
317
334
|
children,
|
|
318
335
|
"data-align": anchored.align,
|
|
319
336
|
"data-side": anchored.side,
|
|
320
|
-
|
|
337
|
+
...presenceProps(presence),
|
|
321
338
|
id: `${card.base}-body`,
|
|
322
339
|
// React calls callback refs during commit; placement effects read it later.
|
|
323
340
|
// uf-lint-disable-next-line react-compiler/refs
|
package/internal/anchor.js
CHANGED
|
@@ -202,7 +202,16 @@ export type AnchorRequest = {|
|
|
|
202
202
|
*/
|
|
203
203
|
readonly anchorRect?: Rect | null,
|
|
204
204
|
readonly overlayRef: { current: HTMLElement | null },
|
|
205
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Whether the overlay is on the page. Nothing is measured while it is not:
|
|
207
|
+
* there is nothing to measure.
|
|
208
|
+
*
|
|
209
|
+
* A part that plays an exit passes its presence here (`usePresence`'s
|
|
210
|
+
* `present`) rather than its open state, so a closing overlay keeps its place
|
|
211
|
+
* against the trigger, and keeps its `data-side`, until it has gone. An exit
|
|
212
|
+
* that slid towards the side it was asked for rather than the side it
|
|
213
|
+
* flipped to would leave in the wrong direction.
|
|
214
|
+
*/
|
|
206
215
|
readonly open: boolean,
|
|
207
216
|
/** Resolved against the trigger's writing direction; see `LogicalSide`. */
|
|
208
217
|
readonly side: LogicalSide,
|
package/internal/collection.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// inside a popover still lets the popover close on the Escape that follows.
|
|
32
32
|
|
|
33
33
|
import * as React from "@uniflowed/react";
|
|
34
|
-
import { useId, useRef, useState } from "@uniflowed/react";
|
|
34
|
+
import { useEffect, useId, useRef, useState } from "@uniflowed/react";
|
|
35
35
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
36
36
|
import { useDragAndDrop } from "../drag-drop.js";
|
|
37
37
|
import { useControlled } from "./controlled-state.js";
|
|
@@ -177,6 +177,36 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
177
177
|
defaultExpandedKeys,
|
|
178
178
|
onExpandedChange,
|
|
179
179
|
);
|
|
180
|
+
// The expansion as the last keystroke left it, which can be ahead of the
|
|
181
|
+
// `expanded` this render read: two tree keys can land before React renders the
|
|
182
|
+
// first, and `keydown` is a `useStableCallback`, so it runs with the values of
|
|
183
|
+
// the render that installed it. Adding to the render's array meant the second
|
|
184
|
+
// write dropped what the first had opened — `*` then `ArrowRight` collapsed the
|
|
185
|
+
// sibling `*` had just expanded (#1621). #1611 and #1620 hold a ref for the same
|
|
186
|
+
// window. It is cleared after every commit, so it only ever spans a batch React
|
|
187
|
+
// has not rendered, and a controlled parent still wins the next keystroke.
|
|
188
|
+
const proposedExpanded = useRef<$ReadOnlyArray<string> | null>(null);
|
|
189
|
+
// The selection as the last gesture left it, for the same reason and with the same
|
|
190
|
+
// lifetime: `Ctrl+A` followed by a toggle in one batch recomputed the toggle from
|
|
191
|
+
// the empty selection its own render saw, so it *selected* the row it meant to
|
|
192
|
+
// deselect and dropped everything else (#1624).
|
|
193
|
+
const proposedSelected = useRef<$ReadOnlySet<string> | null>(null);
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
proposedExpanded.current = null;
|
|
196
|
+
proposedSelected.current = null;
|
|
197
|
+
});
|
|
198
|
+
const latestExpanded = useStableCallback(
|
|
199
|
+
(): $ReadOnlyArray<string> => proposedExpanded.current ?? expanded,
|
|
200
|
+
);
|
|
201
|
+
// Only the array written back comes from the latest keystroke. Every *decision*
|
|
202
|
+
// in `keydown` — which branch to take, where focus lands — keeps reading this
|
|
203
|
+
// render's `expandedSet`, `rows` and `enabled`, because those describe the tree
|
|
204
|
+
// the reader is looking at rather than one that has not been rendered yet.
|
|
205
|
+
const expand = useStableCallback((next: $ReadOnlyArray<string>) => {
|
|
206
|
+
const unique = [...new Set(next)];
|
|
207
|
+
proposedExpanded.current = unique;
|
|
208
|
+
setExpanded(unique);
|
|
209
|
+
});
|
|
180
210
|
const [active, setActive] = useState<string | null>(null);
|
|
181
211
|
const [scrollTop, setScrollTop] = useState(0);
|
|
182
212
|
const [announcement, announce] = useState("");
|
|
@@ -210,20 +240,30 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
210
240
|
enabled[0];
|
|
211
241
|
const activeKey = focused?.item.key;
|
|
212
242
|
|
|
243
|
+
// What the selection helpers are given, and what "nothing changed" is measured
|
|
244
|
+
// against. They return their own input when a gesture is a no-op, so the comparison
|
|
245
|
+
// has to be with the set that went in: measuring against this render's `selectedSet`
|
|
246
|
+
// instead would let a no-op Escape look like a change, claim the key, and stop an
|
|
247
|
+
// enclosing popover from closing.
|
|
248
|
+
const latestSelected = useStableCallback(
|
|
249
|
+
(): $ReadOnlySet<string> => proposedSelected.current ?? selectedSet,
|
|
250
|
+
);
|
|
213
251
|
/** Report a selection, unless the gesture changed nothing. */
|
|
214
252
|
const commit = (next: $ReadOnlySet<string>) => {
|
|
215
|
-
if (next ===
|
|
253
|
+
if (next === latestSelected()) return;
|
|
216
254
|
const keys = orderedKeys(next, order);
|
|
255
|
+
proposedSelected.current = next;
|
|
217
256
|
setSelected(keys);
|
|
218
257
|
announce(`${keys.length} selected`);
|
|
219
258
|
};
|
|
220
259
|
/** A plain or Ctrl/Cmd gesture on one row: toggle or replace, and move the anchor. */
|
|
221
260
|
const selectOne = (key: string, toggle: boolean) => {
|
|
222
261
|
if (mode === "none") return;
|
|
262
|
+
const previous = latestSelected();
|
|
223
263
|
commit(
|
|
224
264
|
toggle || selectionBehavior === "toggle"
|
|
225
|
-
? toggleKey(policy,
|
|
226
|
-
: replaceWith(policy,
|
|
265
|
+
? toggleKey(policy, previous, key)
|
|
266
|
+
: replaceWith(policy, previous, key),
|
|
227
267
|
);
|
|
228
268
|
anchor.current = key;
|
|
229
269
|
lead.current = key;
|
|
@@ -231,7 +271,7 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
231
271
|
/** A Shift gesture: grow from the anchor, which is the active row if nothing set one. */
|
|
232
272
|
const extend = (key: string, from: string | void) => {
|
|
233
273
|
if (anchor.current == null || !order.includes(anchor.current)) anchor.current = from ?? key;
|
|
234
|
-
commit(extendTo(policy,
|
|
274
|
+
commit(extendTo(policy, latestSelected(), order, anchor.current, lead.current, key));
|
|
235
275
|
lead.current = key;
|
|
236
276
|
};
|
|
237
277
|
const scrollTo = (row: Entry) => {
|
|
@@ -325,7 +365,7 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
325
365
|
(row) => row.parent === focused.parent && (row.item.children?.length ?? 0) > 0,
|
|
326
366
|
);
|
|
327
367
|
const missing = siblings.map((row) => row.item.key).filter((key) => !expandedSet.has(key));
|
|
328
|
-
if (missing.length > 0)
|
|
368
|
+
if (missing.length > 0) expand([...latestExpanded(), ...missing]);
|
|
329
369
|
} else if (
|
|
330
370
|
kind === "tree" &&
|
|
331
371
|
focused != null &&
|
|
@@ -334,19 +374,20 @@ export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
|
334
374
|
const open = event.key === (rtl ? "ArrowLeft" : "ArrowRight");
|
|
335
375
|
const key = focused.item.key;
|
|
336
376
|
if (open && (focused.item.children?.length ?? 0) > 0) {
|
|
337
|
-
if (!expandedSet.has(key))
|
|
377
|
+
if (!expandedSet.has(key)) expand([...latestExpanded(), key]);
|
|
338
378
|
else next = enabled[at + 1];
|
|
339
379
|
} else if (!open && expandedSet.has(key))
|
|
340
|
-
|
|
380
|
+
expand(latestExpanded().filter((each) => each !== key));
|
|
341
381
|
else if (!open) next = enabled.find((row) => row.item.key === focused.parent);
|
|
342
382
|
} else if (modifier && event.key.toLowerCase() === "a" && !event.altKey) {
|
|
343
383
|
if (mode !== "multiple") return;
|
|
344
|
-
commit(selectAll(policy,
|
|
384
|
+
commit(selectAll(policy, latestSelected(), order));
|
|
345
385
|
} else if (event.key === "Escape") {
|
|
346
386
|
if (escapeKeyBehavior !== "clearSelection") return;
|
|
347
|
-
const
|
|
387
|
+
const previous = latestSelected();
|
|
388
|
+
const cleared = clearAll(policy, previous);
|
|
348
389
|
// Unclaimed when nothing changed, so an enclosing popover still closes.
|
|
349
|
-
if (cleared ===
|
|
390
|
+
if (cleared === previous) return;
|
|
350
391
|
commit(cleared);
|
|
351
392
|
} else if (event.key === "Enter" && activeKey != null && onAction != null) {
|
|
352
393
|
onAction(activeKey);
|
package/internal/disclosure.js
CHANGED
|
@@ -109,6 +109,9 @@
|
|
|
109
109
|
|
|
110
110
|
import { useEffect } from "@uniflowed/react";
|
|
111
111
|
|
|
112
|
+
import type { Presence } from "./presence.js";
|
|
113
|
+
import { isAnimating, usePresence } from "./presence.js";
|
|
114
|
+
|
|
112
115
|
/**
|
|
113
116
|
* Report that this part is in the document, for as long as it is.
|
|
114
117
|
*
|
|
@@ -117,7 +120,7 @@ import { useEffect } from "@uniflowed/react";
|
|
|
117
120
|
* function lets a part be rendered outside the thing that would name it
|
|
118
121
|
* without the caller having to care.
|
|
119
122
|
*/
|
|
120
|
-
export hook
|
|
123
|
+
export hook useRegistered(register: ((present: boolean) => void) | void): void {
|
|
121
124
|
useEffect(() => {
|
|
122
125
|
if (register == null) {
|
|
123
126
|
return;
|
|
@@ -130,22 +133,51 @@ export hook usePresence(register: ((present: boolean) => void) | void): void {
|
|
|
130
133
|
/**
|
|
131
134
|
* Keep a closed panel hidden the way the platform means it: findable.
|
|
132
135
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
136
|
+
* `shown` is whether the panel is on screen, which for a panel that animates
|
|
137
|
+
* its height is longer than it is open: see `useDisclosurePanel`. The element
|
|
138
|
+
* must be rendered with a plain boolean `hidden` whenever `shown` is false —
|
|
139
|
+
* see the module header. This only upgrades the attribute React has already
|
|
140
|
+
* committed, so a browser that has never heard of `until-found` sees exactly
|
|
141
|
+
* the `hidden` it would have seen, and one that has can reveal the section for
|
|
142
|
+
* a find-in-page hit.
|
|
138
143
|
*/
|
|
139
|
-
export hook useUntilFound(ref: { current: HTMLElement | null },
|
|
144
|
+
export hook useUntilFound(ref: { current: HTMLElement | null }, shown: boolean): void {
|
|
140
145
|
useEffect(() => {
|
|
141
146
|
const element = ref.current;
|
|
142
|
-
// Nothing to do while it is
|
|
143
|
-
// adding one back would hide a panel the reader just opened
|
|
144
|
-
|
|
147
|
+
// Nothing to do while it is shown: React has removed the attribute, and
|
|
148
|
+
// adding one back would hide a panel the reader just opened, or cut short
|
|
149
|
+
// one that is still closing.
|
|
150
|
+
if (element == null || shown) {
|
|
145
151
|
return;
|
|
146
152
|
}
|
|
147
153
|
element.setAttribute("hidden", "until-found");
|
|
148
|
-
}, [ref,
|
|
154
|
+
}, [ref, shown]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A disclosure's panel: whether it is shown, and the state it is in.
|
|
159
|
+
*
|
|
160
|
+
* `open` is the disclosure's state, which the trigger's `aria-expanded` says.
|
|
161
|
+
* The panel is shown for longer than that. When it closes it stays on screen,
|
|
162
|
+
* with `data-state="closed"` and `inert`, for as long as its height transition
|
|
163
|
+
* runs, and only then becomes `hidden` — so a stylesheet can take it from
|
|
164
|
+
* `var(--uf-collapsible-height)` down to `0`, which it could not if the panel
|
|
165
|
+
* were `display: none` at the moment of closing. `internal/presence.js` decides
|
|
166
|
+
* when the transition has finished, and with nothing animating the panel is
|
|
167
|
+
* hidden in the same commit, as it always was.
|
|
168
|
+
*
|
|
169
|
+
* The caller renders `hidden={!presence.present}` and spreads
|
|
170
|
+
* `presenceProps(presence)`.
|
|
171
|
+
*/
|
|
172
|
+
export hook useDisclosurePanel(
|
|
173
|
+
ref: { current: HTMLElement | null },
|
|
174
|
+
open: boolean,
|
|
175
|
+
measure: boolean,
|
|
176
|
+
): Presence {
|
|
177
|
+
const presence = usePresence(open, ref);
|
|
178
|
+
useUntilFound(ref, presence.present);
|
|
179
|
+
useMeasuredHeight(ref, measure);
|
|
180
|
+
return presence;
|
|
149
181
|
}
|
|
150
182
|
|
|
151
183
|
/** The custom property a stylesheet transitions a disclosure's height to. */
|
|
@@ -202,10 +234,20 @@ function widthInFlow(element: HTMLElement): string | null {
|
|
|
202
234
|
* inline declaration this wrote.
|
|
203
235
|
*/
|
|
204
236
|
function heightOf(element: HTMLElement): number {
|
|
237
|
+
const style = element.style;
|
|
205
238
|
if (!element.hasAttribute("hidden")) {
|
|
206
|
-
|
|
239
|
+
// Shown, so it has a box, but not necessarily the height of its content:
|
|
240
|
+
// the stylesheet this property exists for holds an open panel at
|
|
241
|
+
// `var(--uf-collapsible-height)`, and a panel whose content grew or shrank
|
|
242
|
+
// would measure the old number for ever. `height: auto` for the one read,
|
|
243
|
+
// as below. `useMeasuredHeight` only asks while nothing is transitioning,
|
|
244
|
+
// so this cannot cut a transition short.
|
|
245
|
+
const height = style.height;
|
|
246
|
+
style.height = "auto";
|
|
247
|
+
const measured = element.getBoundingClientRect().height;
|
|
248
|
+
style.height = height;
|
|
249
|
+
return measured;
|
|
207
250
|
}
|
|
208
|
-
const style = element.style;
|
|
209
251
|
const before = {
|
|
210
252
|
boxSizing: style.boxSizing,
|
|
211
253
|
contentVisibility: style.getPropertyValue("content-visibility"),
|
|
@@ -267,12 +309,7 @@ export hook useMeasuredHeight(ref: { current: HTMLElement | null }, enabled: boo
|
|
|
267
309
|
if (!enabled || element == null) {
|
|
268
310
|
return;
|
|
269
311
|
}
|
|
270
|
-
|
|
271
|
-
// Compared before writing, so a render that changed nothing does not dirty
|
|
272
|
-
// the element's style and invite another style recalculation.
|
|
273
|
-
if (element.style.getPropertyValue(HEIGHT_PROPERTY) !== measured) {
|
|
274
|
-
element.style.setProperty(HEIGHT_PROPERTY, measured);
|
|
275
|
-
}
|
|
312
|
+
remeasure(element);
|
|
276
313
|
});
|
|
277
314
|
|
|
278
315
|
useEffect(() => {
|
|
@@ -289,10 +326,35 @@ export hook useMeasuredHeight(ref: { current: HTMLElement | null }, enabled: boo
|
|
|
289
326
|
if (typeof host.ResizeObserver !== "function") {
|
|
290
327
|
return;
|
|
291
328
|
}
|
|
292
|
-
const sizes = new host.ResizeObserver(() =>
|
|
293
|
-
element.style.setProperty(HEIGHT_PROPERTY, `${String(heightOf(element))}px`);
|
|
294
|
-
});
|
|
329
|
+
const sizes = new host.ResizeObserver(() => remeasure(element));
|
|
295
330
|
sizes.observe(element);
|
|
331
|
+
// And what is inside it. An open panel is held at the height it was
|
|
332
|
+
// measured at, so its own box does not change when an image inside it
|
|
333
|
+
// loads; the content's does.
|
|
334
|
+
for (const child of Array.from(element.children)) {
|
|
335
|
+
sizes.observe(child);
|
|
336
|
+
}
|
|
296
337
|
return () => sizes.disconnect();
|
|
297
338
|
}, [ref, enabled]);
|
|
298
339
|
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Write what `element` would measure into `HEIGHT_PROPERTY`, unless it is
|
|
343
|
+
* moving.
|
|
344
|
+
*
|
|
345
|
+
* A panel opening or closing is mid-transition, and asking then reads a frame
|
|
346
|
+
* of the transition and, for a shown panel, cancels it: `heightOf` sets
|
|
347
|
+
* `height: auto` for its read. The panel was measured before it started to
|
|
348
|
+
* move, and it is measured again once it has stopped.
|
|
349
|
+
*/
|
|
350
|
+
function remeasure(element: HTMLElement): void {
|
|
351
|
+
if (!element.hasAttribute("hidden") && isAnimating(element)) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const measured = `${String(heightOf(element))}px`;
|
|
355
|
+
// Compared before writing, so a render that changed nothing does not dirty
|
|
356
|
+
// the element's style and invite another style recalculation.
|
|
357
|
+
if (element.style.getPropertyValue(HEIGHT_PROPERTY) !== measured) {
|
|
358
|
+
element.style.setProperty(HEIGHT_PROPERTY, measured);
|
|
359
|
+
}
|
|
360
|
+
}
|