@astryxdesign/core 0.1.7 → 0.1.8-canary.08a0957
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 +65 -0
- package/README.md +30 -19
- package/dist/AppShell/AppShell.d.ts.map +1 -1
- package/dist/AppShell/AppShell.js +6 -2
- package/dist/AppShell/AppShellMobileContext.d.ts +8 -0
- package/dist/AppShell/AppShellMobileContext.d.ts.map +1 -1
- package/dist/Avatar/Avatar.d.ts +10 -5
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +12 -8
- package/dist/Avatar/AvatarSizeContext.d.ts +1 -1
- package/dist/Avatar/AvatarSizeContext.js +1 -1
- package/dist/Avatar/AvatarStatusDot.d.ts +2 -2
- package/dist/Avatar/AvatarStatusDot.js +2 -2
- package/dist/AvatarGroup/AvatarGroup.d.ts +2 -2
- package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroup.js +2 -2
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +16 -14
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +1 -0
- package/dist/Carousel/Carousel.d.ts +3 -2
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +32 -2
- package/dist/Chat/ChatLayout.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatMessage.d.ts +1 -1
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessage.js +1 -1
- package/dist/Chat/ChatMessageMetadata.d.ts.map +1 -1
- package/dist/Chat/ChatSendButton.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Citation/Citation.d.ts.map +1 -1
- package/dist/Citation/Citation.js +10 -4
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/Collapsible/Collapsible.d.ts +11 -1
- package/dist/Collapsible/Collapsible.d.ts.map +1 -1
- package/dist/Collapsible/Collapsible.js +36 -4
- package/dist/CommandPalette/CommandPaletteInput.d.ts +7 -1
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +6 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -3
- package/dist/ContextMenu/index.d.ts +3 -0
- package/dist/ContextMenu/index.d.ts.map +1 -1
- package/dist/ContextMenu/index.js +7 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +8 -4
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +88 -0
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -0
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +169 -0
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +18 -0
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +68 -0
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -0
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +69 -0
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +69 -0
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -0
- package/dist/DropdownMenu/DropdownMenuRadioItem.js +178 -0
- package/dist/DropdownMenu/index.d.ts +5 -1
- package/dist/DropdownMenu/index.d.ts.map +1 -1
- package/dist/DropdownMenu/index.js +10 -1
- package/dist/DropdownMenu/menuItemRoles.d.ts +13 -0
- package/dist/DropdownMenu/menuItemRoles.d.ts.map +1 -0
- package/dist/DropdownMenu/menuItemRoles.js +15 -0
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/Icon/Icon.d.ts +43 -13
- package/dist/Icon/Icon.d.ts.map +1 -1
- package/dist/Icon/Icon.js +62 -27
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +6 -1
- package/dist/MobileNav/MobileNavToggle.d.ts.map +1 -1
- package/dist/MobileNav/MobileNavToggle.js +4 -0
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +36 -10
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/Outline/Outline.d.ts +68 -3
- package/dist/Outline/Outline.d.ts.map +1 -1
- package/dist/Outline/Outline.js +128 -26
- package/dist/Outline/useScrollSpy.d.ts +27 -7
- package/dist/Outline/useScrollSpy.d.ts.map +1 -1
- package/dist/Outline/useScrollSpy.js +202 -70
- package/dist/PowerSearch/usePowerSearchSource.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +2 -2
- package/dist/TabList/TabList.d.ts.map +1 -1
- package/dist/TabList/TabList.js +2 -0
- package/dist/TabList/TabMenu.d.ts.map +1 -1
- package/dist/TabList/TabMenu.js +1 -1
- package/dist/Table/Table.d.ts +1 -1
- package/dist/Table/Table.js +1 -1
- package/dist/Table/index.d.ts +4 -0
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +2 -0
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +24 -2
- package/dist/Table/plugins/rowStatus/index.d.ts +3 -0
- package/dist/Table/plugins/rowStatus/index.d.ts.map +1 -0
- package/dist/Table/plugins/rowStatus/index.js +3 -0
- package/dist/Table/plugins/rowStatus/useTableRowStatus.d.ts +60 -0
- package/dist/Table/plugins/rowStatus/useTableRowStatus.d.ts.map +1 -0
- package/dist/Table/plugins/rowStatus/useTableRowStatus.js +145 -0
- package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
- package/dist/Table/plugins/sortable/useTableSortable.js +18 -7
- package/dist/Table/plugins/tree/index.d.ts +5 -0
- package/dist/Table/plugins/tree/index.d.ts.map +1 -0
- package/dist/Table/plugins/tree/index.js +4 -0
- package/dist/Table/plugins/tree/useTableTreeData.d.ts +38 -0
- package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -0
- package/dist/Table/plugins/tree/useTableTreeData.js +358 -0
- package/dist/Table/plugins/tree/useTableTreeState.d.ts +51 -0
- package/dist/Table/plugins/tree/useTableTreeState.d.ts.map +1 -0
- package/dist/Table/plugins/tree/useTableTreeState.js +175 -0
- package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.js +6 -3
- package/dist/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar/Toolbar.js +8 -1
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +5 -14
- package/dist/astryx.css +26 -4
- package/dist/astryx.umd.js +54 -54
- package/dist/astryx.umd.js.map +4 -4
- package/dist/authoring/doc.d.ts.map +1 -1
- package/dist/theme/Theme.js +1 -1
- package/dist/theme/defineTheme.d.ts +1 -1
- package/dist/theme/defineTheme.js +1 -1
- package/dist/theme/hct.d.ts +0 -1
- package/dist/theme/hct.d.ts.map +1 -1
- package/dist/theme/hct.js +0 -3
- package/docs.mjs +32 -461
- package/locales/en.json +200 -172
- package/locales/pseudo.json +21 -0
- package/package.json +3 -1
- package/scripts/postinstall.mjs +93 -0
- package/src/AppShell/AppShell.tsx +6 -0
- package/src/AppShell/AppShellMobileContext.tsx +8 -0
- package/src/AppShell/useAppShellMobile.doc.mjs +8 -0
- package/src/Avatar/Avatar.doc.mjs +6 -6
- package/src/Avatar/Avatar.tsx +16 -11
- package/src/Avatar/AvatarSizeContext.ts +1 -1
- package/src/Avatar/AvatarStatusDot.tsx +2 -2
- package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +17 -3
- package/src/AvatarGroup/AvatarGroup.tsx +3 -3
- package/src/AvatarGroup/AvatarGroupOverflow.doc.mjs +4 -4
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +21 -5
- package/src/Banner/Banner.tsx +10 -2
- package/src/Button/Button.test.tsx +21 -0
- package/src/Button/Button.tsx +1 -0
- package/src/Carousel/Carousel.doc.mjs +1 -0
- package/src/Carousel/Carousel.test.tsx +96 -1
- package/src/Carousel/Carousel.tsx +29 -2
- package/src/Chat/Chat.doc.mjs +1 -1
- package/src/Chat/ChatLayout.tsx +3 -1
- package/src/Chat/ChatLayoutScrollButton.tsx +3 -1
- package/src/Chat/ChatMessage.tsx +4 -2
- package/src/Chat/ChatMessageMetadata.tsx +1 -2
- package/src/Chat/ChatSendButton.tsx +5 -1
- package/src/Chat/useTriggerMenu.tsx +3 -1
- package/src/CheckboxInput/CheckboxInput.doc.mjs +1 -1
- package/src/Citation/Citation.test.tsx +51 -0
- package/src/Citation/Citation.tsx +8 -2
- package/src/CodeBlock/CodeBlock.tsx +3 -1
- package/src/Collapsible/Collapsible.doc.mjs +7 -0
- package/src/Collapsible/Collapsible.test.tsx +80 -0
- package/src/Collapsible/Collapsible.tsx +58 -5
- package/src/CommandPalette/CommandPaletteInput.doc.mjs +26 -9
- package/src/CommandPalette/CommandPaletteInput.test.tsx +34 -0
- package/src/CommandPalette/CommandPaletteInput.tsx +11 -0
- package/src/ContextMenu/ContextMenu.test.tsx +75 -1
- package/src/ContextMenu/ContextMenu.tsx +10 -5
- package/src/ContextMenu/index.ts +15 -0
- package/src/DateTimeInput/DateTimeInput.tsx +3 -1
- package/src/Dialog/Dialog.tsx +12 -2
- package/src/DropdownMenu/DropdownMenu.doc.mjs +23 -0
- package/src/DropdownMenu/DropdownMenu.tsx +11 -6
- package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +95 -0
- package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +236 -0
- package/src/DropdownMenu/DropdownMenuContext.tsx +29 -1
- package/src/DropdownMenu/DropdownMenuItem.doc.mjs +7 -1
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +80 -0
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +118 -0
- package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +80 -0
- package/src/DropdownMenu/DropdownMenuRadioItem.tsx +232 -0
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +163 -0
- package/src/DropdownMenu/index.ts +23 -3
- package/src/DropdownMenu/menuItemRoles.ts +22 -0
- package/src/Field/Field.doc.mjs +1 -1
- package/src/Field/FieldLabel.doc.mjs +1 -1
- package/src/FileInput/FileInput.tsx +4 -1
- package/src/Icon/Icon.doc.mjs +12 -0
- package/src/Icon/Icon.test.tsx +142 -0
- package/src/Icon/Icon.tsx +98 -30
- package/src/Item/Item.doc.mjs +1 -1
- package/src/MobileNav/MobileNav.tsx +12 -1
- package/src/MobileNav/MobileNavToggle.test.tsx +116 -0
- package/src/MobileNav/MobileNavToggle.tsx +9 -1
- package/src/MultiSelector/MultiSelector.doc.mjs +4 -2
- package/src/MultiSelector/MultiSelector.test.tsx +79 -1
- package/src/MultiSelector/MultiSelector.tsx +51 -11
- package/src/NumberInput/NumberInput.doc.mjs +2 -2
- package/src/NumberInput/NumberInput.tsx +1 -2
- package/src/Outline/Outline.doc.mjs +123 -3
- package/src/Outline/Outline.test.tsx +735 -0
- package/src/Outline/Outline.tsx +183 -33
- package/src/Outline/useScrollSpy.ts +248 -72
- package/src/PowerSearch/usePowerSearchSource.ts +1 -2
- package/src/Selector/SelectorOption.doc.mjs +1 -1
- package/src/SideNav/SideNavCollapseButton.tsx +6 -1
- package/src/SideNav/SideNavItem.doc.mjs +2 -2
- package/src/SideNav/SideNavItem.tsx +5 -1
- package/src/Switch/Switch.doc.mjs +1 -1
- package/src/TabList/Tab.tsx +5 -1
- package/src/TabList/TabList.doc.mjs +3 -3
- package/src/TabList/TabList.test.tsx +66 -0
- package/src/TabList/TabList.tsx +8 -0
- package/src/TabList/TabMenu.tsx +4 -1
- package/src/Table/Table.doc.mjs +2 -0
- package/src/Table/Table.tsx +1 -1
- package/src/Table/index.ts +12 -0
- package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +6 -2
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +90 -1
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +37 -5
- package/src/Table/plugins/rowStatus/index.ts +7 -0
- package/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +138 -0
- package/src/Table/plugins/rowStatus/useTableRowStatus.tsx +189 -0
- package/src/Table/plugins/sortable/useTableSortable.test.tsx +95 -9
- package/src/Table/plugins/sortable/useTableSortable.tsx +23 -7
- package/src/Table/plugins/tree/index.ts +12 -0
- package/src/Table/plugins/tree/useTableTreeData-perf.test.tsx +163 -0
- package/src/Table/plugins/tree/useTableTreeData.test.tsx +594 -0
- package/src/Table/plugins/tree/useTableTreeData.tsx +495 -0
- package/src/Table/plugins/tree/useTableTreeState.test.tsx +636 -0
- package/src/Table/plugins/tree/useTableTreeState.tsx +253 -0
- package/src/Table/useBaseTablePlugins.ts +6 -2
- package/src/Table/useTableRowStatus.doc.mjs +30 -0
- package/src/Table/useTableTreeData.doc.mjs +75 -0
- package/src/Table/useTableTreeState.doc.mjs +98 -0
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextInput/TextInput.doc.mjs +1 -1
- package/src/Toolbar/Toolbar.tsx +10 -3
- package/src/TreeList/TreeList.test.tsx +32 -1
- package/src/TreeList/TreeListItem.tsx +19 -10
- package/src/authoring/doc.ts +1 -3
- package/src/docs-script.test.mjs +18 -13
- package/src/postinstall.test.mjs +71 -0
- package/src/theme/Theme.doc.mjs +4 -4
- package/src/theme/Theme.tsx +1 -1
- package/src/theme/defineTheme.ts +1 -1
- package/src/theme/hct.ts +0 -4
- package/dist/TreeList/treeListItem.markers.stylex.d.ts +0 -16
- package/dist/TreeList/treeListItem.markers.stylex.d.ts.map +0 -1
- package/dist/TreeList/treeListItem.markers.stylex.js +0 -22
- package/src/TreeList/treeListItem.markers.stylex.ts +0 -20
package/src/Icon/Icon.tsx
CHANGED
|
@@ -111,51 +111,64 @@ const colorStyles = stylex.create({
|
|
|
111
111
|
/**
|
|
112
112
|
* Size styles for direct SVG icon components.
|
|
113
113
|
* Uses width/height only — SVG components handle their own viewBox scaling.
|
|
114
|
+
*
|
|
115
|
+
* Sizes are expressed in `rem` (relative to the root font-size) so icons scale
|
|
116
|
+
* in step with text when the document font-size changes, matching the rest of
|
|
117
|
+
* the design system's rem-based type scale. Values are the px-equivalents at a
|
|
118
|
+
* 16px root: 12px → 0.75rem, 16px → 1rem, 20px → 1.25rem, 24px → 1.5rem.
|
|
114
119
|
*/
|
|
115
120
|
const sizeStyles = stylex.create({
|
|
116
121
|
xsm: {
|
|
117
|
-
width:
|
|
118
|
-
height:
|
|
122
|
+
width: '0.75rem',
|
|
123
|
+
height: '0.75rem',
|
|
119
124
|
},
|
|
120
125
|
sm: {
|
|
121
|
-
width:
|
|
122
|
-
height:
|
|
126
|
+
width: '1rem',
|
|
127
|
+
height: '1rem',
|
|
123
128
|
},
|
|
124
129
|
md: {
|
|
125
|
-
width:
|
|
126
|
-
height:
|
|
130
|
+
width: '1.25rem',
|
|
131
|
+
height: '1.25rem',
|
|
127
132
|
},
|
|
128
133
|
lg: {
|
|
129
|
-
width:
|
|
130
|
-
height:
|
|
134
|
+
width: '1.5rem',
|
|
135
|
+
height: '1.5rem',
|
|
131
136
|
},
|
|
132
137
|
});
|
|
133
138
|
|
|
134
139
|
/**
|
|
135
140
|
* Size styles for string-based (registry) icons.
|
|
136
141
|
* Includes fontSize so that 1em-based icons from the registry scale correctly.
|
|
142
|
+
*
|
|
143
|
+
* Expressed in `rem` for the same reason as {@link sizeStyles} — icons track the
|
|
144
|
+
* root font-size instead of being locked to absolute pixels.
|
|
137
145
|
*/
|
|
138
146
|
const spanSizeStyles = stylex.create({
|
|
147
|
+
/* eslint-disable @astryx/no-hardcoded-styles -- fontSize here sizes 1em-based
|
|
148
|
+
registry SVGs to the icon box; icons use their own 12/16/20/24 scale, not
|
|
149
|
+
the 14px-anchored textSizeVars type scale. Values are rem so icons track
|
|
150
|
+
the root font-size. */
|
|
139
151
|
xsm: {
|
|
140
|
-
width:
|
|
141
|
-
height:
|
|
142
|
-
fontSize:
|
|
152
|
+
width: '0.75rem',
|
|
153
|
+
height: '0.75rem',
|
|
154
|
+
fontSize: '0.75rem',
|
|
143
155
|
},
|
|
144
156
|
sm: {
|
|
145
|
-
width:
|
|
146
|
-
height:
|
|
147
|
-
fontSize:
|
|
157
|
+
width: '1rem',
|
|
158
|
+
height: '1rem',
|
|
159
|
+
fontSize: '1rem',
|
|
148
160
|
},
|
|
149
161
|
md: {
|
|
150
|
-
width:
|
|
151
|
-
height:
|
|
152
|
-
fontSize:
|
|
162
|
+
width: '1.25rem',
|
|
163
|
+
height: '1.25rem',
|
|
164
|
+
fontSize: '1.25rem',
|
|
153
165
|
},
|
|
154
166
|
lg: {
|
|
155
|
-
width:
|
|
156
|
-
height:
|
|
157
|
-
fontSize:
|
|
167
|
+
width: '1.5rem',
|
|
168
|
+
height: '1.5rem',
|
|
169
|
+
fontSize: '1.5rem',
|
|
158
170
|
},
|
|
171
|
+
/* eslint-enable @astryx/no-hardcoded-styles */
|
|
159
172
|
});
|
|
160
173
|
|
|
161
174
|
// =============================================================================
|
|
@@ -194,13 +207,57 @@ export interface IconProps extends Omit<
|
|
|
194
207
|
color?: IconColor;
|
|
195
208
|
/**
|
|
196
209
|
* The size of the icon.
|
|
197
|
-
* - 'xsm': 12px
|
|
198
|
-
* - 'sm': 16px
|
|
199
|
-
* - 'md': 20px
|
|
200
|
-
* - 'lg': 24px
|
|
210
|
+
* - 'xsm': 0.75rem (12px at a 16px root)
|
|
211
|
+
* - 'sm': 1rem (16px at a 16px root)
|
|
212
|
+
* - 'md': 1.25rem (20px at a 16px root)
|
|
213
|
+
* - 'lg': 1.5rem (24px at a 16px root)
|
|
201
214
|
* @default 'md'
|
|
202
215
|
*/
|
|
203
216
|
size?: IconSize;
|
|
217
|
+
/**
|
|
218
|
+
* Accessible name for the icon. Set this only when the icon is MEANINGFUL on
|
|
219
|
+
* its own — a standalone status glyph or an icon-only indicator with no
|
|
220
|
+
* adjacent text conveying the same information. Providing it exposes the icon
|
|
221
|
+
* to assistive tech as `role="img"` with this string as the accessible name
|
|
222
|
+
* (via `aria-label`) and drops the default `aria-hidden="true"`.
|
|
223
|
+
*
|
|
224
|
+
* Omit it (the default) for decorative icons — the common case, e.g. an icon
|
|
225
|
+
* beside a text label — and the icon stays hidden from assistive tech
|
|
226
|
+
* (`aria-hidden="true"`). An empty string (`''`) is treated the same as
|
|
227
|
+
* omitting it (decorative), since an empty accessible name is meaningless.
|
|
228
|
+
*
|
|
229
|
+
* Don't set `label` when an interactive parent (Button, IconButton, link)
|
|
230
|
+
* already names the control — that produces a duplicate announcement.
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* ```
|
|
234
|
+
* // Meaningful, standalone icon
|
|
235
|
+
* <Icon icon="success" label="Completed" />
|
|
236
|
+
*
|
|
237
|
+
* // Decorative icon (default) — omit label
|
|
238
|
+
* <Icon icon="search" />
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
label?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Derives the ARIA attributes for an icon from its `label` prop.
|
|
246
|
+
*
|
|
247
|
+
* - Non-empty `label` → meaningful image: `role="img"` + `aria-label`, and no
|
|
248
|
+
* `aria-hidden` (an `aria-hidden` element is removed from the accessibility
|
|
249
|
+
* tree, so its accessible name would be ignored).
|
|
250
|
+
* - Omitted or empty `label` → decorative default: `aria-hidden="true"`.
|
|
251
|
+
*
|
|
252
|
+
* The result is spread BEFORE `{...props}` in both render modes so an explicit
|
|
253
|
+
* `aria-hidden` / `role` / `aria-label` from the consumer always wins.
|
|
254
|
+
*/
|
|
255
|
+
function getIconA11yProps(
|
|
256
|
+
label: string | undefined,
|
|
257
|
+
): {role: 'img'; 'aria-label': string} | {'aria-hidden': 'true'} {
|
|
258
|
+
return label != null && label !== ''
|
|
259
|
+
? {role: 'img', 'aria-label': label}
|
|
260
|
+
: {'aria-hidden': 'true'};
|
|
204
261
|
}
|
|
205
262
|
|
|
206
263
|
// =============================================================================
|
|
@@ -219,9 +276,14 @@ export function Icon({
|
|
|
219
276
|
icon,
|
|
220
277
|
color = 'inherit',
|
|
221
278
|
size = 'md',
|
|
279
|
+
label,
|
|
222
280
|
ref,
|
|
223
281
|
...props
|
|
224
282
|
}: IconProps) {
|
|
283
|
+
// Derive ARIA from `label`: decorative (aria-hidden) by default, or a
|
|
284
|
+
// meaningful image (role="img" + aria-label) when `label` is non-empty.
|
|
285
|
+
const a11yProps = getIconA11yProps(label);
|
|
286
|
+
|
|
225
287
|
// String mode: resolve from icon registry, wrap in styled span
|
|
226
288
|
if (typeof icon === 'string') {
|
|
227
289
|
return (
|
|
@@ -229,6 +291,7 @@ export function Icon({
|
|
|
229
291
|
name={icon}
|
|
230
292
|
color={color}
|
|
231
293
|
size={size}
|
|
294
|
+
a11yProps={a11yProps}
|
|
232
295
|
spanProps={props}
|
|
233
296
|
/>
|
|
234
297
|
);
|
|
@@ -239,7 +302,10 @@ export function Icon({
|
|
|
239
302
|
return (
|
|
240
303
|
<IconComponent
|
|
241
304
|
ref={ref}
|
|
242
|
-
|
|
305
|
+
// Derived a11y (decorative default or meaningful `label`) is spread
|
|
306
|
+
// BEFORE {...props} so an explicit aria-hidden/role/aria-label from the
|
|
307
|
+
// consumer still wins as an escape hatch.
|
|
308
|
+
{...a11yProps}
|
|
243
309
|
{...mergeProps(
|
|
244
310
|
themeProps('icon', {size, color}),
|
|
245
311
|
stylex.props(styles.root, colorStyles[color], sizeStyles[size]),
|
|
@@ -266,11 +332,13 @@ function IconFromRegistry({
|
|
|
266
332
|
name,
|
|
267
333
|
color,
|
|
268
334
|
size,
|
|
335
|
+
a11yProps,
|
|
269
336
|
spanProps,
|
|
270
337
|
}: {
|
|
271
338
|
name: IconName;
|
|
272
339
|
color: IconColor;
|
|
273
340
|
size: IconSize;
|
|
341
|
+
a11yProps: {role: 'img'; 'aria-label': string} | {'aria-hidden': 'true'};
|
|
274
342
|
spanProps?: Omit<SVGProps<SVGSVGElement>, 'ref' | 'color'>;
|
|
275
343
|
}) {
|
|
276
344
|
const resolvedIcon = getIcon(name);
|
|
@@ -281,11 +349,11 @@ function IconFromRegistry({
|
|
|
281
349
|
|
|
282
350
|
return (
|
|
283
351
|
<span
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
352
|
+
// Derived a11y — decorative (aria-hidden) by default, or a meaningful
|
|
353
|
+
// image (role="img" + aria-label) when `label` is set. Placed BEFORE the
|
|
354
|
+
// prop spread so consumers can still override it with explicit
|
|
355
|
+
// aria-hidden/role/aria-label. This mirrors component-mode Icon.
|
|
356
|
+
{...a11yProps}
|
|
289
357
|
{...(spanProps as React.HTMLAttributes<HTMLSpanElement>)}
|
|
290
358
|
{...mergeProps(
|
|
291
359
|
themeProps('icon', {size, color}),
|
package/src/Item/Item.doc.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export const docs = {
|
|
|
28
28
|
props: [
|
|
29
29
|
{name: 'label', type: 'ReactNode', description: 'Primary text identifying this item. Accepts string (auto-truncated) or ReactNode (for rich content).', required: true},
|
|
30
30
|
{name: 'marker', type: 'ReactNode', description: 'Marker rendered before startContent as a direct flex child. Use for list bullets/counters that need custom baseline alignment.'},
|
|
31
|
-
{name: 'startContent', type: 'ReactNode', description: 'Content rendered before the label/description area, such as an icon, avatar, or checkbox.', slotElements: [{__element: 'Avatar', props: {name: 'Ada Lovelace', size: '
|
|
31
|
+
{name: 'startContent', type: 'ReactNode', description: 'Content rendered before the label/description area, such as an icon, avatar, or checkbox.', slotElements: [{__element: 'Avatar', props: {name: 'Ada Lovelace', size: 'sm'}}, {__element: 'Icon', props: {icon: 'info', size: 'sm', color: 'secondary'}}]},
|
|
32
32
|
{name: 'description', type: 'ReactNode', description: 'Secondary text: subtitle, description, or supporting info.'},
|
|
33
33
|
{name: 'endContent', type: 'ReactNode', description: 'Content rendered after the label/description area, such as badges, metadata, timestamps, or action buttons.', slotElements: [{__element: 'Badge', props: {label: 3}}, {__element: 'Text', props: {color: 'secondary'}, children: '2h ago'}]},
|
|
34
34
|
{name: 'as', type: "'div' | 'li' | 'span'", description: 'HTML element to render as the root.', default: "'div'"},
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import {
|
|
30
30
|
useCallback,
|
|
31
31
|
useEffect,
|
|
32
|
+
useId,
|
|
32
33
|
useMemo,
|
|
33
34
|
useRef,
|
|
34
35
|
useState,
|
|
@@ -295,6 +296,10 @@ export function MobileNav({
|
|
|
295
296
|
// Read from AppShell context as fallback
|
|
296
297
|
const appShellMobile = useAppShellMobile();
|
|
297
298
|
const isOpen = isOpenProp ?? appShellMobile.isMobileNavOpen;
|
|
299
|
+
// Share the id from AppShell context so the toggle's aria-controls resolves to
|
|
300
|
+
// this drawer; fall back to a locally generated id when used standalone.
|
|
301
|
+
const fallbackId = useId();
|
|
302
|
+
const dialogId = appShellMobile.mobileNavId || fallbackId;
|
|
298
303
|
const onOpenChange = useMemo(
|
|
299
304
|
() =>
|
|
300
305
|
onOpenChangeProp ??
|
|
@@ -411,6 +416,7 @@ export function MobileNav({
|
|
|
411
416
|
return (
|
|
412
417
|
<dialog
|
|
413
418
|
ref={mergeRefs(ref, dialogRef)}
|
|
419
|
+
id={dialogId}
|
|
414
420
|
{...mergeProps(
|
|
415
421
|
themeProps('mobile-nav', {side: resolvedSide}),
|
|
416
422
|
stylex.props(
|
|
@@ -425,7 +431,12 @@ export function MobileNav({
|
|
|
425
431
|
)}
|
|
426
432
|
{...rest}
|
|
427
433
|
data-testid={testId}
|
|
428
|
-
aria-label={
|
|
434
|
+
aria-label={
|
|
435
|
+
label ??
|
|
436
|
+
(typeof header === 'string'
|
|
437
|
+
? header
|
|
438
|
+
: t('@astryx.mobileNav.navigation'))
|
|
439
|
+
}
|
|
429
440
|
onClick={composeEventHandlers(onClickProp, handleDialogClick)}
|
|
430
441
|
onCancel={handleCancel}>
|
|
431
442
|
{/* Drawer panel — tabIndex so showModal() focuses the drawer, not the close button */}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file MobileNavToggle.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, AppShell + SideNav
|
|
6
|
+
* @output Unit tests for MobileNavToggle ARIA wiring
|
|
7
|
+
* @position Testing; validates MobileNavToggle.tsx exposes aria-expanded and
|
|
8
|
+
* aria-controls so screen-reader users know the drawer state and target.
|
|
9
|
+
*
|
|
10
|
+
* SYNC: When MobileNavToggle.tsx changes, update tests to match new behavior
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
describe,
|
|
15
|
+
it,
|
|
16
|
+
expect,
|
|
17
|
+
vi,
|
|
18
|
+
beforeAll,
|
|
19
|
+
beforeEach,
|
|
20
|
+
afterEach,
|
|
21
|
+
} from 'vitest';
|
|
22
|
+
import {render, screen, fireEvent} from '@testing-library/react';
|
|
23
|
+
import {AppShell} from '../AppShell/AppShell';
|
|
24
|
+
import {SideNav, SideNavItem, SideNavSection} from '../SideNav';
|
|
25
|
+
|
|
26
|
+
// jsdom doesn't implement showModal/close on <dialog>, so we mock them
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
HTMLDialogElement.prototype.showModal =
|
|
29
|
+
HTMLDialogElement.prototype.showModal ||
|
|
30
|
+
function (this: HTMLDialogElement) {
|
|
31
|
+
this.setAttribute('open', '');
|
|
32
|
+
};
|
|
33
|
+
HTMLDialogElement.prototype.close =
|
|
34
|
+
HTMLDialogElement.prototype.close ||
|
|
35
|
+
function (this: HTMLDialogElement) {
|
|
36
|
+
this.removeAttribute('open');
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
class MockResizeObserver {
|
|
41
|
+
observe() {}
|
|
42
|
+
unobserve() {}
|
|
43
|
+
disconnect() {}
|
|
44
|
+
}
|
|
45
|
+
vi.stubGlobal('ResizeObserver', MockResizeObserver);
|
|
46
|
+
|
|
47
|
+
function createMockMatchMedia(matches: boolean) {
|
|
48
|
+
return {
|
|
49
|
+
matches,
|
|
50
|
+
media: '',
|
|
51
|
+
onchange: null,
|
|
52
|
+
addEventListener: vi.fn(),
|
|
53
|
+
removeEventListener: vi.fn(),
|
|
54
|
+
addListener: vi.fn(),
|
|
55
|
+
removeListener: vi.fn(),
|
|
56
|
+
dispatchEvent: vi.fn(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
// Below the breakpoint so the mobile nav (and its toggle) render.
|
|
62
|
+
vi.stubGlobal(
|
|
63
|
+
'matchMedia',
|
|
64
|
+
vi.fn().mockReturnValue(createMockMatchMedia(true)),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function TestShell() {
|
|
73
|
+
return (
|
|
74
|
+
<AppShell
|
|
75
|
+
sideNav={
|
|
76
|
+
<SideNav>
|
|
77
|
+
<SideNavSection title="Test" isHeaderHidden>
|
|
78
|
+
<SideNavItem label="Home" />
|
|
79
|
+
</SideNavSection>
|
|
80
|
+
</SideNav>
|
|
81
|
+
}
|
|
82
|
+
mobileNav={{breakpoint: 'md'}}>
|
|
83
|
+
<div>Content</div>
|
|
84
|
+
</AppShell>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('MobileNavToggle ARIA wiring', () => {
|
|
89
|
+
it('exposes aria-expanded="false" when the drawer is closed', () => {
|
|
90
|
+
render(<TestShell />);
|
|
91
|
+
|
|
92
|
+
const toggle = screen.getByRole('button', {name: /open navigation/i});
|
|
93
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('exposes aria-expanded="true" after opening the drawer', () => {
|
|
97
|
+
render(<TestShell />);
|
|
98
|
+
|
|
99
|
+
const toggle = screen.getByRole('button', {name: /open navigation/i});
|
|
100
|
+
fireEvent.click(toggle);
|
|
101
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('references the nav drawer via aria-controls that resolves to the dialog', () => {
|
|
105
|
+
render(<TestShell />);
|
|
106
|
+
|
|
107
|
+
const toggle = screen.getByRole('button', {name: /open navigation/i});
|
|
108
|
+
const controls = toggle.getAttribute('aria-controls');
|
|
109
|
+
expect(controls).toBeTruthy();
|
|
110
|
+
|
|
111
|
+
const target = document.getElementById(controls!);
|
|
112
|
+
expect(target).not.toBeNull();
|
|
113
|
+
expect(target).toBe(screen.getAllByRole('dialog', {hidden: true})[0]);
|
|
114
|
+
expect(target?.tagName).toBe('DIALOG');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -69,7 +69,13 @@ export function MobileNavToggle({
|
|
|
69
69
|
}: MobileNavToggleProps) {
|
|
70
70
|
const t = useTranslator();
|
|
71
71
|
const label = labelFromProps ?? t('@astryx.mobileNav.toggle.open');
|
|
72
|
-
const {
|
|
72
|
+
const {
|
|
73
|
+
isMobile,
|
|
74
|
+
isMobileNavEnabled,
|
|
75
|
+
isMobileNavOpen,
|
|
76
|
+
mobileNavId,
|
|
77
|
+
toggleMobileNav,
|
|
78
|
+
} = useAppShellMobile();
|
|
73
79
|
|
|
74
80
|
// Don't render above the breakpoint or when mobile nav is disabled
|
|
75
81
|
if (!isMobile || !isMobileNavEnabled) {
|
|
@@ -83,6 +89,8 @@ export function MobileNavToggle({
|
|
|
83
89
|
label={label}
|
|
84
90
|
icon={children ?? <Icon icon="menu" color="inherit" />}
|
|
85
91
|
onClick={toggleMobileNav}
|
|
92
|
+
aria-expanded={isMobileNavOpen}
|
|
93
|
+
aria-controls={mobileNavId || undefined}
|
|
86
94
|
data-testid={testId ?? 'mobile-nav-toggle'}
|
|
87
95
|
xstyle={xstyle}
|
|
88
96
|
className={className}
|
|
@@ -99,7 +99,8 @@ export const docs = {
|
|
|
99
99
|
{
|
|
100
100
|
name: 'hasSearch',
|
|
101
101
|
type: 'boolean',
|
|
102
|
-
description:
|
|
102
|
+
description:
|
|
103
|
+
'Whether to show a search input for filtering options. As the user types, the match count (or "No results found") is announced to screen readers via a polite live region.',
|
|
103
104
|
},
|
|
104
105
|
{
|
|
105
106
|
name: 'searchPlaceholder',
|
|
@@ -239,7 +240,8 @@ export const docsZh = {
|
|
|
239
240
|
hasSearch: '是否显示用于过滤选项的搜索输入。',
|
|
240
241
|
searchPlaceholder: '搜索输入的占位文本。',
|
|
241
242
|
isDisabled: '禁用选择器。',
|
|
242
|
-
htmlName:
|
|
243
|
+
htmlName:
|
|
244
|
+
'用于表单提交的 HTML name 属性。为每个已选值渲染一个隐藏输入,类似原生多选。',
|
|
243
245
|
disabledMessage:
|
|
244
246
|
'解释选择器被禁用的原因。与 isDisabled 一起使用时,悬停/键盘聚焦时显示工具提示,并通过 aria-disabled 保持触发器可聚焦(仍无法激活)。请使用此属性,而不是用 Tooltip 包裹被禁用的选择器。',
|
|
245
247
|
isLabelHidden: '视觉上隐藏标签同时保持其可访问性。',
|
|
@@ -554,6 +554,82 @@ describe('MultiSelector', () => {
|
|
|
554
554
|
expect(screen.getByText('No results found')).toBeInTheDocument();
|
|
555
555
|
});
|
|
556
556
|
|
|
557
|
+
describe('result announcements', () => {
|
|
558
|
+
it('announces the match count politely while searching', async () => {
|
|
559
|
+
const user = userEvent.setup();
|
|
560
|
+
render(
|
|
561
|
+
<MultiSelector
|
|
562
|
+
label="Fruit"
|
|
563
|
+
options={defaultOptions}
|
|
564
|
+
value={EMPTY_VALUE}
|
|
565
|
+
onChange={() => {}}
|
|
566
|
+
hasSearch
|
|
567
|
+
/>,
|
|
568
|
+
);
|
|
569
|
+
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
570
|
+
// "an" matches Banana and Orange.
|
|
571
|
+
await user.type(screen.getByRole('combobox', h), 'an');
|
|
572
|
+
await waitFor(() => {
|
|
573
|
+
expect(politeRegion()).toHaveTextContent('2 results');
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('announces the singular form when one option matches', async () => {
|
|
578
|
+
const user = userEvent.setup();
|
|
579
|
+
render(
|
|
580
|
+
<MultiSelector
|
|
581
|
+
label="Fruit"
|
|
582
|
+
options={defaultOptions}
|
|
583
|
+
value={EMPTY_VALUE}
|
|
584
|
+
onChange={() => {}}
|
|
585
|
+
hasSearch
|
|
586
|
+
/>,
|
|
587
|
+
);
|
|
588
|
+
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
589
|
+
// "app" matches only Apple. Anchored so it cannot pass on "1 results".
|
|
590
|
+
await user.type(screen.getByRole('combobox', h), 'app');
|
|
591
|
+
await waitFor(() => {
|
|
592
|
+
expect(politeRegion()).toHaveTextContent(/^1 result$/);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('announces "No results found" when nothing matches', async () => {
|
|
597
|
+
const user = userEvent.setup();
|
|
598
|
+
render(
|
|
599
|
+
<MultiSelector
|
|
600
|
+
label="Fruit"
|
|
601
|
+
options={defaultOptions}
|
|
602
|
+
value={EMPTY_VALUE}
|
|
603
|
+
onChange={() => {}}
|
|
604
|
+
hasSearch
|
|
605
|
+
/>,
|
|
606
|
+
);
|
|
607
|
+
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
608
|
+
await user.type(screen.getByRole('combobox', h), 'xyz');
|
|
609
|
+
await waitFor(() => {
|
|
610
|
+
expect(politeRegion()).toHaveTextContent('No results found');
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('does not announce results until the user searches', async () => {
|
|
615
|
+
const user = userEvent.setup();
|
|
616
|
+
render(
|
|
617
|
+
<MultiSelector
|
|
618
|
+
label="Fruit"
|
|
619
|
+
options={defaultOptions}
|
|
620
|
+
value={EMPTY_VALUE}
|
|
621
|
+
onChange={() => {}}
|
|
622
|
+
hasSearch
|
|
623
|
+
/>,
|
|
624
|
+
);
|
|
625
|
+
// Popover closed: nothing announced.
|
|
626
|
+
expect(politeRegion()?.textContent ?? '').toBe('');
|
|
627
|
+
// Open with an empty query: still nothing announced.
|
|
628
|
+
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
629
|
+
expect(politeRegion()?.textContent ?? '').toBe('');
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
557
633
|
it('renders with description', () => {
|
|
558
634
|
render(
|
|
559
635
|
<MultiSelector
|
|
@@ -1072,7 +1148,9 @@ describe('MultiSelector', () => {
|
|
|
1072
1148
|
/>
|
|
1073
1149
|
</form>,
|
|
1074
1150
|
);
|
|
1075
|
-
expect([
|
|
1151
|
+
expect([
|
|
1152
|
+
...new FormData(container.querySelector('form')!).keys(),
|
|
1153
|
+
]).toEqual([]);
|
|
1076
1154
|
});
|
|
1077
1155
|
});
|
|
1078
1156
|
});
|
|
@@ -562,6 +562,23 @@ export interface MultiSelectorProps<
|
|
|
562
562
|
'data-testid'?: string;
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
// Case-insensitive substring filter over the selectable options. Shared by the
|
|
566
|
+
// `filteredItems` memo (rendering) and the search-change handler, which needs
|
|
567
|
+
// the count for the *next* query synchronously to announce it exactly once per
|
|
568
|
+
// keystroke rather than reacting to state in an effect.
|
|
569
|
+
function filterOptionsByQuery(
|
|
570
|
+
items: MultiSelectorOptionData[],
|
|
571
|
+
query: string,
|
|
572
|
+
): MultiSelectorOptionData[] {
|
|
573
|
+
if (!query) {
|
|
574
|
+
return items;
|
|
575
|
+
}
|
|
576
|
+
const q = query.toLowerCase();
|
|
577
|
+
return items.filter(item =>
|
|
578
|
+
(item.label ?? item.value).toLowerCase().includes(q),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
565
582
|
/**
|
|
566
583
|
* A multi-select dropdown component with checkboxes for choosing
|
|
567
584
|
* multiple items from a list of options.
|
|
@@ -692,15 +709,10 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
692
709
|
);
|
|
693
710
|
|
|
694
711
|
// Filter items by search query
|
|
695
|
-
const filteredItems = useMemo(
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
const query = searchQuery.toLowerCase();
|
|
700
|
-
return selectableItems.filter(item =>
|
|
701
|
-
(item.label ?? item.value).toLowerCase().includes(query),
|
|
702
|
-
);
|
|
703
|
-
}, [selectableItems, searchQuery]);
|
|
712
|
+
const filteredItems = useMemo(
|
|
713
|
+
() => filterOptionsByQuery(selectableItems, searchQuery),
|
|
714
|
+
[selectableItems, searchQuery],
|
|
715
|
+
);
|
|
704
716
|
|
|
705
717
|
// Single source of truth for item order. Both the hook (keyboard navigation)
|
|
706
718
|
// and renderOptions (DOM rendering) consume this list — no independent sorting.
|
|
@@ -772,8 +784,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
772
784
|
const handleLayerHide = useCallback(() => {
|
|
773
785
|
setSearchQuery('');
|
|
774
786
|
setSelectedAtOpen(null);
|
|
787
|
+
// Clear any lingering result count when the popover closes so stale status
|
|
788
|
+
// text does not linger in the a11y tree.
|
|
789
|
+
announce('');
|
|
775
790
|
triggerRef.current?.focus();
|
|
776
|
-
}, []);
|
|
791
|
+
}, [announce]);
|
|
777
792
|
|
|
778
793
|
const popover = usePopover({
|
|
779
794
|
hasLightDismiss: true,
|
|
@@ -793,6 +808,30 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
793
808
|
// eslint-disable-next-line @eslint-react/exhaustive-deps -- mount-only: isDefaultOpen is not reactive
|
|
794
809
|
}, []);
|
|
795
810
|
|
|
811
|
+
// Announce the filtered result count from the query-change handler (matching
|
|
812
|
+
// BaseTypeahead) rather than a reactive effect: computing the count for the
|
|
813
|
+
// next query here fires the announcement exactly once per keystroke and does
|
|
814
|
+
// not re-speak on unrelated re-renders. Reuses the announce instance shared
|
|
815
|
+
// with the selection-count announcements above.
|
|
816
|
+
const handleSearchChange = useCallback(
|
|
817
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
818
|
+
const nextQuery = event.target.value;
|
|
819
|
+
setSearchQuery(nextQuery);
|
|
820
|
+
if (nextQuery.length === 0) {
|
|
821
|
+
// Emptying the query clears the region rather than announcing a count.
|
|
822
|
+
announce('');
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const count = filterOptionsByQuery(selectableItems, nextQuery).length;
|
|
826
|
+
announce(
|
|
827
|
+
count === 0
|
|
828
|
+
? 'No results found'
|
|
829
|
+
: `${count} result${count === 1 ? '' : 's'}`,
|
|
830
|
+
);
|
|
831
|
+
},
|
|
832
|
+
[announce, selectableItems],
|
|
833
|
+
);
|
|
834
|
+
|
|
796
835
|
// Handle toggle
|
|
797
836
|
// Clear all selected values. Shared by the clear button and the keyboard
|
|
798
837
|
// Delete/Backspace path so clearing is reachable without a mouse.
|
|
@@ -1041,7 +1080,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1041
1080
|
aria-label={t('@astryx.multiSelector.searchOptions')}
|
|
1042
1081
|
type="text"
|
|
1043
1082
|
value={searchQuery}
|
|
1044
|
-
onChange={
|
|
1083
|
+
onChange={handleSearchChange}
|
|
1045
1084
|
onKeyDown={e => {
|
|
1046
1085
|
// Arrow keys navigate options; Enter toggles; Escape/Tab close.
|
|
1047
1086
|
// Space and Home/End are left to the input (type a space / move
|
|
@@ -1067,6 +1106,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1067
1106
|
listboxId,
|
|
1068
1107
|
searchQuery,
|
|
1069
1108
|
searchPlaceholder,
|
|
1109
|
+
handleSearchChange,
|
|
1070
1110
|
onKeyDown,
|
|
1071
1111
|
popover.isOpen,
|
|
1072
1112
|
highlightedIndex,
|
|
@@ -82,12 +82,12 @@ export const docs = {
|
|
|
82
82
|
{
|
|
83
83
|
name: 'startIcon',
|
|
84
84
|
type: 'IconType',
|
|
85
|
-
description: 'Icon to display at the start of the input. See `
|
|
85
|
+
description: 'Icon to display at the start of the input. See `astryx docs icons` for valid semantic names.',
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
name: 'labelIcon',
|
|
89
89
|
type: 'IconType',
|
|
90
|
-
description: 'Icon to display before the label text. See `
|
|
90
|
+
description: 'Icon to display before the label text. See `astryx docs icons` for valid semantic names.',
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
name: 'status',
|
|
@@ -311,8 +311,7 @@ type NumberInputPropsClearable = NumberInputPropsBase & {
|
|
|
311
311
|
};
|
|
312
312
|
|
|
313
313
|
export type NumberInputProps =
|
|
314
|
-
|
|
|
315
|
-
| NumberInputPropsClearable;
|
|
314
|
+
NumberInputPropsNonClearable | NumberInputPropsClearable;
|
|
316
315
|
|
|
317
316
|
/**
|
|
318
317
|
* Parse and validate a string input as a number.
|