@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 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 { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
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
- usePresence(item.registerContent);
337
- useUntilFound(contentRef, item.open);
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
- hidden: !item.open,
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 { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
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
- usePresence(collapsible.registerContent);
153
- useUntilFound(contentRef, collapsible.open);
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: !collapsible.open,
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: combobox.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 (!combobox.open) {
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
- if (!dialog.open) {
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, { "aria-hidden": "true", "data-state": "open" });
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 (!dialog.open) {
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
- "aria-modal": "true",
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
- if (!entry.inert) {
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 { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
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 effect below is cleaned up the
222
- // element is gone from the document and `activeElement` has already fallen to
223
- // `<body>` — so the answer is kept while it is still true.
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
- useEffect(() => {
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 (!open) {
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
- "data-state": "open",
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
@@ -202,7 +202,16 @@ export type AnchorRequest = {|
202
202
  */
203
203
  readonly anchorRect?: Rect | null,
204
204
  readonly overlayRef: { current: HTMLElement | null },
205
- /** Nothing is measured while it is closed: there is nothing to measure. */
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,
@@ -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 === selectedSet) return;
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, selectedSet, key)
226
- : replaceWith(policy, selectedSet, key),
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, selectedSet, order, anchor.current, lead.current, key));
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) setExpanded([...expanded, ...missing]);
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)) setExpanded([...expanded, 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
- setExpanded(expanded.filter((each) => each !== key));
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, selectedSet, order));
384
+ commit(selectAll(policy, latestSelected(), order));
345
385
  } else if (event.key === "Escape") {
346
386
  if (escapeKeyBehavior !== "clearSelection") return;
347
- const cleared = clearAll(policy, selectedSet);
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 === selectedSet) return;
390
+ if (cleared === previous) return;
350
391
  commit(cleared);
351
392
  } else if (event.key === "Enter" && activeKey != null && onAction != null) {
352
393
  onAction(activeKey);
@@ -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 usePresence(register: ((present: boolean) => void) | void): void {
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
- * The element must be rendered with a plain boolean `hidden` as well — see the
134
- * module header. This only upgrades the attribute React has already committed,
135
- * so a browser that has never heard of `until-found` sees exactly the `hidden`
136
- * it would have seen, and one that has can reveal the section for a
137
- * find-in-page hit.
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 }, open: boolean): void {
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 open: React has removed the attribute, and
143
- // adding one back would hide a panel the reader just opened.
144
- if (element == null || open) {
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, open]);
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
- return element.getBoundingClientRect().height;
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
- const measured = `${String(heightOf(element))}px`;
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
+ }