@uniflowed/ui 0.0.0-alpha.39 → 0.0.0-alpha.40

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/calendar.js CHANGED
@@ -401,17 +401,14 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
401
401
  {columns.map((day) => (
402
402
  <th
403
403
  // The full day name is the accessible name and the short one is
404
- // what is drawn, because a column of `Wednesday` is not a calendar
405
- // and a screen reader saying "We" is not a day. `aria-hidden` on
406
- // the visible text is what keeps the two from being announced one
407
- // after the other.
404
+ // real header text, because axe rightly refuses an empty table
405
+ // header. `aria-label` wins over the contents for the announced
406
+ // name, so a reader still hears "Wednesday" rather than "Wed".
408
407
  aria-label={day.toLocaleString(calendar.locale, COLUMN_FORMAT)}
409
408
  key={day.dayOfWeek}
410
409
  scope="col"
411
410
  >
412
- <span aria-hidden="true">
413
- {day.toLocaleString(calendar.locale, COLUMN_ABBREVIATION)}
414
- </span>
411
+ {day.toLocaleString(calendar.locale, COLUMN_ABBREVIATION)}
415
412
  </th>
416
413
  ))}
417
414
  </tr>
package/combobox.js CHANGED
@@ -127,6 +127,8 @@ import {
127
127
  } from "@uniflowed/react";
128
128
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
129
129
 
130
+ import { useInteractOutside } from "./interactions.js";
131
+
130
132
  import type { Align, LogicalSide } from "./internal/anchor.js";
131
133
  import { useAnchor } from "./internal/anchor.js";
132
134
  import type { Rest } from "./internal/merge-props.js";
@@ -496,33 +498,18 @@ export component ComboboxList(
496
498
  }
497
499
  });
498
500
 
499
- // Keyed on `combobox.open`, and that is load-bearing. This component is
500
- // mounted the whole time and only *renders* while the list is open, so keyed
501
- // on the stable callbacks alone the effect ran once — on the first commit,
502
- // when `listRef.current` was still null — and never again. The listener was
503
- // never attached, and a press outside the combobox closed nothing.
504
- useEffect(() => {
505
- const list = listRef.current;
506
- if (list == null) {
507
- return;
508
- }
509
- const document = list.ownerDocument;
510
- const onOutsidePress = (event: Event) => {
511
- const target: $FlowFixMe = event.target;
512
- if (target == null || list.contains(target)) {
513
- return;
514
- }
515
- // The field is not "outside": pressing it is how a reader gets back to
516
- // typing, and closing on it would fight the input's own handlers.
517
- const input = inputRef.current;
518
- if (input != null && input.contains(target)) {
519
- return;
520
- }
521
- close();
522
- };
523
- document.addEventListener("pointerdown", onOutsidePress, true);
524
- return () => document.removeEventListener("pointerdown", onOutsidePress, true);
525
- }, [combobox.open, close, listRef, inputRef]);
501
+ // The field is not "outside": pressing it is how a reader gets back to
502
+ // typing, and closing on it would fight the input's own handlers.
503
+ //
504
+ // The refs are read when a press arrives rather than when the listener is
505
+ // attached. This component is mounted the whole time and only *renders*
506
+ // while the list is open, and the listener that was attached on the first
507
+ // commit — when `listRef.current` was still null — closed nothing at all.
508
+ useInteractOutside({
509
+ isDisabled: !combobox.open,
510
+ onInteractOutside: () => close(),
511
+ refs: [listRef, inputRef],
512
+ });
526
513
 
