@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 +4 -7
- package/combobox.js +14 -27
- package/dialog.js +16 -25
- package/index.js +3 -0
- package/interactions.js +160 -0
- package/menu.js +14 -24
- package/menubar.js +2 -1
- package/package.json +5 -5
- package/pagination.js +6 -6
- package/popover.js +13 -12
- package/select.js +12 -26
- package/sidebar.js +16 -3
- package/table.js +22 -4
- package/toggle-group.js +0 -1
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
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
23
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
24
|
-
"@uniflowed/react": "0.0.0-alpha.
|
|
25
|
-
"@uniflowed/state": "0.0.0-alpha.
|
|
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`
|
|
174
|
-
*
|
|
175
|
-
* tab order
|
|
176
|
-
*
|
|
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:
|
|
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
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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(
|
|
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}>
|
|
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
|
|
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
|
|
61
|
-
//
|
|
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
|
|
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;
|