@astryxdesign/core 0.4.6 → 0.4.7-canary.4c3982f
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/CHANGELOG.md +35 -0
- package/dist/BottomSheet/BottomSheet.d.ts +1 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +37 -11
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +6 -0
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetEdgeTint.js +62 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +10 -4
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +23 -5
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +34 -1
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +5 -1
- package/dist/astryx.css +4 -0
- package/dist/hooks/useListFocus.d.ts +5 -2
- package/dist/hooks/useListFocus.d.ts.map +1 -1
- package/dist/hooks/useListFocus.js +12 -6
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +2 -1
- package/src/BottomSheet/BottomSheet.tsx +19 -0
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +225 -0
- package/src/BottomSheet/BottomSheetEdgeTint.tsx +82 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +66 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +19 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +13 -0
- package/src/BottomSheet/useSheetGestures.test.ts +27 -0
- package/src/BottomSheet/useSheetGestures.ts +25 -5
- package/src/DateInput/DateInputTouch.test.tsx +36 -0
- package/src/DateInput/TouchDateField.tsx +35 -1
- package/src/Popover/Popover.test.tsx +27 -1
- package/src/TabList/Tab.tsx +5 -1
- package/src/TabList/TabList.test.tsx +21 -4
- package/src/hooks/useListFocus.doc.mjs +2 -2
- package/src/hooks/useListFocus.test.tsx +65 -3
- package/src/hooks/useListFocus.ts +15 -7
- package/src/theme/MediaTheme.doc.mjs +5 -5
|
@@ -118,6 +118,20 @@ const OVERSCROLL_MAX = 48;
|
|
|
118
118
|
// the scroll, large enough that a swipe merely coming to rest on the last pixel
|
|
119
119
|
// doesn't start a drag on jitter.
|
|
120
120
|
const CONTENT_END_HANDOFF_SLOP = 4;
|
|
121
|
+
// How far a finger resting on the body must travel before the pull becomes a
|
|
122
|
+
// sheet drag rather than a tap.
|
|
123
|
+
//
|
|
124
|
+
// This used to be zero: any downward movement promoted. A finger is never
|
|
125
|
+
// still, so tapping a control inside the sheet drifted a pixel or two and
|
|
126
|
+
// started a drag -- which suppresses the panel's transition, correctly, for as
|
|
127
|
+
// long as the sheet is tracking the finger. The close that the tap triggered
|
|
128
|
+
// then landed inside that window and cut instead of animating. A deliberate
|
|
129
|
+
// pull was unaffected; only a tap, and only a tap inside the sheet, since the
|
|
130
|
+
// scrim is outside the body and arms nothing.
|
|
131
|
+
//
|
|
132
|
+
// 8px is the conventional tap slop, a shade over iOS's own recognizer, and
|
|
133
|
+
// well under what a real pull covers in its first frames.
|
|
134
|
+
const DRAG_PROMOTION_SLOP = 8;
|
|
121
135
|
|
|
122
136
|
// Short haptic tick on detent settle where supported. iOS Safari doesn't
|
|
123
137
|
// expose navigator.vibrate, so this is a no-op there; skipped under
|
|
@@ -1140,7 +1154,7 @@ export function useSheetGestures({
|
|
|
1140
1154
|
return;
|
|
1141
1155
|
}
|
|
1142
1156
|
const delta = event.clientY - armed.startCoord;
|
|
1143
|
-
if (delta >
|
|
1157
|
+
if (delta > DRAG_PROMOTION_SLOP && armed.scroller.scrollTop <= 0) {
|
|
1144
1158
|
// Downward pull at the top: promote to a sheet drag, anchored at the
|
|
1145
1159
|
// original pointer-down position so the pull distance carries over.
|
|
1146
1160
|
armedBodyRef.current = null;
|
|
@@ -1302,8 +1316,10 @@ export function useSheetGestures({
|
|
|
1302
1316
|
// no longer scroll that way: at the top, a downward pull (delta > 0)
|
|
1303
1317
|
// collapses; at the bottom, an upward pull (delta < 0) expands. The
|
|
1304
1318
|
// opposite direction is a real scroll, so disarm and let it through.
|
|
1305
|
-
const pullDownAtTop =
|
|
1306
|
-
|
|
1319
|
+
const pullDownAtTop =
|
|
1320
|
+
armed.top && delta > DRAG_PROMOTION_SLOP && atTop(scroller);
|
|
1321
|
+
const pullUpAtBottom =
|
|
1322
|
+
armed.bottom && delta < -DRAG_PROMOTION_SLOP && atBottom(scroller);
|
|
1307
1323
|
if (pullDownAtTop || pullUpAtBottom) {
|
|
1308
1324
|
event.preventDefault();
|
|
1309
1325
|
touchDragRef.current = null;
|
|
@@ -1433,15 +1449,19 @@ export function useSheetGestures({
|
|
|
1433
1449
|
style: {
|
|
1434
1450
|
transform:
|
|
1435
1451
|
activeOffset !== 0 ? `translateY(${activeOffset}px)` : undefined,
|
|
1452
|
+
// Gated on the sheet being up: suppression must not outlive the sheet
|
|
1453
|
+
// it was suppressing for. The host swaps the panel to its closing
|
|
1454
|
+
// state in whatever frame the dismissal lands, and a stale `none`
|
|
1455
|
+
// would apply to that transform too, cutting the exit.
|
|
1436
1456
|
transition:
|
|
1437
|
-
isDragging || isScrollAreaReconciling || reducedMotion
|
|
1457
|
+
isOpen && (isDragging || isScrollAreaReconciling || reducedMotion)
|
|
1438
1458
|
? 'none'
|
|
1439
1459
|
: undefined,
|
|
1440
1460
|
touchAction: 'none',
|
|
1441
1461
|
overscrollBehavior: 'contain',
|
|
1442
1462
|
},
|
|
1443
1463
|
}),
|
|
1444
|
-
[activeOffset, isDragging, isScrollAreaReconciling, reducedMotion],
|
|
1464
|
+
[activeOffset, isDragging, isOpen, isScrollAreaReconciling, reducedMotion],
|
|
1445
1465
|
);
|
|
1446
1466
|
|
|
1447
1467
|
const handleProps = useMemo<SheetHandleProps>(
|
|
@@ -419,6 +419,42 @@ describe('DateInput — field parity', () => {
|
|
|
419
419
|
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
420
420
|
});
|
|
421
421
|
|
|
422
|
+
it('returns focus to the field without letting the page scroll', async () => {
|
|
423
|
+
// Clearing unmounts the clear button, and focusing another element in the
|
|
424
|
+
// same task as that unmount makes iOS Safari scroll the document to the
|
|
425
|
+
// top. Measured on the iOS 26 simulator against the live docsite, field at
|
|
426
|
+
// scrollY 2055: synchronous focus lands at 0, deferred focus stays at
|
|
427
|
+
// 2055. `preventScroll` alone does not fix it, so both halves are
|
|
428
|
+
// asserted: the focus is deferred past the unmount, and it is passed
|
|
429
|
+
// preventScroll. jsdom implements no scrolling, so the guard is asserted
|
|
430
|
+
// at the call.
|
|
431
|
+
vi.useFakeTimers();
|
|
432
|
+
try {
|
|
433
|
+
render(
|
|
434
|
+
<DateInput
|
|
435
|
+
label="Ship date"
|
|
436
|
+
value="2026-03-21"
|
|
437
|
+
hasClear
|
|
438
|
+
onChange={() => {}}
|
|
439
|
+
/>,
|
|
440
|
+
);
|
|
441
|
+
const input = field();
|
|
442
|
+
const focus = vi.spyOn(input, 'focus');
|
|
443
|
+
|
|
444
|
+
fireEvent.click(screen.getByRole('button', {name: /Clear Ship date/}));
|
|
445
|
+
|
|
446
|
+
// Not synchronous — that is the whole point.
|
|
447
|
+
expect(focus).not.toHaveBeenCalled();
|
|
448
|
+
|
|
449
|
+
vi.runAllTimers();
|
|
450
|
+
|
|
451
|
+
expect(focus).toHaveBeenCalledWith({preventScroll: true});
|
|
452
|
+
focus.mockRestore();
|
|
453
|
+
} finally {
|
|
454
|
+
vi.useRealTimers();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
422
458
|
it('does not open the picker until the field is tapped', () => {
|
|
423
459
|
withLayout(() => {
|
|
424
460
|
render(<DateInput label="Ship date" onChange={() => {}} />);
|
|
@@ -599,6 +599,16 @@ export function TouchDateField({
|
|
|
599
599
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
|
600
600
|
const [isWheelOpen, setIsWheelOpen] = useState(false);
|
|
601
601
|
const scrollerHandleRef = useRef<MonthScrollerHandle | null>(null);
|
|
602
|
+
// Pending focus handoff from the clear button; see handleClear.
|
|
603
|
+
const clearFocusTimerRef = useRef<number | null>(null);
|
|
604
|
+
useEffect(
|
|
605
|
+
() => () => {
|
|
606
|
+
if (clearFocusTimerRef.current != null) {
|
|
607
|
+
clearTimeout(clearFocusTimerRef.current);
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
[],
|
|
611
|
+
);
|
|
602
612
|
|
|
603
613
|
const today = useMemo(() => plainDateToday(), []);
|
|
604
614
|
const selectedDate = useMemo(
|
|
@@ -708,7 +718,31 @@ export function TouchDateField({
|
|
|
708
718
|
|
|
709
719
|
const handleClear = useCallback(() => {
|
|
710
720
|
fireChange(undefined);
|
|
711
|
-
|
|
721
|
+
// Focus goes back to the field on the NEXT task, not synchronously.
|
|
722
|
+
//
|
|
723
|
+
// Clearing unmounts this button (it only renders while there is a value),
|
|
724
|
+
// and focusing another element in the same task as that unmount makes iOS
|
|
725
|
+
// Safari scroll the whole document to the top — the user is thrown from
|
|
726
|
+
// wherever the field sat to the start of the page. Measured on the iOS 26
|
|
727
|
+
// simulator against the live docsite, field at scrollY 2055: synchronous
|
|
728
|
+
// focus lands at 0, deferred focus stays at 2055.
|
|
729
|
+
//
|
|
730
|
+
// `preventScroll` alone does NOT fix it (verified: still 0) — this is not
|
|
731
|
+
// the browser's ordinary scroll-the-focused-element-into-view step, so the
|
|
732
|
+
// deferral is the load-bearing half. It is kept because the reveal scroll
|
|
733
|
+
// is real too, and unwanted for the same reason: the field the user just
|
|
734
|
+
// tapped is already on screen (+12px on a plain page without it).
|
|
735
|
+
//
|
|
736
|
+
// Skipping the focus entirely would also stop the scroll, but then focus
|
|
737
|
+
// dies with the unmounting button and lands on <body>.
|
|
738
|
+
const field = inputRef.current;
|
|
739
|
+
if (field == null) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
clearFocusTimerRef.current = window.setTimeout(() => {
|
|
743
|
+
clearFocusTimerRef.current = null;
|
|
744
|
+
field.focus({preventScroll: true});
|
|
745
|
+
}, 0);
|
|
712
746
|
}, [fireChange]);
|
|
713
747
|
|
|
714
748
|
/**
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file Popover.test.tsx
|
|
5
|
-
* @input Uses vitest, @testing-library/react, Popover
|
|
5
|
+
* @input Uses vitest, @testing-library/react, Popover, Dialog,
|
|
6
|
+
* SegmentedControl
|
|
6
7
|
* @output Unit tests for Popover component behavior
|
|
7
8
|
* @position Testing; validates Popover.tsx implementation
|
|
8
9
|
*
|
|
@@ -14,6 +15,7 @@ import {render, screen, fireEvent} from '@testing-library/react';
|
|
|
14
15
|
import React, {useRef} from 'react';
|
|
15
16
|
import {Popover} from './Popover';
|
|
16
17
|
import {Dialog} from '../Dialog';
|
|
18
|
+
import {SegmentedControl, SegmentedControlItem} from '../SegmentedControl';
|
|
17
19
|
|
|
18
20
|
// Store original matches to restore later
|
|
19
21
|
const originalMatches = HTMLElement.prototype.matches;
|
|
@@ -306,6 +308,30 @@ describe('Popover', () => {
|
|
|
306
308
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
307
309
|
});
|
|
308
310
|
|
|
311
|
+
it('dismisses on Escape pressed inside a roving-focus list', () => {
|
|
312
|
+
render(
|
|
313
|
+
<Popover
|
|
314
|
+
content={
|
|
315
|
+
<SegmentedControl value="grid" onChange={() => {}} label="View">
|
|
316
|
+
<SegmentedControlItem value="grid" label="Grid" />
|
|
317
|
+
<SegmentedControlItem value="list" label="List" />
|
|
318
|
+
</SegmentedControl>
|
|
319
|
+
}
|
|
320
|
+
label="Test">
|
|
321
|
+
<button type="button">Open</button>
|
|
322
|
+
</Popover>,
|
|
323
|
+
);
|
|
324
|
+
const trigger = screen.getByRole('button', {name: 'Open'});
|
|
325
|
+
fireEvent.click(trigger);
|
|
326
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
327
|
+
|
|
328
|
+
// From the segment, so the list's own key handler runs first. jsdom has
|
|
329
|
+
// no popover display, so the open content still reads as hidden.
|
|
330
|
+
const segment = screen.getByRole('radio', {name: 'Grid', hidden: true});
|
|
331
|
+
fireEvent.keyDown(segment, {key: 'Escape'});
|
|
332
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
333
|
+
});
|
|
334
|
+
|
|
309
335
|
it('stays open on Escape when hasEscapeDismiss is false', () => {
|
|
310
336
|
render(
|
|
311
337
|
<Popover
|
package/src/TabList/Tab.tsx
CHANGED
|
@@ -260,7 +260,11 @@ export function Tab({
|
|
|
260
260
|
...(isLabelHidden ? {'aria-label': label} : {}),
|
|
261
261
|
[EDGE_COMP_ATTR]: '',
|
|
262
262
|
'data-tab-value': value,
|
|
263
|
-
|
|
263
|
+
// Generic `true` ("the current item within a set"), not `page`: the strip
|
|
264
|
+
// switches views in place at least as often as it navigates, and claiming
|
|
265
|
+
// "current page" when no page changed is a false statement to a screen
|
|
266
|
+
// reader. Stays truthful for the `href` case too, just less specific.
|
|
267
|
+
'aria-current': isSelected ? ('true' as const) : undefined,
|
|
264
268
|
// Roving tabindex: the tab strip is a single Tab stop. The selected tab is
|
|
265
269
|
// the tabbable one; the rest are reachable via arrow keys (handled by
|
|
266
270
|
// TabList's onKeyDown). When no tab is selected, TabList's repair effect
|
|
@@ -106,7 +106,7 @@ describe('TabList', () => {
|
|
|
106
106
|
);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
it('marks selected tab with aria-current', () => {
|
|
109
|
+
it('marks selected tab with a generic aria-current, not "page"', () => {
|
|
110
110
|
render(
|
|
111
111
|
<TabList value="home" onChange={() => {}}>
|
|
112
112
|
<Tab value="home" label="Home" />
|
|
@@ -116,13 +116,30 @@ describe('TabList', () => {
|
|
|
116
116
|
|
|
117
117
|
expect(screen.getByRole('button', {name: 'Home'})).toHaveAttribute(
|
|
118
118
|
'aria-current',
|
|
119
|
-
'
|
|
119
|
+
'true',
|
|
120
120
|
);
|
|
121
121
|
expect(screen.getByRole('button', {name: 'Settings'})).not.toHaveAttribute(
|
|
122
122
|
'aria-current',
|
|
123
123
|
);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it('marks a selected link tab with the same generic aria-current', () => {
|
|
127
|
+
render(
|
|
128
|
+
<TabList value="home" onChange={() => {}}>
|
|
129
|
+
<Tab value="home" label="Home" href="/home" />
|
|
130
|
+
<Tab value="settings" label="Settings" href="/settings" />
|
|
131
|
+
</TabList>,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(screen.getByRole('link', {name: 'Home'})).toHaveAttribute(
|
|
135
|
+
'aria-current',
|
|
136
|
+
'true',
|
|
137
|
+
);
|
|
138
|
+
expect(screen.getByRole('link', {name: 'Settings'})).not.toHaveAttribute(
|
|
139
|
+
'aria-current',
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
126
143
|
it('calls onChange when a tab is clicked', async () => {
|
|
127
144
|
const user = userEvent.setup();
|
|
128
145
|
const handleChange = vi.fn();
|
|
@@ -148,7 +165,7 @@ describe('TabList', () => {
|
|
|
148
165
|
|
|
149
166
|
expect(screen.getByRole('button', {name: 'Home'})).toHaveAttribute(
|
|
150
167
|
'aria-current',
|
|
151
|
-
'
|
|
168
|
+
'true',
|
|
152
169
|
);
|
|
153
170
|
|
|
154
171
|
rerender(
|
|
@@ -163,7 +180,7 @@ describe('TabList', () => {
|
|
|
163
180
|
);
|
|
164
181
|
expect(screen.getByRole('button', {name: 'Settings'})).toHaveAttribute(
|
|
165
182
|
'aria-current',
|
|
166
|
-
'
|
|
183
|
+
'true',
|
|
167
184
|
);
|
|
168
185
|
});
|
|
169
186
|
|
|
@@ -36,7 +36,7 @@ export const docs = {
|
|
|
36
36
|
{
|
|
37
37
|
name: 'options.onEscape',
|
|
38
38
|
type: '() => void',
|
|
39
|
-
description: 'Callback when Escape key is pressed (e.g., close menu).',
|
|
39
|
+
description: 'Callback when Escape key is pressed (e.g., close menu). Supplying it also consumes the key (preventDefault); without it Escape passes through to the surrounding layer.',
|
|
40
40
|
required: false,
|
|
41
41
|
},
|
|
42
42
|
{
|
|
@@ -144,7 +144,7 @@ export const docsDense = {
|
|
|
144
144
|
'options.itemSelector': 'selector for focusable items in list.',
|
|
145
145
|
'options.boundarySelector': "boundary selector for lists that contain nested lists (e.g. submenu flyouts); scopes items + key handling to this level.",
|
|
146
146
|
'options.wrap': 'whether arrow navigation wraps around at ends.',
|
|
147
|
-
'options.onEscape': 'callback when Escape key pressed (e.g. close menu).',
|
|
147
|
+
'options.onEscape': 'callback when Escape key pressed (e.g. close menu). Also consumes the key; without it Escape passes through to the surrounding layer.',
|
|
148
148
|
'options.orientation': "navigation orientation. 'horizontal' uses ArrowLeft/ArrowRight, 'vertical' uses ArrowUp/ArrowDown, 'both' accepts all four arrows.",
|
|
149
149
|
'options.hasHomeEnd': 'whether Home/End jump to first/last enabled item.',
|
|
150
150
|
'options.isRtl': 'ArrowLeft/ArrowRight swap for horizontal nav (RTL). default: auto-detect from container computed direction; explicit boolean wins.',
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file useListFocus.test.tsx
|
|
5
5
|
* @input Uses vitest, @testing-library/react, useListFocus hook
|
|
6
|
-
* @output Unit tests for useListFocus disabled-item skipping, navigation,
|
|
7
|
-
* RTL auto-detection
|
|
6
|
+
* @output Unit tests for useListFocus disabled-item skipping, navigation,
|
|
7
|
+
* Escape consumption, and RTL auto-detection
|
|
8
8
|
* @position Testing; validates useListFocus.ts keyboard navigation
|
|
9
9
|
*
|
|
10
10
|
* SYNC: When useListFocus.ts changes, update tests to match new behavior
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {describe, it, expect} from 'vitest';
|
|
13
|
+
import {describe, it, expect, vi} from 'vitest';
|
|
14
14
|
import type {KeyboardEvent as ReactKeyboardEvent} from 'react';
|
|
15
15
|
import {render, screen, fireEvent} from '@testing-library/react';
|
|
16
16
|
import {useListFocus} from './useListFocus';
|
|
@@ -525,3 +525,65 @@ describe('useListFocus boundarySelector (nested lists)', () => {
|
|
|
525
525
|
expect(innerProbe).toHaveAttribute('data-owns', 'false');
|
|
526
526
|
});
|
|
527
527
|
});
|
|
528
|
+
|
|
529
|
+
// A list inside a host that dismisses on Escape. The host's guard mirrors
|
|
530
|
+
// `useFocusTrap`: it acts only on a key no inner handler has consumed.
|
|
531
|
+
function EscapeHost({
|
|
532
|
+
onEscape,
|
|
533
|
+
onHostEscape,
|
|
534
|
+
}: {
|
|
535
|
+
onEscape?: () => void;
|
|
536
|
+
onHostEscape: () => void;
|
|
537
|
+
}) {
|
|
538
|
+
const {listRef, handleKeyDown} = useListFocus<HTMLDivElement>({onEscape});
|
|
539
|
+
return (
|
|
540
|
+
<div
|
|
541
|
+
data-testid="host"
|
|
542
|
+
onKeyDown={e => {
|
|
543
|
+
if (e.key === 'Escape' && !e.defaultPrevented) {
|
|
544
|
+
onHostEscape();
|
|
545
|
+
}
|
|
546
|
+
}}>
|
|
547
|
+
<div ref={listRef} role="menu" onKeyDown={handleKeyDown}>
|
|
548
|
+
<div role="menuitem" tabIndex={-1} data-testid="One">
|
|
549
|
+
One
|
|
550
|
+
</div>
|
|
551
|
+
<div role="menuitem" tabIndex={-1} data-testid="Two">
|
|
552
|
+
Two
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
describe('useListFocus Escape', () => {
|
|
560
|
+
it('leaves Escape to the host when no onEscape is supplied', () => {
|
|
561
|
+
const onHostEscape = vi.fn();
|
|
562
|
+
render(<EscapeHost onHostEscape={onHostEscape} />);
|
|
563
|
+
|
|
564
|
+
fireEvent.keyDown(screen.getByRole('menu'), {key: 'Escape'});
|
|
565
|
+
expect(onHostEscape).toHaveBeenCalledTimes(1);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('consumes Escape and runs onEscape when one is supplied', () => {
|
|
569
|
+
const onEscape = vi.fn();
|
|
570
|
+
const onHostEscape = vi.fn();
|
|
571
|
+
render(<EscapeHost onEscape={onEscape} onHostEscape={onHostEscape} />);
|
|
572
|
+
|
|
573
|
+
fireEvent.keyDown(screen.getByRole('menu'), {key: 'Escape'});
|
|
574
|
+
expect(onEscape).toHaveBeenCalledTimes(1);
|
|
575
|
+
expect(onHostEscape).not.toHaveBeenCalled();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('still consumes arrow keys with no onEscape (page-scroll suppression)', () => {
|
|
579
|
+
render(<EscapeHost onHostEscape={() => {}} />);
|
|
580
|
+
screen.getByTestId('One').focus();
|
|
581
|
+
|
|
582
|
+
// fireEvent returns false when a handler cancelled the event.
|
|
583
|
+
const wasCancelled = !fireEvent.keyDown(screen.getByRole('menu'), {
|
|
584
|
+
key: 'ArrowDown',
|
|
585
|
+
});
|
|
586
|
+
expect(wasCancelled).toBe(true);
|
|
587
|
+
expect(screen.getByTestId('Two')).toHaveFocus();
|
|
588
|
+
});
|
|
589
|
+
});
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* SYNC: When modified, update:
|
|
16
16
|
* - /packages/core/src/hooks/index.ts
|
|
17
|
+
* - /packages/core/src/hooks/useListFocus.doc.mjs
|
|
18
|
+
* - /packages/core/src/hooks/useListFocus.test.tsx
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import {useCallback, useRef} from 'react';
|
|
@@ -64,7 +66,9 @@ export interface UseListFocusOptions {
|
|
|
64
66
|
wrap?: boolean;
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
|
-
* Callback when Escape key is pressed.
|
|
69
|
+
* Callback when Escape key is pressed. Supplying it also makes the list
|
|
70
|
+
* consume the key (`preventDefault`); without it Escape passes through to
|
|
71
|
+
* the surrounding layer.
|
|
68
72
|
*/
|
|
69
73
|
onEscape?: () => void;
|
|
70
74
|
|
|
@@ -277,7 +281,8 @@ function shouldDeferToCaret(target: EventTarget | null, key: string): boolean {
|
|
|
277
281
|
* - ArrowUp/ArrowLeft: Move to previous item (wraps to last)
|
|
278
282
|
* - Home: Move to first item
|
|
279
283
|
* - End: Move to last item
|
|
280
|
-
* - Escape:
|
|
284
|
+
* - Escape: runs `onEscape` and consumes the key. With no `onEscape` the key
|
|
285
|
+
* is left alone, so a surrounding layer can still dismiss on it.
|
|
281
286
|
*
|
|
282
287
|
* By default the hook only *moves* focus and leaves `tabindex` management to
|
|
283
288
|
* the caller. Opt into {@link UseListFocusOptions.hasRovingTabIndex} for a hook
|
|
@@ -557,12 +562,15 @@ export function useListFocus<T extends HTMLElement = HTMLElement>(
|
|
|
557
562
|
return;
|
|
558
563
|
}
|
|
559
564
|
|
|
560
|
-
// Escape is handled regardless of orientation
|
|
561
|
-
//
|
|
562
|
-
//
|
|
565
|
+
// Escape is handled regardless of orientation, but only *consumed* when
|
|
566
|
+
// a handler asked for it: a list with no dismissal to perform must leave
|
|
567
|
+
// the key to whatever host layer does have one, and those defer to
|
|
568
|
+
// `defaultPrevented` (see `useFocusTrap`) or to the native popover.
|
|
563
569
|
if (e.key === 'Escape') {
|
|
564
|
-
|
|
565
|
-
|
|
570
|
+
if (onEscape) {
|
|
571
|
+
e.preventDefault();
|
|
572
|
+
onEscape();
|
|
573
|
+
}
|
|
566
574
|
return;
|
|
567
575
|
}
|
|
568
576
|
|
|
@@ -68,7 +68,7 @@ export const docs = {
|
|
|
68
68
|
{
|
|
69
69
|
guidance: true,
|
|
70
70
|
description:
|
|
71
|
-
'Prefer mode="auto" when the surface color comes from a theme token. A token named "inverted" is not guaranteed to be inverted, and auto measures what was actually painted instead of trusting the name
|
|
71
|
+
'Prefer mode="auto" when the surface color comes from a theme token. A token named "inverted" is not guaranteed to be inverted, and auto measures what was actually painted instead of trusting the name. It can even decide that a surface needs no media context at all.',
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
74
|
guidance: true,
|
|
@@ -93,14 +93,14 @@ export const docs = {
|
|
|
93
93
|
type: "'dark' | 'light' | 'auto' | 'off'",
|
|
94
94
|
required: true,
|
|
95
95
|
description:
|
|
96
|
-
'Surface luminance context: dark for content over dark backgrounds (light text, white-tinted interactions), light for content over light backgrounds (dark text, black-tinted interactions), auto to decide from the painted surface
|
|
96
|
+
'Surface luminance context: dark for content over dark backgrounds (light text, white-tinted interactions), light for content over light backgrounds (dark text, black-tinted interactions), auto to decide from the painted surface (no media context when the ambient text already reads on the surface at 3:1, otherwise the side that reads better), and off to turn it off explicitly. The element renders either way, so a surface can switch contexts without remounting children.',
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
name: 'fallback',
|
|
100
100
|
type: "'dark' | 'light'",
|
|
101
101
|
default: "'dark'",
|
|
102
102
|
description:
|
|
103
|
-
'Which side auto uses when the surface cannot be measured: during SSR, on the first client frame, and whenever the backdrop is not knowable from CSS
|
|
103
|
+
'Which side auto uses when the surface cannot be measured: during SSR, on the first client frame, and whenever the backdrop is not knowable from CSS, most often a background-image, whose pixels need sampling (useImageMode) rather than a computed style. Ignored unless mode is auto.',
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
name: 'children',
|
|
@@ -126,7 +126,7 @@ export const docsDense = {
|
|
|
126
126
|
{
|
|
127
127
|
guidance: true,
|
|
128
128
|
description:
|
|
129
|
-
'Prefer mode="auto" when surface color comes from a theme token
|
|
129
|
+
'Prefer mode="auto" when surface color comes from a theme token; a token named "inverted" is not guaranteed to be; auto measures what was painted.',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
guidance: true,
|
|
@@ -148,6 +148,6 @@ export const docsDense = {
|
|
|
148
148
|
propDescriptions: {
|
|
149
149
|
mode: 'surface luminance context: dark for content over dark backgrounds (light text, white-tinted interactions), light for content over light backgrounds (dark text, black-tinted interactions), auto to decide from painted surface (none if ambient text already reads at 3:1, else better-reading side), off to turn off explicitly (element still renders, so children never remount)',
|
|
150
150
|
fallback:
|
|
151
|
-
'side auto uses when surface is unmeasurable (SSR, first frame, background-image
|
|
151
|
+
'side auto uses when surface is unmeasurable (SSR, first frame, background-image, which needs useImageMode sampling); ignored unless mode is auto',
|
|
152
152
|
},
|
|
153
153
|
};
|