@astryxdesign/core 0.7.1 → 0.7.2-canary.07f97c6
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/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +38 -14
- package/dist/Drawer/Drawer.d.ts +191 -0
- package/dist/Drawer/Drawer.d.ts.map +1 -0
- package/dist/Drawer/Drawer.js +382 -0
- package/dist/Drawer/DrawerHeader.d.ts +85 -0
- package/dist/Drawer/DrawerHeader.d.ts.map +1 -0
- package/dist/Drawer/DrawerHeader.js +134 -0
- package/dist/Drawer/index.d.ts +11 -0
- package/dist/Drawer/index.d.ts.map +1 -0
- package/dist/Drawer/index.js +11 -0
- package/dist/Drawer/useDrawerDialogPresence.d.ts +42 -0
- package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -0
- package/dist/Drawer/useDrawerDialogPresence.js +236 -0
- package/dist/DropdownMenu/DropdownMenu.js +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +4 -4
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +10 -2
- package/dist/Layer/layerViewportInset.stylex.d.ts +6 -2
- package/dist/Layer/layerViewportInset.stylex.d.ts.map +1 -1
- package/dist/Layer/layerViewportInset.stylex.js +6 -2
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +13 -7
- package/dist/Layer/useLayerDismissal.d.ts +1 -1
- package/dist/Layer/useLayerDismissal.js +1 -1
- package/dist/Layout/container.stylex.d.ts +5 -5
- package/dist/Layout/container.stylex.js +4 -4
- package/dist/Markdown/plugins/frontmatter.d.ts.map +1 -1
- package/dist/Markdown/plugins/frontmatter.js +7 -3
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +22 -8
- package/dist/MultiSelector/OptionActions.a11y.chromium.spec.js +100 -2
- package/dist/MultiSelector/hooks.d.ts.map +1 -1
- package/dist/MultiSelector/hooks.js +3 -1
- package/dist/Pagination/Pagination.d.ts +8 -5
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +21 -8
- package/dist/Popover/Popover.js +2 -2
- package/dist/PowerSearch/PowerSearch.js +1 -1
- package/dist/Selector/hooks.d.ts.map +1 -1
- package/dist/Selector/hooks.js +6 -2
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +13 -1
- package/dist/TabList/TabMenu.d.ts +2 -2
- package/dist/TabList/TabMenu.d.ts.map +1 -1
- package/dist/TabList/TabMenu.js +6 -3
- package/dist/Text/useTruncation.d.ts +7 -5
- package/dist/Text/useTruncation.d.ts.map +1 -1
- package/dist/Text/useTruncation.js +16 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +11 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +26 -0
- package/dist/astryx.css +21 -10
- package/dist/hooks/useEntryAnimation.d.ts +2 -1
- package/dist/hooks/useEntryAnimation.d.ts.map +1 -1
- package/dist/hooks/useEntryAnimation.js +14 -12
- package/dist/hooks/useIsHydrating.d.ts +6 -0
- package/dist/hooks/useIsHydrating.d.ts.map +1 -0
- package/dist/hooks/useIsHydrating.js +30 -0
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +112 -1
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +112 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +0 -3
- package/dist/utils/focusOutline.stylex.d.ts +4 -0
- package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
- package/dist/utils/focusOutline.stylex.js +4 -0
- package/dist/utils/sharedMutationObserver.d.ts +21 -0
- package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
- package/dist/utils/sharedMutationObserver.js +69 -0
- package/locales/en.json +444 -0
- package/locales/pseudo.json +333 -0
- package/package.json +9 -4
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.test.tsx +155 -9
- package/src/Button/Button.tsx +39 -16
- package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
- package/src/Button/__tests__/Button.a11y.states.ts +0 -10
- package/src/Drawer/Drawer.doc.mjs +472 -0
- package/src/Drawer/Drawer.spec.md +357 -0
- package/src/Drawer/Drawer.test.tsx +1300 -0
- package/src/Drawer/Drawer.tsx +613 -0
- package/src/Drawer/DrawerDirection.test.tsx +127 -0
- package/src/Drawer/DrawerHeader.doc.mjs +293 -0
- package/src/Drawer/DrawerHeader.test.tsx +102 -0
- package/src/Drawer/DrawerHeader.tsx +235 -0
- package/src/Drawer/__tests__/DrawerAudit.a11y.chromium.spec.ts +401 -0
- package/src/Drawer/__tests__/DrawerHeaderAudit.a11y.chromium.spec.ts +297 -0
- package/src/Drawer/index.ts +13 -0
- package/src/Drawer/useDrawerDialogPresence.ts +284 -0
- package/src/DropdownMenu/DropdownMenu.tsx +2 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +2 -2
- package/src/HoverCard/HoverCard.test.tsx +58 -0
- package/src/HoverCard/useHoverCard.tsx +10 -2
- package/src/IconButton/IconButton.doc.mjs +2 -2
- package/src/IconButton/IconButton.test.tsx +3 -1
- package/src/Layer/layerViewportInset.source-build.test.mjs +90 -0
- package/src/Layer/layerViewportInset.stylex.ts +6 -2
- package/src/Layer/layerViewportInset.test.ts +1 -0
- package/src/Layer/useLayer.tsx +24 -13
- package/src/Layer/useLayerDismissal.ts +1 -1
- package/src/Layout/container.stylex.ts +6 -6
- package/src/List/ListItem.doc.mjs +8 -5
- package/src/Markdown/plugins/frontmatter.test.tsx +25 -0
- package/src/Markdown/plugins/frontmatter.ts +7 -3
- package/src/MultiSelector/MultiSelector.test.tsx +159 -0
- package/src/MultiSelector/MultiSelector.tsx +40 -7
- package/src/MultiSelector/OptionActions.a11y.chromium.spec.ts +112 -2
- package/src/MultiSelector/hooks.ts +3 -1
- package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
- package/src/Pagination/Pagination.doc.mjs +7 -7
- package/src/Pagination/Pagination.spec.md +9 -9
- package/src/Pagination/Pagination.test.tsx +242 -0
- package/src/Pagination/Pagination.tsx +27 -11
- package/src/Popover/Popover.test.tsx +2 -2
- package/src/Popover/Popover.tsx +4 -4
- package/src/PowerSearch/PowerSearch.test.tsx +85 -0
- package/src/PowerSearch/PowerSearch.tsx +2 -2
- package/src/Selector/hooks.ts +6 -2
- package/src/Selector/useCombobox.doc.mjs +237 -0
- package/src/Switch/Switch.test.tsx +49 -0
- package/src/Switch/Switch.tsx +11 -0
- package/src/TabList/TabList.test.tsx +67 -0
- package/src/TabList/TabMenu.doc.mjs +4 -3
- package/src/TabList/TabMenu.tsx +7 -4
- package/src/Text/useTruncation.test.tsx +69 -0
- package/src/Text/useTruncation.ts +16 -5
- package/src/Tokenizer/Tokenizer.test.tsx +54 -1
- package/src/Tooltip/Tooltip.test.tsx +31 -0
- package/src/Tooltip/useTooltip.test.tsx +13 -0
- package/src/Tooltip/useTooltip.tsx +11 -0
- package/src/Typeahead/BaseTypeahead.tsx +26 -0
- package/src/Typeahead/Typeahead.test.tsx +260 -2
- package/src/hooks/useEntryAnimation.doc.mjs +4 -4
- package/src/hooks/useEntryAnimation.ssr.test.tsx +143 -0
- package/src/hooks/useEntryAnimation.ts +15 -16
- package/src/hooks/useIsHydrating.test.tsx +70 -0
- package/src/hooks/useIsHydrating.ts +31 -0
- package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
- package/src/i18n/InternationalizationProvider.spec.md +252 -0
- package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
- package/src/i18n/generated-locales/en.generated.ts +121 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +121 -0
- package/src/i18n/useTranslator.test.tsx +37 -0
- package/src/index.ts +1 -0
- package/src/theme/derivedVarRegistry.test.ts +0 -3
- package/src/theme/derivedVarRegistry.ts +0 -3
- package/src/utils/focusOutline.stylex.ts +4 -0
- package/src/utils/sharedMutationObserver.test.ts +53 -0
- package/src/utils/sharedMutationObserver.ts +78 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').HookDoc} */
|
|
4
|
+
export const docs = {
|
|
5
|
+
name: 'useCombobox',
|
|
6
|
+
displayName: 'useCombobox',
|
|
7
|
+
group: 'Selector',
|
|
8
|
+
keywords: [
|
|
9
|
+
'combobox',
|
|
10
|
+
'listbox',
|
|
11
|
+
'select',
|
|
12
|
+
'dropdown',
|
|
13
|
+
'highlight',
|
|
14
|
+
'active option',
|
|
15
|
+
'aria-activedescendant',
|
|
16
|
+
'keyboard',
|
|
17
|
+
'arrow keys',
|
|
18
|
+
'headless',
|
|
19
|
+
'apg',
|
|
20
|
+
'accessibility',
|
|
21
|
+
'a11y',
|
|
22
|
+
],
|
|
23
|
+
params: [
|
|
24
|
+
{
|
|
25
|
+
name: 'options',
|
|
26
|
+
type: 'UseComboboxOptions',
|
|
27
|
+
description: 'Configuration object.',
|
|
28
|
+
required: true,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'options.selectableItems',
|
|
32
|
+
type: 'SelectorOptionData[]',
|
|
33
|
+
description:
|
|
34
|
+
'The options in the same order as they render. Options with `disabled` cannot be picked and are skipped while moving through the open list; ArrowDown or ArrowUp that opens the list still lands on the first or last option.',
|
|
35
|
+
required: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'options.isOpen',
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
description: 'Whether the list is shown. The caller owns this state.',
|
|
41
|
+
required: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'options.onOpen',
|
|
45
|
+
type: '() => unknown',
|
|
46
|
+
description:
|
|
47
|
+
'Called to show the list. Return `false` to refuse; the hook then leaves the highlight alone.',
|
|
48
|
+
required: true,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'options.onClose',
|
|
52
|
+
type: '() => void',
|
|
53
|
+
description:
|
|
54
|
+
'Called when the hook hides the list; the hook resets the highlight first. When the caller closes the list itself (an outside press, say), it should also call `setHighlightedIndex(-1)`.',
|
|
55
|
+
required: true,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'options.listboxId',
|
|
59
|
+
type: 'string',
|
|
60
|
+
description:
|
|
61
|
+
"The listbox element's id. Option ids are derived from it through `getItemId`.",
|
|
62
|
+
required: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'options.value',
|
|
66
|
+
type: 'string',
|
|
67
|
+
description:
|
|
68
|
+
'The selected value. Without search, opening by a press, Enter or Space highlights it, or the first option when nothing is selected; ArrowDown and ArrowUp open on the first and last option.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'options.onSelect',
|
|
72
|
+
type: '(value: string) => void',
|
|
73
|
+
description:
|
|
74
|
+
'Called with the picked value. Picking also closes the list.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'options.onClear',
|
|
78
|
+
type: '() => void',
|
|
79
|
+
description:
|
|
80
|
+
'Clears the value. When provided, Delete or Backspace on the closed control clears it, so clearing is not pointer-only. Ignored while the list is open or with `hasSearch`.',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'options.hasSearch',
|
|
84
|
+
type: 'boolean',
|
|
85
|
+
description:
|
|
86
|
+
'Whether the list has its own search field. Space then types instead of picking, opening by a press or Enter does not pre-highlight (ArrowDown and ArrowUp still open on the first and last option), and printable keys on the closed control go to `onSearchSeed`.',
|
|
87
|
+
default: 'false',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'options.onSearchSeed',
|
|
91
|
+
type: '(char: string) => void',
|
|
92
|
+
description:
|
|
93
|
+
'With `hasSearch`, receives each printable character except Space typed while the control has focus, opening the list first when it is closed, so type-to-find needs no separate open step.',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'options.isDisabled',
|
|
97
|
+
type: 'boolean',
|
|
98
|
+
description: 'Whether the control ignores clicks and keys.',
|
|
99
|
+
default: 'false',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
returns: [
|
|
103
|
+
{
|
|
104
|
+
name: 'highlightedIndex',
|
|
105
|
+
type: 'number',
|
|
106
|
+
description:
|
|
107
|
+
'Index of the active option, or -1 when none is active. With `hasSearch`, opening by a press or Enter does not set one, so it stays -1 after the hook itself closes the list; ArrowDown and ArrowUp still open on the first or last option. Render it as the active option and name it in `aria-activedescendant` only while it points at a rendered option.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'setHighlightedIndex',
|
|
111
|
+
type: '(index: number) => void',
|
|
112
|
+
description:
|
|
113
|
+
'Sets the active option, for example from a filter that changes the options.',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'getItemId',
|
|
117
|
+
type: '(index: number) => string',
|
|
118
|
+
description:
|
|
119
|
+
"The id to render on the option at `index`. Set the control's `aria-activedescendant` to `isOpen && highlightedIndex >= 0 && highlightedIndex < selectableItems.length ? getItemId(highlightedIndex) : undefined`, so it never names an option that does not exist, including in an empty list.",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'onTriggerClick',
|
|
123
|
+
type: '() => void',
|
|
124
|
+
description: 'Toggles the list from a press on the control.',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'onKeyDown',
|
|
128
|
+
type: '(e: React.KeyboardEvent) => void',
|
|
129
|
+
description:
|
|
130
|
+
'Key handler for the control that keeps focus. ArrowDown and ArrowUp open the list or move the active option; Enter (and Space without search) picks it or opens the list; Escape and Tab close it; Home, End, PageUp and PageDown jump to the first or last option. An editable control that keeps Home and End for the caret does not pass them to this handler, as CommandPalette does.',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'onItemSelect',
|
|
134
|
+
type: '(item: SelectorOptionData) => void',
|
|
135
|
+
description: 'Picks an option from a press on it, then closes the list.',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'onItemMouseEnter',
|
|
139
|
+
type: '(item: SelectorOptionData, index: number) => void',
|
|
140
|
+
description:
|
|
141
|
+
'Makes the hovered option active without scrolling the list or moving focus.',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
usage: {
|
|
145
|
+
description:
|
|
146
|
+
'Tracks the active option of a single-select combobox and handles its keys, while DOM focus stays on the control. It powers Selector and CommandPalette; use it to build a combobox those components do not cover. It renders nothing and sets no attributes: the caller renders the control, the listbox and the options, and wires `role`, `aria-expanded`, `aria-controls` and `aria-activedescendant` from what it returns.',
|
|
147
|
+
bestPractices: [
|
|
148
|
+
{
|
|
149
|
+
guidance: true,
|
|
150
|
+
description:
|
|
151
|
+
"Render each option with `id={getItemId(index)}` and set the control's `aria-activedescendant` only while `isOpen` and `highlightedIndex` points at a rendered option, as CommandPalette does.",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
guidance: true,
|
|
155
|
+
description:
|
|
156
|
+
"For type-to-select without a search field, run useTypeahead's handler first and fall through to this `onKeyDown` when it returns false, as Selector does.",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
guidance: true,
|
|
160
|
+
description:
|
|
161
|
+
'Announce the number of results through useAnnounce when the options change for a new query.',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
guidance: false,
|
|
165
|
+
description:
|
|
166
|
+
'Move DOM focus onto the options; the active option is announced through `aria-activedescendant` while focus stays on the control.',
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
relatedComponents: ['Selector', 'CommandPalette'],
|
|
171
|
+
relatedHooks: ['useMultiCombobox', 'useTypeahead', 'useAnnounce'],
|
|
172
|
+
importPath: '@astryxdesign/core/Selector',
|
|
173
|
+
category: 'focus',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
|
|
177
|
+
export const docsDense = {
|
|
178
|
+
description:
|
|
179
|
+
'Single-select combobox state + keys; focus stays on control. Powers Selector / CommandPalette. Renders nothing, sets no attrs: caller renders control / listbox / options and wires role, aria-expanded, aria-controls, aria-activedescendant.',
|
|
180
|
+
paramDescriptions: {
|
|
181
|
+
options: 'config.',
|
|
182
|
+
'options.selectableItems':
|
|
183
|
+
'options in render order; disabled ones not pickable, skipped while moving in open list (ArrowDown/Up opening still lands first / last).',
|
|
184
|
+
'options.isOpen': 'list shown; caller owns state.',
|
|
185
|
+
'options.onOpen':
|
|
186
|
+
'show list; return false to refuse (highlight untouched).',
|
|
187
|
+
'options.onClose':
|
|
188
|
+
'hook hides list; resets highlight first. caller closing itself (outside press): also setHighlightedIndex(-1).',
|
|
189
|
+
'options.listboxId': 'listbox id; option ids derive from it (getItemId).',
|
|
190
|
+
'options.value':
|
|
191
|
+
'selected value; w/o search, press / Enter / Space opening highlights it (else first); ArrowDown/Up open on first / last.',
|
|
192
|
+
'options.onSelect': 'called w/ picked value; picking closes.',
|
|
193
|
+
'options.onClear':
|
|
194
|
+
'clears value; Delete / Backspace on closed control. ignored when open or hasSearch.',
|
|
195
|
+
'options.hasSearch':
|
|
196
|
+
'list has own search field: Space types, press / Enter opening does not pre-highlight (ArrowDown/Up still first / last), printable keys on closed control -> onSearchSeed.',
|
|
197
|
+
'options.onSearchSeed':
|
|
198
|
+
'w/ hasSearch, each printable char except Space typed while control focused (opens first if closed).',
|
|
199
|
+
'options.isDisabled': 'ignore clicks + keys.',
|
|
200
|
+
},
|
|
201
|
+
returnDescriptions: {
|
|
202
|
+
highlightedIndex:
|
|
203
|
+
'active option index, -1 = none. hasSearch: press / Enter opening sets none (stays -1 after hook closes); ArrowDown/Up still first / last. name in aria-activedescendant only when it points at a rendered option.',
|
|
204
|
+
setHighlightedIndex: 'set active option (e.g. after filtering).',
|
|
205
|
+
getItemId:
|
|
206
|
+
'id for option at index; aria-activedescendant = isOpen && highlightedIndex >= 0 && highlightedIndex < selectableItems.length ? getItemId(highlightedIndex) : undefined (empty list safe).',
|
|
207
|
+
onTriggerClick: 'toggle list from a press on control.',
|
|
208
|
+
onKeyDown:
|
|
209
|
+
'control keydown: ArrowDown/Up open or move; Enter (Space w/o search) picks or opens; Escape / Tab close; Home / End / PageUp / PageDown jump. editable control keeping Home / End for caret: do not pass them.',
|
|
210
|
+
onItemSelect: 'pick from a press, then close.',
|
|
211
|
+
onItemMouseEnter: 'hover makes option active; no scroll, no focus move.',
|
|
212
|
+
},
|
|
213
|
+
usage: {
|
|
214
|
+
description:
|
|
215
|
+
'Headless single-select combobox: active option + keys, focus stays on control. Caller renders + wires ARIA.',
|
|
216
|
+
bestPractices: [
|
|
217
|
+
{
|
|
218
|
+
guidance: true,
|
|
219
|
+
description:
|
|
220
|
+
'Option id = getItemId(index); aria-activedescendant only while open and highlightedIndex points at a rendered option.',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
guidance: true,
|
|
224
|
+
description:
|
|
225
|
+
'Type-to-select w/o search: run useTypeahead first, fall through when it returns false.',
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
guidance: true,
|
|
229
|
+
description: 'Announce result count via useAnnounce on new query.',
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
guidance: false,
|
|
233
|
+
description: 'Move DOM focus onto options; use aria-activedescendant.',
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
};
|
|
@@ -834,6 +834,55 @@ describe('Switch', () => {
|
|
|
834
834
|
expect(root).toHaveAttribute('id', 'switch-1');
|
|
835
835
|
expect(root).toHaveAttribute('aria-label', 'Toggle notifications');
|
|
836
836
|
});
|
|
837
|
+
|
|
838
|
+
it('composes a caller aria-describedby onto the switch control', () => {
|
|
839
|
+
const {container} = render(
|
|
840
|
+
<>
|
|
841
|
+
<p id="external-hint">Applies to every repository</p>
|
|
842
|
+
<Switch
|
|
843
|
+
label="Notifications"
|
|
844
|
+
description="Sends a daily digest"
|
|
845
|
+
value={false}
|
|
846
|
+
onChange={() => {}}
|
|
847
|
+
data-testid="my-switch"
|
|
848
|
+
aria-describedby="external-hint"
|
|
849
|
+
/>
|
|
850
|
+
</>,
|
|
851
|
+
);
|
|
852
|
+
const control = screen.getByRole('switch');
|
|
853
|
+
const description = screen.getByText('Sends a daily digest');
|
|
854
|
+
expect(control.getAttribute('aria-describedby')?.split(' ')).toEqual([
|
|
855
|
+
'external-hint',
|
|
856
|
+
description.id,
|
|
857
|
+
]);
|
|
858
|
+
expect(control).toHaveAccessibleDescription(
|
|
859
|
+
'Applies to every repository Sends a daily digest',
|
|
860
|
+
);
|
|
861
|
+
// The id reference lives on the control only, not on the field wrapper.
|
|
862
|
+
const root = container.querySelector('[data-testid="my-switch"]');
|
|
863
|
+
expect(root).not.toHaveAttribute('aria-describedby');
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('names the switch control from a caller aria-labelledby', () => {
|
|
867
|
+
const {container} = render(
|
|
868
|
+
<>
|
|
869
|
+
<span id="row-title">Release notes</span>
|
|
870
|
+
<Switch
|
|
871
|
+
label="Pin Release notes"
|
|
872
|
+
isLabelHidden
|
|
873
|
+
value={false}
|
|
874
|
+
onChange={() => {}}
|
|
875
|
+
data-testid="my-switch"
|
|
876
|
+
aria-labelledby="row-title"
|
|
877
|
+
/>
|
|
878
|
+
</>,
|
|
879
|
+
);
|
|
880
|
+
const control = screen.getByRole('switch', {name: 'Release notes'});
|
|
881
|
+
expect(control).toHaveAttribute('aria-labelledby', 'row-title');
|
|
882
|
+
// The id reference lives on the control only, not on the field wrapper.
|
|
883
|
+
const root = container.querySelector('[data-testid="my-switch"]');
|
|
884
|
+
expect(root).not.toHaveAttribute('aria-labelledby');
|
|
885
|
+
});
|
|
837
886
|
});
|
|
838
887
|
|
|
839
888
|
describe('pressed state', () => {
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -566,6 +566,8 @@ export function Switch({
|
|
|
566
566
|
className,
|
|
567
567
|
style,
|
|
568
568
|
ref,
|
|
569
|
+
'aria-describedby': ariaDescribedByProp,
|
|
570
|
+
'aria-labelledby': ariaLabelledByProp,
|
|
569
571
|
...rest
|
|
570
572
|
}: SwitchProps) {
|
|
571
573
|
const t = useTranslator();
|
|
@@ -610,7 +612,12 @@ export function Switch({
|
|
|
610
612
|
// Only include descriptionID when the element actually renders.
|
|
611
613
|
// FieldLabel renders the description (with descriptionID) even when the
|
|
612
614
|
// label is visually hidden — it's sr-only, so keep it linked.
|
|
615
|
+
// A consumer's own `aria-describedby` describes the control, so it comes
|
|
616
|
+
// first here rather than landing on the field wrapper via `...rest`.
|
|
613
617
|
const describedByParts: string[] = [];
|
|
618
|
+
if (ariaDescribedByProp) {
|
|
619
|
+
describedByParts.push(ariaDescribedByProp);
|
|
620
|
+
}
|
|
614
621
|
if (description) {
|
|
615
622
|
describedByParts.push(descriptionID);
|
|
616
623
|
}
|
|
@@ -658,6 +665,10 @@ export function Switch({
|
|
|
658
665
|
}}
|
|
659
666
|
onFocus={onFocus}
|
|
660
667
|
onBlur={onBlur}
|
|
668
|
+
// A caller's `aria-labelledby` names the control (a visible title
|
|
669
|
+
// elsewhere in the row, for example), so it reaches the input rather
|
|
670
|
+
// than the field wrapper via `...rest`.
|
|
671
|
+
aria-labelledby={ariaLabelledByProp}
|
|
661
672
|
aria-describedby={ariaDescribedBy}
|
|
662
673
|
aria-invalid={status?.type === 'error' ? true : undefined}
|
|
663
674
|
aria-busy={isBusy || undefined}
|
|
@@ -783,6 +783,73 @@ describe('TabMenu', () => {
|
|
|
783
783
|
});
|
|
784
784
|
});
|
|
785
785
|
|
|
786
|
+
describe('TabMenu host attributes', () => {
|
|
787
|
+
const menuOptions = [
|
|
788
|
+
{value: 'analytics', label: 'Analytics'},
|
|
789
|
+
{value: 'reports', label: 'Reports'},
|
|
790
|
+
];
|
|
791
|
+
|
|
792
|
+
it('forwards host attributes to the trigger, not the popup', () => {
|
|
793
|
+
render(
|
|
794
|
+
<TabList value="home" onChange={() => {}}>
|
|
795
|
+
<Tab value="home" label="Home" />
|
|
796
|
+
<TabMenu
|
|
797
|
+
label="More"
|
|
798
|
+
options={menuOptions}
|
|
799
|
+
id="more-tabs"
|
|
800
|
+
aria-label="More views"
|
|
801
|
+
data-view="overflow"
|
|
802
|
+
/>
|
|
803
|
+
</TabList>,
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const trigger = screen.getByRole('button', {name: 'More views'});
|
|
807
|
+
expect(trigger).toHaveAttribute('id', 'more-tabs');
|
|
808
|
+
expect(trigger).toHaveAttribute('data-view', 'overflow');
|
|
809
|
+
const menu = screen.getByRole('menu', {name: 'More', hidden: true});
|
|
810
|
+
expect(trigger).toHaveAttribute('aria-controls', menu.id);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('runs drag-and-drop handlers on the trigger', () => {
|
|
814
|
+
const onDragOver = vi.fn();
|
|
815
|
+
const onDrop = vi.fn();
|
|
816
|
+
render(
|
|
817
|
+
<TabList value="home" onChange={() => {}}>
|
|
818
|
+
<Tab value="home" label="Home" />
|
|
819
|
+
<TabMenu
|
|
820
|
+
label="More"
|
|
821
|
+
options={menuOptions}
|
|
822
|
+
onDragOver={onDragOver}
|
|
823
|
+
onDrop={onDrop}
|
|
824
|
+
/>
|
|
825
|
+
</TabList>,
|
|
826
|
+
);
|
|
827
|
+
const trigger = screen.getByRole('button', {name: 'More'});
|
|
828
|
+
fireEvent.dragOver(trigger);
|
|
829
|
+
fireEvent.drop(trigger);
|
|
830
|
+
expect(onDragOver).toHaveBeenCalledTimes(1);
|
|
831
|
+
expect(onDrop).toHaveBeenCalledTimes(1);
|
|
832
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it('onClick preserves the toggle', async () => {
|
|
836
|
+
const user = userEvent.setup();
|
|
837
|
+
const onClick = vi.fn((event: React.MouseEvent<HTMLButtonElement>) => {
|
|
838
|
+
event.preventDefault();
|
|
839
|
+
});
|
|
840
|
+
render(
|
|
841
|
+
<TabList value="home" onChange={() => {}}>
|
|
842
|
+
<Tab value="home" label="Home" />
|
|
843
|
+
<TabMenu label="More" options={menuOptions} onClick={onClick} />
|
|
844
|
+
</TabList>,
|
|
845
|
+
);
|
|
846
|
+
const trigger = screen.getByRole('button', {name: 'More'});
|
|
847
|
+
await user.click(trigger);
|
|
848
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
849
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
786
853
|
describe('TabMenu keyboard navigation (roving tabindex)', () => {
|
|
787
854
|
const menuOptions = [
|
|
788
855
|
{value: 'analytics', label: 'Analytics'},
|
|
@@ -7,7 +7,7 @@ export const docs = {
|
|
|
7
7
|
subComponentOf: 'TabList',
|
|
8
8
|
displayName: 'Tab Menu',
|
|
9
9
|
isHiddenFromOverview: true,
|
|
10
|
-
description: "Overflow menu trigger that opens a dropdown of additional tab options, showing the selected option's label as the trigger text.",
|
|
10
|
+
description: "Overflow menu trigger that opens a dropdown of additional tab options, showing the selected option's label as the trigger text. Host attributes and DOM event handlers (id, aria-*, data-*, drag and drop) reach the trigger button, not the popup or its options. The button keeps its role, tab stop, and menu disclosure attributes.",
|
|
11
11
|
// TabMenu requires TabList context; wrap it so the preview doesn't throw.
|
|
12
12
|
// The wrapper value matches none of the options, so the trigger shows its
|
|
13
13
|
// own label (the idle overflow state).
|
|
@@ -42,7 +42,7 @@ export const docsZh = {
|
|
|
42
42
|
name: 'TabMenu',
|
|
43
43
|
isHiddenFromOverview: true,
|
|
44
44
|
displayName: 'Tab Menu',
|
|
45
|
-
description: '
|
|
45
|
+
description: '溢出菜单触发器,打开包含额外标签选项的下拉菜单,将选中选项的标签显示为触发器文本。宿主属性和 DOM 事件处理器(id、aria-*、data-*、拖放)传递给触发按钮,而不是弹出菜单或选项。按钮保留自己的角色、Tab 停靠点及菜单展开属性。',
|
|
46
46
|
props: [
|
|
47
47
|
{
|
|
48
48
|
name: 'label',
|
|
@@ -63,7 +63,8 @@ export const docsDense = {
|
|
|
63
63
|
name: 'TabMenu',
|
|
64
64
|
isHiddenFromOverview: true,
|
|
65
65
|
displayName: 'Tab Menu',
|
|
66
|
-
description:
|
|
66
|
+
description:
|
|
67
|
+
"Overflow menu trigger; shows selected option's label. Host attributes/DOM handlers reach the trigger button, not the popup/options; role, tab stop and disclosure attributes stay owned.",
|
|
67
68
|
propDescriptions: {
|
|
68
69
|
label: 'Trigger text (when no option selected) + dropdown heading.',
|
|
69
70
|
options: 'Menu options array rendered in dropdown.',
|
package/src/TabList/TabMenu.tsx
CHANGED
|
@@ -37,7 +37,7 @@ import {useListFocus} from '../hooks/useListFocus';
|
|
|
37
37
|
import {useTabListContext} from './TabListContext';
|
|
38
38
|
import type {TabListSize} from './TabListContext';
|
|
39
39
|
import {tabScope} from './tab.markers.stylex';
|
|
40
|
-
import {mergeProps} from '../utils';
|
|
40
|
+
import {composeEventHandlers, mergeProps} from '../utils';
|
|
41
41
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
42
42
|
import type {BaseProps} from '../BaseProps';
|
|
43
43
|
import {themeProps} from '../utils/themeProps';
|
|
@@ -51,9 +51,9 @@ export interface TabMenuOption {
|
|
|
51
51
|
icon?: ReactNode | IconType;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export interface TabMenuProps extends
|
|
54
|
+
export interface TabMenuProps extends Omit<
|
|
55
55
|
BaseProps<HTMLButtonElement>,
|
|
56
|
-
'
|
|
56
|
+
'role' | 'tabIndex' | 'aria-haspopup' | 'aria-expanded' | 'aria-controls'
|
|
57
57
|
> {
|
|
58
58
|
ref?: React.Ref<HTMLButtonElement>;
|
|
59
59
|
/**
|
|
@@ -263,6 +263,8 @@ export function TabMenu({
|
|
|
263
263
|
xstyle,
|
|
264
264
|
className,
|
|
265
265
|
style,
|
|
266
|
+
onClick,
|
|
267
|
+
...rest
|
|
266
268
|
}: TabMenuProps) {
|
|
267
269
|
const tabListCtx = useTabListContext();
|
|
268
270
|
const menuId = useId();
|
|
@@ -346,6 +348,7 @@ export function TabMenu({
|
|
|
346
348
|
return (
|
|
347
349
|
<>
|
|
348
350
|
<button
|
|
351
|
+
{...rest}
|
|
349
352
|
ref={setButtonRef}
|
|
350
353
|
type="button"
|
|
351
354
|
aria-haspopup="menu"
|
|
@@ -353,7 +356,7 @@ export function TabMenu({
|
|
|
353
356
|
aria-controls={menuId}
|
|
354
357
|
data-tab-menu=""
|
|
355
358
|
tabIndex={hasSelectedOption ? 0 : -1}
|
|
356
|
-
onClick={handleToggle}
|
|
359
|
+
onClick={composeEventHandlers(handleToggle, onClick)}
|
|
357
360
|
{...mergeProps(
|
|
358
361
|
themeProps('tab-menu'),
|
|
359
362
|
focusOutlineProps.focusVisible(
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useTruncation.test.tsx
|
|
5
|
+
* Tests for useTruncation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {render, screen, waitFor} from '@testing-library/react';
|
|
9
|
+
import {afterEach, describe, it, expect, vi} from 'vitest';
|
|
10
|
+
import {useTruncation} from './useTruncation';
|
|
11
|
+
|
|
12
|
+
function Probe({text}: {text: string}) {
|
|
13
|
+
const truncation = useTruncation({maxLines: 1});
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<span ref={truncation.ref}>{text}</span>
|
|
17
|
+
<output data-testid="full-text">{truncation.fullText}</output>
|
|
18
|
+
</>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A status line at a fixed width never resizes when its message changes, so
|
|
23
|
+
// the tooltip text must follow the content, not only the element's size.
|
|
24
|
+
describe('useTruncation', () => {
|
|
25
|
+
it('re-measures when the text changes without a resize', async () => {
|
|
26
|
+
const {rerender} = render(<Probe text="planning the first step" />);
|
|
27
|
+
expect(screen.getByTestId('full-text')).toHaveTextContent(
|
|
28
|
+
'planning the first step',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
rerender(<Probe text="writing the second step" />);
|
|
32
|
+
|
|
33
|
+
await waitFor(() =>
|
|
34
|
+
expect(screen.getByTestId('full-text')).toHaveTextContent(
|
|
35
|
+
'writing the second step',
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('content observation', () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('shares one observer across every truncating instance', () => {
|
|
46
|
+
const NativeMutationObserver = globalThis.MutationObserver;
|
|
47
|
+
let created = 0;
|
|
48
|
+
vi.stubGlobal(
|
|
49
|
+
'MutationObserver',
|
|
50
|
+
class extends NativeMutationObserver {
|
|
51
|
+
constructor(callback: MutationCallback) {
|
|
52
|
+
super(callback);
|
|
53
|
+
created += 1;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
render(
|
|
59
|
+
<>
|
|
60
|
+
{Array.from({length: 50}, (_, index) => (
|
|
61
|
+
<Probe key={index} text={`cell ${index}`} />
|
|
62
|
+
))}
|
|
63
|
+
</>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(created).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file useTruncation.ts
|
|
7
|
-
* @input Uses React hooks, sharedResizeObserver
|
|
7
|
+
* @input Uses React hooks, sharedResizeObserver, sharedMutationObserver
|
|
8
8
|
* @output Exports useTruncation hook for detecting text overflow
|
|
9
9
|
* @position Hook; consumed by Text.tsx, Heading.tsx
|
|
10
10
|
*
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import {useCallback, useRef, useState, type RefCallback} from 'react';
|
|
16
16
|
import {observeResize} from '../utils/sharedResizeObserver';
|
|
17
|
+
import {observeContent} from '../utils/sharedMutationObserver';
|
|
17
18
|
|
|
18
19
|
export interface UseTruncationOptions {
|
|
19
20
|
/**
|
|
@@ -43,10 +44,12 @@ export interface UseTruncationReturn {
|
|
|
43
44
|
/**
|
|
44
45
|
* Hook for detecting text overflow/truncation.
|
|
45
46
|
*
|
|
46
|
-
* Uses a shared ResizeObserver singleton (via observeResize)
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* Uses a shared ResizeObserver singleton (via observeResize) for size
|
|
48
|
+
* changes and a shared MutationObserver singleton (via observeContent) for
|
|
49
|
+
* content changes, so even hundreds of table cells create one observer of
|
|
50
|
+
* each kind. Observing content matters because an element whose text changes
|
|
51
|
+
* without resizing (a fixed-width status line) must still be re-measured for
|
|
52
|
+
* the tooltip text and the truncated state to follow the current content.
|
|
50
53
|
*
|
|
51
54
|
* - Single-line: compares scrollWidth > offsetWidth
|
|
52
55
|
* - Multi-line: uses Range.getBoundingClientRect() to measure actual content
|
|
@@ -71,6 +74,7 @@ export function useTruncation(
|
|
|
71
74
|
const [fullText, setFullText] = useState('');
|
|
72
75
|
const elementRef = useRef<HTMLElement | null>(null);
|
|
73
76
|
const unobserveRef = useRef<(() => void) | null>(null);
|
|
77
|
+
const unobserveContentRef = useRef<(() => void) | null>(null);
|
|
74
78
|
|
|
75
79
|
const checkTruncation = useCallback(
|
|
76
80
|
(element: HTMLElement) => {
|
|
@@ -113,6 +117,8 @@ export function useTruncation(
|
|
|
113
117
|
// by element leaves any other observer of the same node untouched.
|
|
114
118
|
unobserveRef.current?.();
|
|
115
119
|
unobserveRef.current = null;
|
|
120
|
+
unobserveContentRef.current?.();
|
|
121
|
+
unobserveContentRef.current = null;
|
|
116
122
|
|
|
117
123
|
elementRef.current = element;
|
|
118
124
|
|
|
@@ -127,6 +133,11 @@ export function useTruncation(
|
|
|
127
133
|
} else {
|
|
128
134
|
checkTruncation(element);
|
|
129
135
|
}
|
|
136
|
+
// New text in a box that keeps its size fires no resize, so the
|
|
137
|
+
// captured text and truncated state would otherwise stay stale.
|
|
138
|
+
unobserveContentRef.current = observeContent(element, () => {
|
|
139
|
+
checkTruncation(element);
|
|
140
|
+
});
|
|
130
141
|
} else {
|
|
131
142
|
setIsTruncated(false);
|
|
132
143
|
setFullText('');
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
afterEach,
|
|
21
21
|
} from 'vitest';
|
|
22
22
|
import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
|
|
23
|
-
import {createRef, Profiler} from 'react';
|
|
23
|
+
import {createRef, Profiler, useState} from 'react';
|
|
24
24
|
import userEvent from '@testing-library/user-event';
|
|
25
25
|
import {Tokenizer} from './Tokenizer';
|
|
26
26
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
@@ -1790,6 +1790,59 @@ describe('Tokenizer end-lane reserve', () => {
|
|
|
1790
1790
|
expect(renderedTokens).toEqual([]);
|
|
1791
1791
|
});
|
|
1792
1792
|
|
|
1793
|
+
it('clears the busy spinner when a token is added while a bootstrap load is in flight', async () => {
|
|
1794
|
+
let settle: (items: SearchableItem[]) => void = () => {};
|
|
1795
|
+
const bootstrap = vi.fn(
|
|
1796
|
+
async () =>
|
|
1797
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
1798
|
+
settle = resolve;
|
|
1799
|
+
}),
|
|
1800
|
+
);
|
|
1801
|
+
const source: SearchSource = {search: () => [], bootstrap};
|
|
1802
|
+
|
|
1803
|
+
function Harness() {
|
|
1804
|
+
const [value, setValue] = useState<SearchableItem[]>([]);
|
|
1805
|
+
return (
|
|
1806
|
+
<>
|
|
1807
|
+
<Tokenizer
|
|
1808
|
+
label="Items"
|
|
1809
|
+
searchSource={source}
|
|
1810
|
+
value={value}
|
|
1811
|
+
onChange={setValue}
|
|
1812
|
+
hasEntriesOnFocus
|
|
1813
|
+
/>
|
|
1814
|
+
<button type="button" onClick={() => setValue([users[0]])}>
|
|
1815
|
+
Add Token
|
|
1816
|
+
</button>
|
|
1817
|
+
</>
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
render(<Harness />);
|
|
1822
|
+
const input = screen.getByRole('combobox');
|
|
1823
|
+
|
|
1824
|
+
fireEvent.focus(input);
|
|
1825
|
+
await waitFor(() => {
|
|
1826
|
+
expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
fireEvent.click(screen.getByRole('button', {name: 'Add Token'}));
|
|
1830
|
+
|
|
1831
|
+
await waitFor(() => {
|
|
1832
|
+
expect(
|
|
1833
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1834
|
+
).not.toBeInTheDocument();
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
await act(async () => {
|
|
1838
|
+
settle([]);
|
|
1839
|
+
await Promise.resolve();
|
|
1840
|
+
});
|
|
1841
|
+
expect(
|
|
1842
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1843
|
+
).not.toBeInTheDocument();
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1793
1846
|
it('shares one observer across every field on the page', () => {
|
|
1794
1847
|
// Browsers batch per observer instance, so one observer per lane meant N
|
|
1795
1848
|
// callback dispatches a frame for N fields.
|