@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.
Files changed (262) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +30 -19
  3. package/dist/AppShell/AppShell.d.ts.map +1 -1
  4. package/dist/AppShell/AppShell.js +6 -2
  5. package/dist/AppShell/AppShellMobileContext.d.ts +8 -0
  6. package/dist/AppShell/AppShellMobileContext.d.ts.map +1 -1
  7. package/dist/Avatar/Avatar.d.ts +10 -5
  8. package/dist/Avatar/Avatar.d.ts.map +1 -1
  9. package/dist/Avatar/Avatar.js +12 -8
  10. package/dist/Avatar/AvatarSizeContext.d.ts +1 -1
  11. package/dist/Avatar/AvatarSizeContext.js +1 -1
  12. package/dist/Avatar/AvatarStatusDot.d.ts +2 -2
  13. package/dist/Avatar/AvatarStatusDot.js +2 -2
  14. package/dist/AvatarGroup/AvatarGroup.d.ts +2 -2
  15. package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
  16. package/dist/AvatarGroup/AvatarGroup.js +2 -2
  17. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  18. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  19. package/dist/AvatarGroup/AvatarGroupOverflow.js +16 -14
  20. package/dist/Banner/Banner.d.ts.map +1 -1
  21. package/dist/Button/Button.d.ts.map +1 -1
  22. package/dist/Button/Button.js +1 -0
  23. package/dist/Carousel/Carousel.d.ts +3 -2
  24. package/dist/Carousel/Carousel.d.ts.map +1 -1
  25. package/dist/Carousel/Carousel.js +32 -2
  26. package/dist/Chat/ChatLayout.d.ts.map +1 -1
  27. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  28. package/dist/Chat/ChatMessage.d.ts +1 -1
  29. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  30. package/dist/Chat/ChatMessage.js +1 -1
  31. package/dist/Chat/ChatMessageMetadata.d.ts.map +1 -1
  32. package/dist/Chat/ChatSendButton.d.ts.map +1 -1
  33. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  34. package/dist/Citation/Citation.d.ts.map +1 -1
  35. package/dist/Citation/Citation.js +10 -4
  36. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  37. package/dist/Collapsible/Collapsible.d.ts +11 -1
  38. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  39. package/dist/Collapsible/Collapsible.js +36 -4
  40. package/dist/CommandPalette/CommandPaletteInput.d.ts +7 -1
  41. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  42. package/dist/CommandPalette/CommandPaletteInput.js +6 -1
  43. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  44. package/dist/ContextMenu/ContextMenu.js +4 -3
  45. package/dist/ContextMenu/index.d.ts +3 -0
  46. package/dist/ContextMenu/index.d.ts.map +1 -1
  47. package/dist/ContextMenu/index.js +7 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/Dialog/Dialog.d.ts.map +1 -1
  50. package/dist/Dialog/Dialog.js +2 -1
  51. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  52. package/dist/DropdownMenu/DropdownMenu.js +8 -4
  53. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +88 -0
  54. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -0
  55. package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +169 -0
  56. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -1
  57. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  58. package/dist/DropdownMenu/DropdownMenuContext.js +18 -0
  59. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +68 -0
  60. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -0
  61. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +69 -0
  62. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +69 -0
  63. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -0
  64. package/dist/DropdownMenu/DropdownMenuRadioItem.js +178 -0
  65. package/dist/DropdownMenu/index.d.ts +5 -1
  66. package/dist/DropdownMenu/index.d.ts.map +1 -1
  67. package/dist/DropdownMenu/index.js +10 -1
  68. package/dist/DropdownMenu/menuItemRoles.d.ts +13 -0
  69. package/dist/DropdownMenu/menuItemRoles.d.ts.map +1 -0
  70. package/dist/DropdownMenu/menuItemRoles.js +15 -0
  71. package/dist/FileInput/FileInput.d.ts.map +1 -1
  72. package/dist/Icon/Icon.d.ts +43 -13
  73. package/dist/Icon/Icon.d.ts.map +1 -1
  74. package/dist/Icon/Icon.js +62 -27
  75. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  76. package/dist/MobileNav/MobileNav.js +6 -1
  77. package/dist/MobileNav/MobileNavToggle.d.ts.map +1 -1
  78. package/dist/MobileNav/MobileNavToggle.js +4 -0
  79. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  80. package/dist/MultiSelector/MultiSelector.js +36 -10
  81. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  82. package/dist/Outline/Outline.d.ts +68 -3
  83. package/dist/Outline/Outline.d.ts.map +1 -1
  84. package/dist/Outline/Outline.js +128 -26
  85. package/dist/Outline/useScrollSpy.d.ts +27 -7
  86. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  87. package/dist/Outline/useScrollSpy.js +202 -70
  88. package/dist/PowerSearch/usePowerSearchSource.d.ts.map +1 -1
  89. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  90. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  91. package/dist/TabList/Tab.d.ts.map +1 -1
  92. package/dist/TabList/Tab.js +2 -2
  93. package/dist/TabList/TabList.d.ts.map +1 -1
  94. package/dist/TabList/TabList.js +2 -0
  95. package/dist/TabList/TabMenu.d.ts.map +1 -1
  96. package/dist/TabList/TabMenu.js +1 -1
  97. package/dist/Table/Table.d.ts +1 -1
  98. package/dist/Table/Table.js +1 -1
  99. package/dist/Table/index.d.ts +4 -0
  100. package/dist/Table/index.d.ts.map +1 -1
  101. package/dist/Table/index.js +2 -0
  102. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  103. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  104. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +24 -2
  105. package/dist/Table/plugins/rowStatus/index.d.ts +3 -0
  106. package/dist/Table/plugins/rowStatus/index.d.ts.map +1 -0
  107. package/dist/Table/plugins/rowStatus/index.js +3 -0
  108. package/dist/Table/plugins/rowStatus/useTableRowStatus.d.ts +60 -0
  109. package/dist/Table/plugins/rowStatus/useTableRowStatus.d.ts.map +1 -0
  110. package/dist/Table/plugins/rowStatus/useTableRowStatus.js +145 -0
  111. package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
  112. package/dist/Table/plugins/sortable/useTableSortable.js +18 -7
  113. package/dist/Table/plugins/tree/index.d.ts +5 -0
  114. package/dist/Table/plugins/tree/index.d.ts.map +1 -0
  115. package/dist/Table/plugins/tree/index.js +4 -0
  116. package/dist/Table/plugins/tree/useTableTreeData.d.ts +38 -0
  117. package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -0
  118. package/dist/Table/plugins/tree/useTableTreeData.js +358 -0
  119. package/dist/Table/plugins/tree/useTableTreeState.d.ts +51 -0
  120. package/dist/Table/plugins/tree/useTableTreeState.d.ts.map +1 -0
  121. package/dist/Table/plugins/tree/useTableTreeState.js +175 -0
  122. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  123. package/dist/Table/useBaseTablePlugins.js +6 -3
  124. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  125. package/dist/Toolbar/Toolbar.js +8 -1
  126. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  127. package/dist/TreeList/TreeListItem.js +5 -14
  128. package/dist/astryx.css +26 -4
  129. package/dist/astryx.umd.js +54 -54
  130. package/dist/astryx.umd.js.map +4 -4
  131. package/dist/authoring/doc.d.ts.map +1 -1
  132. package/dist/theme/Theme.js +1 -1
  133. package/dist/theme/defineTheme.d.ts +1 -1
  134. package/dist/theme/defineTheme.js +1 -1
  135. package/dist/theme/hct.d.ts +0 -1
  136. package/dist/theme/hct.d.ts.map +1 -1
  137. package/dist/theme/hct.js +0 -3
  138. package/docs.mjs +32 -461
  139. package/locales/en.json +200 -172
  140. package/locales/pseudo.json +21 -0
  141. package/package.json +3 -1
  142. package/scripts/postinstall.mjs +93 -0
  143. package/src/AppShell/AppShell.tsx +6 -0
  144. package/src/AppShell/AppShellMobileContext.tsx +8 -0
  145. package/src/AppShell/useAppShellMobile.doc.mjs +8 -0
  146. package/src/Avatar/Avatar.doc.mjs +6 -6
  147. package/src/Avatar/Avatar.tsx +16 -11
  148. package/src/Avatar/AvatarSizeContext.ts +1 -1
  149. package/src/Avatar/AvatarStatusDot.tsx +2 -2
  150. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  151. package/src/AvatarGroup/AvatarGroup.test.tsx +17 -3
  152. package/src/AvatarGroup/AvatarGroup.tsx +3 -3
  153. package/src/AvatarGroup/AvatarGroupOverflow.doc.mjs +4 -4
  154. package/src/AvatarGroup/AvatarGroupOverflow.tsx +21 -5
  155. package/src/Banner/Banner.tsx +10 -2
  156. package/src/Button/Button.test.tsx +21 -0
  157. package/src/Button/Button.tsx +1 -0
  158. package/src/Carousel/Carousel.doc.mjs +1 -0
  159. package/src/Carousel/Carousel.test.tsx +96 -1
  160. package/src/Carousel/Carousel.tsx +29 -2
  161. package/src/Chat/Chat.doc.mjs +1 -1
  162. package/src/Chat/ChatLayout.tsx +3 -1
  163. package/src/Chat/ChatLayoutScrollButton.tsx +3 -1
  164. package/src/Chat/ChatMessage.tsx +4 -2
  165. package/src/Chat/ChatMessageMetadata.tsx +1 -2
  166. package/src/Chat/ChatSendButton.tsx +5 -1
  167. package/src/Chat/useTriggerMenu.tsx +3 -1
  168. package/src/CheckboxInput/CheckboxInput.doc.mjs +1 -1
  169. package/src/Citation/Citation.test.tsx +51 -0
  170. package/src/Citation/Citation.tsx +8 -2
  171. package/src/CodeBlock/CodeBlock.tsx +3 -1
  172. package/src/Collapsible/Collapsible.doc.mjs +7 -0
  173. package/src/Collapsible/Collapsible.test.tsx +80 -0
  174. package/src/Collapsible/Collapsible.tsx +58 -5
  175. package/src/CommandPalette/CommandPaletteInput.doc.mjs +26 -9
  176. package/src/CommandPalette/CommandPaletteInput.test.tsx +34 -0
  177. package/src/CommandPalette/CommandPaletteInput.tsx +11 -0
  178. package/src/ContextMenu/ContextMenu.test.tsx +75 -1
  179. package/src/ContextMenu/ContextMenu.tsx +10 -5
  180. package/src/ContextMenu/index.ts +15 -0
  181. package/src/DateTimeInput/DateTimeInput.tsx +3 -1
  182. package/src/Dialog/Dialog.tsx +12 -2
  183. package/src/DropdownMenu/DropdownMenu.doc.mjs +23 -0
  184. package/src/DropdownMenu/DropdownMenu.tsx +11 -6
  185. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +95 -0
  186. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +236 -0
  187. package/src/DropdownMenu/DropdownMenuContext.tsx +29 -1
  188. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +7 -1
  189. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +80 -0
  190. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +118 -0
  191. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +80 -0
  192. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +232 -0
  193. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +163 -0
  194. package/src/DropdownMenu/index.ts +23 -3
  195. package/src/DropdownMenu/menuItemRoles.ts +22 -0
  196. package/src/Field/Field.doc.mjs +1 -1
  197. package/src/Field/FieldLabel.doc.mjs +1 -1
  198. package/src/FileInput/FileInput.tsx +4 -1
  199. package/src/Icon/Icon.doc.mjs +12 -0
  200. package/src/Icon/Icon.test.tsx +142 -0
  201. package/src/Icon/Icon.tsx +98 -30
  202. package/src/Item/Item.doc.mjs +1 -1
  203. package/src/MobileNav/MobileNav.tsx +12 -1
  204. package/src/MobileNav/MobileNavToggle.test.tsx +116 -0
  205. package/src/MobileNav/MobileNavToggle.tsx +9 -1
  206. package/src/MultiSelector/MultiSelector.doc.mjs +4 -2
  207. package/src/MultiSelector/MultiSelector.test.tsx +79 -1
  208. package/src/MultiSelector/MultiSelector.tsx +51 -11
  209. package/src/NumberInput/NumberInput.doc.mjs +2 -2
  210. package/src/NumberInput/NumberInput.tsx +1 -2
  211. package/src/Outline/Outline.doc.mjs +123 -3
  212. package/src/Outline/Outline.test.tsx +735 -0
  213. package/src/Outline/Outline.tsx +183 -33
  214. package/src/Outline/useScrollSpy.ts +248 -72
  215. package/src/PowerSearch/usePowerSearchSource.ts +1 -2
  216. package/src/Selector/SelectorOption.doc.mjs +1 -1
  217. package/src/SideNav/SideNavCollapseButton.tsx +6 -1
  218. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  219. package/src/SideNav/SideNavItem.tsx +5 -1
  220. package/src/Switch/Switch.doc.mjs +1 -1
  221. package/src/TabList/Tab.tsx +5 -1
  222. package/src/TabList/TabList.doc.mjs +3 -3
  223. package/src/TabList/TabList.test.tsx +66 -0
  224. package/src/TabList/TabList.tsx +8 -0
  225. package/src/TabList/TabMenu.tsx +4 -1
  226. package/src/Table/Table.doc.mjs +2 -0
  227. package/src/Table/Table.tsx +1 -1
  228. package/src/Table/index.ts +12 -0
  229. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +6 -2
  230. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +90 -1
  231. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +37 -5
  232. package/src/Table/plugins/rowStatus/index.ts +7 -0
  233. package/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +138 -0
  234. package/src/Table/plugins/rowStatus/useTableRowStatus.tsx +189 -0
  235. package/src/Table/plugins/sortable/useTableSortable.test.tsx +95 -9
  236. package/src/Table/plugins/sortable/useTableSortable.tsx +23 -7
  237. package/src/Table/plugins/tree/index.ts +12 -0
  238. package/src/Table/plugins/tree/useTableTreeData-perf.test.tsx +163 -0
  239. package/src/Table/plugins/tree/useTableTreeData.test.tsx +594 -0
  240. package/src/Table/plugins/tree/useTableTreeData.tsx +495 -0
  241. package/src/Table/plugins/tree/useTableTreeState.test.tsx +636 -0
  242. package/src/Table/plugins/tree/useTableTreeState.tsx +253 -0
  243. package/src/Table/useBaseTablePlugins.ts +6 -2
  244. package/src/Table/useTableRowStatus.doc.mjs +30 -0
  245. package/src/Table/useTableTreeData.doc.mjs +75 -0
  246. package/src/Table/useTableTreeState.doc.mjs +98 -0
  247. package/src/TextArea/TextArea.doc.mjs +1 -1
  248. package/src/TextInput/TextInput.doc.mjs +1 -1
  249. package/src/Toolbar/Toolbar.tsx +10 -3
  250. package/src/TreeList/TreeList.test.tsx +32 -1
  251. package/src/TreeList/TreeListItem.tsx +19 -10
  252. package/src/authoring/doc.ts +1 -3
  253. package/src/docs-script.test.mjs +18 -13
  254. package/src/postinstall.test.mjs +71 -0
  255. package/src/theme/Theme.doc.mjs +4 -4
  256. package/src/theme/Theme.tsx +1 -1
  257. package/src/theme/defineTheme.ts +1 -1
  258. package/src/theme/hct.ts +0 -4
  259. package/dist/TreeList/treeListItem.markers.stylex.d.ts +0 -16
  260. package/dist/TreeList/treeListItem.markers.stylex.d.ts.map +0 -1
  261. package/dist/TreeList/treeListItem.markers.stylex.js +0 -22
  262. 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: 12,
