@astryxdesign/core 0.4.6 → 0.4.7-canary.405f1ee
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/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +5 -0
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +5 -1
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +4 -1
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +20 -8
- package/dist/Table/plugins/selection/useTableSelection.d.ts +16 -0
- package/dist/Table/plugins/selection/useTableSelection.d.ts.map +1 -1
- package/dist/Table/plugins/selection/useTableSelection.js +19 -5
- package/dist/Table/types.d.ts +22 -4
- package/dist/Table/types.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.js +5 -0
- 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/MultiSelector/MultiSelector.test.tsx +21 -0
- package/src/MultiSelector/MultiSelector.tsx +10 -1
- package/src/Popover/Popover.test.tsx +27 -1
- package/src/Selector/Selector.test.tsx +21 -0
- package/src/Selector/Selector.tsx +11 -1
- package/src/TabList/Tab.tsx +5 -1
- package/src/TabList/TabList.test.tsx +21 -4
- package/src/Table/BaseTable.tsx +6 -3
- package/src/Table/Table.test.tsx +35 -0
- package/src/Table/plugins/groupedRows/useTableGroupedRows-perf.test.tsx +112 -0
- package/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx +100 -0
- package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +17 -8
- package/src/Table/plugins/selection/useTableSelection.test.tsx +76 -0
- package/src/Table/plugins/selection/useTableSelection.tsx +40 -7
- package/src/Table/types.ts +22 -4
- package/src/Table/useBaseTablePlugins.ts +5 -0
- package/src/Table/useTableGroupedRows.doc.mjs +5 -4
- package/src/Table/useTableSelection.doc.mjs +31 -0
- 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
|
@@ -26,7 +26,13 @@
|
|
|
26
26
|
* DOM elements and no central element tracking. Each subscription
|
|
27
27
|
* self-cleans when the row disconnects.
|
|
28
28
|
*
|
|
29
|
+
* Because the background is an inline style, it outranks anything
|
|
30
|
+
* userland can layer on with StyleX. `hasRowHighlight: false` is the
|
|
31
|
+
* supported way to reclaim the row background; `aria-selected` is
|
|
32
|
+
* unaffected by it.
|
|
33
|
+
*
|
|
29
34
|
* SYNC: When modified, update these files to stay in sync:
|
|
35
|
+
* - /packages/core/src/Table/useTableSelection.doc.mjs (config prop table)
|
|
30
36
|
* - /packages/core/src/Table/Table.doc.mjs (selection documentation)
|
|
31
37
|
* - /packages/core/src/Table/index.ts (exports)
|
|
32
38
|
*/
|
|
@@ -81,6 +87,22 @@ export interface UseTableSelectionConfig<T extends Record<string, unknown>> {
|
|
|
81
87
|
* ```
|
|
82
88
|
*/
|
|
83
89
|
getRowLabel?: (item: T) => string;
|
|
90
|
+
/**
|
|
91
|
+
* Paint checked rows with the accent wash. Set false when the surrounding
|
|
92
|
+
* UI already uses row background to mean something else — a row that is
|
|
93
|
+
* open in a detail panel, for instance. The wash is an inline style, so
|
|
94
|
+
* it cannot be overridden from userland; this flag is the way off it.
|
|
95
|
+
*
|
|
96
|
+
* Only the background is dropped. `aria-selected` is still set on checked
|
|
97
|
+
* rows either way, so the selection stays legible to screen readers.
|
|
98
|
+
*
|
|
99
|
+
* @default true
|
|
100
|
+
* @example
|
|
101
|
+
* ```
|
|
102
|
+
* useTableSelection({...config, hasRowHighlight: false})
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
hasRowHighlight?: boolean;
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
// =============================================================================
|
|
@@ -121,18 +143,24 @@ function createSelectionStore<T extends Record<string, unknown>>(
|
|
|
121
143
|
|
|
122
144
|
/**
|
|
123
145
|
* Apply or remove selection styling on a <tr> element.
|
|
146
|
+
*
|
|
147
|
+
* `aria-selected` tracks selection on its own: it is the semantic half of
|
|
148
|
+
* the state and stays correct whether or not the row is painted.
|
|
124
149
|
*/
|
|
125
150
|
function applyRowSelectionStyle(
|
|
126
151
|
el: HTMLTableRowElement,
|
|
127
152
|
isSelected: boolean,
|
|
153
|
+
hasRowHighlight: boolean,
|
|
128
154
|
): void {
|
|
129
155
|
if (isSelected) {
|
|
130
156
|
el.setAttribute('aria-selected', 'true');
|
|
131
|
-
el.style.backgroundColor = selectedBgColor;
|
|
132
157
|
} else {
|
|
133
158
|
el.removeAttribute('aria-selected');
|
|
134
|
-
el.style.backgroundColor = '';
|
|
135
159
|
}
|
|
160
|
+
// Written on every pass, not just when painting: the flag can flip while a
|
|
161
|
+
// row is already selected, and the wash has to come back off.
|
|
162
|
+
el.style.backgroundColor =
|
|
163
|
+
isSelected && hasRowHighlight ? selectedBgColor : '';
|
|
136
164
|
}
|
|
137
165
|
|
|
138
166
|
// =============================================================================
|
|
@@ -358,18 +386,23 @@ export function useTableSelection<T extends Record<string, unknown>>(
|
|
|
358
386
|
if (!el) {
|
|
359
387
|
return;
|
|
360
388
|
}
|
|
389
|
+
const applyStyle = () => {
|
|
390
|
+
const config = store.getConfig();
|
|
391
|
+
applyRowSelectionStyle(
|
|
392
|
+
el,
|
|
393
|
+
config.getIsItemSelected(item),
|
|
394
|
+
config.hasRowHighlight ?? true,
|
|
395
|
+
);
|
|
396
|
+
};
|
|
361
397
|
// Apply initial style
|
|
362
|
-
|
|
398
|
+
applyStyle();
|
|
363
399
|
// Subscribe for future changes
|
|
364
400
|
const unsub = store.subscribe(() => {
|
|
365
401
|
if (!el.isConnected) {
|
|
366
402
|
unsub();
|
|
367
403
|
return;
|
|
368
404
|
}
|
|
369
|
-
|
|
370
|
-
el,
|
|
371
|
-
store.getConfig().getIsItemSelected(item),
|
|
372
|
-
);
|
|
405
|
+
applyStyle();
|
|
373
406
|
});
|
|
374
407
|
return () => {
|
|
375
408
|
unsub();
|
package/src/Table/types.ts
CHANGED
|
@@ -322,6 +322,19 @@ export interface BodyCellRenderProps {
|
|
|
322
322
|
* sticky offsets. Optional for backward compatibility.
|
|
323
323
|
*/
|
|
324
324
|
columns?: ReadonlyArray<TableColumn<Record<string, unknown>>>;
|
|
325
|
+
/**
|
|
326
|
+
* When true, the cell renders empty: BaseTable calls neither the column's
|
|
327
|
+
* `renderCell` nor the default renderer. Set it for a row whose cells a
|
|
328
|
+
* plugin is going to replace wholesale in `transformBodyRow` (a grouped-rows
|
|
329
|
+
* section header, a summary row) — the row is not one the consumer supplied,
|
|
330
|
+
* so their renderer can only misread it or throw, and its output is
|
|
331
|
+
* discarded moments later anyway.
|
|
332
|
+
*
|
|
333
|
+
* Applies to this cell only, and is decided per render against the final
|
|
334
|
+
* column list, so it covers columns other plugins contributed regardless of
|
|
335
|
+
* where in the pipeline this plugin sits.
|
|
336
|
+
*/
|
|
337
|
+
isContentSuppressed?: boolean;
|
|
325
338
|
}
|
|
326
339
|
|
|
327
340
|
/**
|
|
@@ -414,13 +427,18 @@ export type TableContextActions =
|
|
|
414
427
|
*
|
|
415
428
|
* 1. `transformColumns` — filter, reorder, or inject columns before rendering
|
|
416
429
|
* 2. `transformTable` — transform the root `<table>` element props
|
|
417
|
-
* 3. `
|
|
418
|
-
* 4. `
|
|
419
|
-
* 5. `
|
|
420
|
-
* 6. `
|
|
430
|
+
* 3. `transformHeaderCell` — transform each `<th>` props
|
|
431
|
+
* 4. `transformHeaderRow` — transform the header `<tr>` props
|
|
432
|
+
* 5. `transformBodyCell` — transform each body `<td>` props
|
|
433
|
+
* 6. `transformBodyRow` — transform each body `<tr>` props
|
|
421
434
|
* 7. `transformScrollWrapper` — transform the scroll-container wrapper around the table
|
|
422
435
|
* 8. `transformTableContext` — wrap the table output in context providers
|
|
423
436
|
*
|
|
437
|
+
* A row's cells are built before its row transform runs, and reach it as
|
|
438
|
+
* `children`: the cell hook is the earliest per-cell interception point, and a
|
|
439
|
+
* row transform can discard cells but cannot stop them being built (see
|
|
440
|
+
* `isContentSuppressed` on `BodyCellRenderProps` for that).
|
|
441
|
+
*
|
|
424
442
|
* Plugins may also contribute right-click menu actions by appending to
|
|
425
443
|
* `contextMenuActions` in `transformHeaderCell` / `transformBodyCell`
|
|
426
444
|
* (aggregated into one menu per header cell / row).
|
|
@@ -46,6 +46,11 @@ import {devWarn} from '../utils/devWarning';
|
|
|
46
46
|
* Canonical ordering for first-party plugin names.
|
|
47
47
|
* Plugins are sorted by their position in this array.
|
|
48
48
|
* Unknown names are appended after the known set.
|
|
49
|
+
*
|
|
50
|
+
* This decides LAYOUT — which column lands left of which, who wraps whom. It
|
|
51
|
+
* must never decide whether the table works: a plugin that renders in one
|
|
52
|
+
* order and crashes in the other is broken, and adding it here buys a lucky
|
|
53
|
+
* order rather than a fix.
|
|
49
54
|
*/
|
|
50
55
|
const PLUGIN_ORDER: ReadonlyArray<string> = [
|
|
51
56
|
'columnSettings',
|
|
@@ -7,7 +7,7 @@ export const docs = {
|
|
|
7
7
|
subComponentOf: 'Table',
|
|
8
8
|
displayName: 'useTableGroupedRows',
|
|
9
9
|
description:
|
|
10
|
-
|
|
10
|
+
"Hook that groups a flat data array into collapsible section rows. Each distinct groupBy value becomes a full-width section-header row with a chevron toggle, the group label, and a member count; collapsing hides that group's data rows while keeping the header visible. Mirrors useTableTreeState: the consumer owns the collapsedGroups set and the hook returns {data, plugin, idKey}: pass them to Table as data, plugins, and idKey respectively.",
|
|
11
11
|
props: [
|
|
12
12
|
{
|
|
13
13
|
name: 'data',
|
|
@@ -58,13 +58,14 @@ export const docs = {
|
|
|
58
58
|
/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
|
|
59
59
|
export const docsDense = {
|
|
60
60
|
description:
|
|
61
|
-
'Groups a flat data array into collapsible section rows. Each groupBy value becomes a full-width header (chevron + label + count); collapsing hides its rows. Returns {data, plugin, idKey}: pass to Table data / plugins / idKey. Consumer owns the collapsedGroups set.',
|
|
61
|
+
'Groups a flat data array into collapsible section rows. Each groupBy value becomes a full-width header (chevron + label + count); collapsing hides its rows. Returns {data, plugin, idKey}: pass them to Table data / plugins / idKey. Consumer owns the collapsedGroups set.',
|
|
62
62
|
propDescriptions: {
|
|
63
63
|
data: 'The flat data to group.',
|
|
64
|
-
groupBy:
|
|
64
|
+
groupBy: "Derive a row's group key. Same key = same section.",
|
|
65
65
|
collapsedGroups: 'Set of currently-collapsed group keys.',
|
|
66
66
|
onToggleGroup: 'Called with the group key when a header is toggled.',
|
|
67
|
-
renderGroupHeader:
|
|
67
|
+
renderGroupHeader:
|
|
68
|
+
"Custom header content (right of chevron). Default '<key> (<count>)'.",
|
|
68
69
|
getRowKey: 'Stable key for a real row; positional fallback when omitted.',
|
|
69
70
|
groupOrder: 'Pin these group keys first; others keep first-seen order.',
|
|
70
71
|
},
|
|
@@ -61,6 +61,13 @@ export const docs = {
|
|
|
61
61
|
description:
|
|
62
62
|
'Derives a human-readable identity for a row; the row checkbox\'s hidden label becomes `Select ${getRowLabel(item)}` so screen readers announce which row each checkbox selects. Falls back to "Select row" when omitted.',
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
name: 'hasRowHighlight',
|
|
66
|
+
type: 'boolean',
|
|
67
|
+
description:
|
|
68
|
+
'Paints checked rows with the accent wash. Set false when the surrounding UI already uses row background to mean something else (a row open in a detail panel, say) — the wash is an inline style, so it cannot be overridden from userland. Only the background is dropped: aria-selected is still set on checked rows either way.',
|
|
69
|
+
default: 'true',
|
|
70
|
+
},
|
|
64
71
|
],
|
|
65
72
|
examples: [
|
|
66
73
|
{
|
|
@@ -82,6 +89,21 @@ const selectionPlugin = useTableSelection({
|
|
|
82
89
|
idKey="id"
|
|
83
90
|
plugins={{selection: selectionPlugin}}
|
|
84
91
|
/>;
|
|
92
|
+
`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: 'Opt out of the checked-row wash',
|
|
96
|
+
code: `
|
|
97
|
+
// This table already uses row background to mean "open in the detail
|
|
98
|
+
// panel". Turning the selection wash off keeps that meaning unambiguous;
|
|
99
|
+
// the checkbox and aria-selected still carry the selection.
|
|
100
|
+
const selectionPlugin = useTableSelection({
|
|
101
|
+
getIsItemSelected: item => selectedIds.has(item.id),
|
|
102
|
+
onSelectItem: ({item, isSelected}) => toggle(item.id, isSelected),
|
|
103
|
+
onSelectAll: ({isAllSelected}) => selectAll(isAllSelected),
|
|
104
|
+
getIsAllSelected: () => selectedIds.size === users.length,
|
|
105
|
+
hasRowHighlight: false,
|
|
106
|
+
});
|
|
85
107
|
`,
|
|
86
108
|
},
|
|
87
109
|
],
|
|
@@ -142,6 +164,13 @@ export const docsZh = {
|
|
|
142
164
|
description:
|
|
143
165
|
'为行派生人类可读的标识;行复选框的隐藏标签变为 `Select ${getRowLabel(item)}`,让屏幕阅读器播报每个复选框选择的是哪一行。省略时回退为 "Select row"。',
|
|
144
166
|
},
|
|
167
|
+
{
|
|
168
|
+
name: 'hasRowHighlight',
|
|
169
|
+
type: 'boolean',
|
|
170
|
+
description:
|
|
171
|
+
'为选中的行绘制强调色背景。当周围的界面已用行背景表达其他含义(例如该行已在详情面板中打开)时设为 false —— 该背景是内联样式,无法从业务代码覆盖。仅去掉背景:无论如何选中的行仍会设置 aria-selected。',
|
|
172
|
+
default: 'true',
|
|
173
|
+
},
|
|
145
174
|
],
|
|
146
175
|
};
|
|
147
176
|
|
|
@@ -164,5 +193,7 @@ export const docsDense = {
|
|
|
164
193
|
'Returns whether row checkbox is interactive; disabled rows show disabled checkbox.',
|
|
165
194
|
getRowLabel:
|
|
166
195
|
'Derives row identity for the checkbox\'s hidden label: `Select ${getRowLabel(item)}`. Falls back to "Select row" when omitted.',
|
|
196
|
+
hasRowHighlight:
|
|
197
|
+
'false => skip the accent wash on checked rows (inline style, not overridable from userland). aria-selected is unaffected. Defaults to true.',
|
|
167
198
|
},
|
|
168
199
|
};
|
|
@@ -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
|
};
|