527
514
  if (!combobox.open) {
528
515
  return null;
package/dialog.js CHANGED
@@ -78,6 +78,8 @@ import {
78
78
  import { useScrollLock } from "@uniflowed/hooks/browser";
79
79
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
80
80
 
81
+ import { useInteractOutside } from "./interactions.js";
82
+
81
83
  import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
82
84
  import {
83
85
  composeHandlers,
@@ -261,29 +263,6 @@ export component DialogBody(
261
263
 
262
264
  const restorePage = concealOutside(body);
263
265
 
264
- const onOutsidePress = (event: Event) => {
265
- const target: $FlowFixMe = event.target;
266
- if (target == null || body.contains(target)) {
267
- return;
268
- }
269
- // The trigger is outside the dialog and is not "outside" for this
270
- // purpose: closing here and letting the trigger's own click reopen made
271
- // a press on the trigger a no-op that flickered.
272
- if (trigger != null && trigger.contains(target)) {
273
- return;
274
- }
275
- // A confirmation declines to close here, and `Escape` still does. See
276
- // the module header: there is a difference between a dialog the reader
277
- // dismissed and one that went away while they were reaching for it.
278
- if (!dismissable()) {
279
- return;
280
- }
281
- close();
282
- };
283
- // Capture, so a press is seen even where something below it stops the
284
- // event — a menu inside the dialog, for instance.
285
- document.addEventListener("pointerdown", onOutsidePress, true);
286
-
287
266
  // Where the caller said, then the first thing worth acting on, then the
288
267
  // dialog itself when it holds nothing focusable — so focus is inside it
289
268
  // whichever of the three answers.
@@ -297,14 +276,26 @@ export component DialogBody(
297
276
  target.focus();
298
277
 
299
278
  return () => {
300
- document.removeEventListener("pointerdown", onOutsidePress, true);
301
279
  // Order matters: the page comes back before focus is restored, because
302
280
  // the trigger is one of the elements that was made `inert` and an inert
303
281
  // element cannot take focus.
304
282
  restorePage();
305
283
  opener?.focus?.();
306
284
  };
307
- }, [dialog.open, dialog.triggerRef, close, dismissable, initialFocus]);
285
+ }, [dialog.open, dialog.triggerRef, initialFocus]);
286
+
287
+ // A confirmation declines to close here, and `Escape` still does. See the
288
+ // module header: there is a difference between a dialog the reader dismissed
289
+ // and one that went away while they were reaching for it.
290
+ useInteractOutside({
291
+ isDisabled: !dialog.open,
292
+ onInteractOutside: () => {
293
+ if (dismissable()) {
294
+ close();
295
+ }
296
+ },
297
+ refs: [bodyRef, dialog.triggerRef],
298
+ });
308
299
 
309
300
  if (!dialog.open) {
310
301
  return null;
package/index.js CHANGED
@@ -799,6 +799,7 @@ export {
799
799
  useFocusVisible,
800
800
  useHover,
801
801
  useInteractionModality,
802
+ useInteractOutside,
802
803
  useKeyboard,
803
804
  useLongPress,
804
805
  useMove,
@@ -815,6 +816,8 @@ export type {
815
816
  HoverResult,
816
817
  InteractionEvent,
817
818
  InteractionProps,
819
+ InteractOutsideOptions,
820
+ InteractOutsideRef,
818
821
  KeyboardInteraction,
819
822
  KeyboardOptions,
820
823
  KeyboardProps,
package/interactions.js CHANGED
@@ -107,6 +107,27 @@
107
107
  // "a press outside closes it" is a promise a component makes to a reader who
108
108
  // pressed something else, not to the thing they pressed.
109
109
  //
110
+ // # A press outside
111
+ //
112
+ // `useInteractOutside` is the other half of a press, and the one an overlay
113
+ // asks for: not "was this element pressed?" but "did the reader press
114
+ // somewhere else?". A `pointerdown` alone cannot answer it. On a touchscreen
115
+ // that event is the first of *every* touch, the one that scrolls the page
116
+ // included; the browser cancels that pointer as the scroll begins and never
117
+ // sends a click, so an overlay that dismissed on `pointerdown` had already
118
+ // gone by the time the reader's finger moved. It is wrong for a mouse in two
119
+ // smaller ways as well: a press that starts outside and is released inside
120
+ // dismissed, and so did the right button, which is asking for a context menu
121
+ // beside an overlay rather than asking for it to go away.
122
+ //
123
+ // So an outside interaction is a whole gesture: a `pointerdown` *and* the
124
+ // release that ends the same pointer, both outside, from the primary button,
125
+ // with the target still in the document. A gesture the browser cancels has no
126
+ // end and dismisses nothing. The listeners are the document's and in the
127
+ // capture phase, so a component that stops the event inside the page cannot
128
+ // hide a press from the overlay above it — "a press outside closes it" is a
129
+ // promise made to the reader who pressed something else.
130
+ //
110
131
  // # Hover
111
132
  //
112
133
  // `useHover` is a mouse's and a pen's. A finger has no hover, so a touch
@@ -1134,6 +1155,145 @@ export hook usePress(options?: PressOptions): PressResult {
1134
1155
  return { isPressed, pressProps };
1135
1156
  }
1136
1157
 
1158
+ // ---------------------------------------------------------------------------
1159
+ // useInteractOutside
1160
+ // ---------------------------------------------------------------------------
1161
+
1162
+ /** A ref to an element a press may land in without being "outside". */
1163
+ export type InteractOutsideRef = { readonly current: HTMLElement | null, ... };
1164
+
1165
+ /** What `useInteractOutside` is told. */
1166
+ export type InteractOutsideOptions = {|
1167
+ /** Hear nothing: what an overlay that is closed asks for. */
1168
+ readonly isDisabled?: boolean,
1169
+ /** A whole gesture began and ended outside every ref. */
1170
+ readonly onInteractOutside: (event: Event) => mixed,
1171
+ /**
1172
+ * The elements that are not "outside".
1173
+ *
1174
+ * The overlay, and whatever opens it: a trigger sits outside the overlay's
1175
+ * own box and is not "outside" for this purpose, because dismissing there
1176
+ * and then letting the trigger's own click reopen makes a press on it a
1177
+ * no-op that flickers.
1178
+ *
1179
+ * Read when an event arrives rather than when the listener is attached, so a
1180
+ * ref that is still null on the commit that opened the overlay is not a
1181
+ * listener that quietly never worked.
1182
+ */
1183
+ readonly refs: $ReadOnlyArray<InteractOutsideRef>,
1184
+ |};
1185
+
1186
+ /** A pointer that went down outside and has not ended anywhere yet. */
1187
+ type OutsideGesture = {| readonly pointerId: number |};
1188
+
1189
+ /**
1190
+ * Whether `node` is still in the document.
1191
+ *
1192
+ * A press whose target left the page between going down and coming up did not
1193
+ * end outside anything: it ended on something that is no longer there, and the
1194
+ * reader was most likely pressing what replaced it.
1195
+ */
1196
+ function isStillInDocument(document: mixed, node: mixed): boolean {
1197
+ const root: $FlowFixMe = document;
1198
+ const element: $FlowFixMe = root?.documentElement;
1199
+ return element != null && contains(element, node);
1200
+ }
1201
+
1202
+ /**
1203
+ * A press that began *and* ended outside an element: what dismisses an overlay.
1204
+ *
1205
+ * useInteractOutside({
1206
+ * isDisabled: !open,
1207
+ * onInteractOutside: close,
1208
+ * refs: [bodyRef, triggerRef],
1209
+ * });
1210
+ *
1211
+ * The module header says what a bare `pointerdown` gets wrong and why this
1212
+ * waits for the end of the gesture. One hook rather than a copy in each
1213
+ * overlay, because a copy is how the answers drift apart: the case that a
1214
+ * scroll must not dismiss is one rule, not five.
1215
+ */
1216
+ export hook useInteractOutside(options: InteractOutsideOptions): void {
1217
+ const gesture = useRef<OutsideGesture | null>(null);
1218
+ const isDisabled = options.isDisabled === true;
1219
+
1220
+ const isOutside = useStableCallback((target: mixed): boolean => {
1221
+ for (const ref of options.refs) {
1222
+ const element: $FlowFixMe = ref.current;
1223
+ if (element != null && contains(element, target)) {
1224
+ return false;
1225
+ }
1226
+ }
1227
+ return true;
1228
+ });
1229
+
1230
+ const dismiss = useStableCallback((event: Event) => {
1231
+ options.onInteractOutside(event);
1232
+ });
1233
+
1234
+ useEffect(() => {
1235
+ if (isDisabled) {
1236
+ gesture.current = null;
1237
+ return;
1238
+ }
1239
+ const host: $FlowFixMe = globalThis;
1240
+ const document = host.document;
1241
+ if (document == null) {
1242
+ return;
1243
+ }
1244
+
1245
+ // Only the primary button begins one. A right click is asking for a
1246
+ // context menu beside the overlay, not for the overlay to go away.
1247
+ const onPointerDown = (event: $FlowFixMe) => {
1248
+ gesture.current =
1249
+ event.button === 0 && isOutside(event.target) ? { pointerId: event.pointerId ?? 0 } : null;
1250
+ };
1251
+
1252
+ // The release that ends the gesture this began. A click is accepted as
1253
+ // that end as well, for a host where the release never arrives — and it
1254
+ // cannot dismiss twice, because the first end takes the gesture with it.
1255
+ const end = (event: $FlowFixMe, samePointer: boolean) => {
1256
+ const began = gesture.current;
1257
+ if (began == null || (samePointer && (event.pointerId ?? 0) !== began.pointerId)) {
1258
+ return;
1259
+ }
1260
+ gesture.current = null;
1261
+ if (
1262
+ event.button !== 0 ||
1263
+ !isOutside(event.target) ||
1264
+ !isStillInDocument(document, event.target)
1265
+ ) {
1266
+ return;
1267
+ }
1268
+ dismiss(event);
1269
+ };
1270
+
1271
+ const onPointerUp = (event: $FlowFixMe) => end(event, true);
1272
+ const onClick = (event: $FlowFixMe) => end(event, false);
1273
+
1274
+ // A gesture the browser took back — the scroll this hook exists for — ends
1275
+ // nowhere, and dismisses nothing.
1276
+ const onPointerCancel = (event: $FlowFixMe) => {
1277
+ const began = gesture.current;
1278
+ if (began != null && (event.pointerId ?? 0) === began.pointerId) {
1279
+ gesture.current = null;
1280
+ }
1281
+ };
1282
+
1283
+ document.addEventListener("pointerdown", onPointerDown, true);
1284
+ document.addEventListener("pointerup", onPointerUp, true);
1285
+ document.addEventListener("pointercancel", onPointerCancel, true);
1286
+ document.addEventListener("click", onClick, true);
1287
+ return () => {
1288
+ gesture.current = null;
1289
+ document.removeEventListener("pointerdown", onPointerDown, true);
1290
+ document.removeEventListener("pointerup", onPointerUp, true);
1291
+ document.removeEventListener("pointercancel", onPointerCancel, true);
1292
+ document.removeEventListener("click", onClick, true);
1293
+ };
1294
+ }, [isDisabled, isOutside, dismiss]);
1295
+ }
1296
+
1137
1297
  // ---------------------------------------------------------------------------
1138
1298
  // Which input came last
1139
1299
  // ---------------------------------------------------------------------------
package/menu.js CHANGED
@@ -106,6 +106,8 @@ import {
106
106
  } from "@uniflowed/react";
107
107
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
108
108
 
109
+ import { useInteractOutside } from "./interactions.js";
110
+
109
111
  import type { Align, LogicalSide } from "./internal/anchor.js";
110
112
  import { useAnchor } from "./internal/anchor.js";
111
113
  import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
@@ -337,30 +339,7 @@ export component MenuBody(
337
339
  setActiveId(landing.id);
338
340
  }
339
341
 
340
- const onOutsidePress = (event: Event) => {
341
- const target: $FlowFixMe = event.target;
342
- if (target == null || body.contains(target)) {
343
- return;
344
- }
345
- // The trigger is outside the menu and is not "outside" for this purpose:
346
- // closing here and letting the trigger's own click reopen made a press on
347
- // the trigger a no-op that flickered.
348
- if (trigger != null && trigger.contains(target)) {
349
- return;
350
- }
351
- dismissed.current = true;
352
- closeAll();
353
- };
354
- // Only the outermost menu listens. A submenu closes with the tree, and two
355
- // listeners would each answer the same press.
356
- if (isRoot) {
357
- document.addEventListener("pointerdown", onOutsidePress, true);
358
- }
359
-
360
342
  return () => {
361
- if (isRoot) {
362
- document.removeEventListener("pointerdown", onOutsidePress, true);
363
- }
364
343
  if (dismissed.current) {
365
344
  dismissed.current = false;
366
345
  return;
@@ -373,7 +352,18 @@ export component MenuBody(
373
352
  trigger?.focus?.();
374
353
  }
375
354
  };
376
- }, [menu.open, isRoot, triggerRef, pendingFocus, closeAll]);
355
+ }, [menu.open, triggerRef, pendingFocus]);
356
+
357
+ // Only the outermost menu listens. A submenu closes with the tree, and two
358
+ // of these would each answer the same press.
359
+ useInteractOutside({
360
+ isDisabled: !menu.open || !isRoot,
361
+ onInteractOutside: () => {
362
+ dismissed.current = true;
363
+ closeAll();
364
+ },
365
+ refs: [bodyRef, triggerRef],
366
+ });
377
367
 
378
368
  const list = useMemo(() => ({ activeId, setActiveId }), [activeId]);
379
369
 
package/menubar.js CHANGED
@@ -82,7 +82,7 @@ import {
82
82
  moveTo,
83
83
  useFirstItem,
84
84
  } from "./internal/roving-focus.js";
85
- import { MenuLevel, useMenu } from "./internal/menu-tree.js";
85
+ import { MenuLevel, useMenu, useTriggerRegistration } from "./internal/menu-tree.js";
86
86
 
87
87
  /**
88
88
  * The bar's own items, and the two things that may own one.
@@ -239,6 +239,7 @@ export component MenubarTrigger(children: React.Node, render?: RenderProp, ...re
239
239
  const bar = useMenubar("Menubar.Trigger");
240
240
  const menu = useMenu("Menubar.Trigger");
241
241
  const value = useContext(MenubarMenuContext);
242
+ useTriggerRegistration(menu);
242
243
  if (value == null) {
243
244
  throw new Error("Menubar.Trigger must be rendered inside a Menubar.Menu");
244
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/ui",
3
- "version": "0.0.0-alpha.39",
3
+ "version": "0.0.0-alpha.40",
4
4
  "description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,10 +19,10 @@
19
19
  "!*.test.js"
20
20
  ],
21
21
  "dependencies": {
22
- "@uniflowed/core": "0.0.0-alpha.39",
23
- "@uniflowed/hooks": "0.0.0-alpha.39",
24
- "@uniflowed/react": "0.0.0-alpha.39",
25
- "@uniflowed/state": "0.0.0-alpha.39"
22
+ "@uniflowed/core": "0.0.0-alpha.40",
23
+ "@uniflowed/hooks": "0.0.0-alpha.40",
24
+ "@uniflowed/react": "0.0.0-alpha.40",
25
+ "@uniflowed/state": "0.0.0-alpha.40"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=19"
package/pagination.js CHANGED
@@ -170,11 +170,10 @@ export component PaginationNext(
170
170
  * say, correctly, that `disabled` might not be a boolean. Handing the bag over
171
171
  * as one value keeps it a bag until it reaches the element it was always for.
172
172
  *
173
- * `disabled` drops the `href` rather than adding an attribute, because there
174
- * is no such thing as a disabled link: an `<a>` with no `href` is not in the
175
- * tab order and is not announced as a link, which is exactly what "there is no
176
- * previous page" means. `aria-disabled` is there too, so a reader who reaches
177
- * it another way is told why it does nothing.
173
+ * `disabled` drops the `href` and keeps the link role. That is the ARIA shape
174
+ * for "this page direction exists, but cannot be taken now": it is out of the
175
+ * tab order because there is no `href`, still named as Previous or Next, and
176
+ * announced as disabled rather than as a silent generic element.
178
177
  */
179
178
  component PageLink(
180
179
  rest: Rest,
@@ -190,10 +189,11 @@ component PageLink(
190
189
  "aria-disabled": disabled ? "true" : undefined,
191
190
  "aria-label": label,
192
191
  children,
192
+ role: disabled ? "link" : undefined,
193
193
  });
194
194
 
195
195
  if (render != null) {
196
- return <li>{render(withProps(props, { role: disabled ? undefined : "link" }))}</li>;
196
+ return <li>{render(withProps(props, { role: "link" }))}</li>;
197
197
  }
198
198
 
199
199
  return (
package/popover.js CHANGED
@@ -58,6 +58,8 @@ import {
58
58
  } from "@uniflowed/react";
59
59
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
60
60
 
61
+ import { useInteractOutside } from "./interactions.js";
62
+
61
63
  import type { Align, LogicalSide } from "./internal/anchor.js";
62
64
  import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
63
65
  import {
@@ -247,17 +249,6 @@ export component PopoverBody(
247
249
  return node != null && !body.contains(node) && !(trigger?.contains(node) ?? false);
248
250
  };
249
251
 
250
- const onOutsidePress = (event: Event) => {
251
- if (!outside(event.target)) {
252
- return;
253
- }
254
- left.current = true;
255
- close();
256
- };
257
- // Capture, so a press is seen even where something below it stops the
258
- // event — a menu inside the popover, for instance.
259
- document.addEventListener("pointerdown", onOutsidePress, true);
260
-
261
252
  // Tab out is a dismissal, not an escape hatch that leaves a popover open
262
253
  // behind the reader: a non-modal overlay whose reader has gone is one they
263
254
  // can no longer press Escape at, because Escape is handled where focus is.
@@ -281,7 +272,6 @@ export component PopoverBody(
281
272
  ((named != null && body.contains(named) ? named : focusable(body)[0]) ?? body).focus();
282
273
 
283
274
  return () => {
284
- document.removeEventListener("pointerdown", onOutsidePress, true);
285
275
  document.removeEventListener("focusin", onFocusMoved, true);
286
276
  if (left.current) {
287
277
  left.current = false;
@@ -298,6 +288,17 @@ export component PopoverBody(
298
288
  };
299
289
  }, [popover.open, triggerRef, close, initialFocus]);
300
290
 
291
+ // A press outside is the other way a reader leaves, and the trigger is not
292
+ // "outside" for the reason the module header gives.
293
+ useInteractOutside({
294
+ isDisabled: !popover.open,
295
+ onInteractOutside: () => {
296
+ left.current = true;
297
+ close();
298
+ },
299
+ refs: [bodyRef, triggerRef],
300
+ });
301
+
301
302
  if (!popover.open) {
302
303
  return null;
303
304
  }
package/select.js CHANGED
@@ -146,6 +146,8 @@ import {
146
146
  } from "@uniflowed/react";
147
147
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
148
148
 
149
+ import { useInteractOutside } from "./interactions.js";
150
+
149
151
  import type { Align, LogicalSide } from "./internal/anchor.js";
150
152
  import { useAnchor } from "./internal/anchor.js";
151
153
  import type { Rest } from "./internal/merge-props.js";
@@ -691,32 +693,16 @@ export component SelectList(
691
693
  }
692
694
  });
693
695
 
694
- // Keyed on `select.open`, which is load-bearing: this component is mounted
695
- // the whole time and only *renders* while the list is open, so keyed on the
696
- // stable callbacks alone the effect would run once, on the commit where
697
- // `listRef.current` was still null, and never attach the listener at all.
698
- useEffect(() => {
699
- const list = listRef.current;
700
- if (list == null) {
701
- return;
702
- }
703
- const document = list.ownerDocument;
704
- const onOutsidePress = (event: Event) => {
705
- const target: $FlowFixMe = event.target;
706
- if (target == null || list.contains(target)) {
707
- return;
708
- }
709
- // The trigger is not "outside": closing here and letting its own click
710
- // reopen the list makes a press on the trigger a no-op that flickers.
711
- const trigger = triggerRef.current;
712
- if (trigger != null && trigger.contains(target)) {
713
- return;
714
- }
715
- close();
716
- };
717
- document.addEventListener("pointerdown", onOutsidePress, true);
718
- return () => document.removeEventListener("pointerdown", onOutsidePress, true);
719
- }, [select.open, close, listRef, triggerRef]);
696
+ // The refs are read when a press arrives rather than when the listener is
697
+ // attached, which is what makes `select.open` the only thing this depends on:
698
+ // this component is mounted the whole time and only *renders* while the list
699
+ // is open, and a listener attached on the commit where `listRef.current` was
700
+ // still null used to be one that never worked.
701
+ useInteractOutside({
702
+ isDisabled: !select.open,
703
+ onInteractOutside: () => close(),
704
+ refs: [listRef, triggerRef],
705
+ });
720
706
 
721
707
  if (!select.open) {
722
708
  return null;
package/sidebar.js CHANGED
@@ -208,7 +208,12 @@ export component SidebarTrigger(children: React.Node, ...rest: Rest) {
208
208
  * announced as "navigation" — and a page with two of those has told the reader
209
209
  * there are two and which is which is a guess.
210
210
  */
211
- export component SidebarBody(children: React.Node, label: string, ...rest: Rest) {
211
+ export component SidebarBody(
212
+ children: React.Node,
213
+ label: string,
214
+ sheetProps?: Rest,
215
+ ...rest: Rest
216
+ ) {
212
217
  const sidebar = useSidebar("Sidebar.Body");
213
218
  const nav = (
214
219
  <nav
@@ -230,7 +235,9 @@ export component SidebarBody(children: React.Node, label: string, ...rest: Rest)
230
235
  return (
231
236
  <>
232
237
  <SheetOverlay />
233
- <SheetBody aria-label={label}>{nav}</SheetBody>
238
+ <SheetBody {...forwarded(sheetProps ?? {})} aria-label={label}>
239
+ {nav}
240
+ </SheetBody>
234
241
  </>
235
242
  );
236
243
  }
@@ -264,6 +271,7 @@ export component SidebarItem(
264
271
  children: React.Node,
265
272
  label: string,
266
273
  render?: RenderProp,
274
+ tooltipProps?: Rest,
267
275
  ...rest: Rest
268
276
  ) {
269
277
  const sidebar = useSidebar("Sidebar.Item");
@@ -294,7 +302,12 @@ export component SidebarItem(
294
302
  return (
295
303
  <TooltipRoot>
296
304
  <TooltipTrigger ref={rest.ref} render={(props: Rest) => entry(props)} />
297
- <TooltipBody side={sidebar.side === "left" ? "right" : "left"}>{label}</TooltipBody>
305
+ <TooltipBody
306
+ {...forwarded(tooltipProps ?? {})}
307
+ side={sidebar.side === "left" ? "right" : "left"}
308
+ >
309
+ {label}
310
+ </TooltipBody>
298
311
  </TooltipRoot>
299
312
  );
300
313
  }
package/table.js CHANGED
@@ -57,9 +57,8 @@
57
57
  // It is rendered by the root rather than offered as a part a caller places,
58
58
  // because a sort that announces nothing is the failure this component exists
59
59
  // to prevent and a part is a thing somebody forgets. The cost is one extra
60
- // element in the caller's layout, and a sentence that is visible until they
61
- // style it — which is not a bad thing to see, and a table that shows its sort
62
- // status in words is a table more people can use.
60
+ // element after the table, hidden from layout and visible to the screen readers
61
+ // watching it.
63
62
 
64
63
  "use client";
65
64
 
@@ -105,6 +104,19 @@ const TableContext: React.Context<TableState | null> = createContext(null);
105
104
  /** Whether the rows below are header rows, which decides `th` versus `td`. */
106
105
  const HeaderContext: React.Context<boolean> = createContext(false);
107
106
 
107
+ const VISUALLY_HIDDEN_STYLE = Object.freeze({
108
+ border: 0,
109
+ clip: "rect(0, 0, 0, 0)",
110
+ clipPath: "inset(50%)",
111
+ height: 1,
112
+ margin: -1,
113
+ overflow: "hidden",
114
+ padding: 0,
115
+ position: "absolute",
116
+ whiteSpace: "nowrap",
117
+ width: 1,
118
+ });
119
+
108
120
  hook useTable(part: string): TableState {
109
121
  const state = useContext(TableContext);
110
122
  if (state == null) {
@@ -205,7 +217,13 @@ export component TableRoot(
205
217
  first render, holding nothing, because a live region that appears with
206
218
  its text is a live region that says nothing.
207
219
  */}
208
- <div aria-atomic="true" aria-live="polite" data-uf-table-status="" role="status">
220
+ <div
221
+ aria-atomic="true"
222
+ aria-live="polite"
223
+ data-uf-table-status=""
224
+ role="status"
225
+ style={VISUALLY_HIDDEN_STYLE}
226
+ >
209
227
  {message}
210
228
  </div>
211
229
  </TableContext.Provider>
package/toggle-group.js CHANGED
@@ -193,7 +193,6 @@ export component ToggleGroupRoot(
193
193
 
194
194
  const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
195
195
  const rootProps = withProps(passed, {
196
- "aria-orientation": orientation,
197
196
  children,
198
197
  onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
199
198
  const group: $FlowFixMe = event.currentTarget;