118
- height: 12,
122
+ width: '0.75rem',
123
+ height: '0.75rem',
119
124
  },
120
125
  sm: {
121
- width: 16,
122
- height: 16,
126
+ width: '1rem',
127
+ height: '1rem',
123
128
  },
124
129
  md: {
125
- width: 20,
126
- height: 20,
130
+ width: '1.25rem',
131
+ height: '1.25rem',
127
132
  },
128
133
  lg: {
129
- width: 24,
130
- height: 24,
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: 12,
141
- height: 12,
142
- fontSize: 12,
152
+ width: '0.75rem',
153
+ height: '0.75rem',
154
+ fontSize: '0.75rem',
143
155
  },
144
156
  sm: {
145
- width: 16,
146
- height: 16,
147
- fontSize: 16,
157
+ width: '1rem',
158
+ height: '1rem',
159
+ fontSize: '1rem',
148
160
  },
149
161
  md: {
150
- width: 20,
151
- height: 20,
152
- fontSize: 20,
162
+ width: '1.25rem',
163
+ height: '1.25rem',
164
+ fontSize: '1.25rem',
153
165
  },
154
166
  lg: {
155
- width: 24,
156
- height: 24,
157
- fontSize: 24,
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
- aria-hidden="true"
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
- // Decorative by default, but placed BEFORE the prop spread so consumers
285
- // can override it (e.g. `aria-hidden={false}` + `role="img"` +
286
- // `aria-label`) to expose a meaningful standalone icon. This matches
287
- // component-mode Icon, which already sets aria-hidden before its spread.
288
- aria-hidden="true"
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}),
@@ -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: 'xsmall'}}, {__element: 'Icon', props: {icon: 'info', size: 'sm', color: 'secondary'}}]},
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={label ?? (typeof header === 'string' ? header : t('@astryx.mobileNav.navigation'))}
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 {isMobile, isMobileNavEnabled, toggleMobileNav} = useAppShellMobile();
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: 'Whether to show a search input for filtering options.',
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: '用于表单提交的 HTML name 属性。为每个已选值渲染一个隐藏输入,类似原生多选。',
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([...new FormData(container.querySelector('form')!).keys()]).toEqual([]);
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
- if (!searchQuery) {
697
- return selectableItems;
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={e => setSearchQuery(e.target.value)}
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 `npx astryx docs icons` for valid semantic names.',
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 `npx astryx docs icons` for valid semantic names.',
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
- | NumberInputPropsNonClearable
315
- | NumberInputPropsClearable;
314
+ NumberInputPropsNonClearable | NumberInputPropsClearable;
316
315
 
317
316
  /**
318
317
  * Parse and validate a string input as a number.