@astryxdesign/core 0.1.7 → 0.1.8-canary.c5fa19b

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 (190) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +30 -19
  3. package/dist/Avatar/Avatar.d.ts +10 -5
  4. package/dist/Avatar/Avatar.d.ts.map +1 -1
  5. package/dist/Avatar/Avatar.js +12 -8
  6. package/dist/Avatar/AvatarSizeContext.d.ts +1 -1
  7. package/dist/Avatar/AvatarSizeContext.js +1 -1
  8. package/dist/Avatar/AvatarStatusDot.d.ts +2 -2
  9. package/dist/Avatar/AvatarStatusDot.js +2 -2
  10. package/dist/AvatarGroup/AvatarGroup.d.ts +2 -2
  11. package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
  12. package/dist/AvatarGroup/AvatarGroup.js +2 -2
  13. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  14. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  15. package/dist/AvatarGroup/AvatarGroupOverflow.js +16 -14
  16. package/dist/Carousel/Carousel.d.ts +3 -2
  17. package/dist/Carousel/Carousel.d.ts.map +1 -1
  18. package/dist/Carousel/Carousel.js +32 -2
  19. package/dist/Chat/ChatMessage.d.ts +1 -1
  20. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  21. package/dist/Chat/ChatMessage.js +1 -1
  22. package/dist/Citation/Citation.d.ts.map +1 -1
  23. package/dist/Citation/Citation.js +10 -4
  24. package/dist/Collapsible/Collapsible.d.ts +11 -1
  25. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  26. package/dist/Collapsible/Collapsible.js +36 -4
  27. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  28. package/dist/ContextMenu/ContextMenu.js +4 -3
  29. package/dist/ContextMenu/index.d.ts +3 -0
  30. package/dist/ContextMenu/index.d.ts.map +1 -1
  31. package/dist/ContextMenu/index.js +7 -1
  32. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  33. package/dist/DropdownMenu/DropdownMenu.js +8 -4
  34. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +89 -0
  35. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -0
  36. package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +170 -0
  37. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -1
  38. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenuContext.js +18 -0
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +69 -0
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -0
  42. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +70 -0
  43. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +69 -0
  44. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -0
  45. package/dist/DropdownMenu/DropdownMenuRadioItem.js +178 -0
  46. package/dist/DropdownMenu/index.d.ts +5 -1
  47. package/dist/DropdownMenu/index.d.ts.map +1 -1
  48. package/dist/DropdownMenu/index.js +10 -1
  49. package/dist/DropdownMenu/menuItemRoles.d.ts +13 -0
  50. package/dist/DropdownMenu/menuItemRoles.d.ts.map +1 -0
  51. package/dist/DropdownMenu/menuItemRoles.js +15 -0
  52. package/dist/Icon/Icon.d.ts +43 -13
  53. package/dist/Icon/Icon.d.ts.map +1 -1
  54. package/dist/Icon/Icon.js +62 -27
  55. package/dist/Outline/Outline.d.ts +69 -3
  56. package/dist/Outline/Outline.d.ts.map +1 -1
  57. package/dist/Outline/Outline.js +129 -26
  58. package/dist/Outline/useScrollSpy.d.ts +27 -7
  59. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  60. package/dist/Outline/useScrollSpy.js +202 -70
  61. package/dist/TabList/Tab.d.ts.map +1 -1
  62. package/dist/TabList/Tab.js +2 -2
  63. package/dist/TabList/TabList.d.ts.map +1 -1
  64. package/dist/TabList/TabList.js +2 -0
  65. package/dist/TabList/TabMenu.d.ts.map +1 -1
  66. package/dist/TabList/TabMenu.js +1 -1
  67. package/dist/Table/Table.d.ts +1 -1
  68. package/dist/Table/Table.js +1 -1
  69. package/dist/Table/index.d.ts +2 -0
  70. package/dist/Table/index.d.ts.map +1 -1
  71. package/dist/Table/index.js +1 -0
  72. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  73. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +24 -2
  74. package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
  75. package/dist/Table/plugins/sortable/useTableSortable.js +18 -7
  76. package/dist/Table/plugins/tree/index.d.ts +5 -0
  77. package/dist/Table/plugins/tree/index.d.ts.map +1 -0
  78. package/dist/Table/plugins/tree/index.js +4 -0
  79. package/dist/Table/plugins/tree/useTableTreeData.d.ts +38 -0
  80. package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -0
  81. package/dist/Table/plugins/tree/useTableTreeData.js +358 -0
  82. package/dist/Table/plugins/tree/useTableTreeState.d.ts +51 -0
  83. package/dist/Table/plugins/tree/useTableTreeState.d.ts.map +1 -0
  84. package/dist/Table/plugins/tree/useTableTreeState.js +175 -0
  85. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  86. package/dist/Table/useBaseTablePlugins.js +6 -3
  87. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  88. package/dist/Toolbar/Toolbar.js +8 -1
  89. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  90. package/dist/TreeList/TreeListItem.js +5 -14
  91. package/dist/astryx.css +24 -4
  92. package/dist/astryx.umd.js +54 -54
  93. package/dist/astryx.umd.js.map +4 -4
  94. package/dist/theme/Theme.js +1 -1
  95. package/dist/theme/defineTheme.d.ts +1 -1
  96. package/dist/theme/defineTheme.js +1 -1
  97. package/dist/theme/hct.d.ts +0 -1
  98. package/dist/theme/hct.d.ts.map +1 -1
  99. package/dist/theme/hct.js +0 -3
  100. package/docs.mjs +32 -461
  101. package/locales/en.json +200 -172
  102. package/locales/pseudo.json +21 -0
  103. package/package.json +3 -1
  104. package/scripts/postinstall.mjs +93 -0
  105. package/src/Avatar/Avatar.doc.mjs +6 -6
  106. package/src/Avatar/Avatar.tsx +16 -11
  107. package/src/Avatar/AvatarSizeContext.ts +1 -1
  108. package/src/Avatar/AvatarStatusDot.tsx +2 -2
  109. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  110. package/src/AvatarGroup/AvatarGroup.test.tsx +17 -3
  111. package/src/AvatarGroup/AvatarGroup.tsx +3 -3
  112. package/src/AvatarGroup/AvatarGroupOverflow.doc.mjs +4 -4
  113. package/src/AvatarGroup/AvatarGroupOverflow.tsx +21 -5
  114. package/src/Carousel/Carousel.doc.mjs +1 -0
  115. package/src/Carousel/Carousel.test.tsx +96 -1
  116. package/src/Carousel/Carousel.tsx +29 -2
  117. package/src/Chat/Chat.doc.mjs +1 -1
  118. package/src/Chat/ChatMessage.tsx +4 -2
  119. package/src/CheckboxInput/CheckboxInput.doc.mjs +1 -1
  120. package/src/Citation/Citation.test.tsx +51 -0
  121. package/src/Citation/Citation.tsx +8 -2
  122. package/src/Collapsible/Collapsible.doc.mjs +7 -0
  123. package/src/Collapsible/Collapsible.test.tsx +80 -0
  124. package/src/Collapsible/Collapsible.tsx +58 -5
  125. package/src/ContextMenu/ContextMenu.test.tsx +75 -1
  126. package/src/ContextMenu/ContextMenu.tsx +10 -5
  127. package/src/ContextMenu/index.ts +15 -0
  128. package/src/DropdownMenu/DropdownMenu.doc.mjs +23 -0
  129. package/src/DropdownMenu/DropdownMenu.tsx +11 -6
  130. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +95 -0
  131. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +237 -0
  132. package/src/DropdownMenu/DropdownMenuContext.tsx +29 -1
  133. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +7 -1
  134. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +80 -0
  135. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +119 -0
  136. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +80 -0
  137. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +232 -0
  138. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +163 -0
  139. package/src/DropdownMenu/index.ts +23 -3
  140. package/src/DropdownMenu/menuItemRoles.ts +22 -0
  141. package/src/Field/Field.doc.mjs +1 -1
  142. package/src/Field/FieldLabel.doc.mjs +1 -1
  143. package/src/Icon/Icon.doc.mjs +12 -0
  144. package/src/Icon/Icon.test.tsx +142 -0
  145. package/src/Icon/Icon.tsx +98 -30
  146. package/src/Item/Item.doc.mjs +1 -1
  147. package/src/NumberInput/NumberInput.doc.mjs +2 -2
  148. package/src/Outline/Outline.doc.mjs +123 -3
  149. package/src/Outline/Outline.test.tsx +735 -0
  150. package/src/Outline/Outline.tsx +184 -33
  151. package/src/Outline/useScrollSpy.ts +248 -72
  152. package/src/Selector/SelectorOption.doc.mjs +1 -1
  153. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  154. package/src/Switch/Switch.doc.mjs +1 -1
  155. package/src/TabList/Tab.tsx +5 -1
  156. package/src/TabList/TabList.doc.mjs +3 -3
  157. package/src/TabList/TabList.test.tsx +66 -0
  158. package/src/TabList/TabList.tsx +8 -0
  159. package/src/TabList/TabMenu.tsx +4 -1
  160. package/src/Table/Table.doc.mjs +2 -0
  161. package/src/Table/Table.tsx +1 -1
  162. package/src/Table/index.ts +7 -0
  163. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +90 -1
  164. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +37 -5
  165. package/src/Table/plugins/sortable/useTableSortable.test.tsx +95 -9
  166. package/src/Table/plugins/sortable/useTableSortable.tsx +23 -7
  167. package/src/Table/plugins/tree/index.ts +12 -0
  168. package/src/Table/plugins/tree/useTableTreeData-perf.test.tsx +163 -0
  169. package/src/Table/plugins/tree/useTableTreeData.test.tsx +594 -0
  170. package/src/Table/plugins/tree/useTableTreeData.tsx +495 -0
  171. package/src/Table/plugins/tree/useTableTreeState.test.tsx +636 -0
  172. package/src/Table/plugins/tree/useTableTreeState.tsx +253 -0
  173. package/src/Table/useBaseTablePlugins.ts +6 -2
  174. package/src/Table/useTableTreeData.doc.mjs +75 -0
  175. package/src/Table/useTableTreeState.doc.mjs +98 -0
  176. package/src/TextArea/TextArea.doc.mjs +1 -1
  177. package/src/TextInput/TextInput.doc.mjs +1 -1
  178. package/src/Toolbar/Toolbar.tsx +10 -3
  179. package/src/TreeList/TreeList.test.tsx +32 -1
  180. package/src/TreeList/TreeListItem.tsx +19 -10
  181. package/src/docs-script.test.mjs +18 -13
  182. package/src/postinstall.test.mjs +71 -0
  183. package/src/theme/Theme.doc.mjs +4 -4
  184. package/src/theme/Theme.tsx +1 -1
  185. package/src/theme/defineTheme.ts +1 -1
  186. package/src/theme/hct.ts +0 -4
  187. package/dist/TreeList/treeListItem.markers.stylex.d.ts +0 -16
  188. package/dist/TreeList/treeListItem.markers.stylex.d.ts.map +0 -1
  189. package/dist/TreeList/treeListItem.markers.stylex.js +0 -22
  190. package/src/TreeList/treeListItem.markers.stylex.ts +0 -20
@@ -317,6 +317,21 @@
317
317
  "@astryx.table.sort.clear": {
318
318
  "defaultMessage": "⟦Çłéàř šóřţ⟧"
319
319
  },
320
+ "@astryx.table.sort.direction.ascending": {
321
+ "defaultMessage": "⟦àšçéñðíñĝ⟧"
322
+ },
323
+ "@astryx.table.sort.direction.descending": {
324
+ "defaultMessage": "⟦ðéšçéñðíñĝ⟧"
325
+ },
326
+ "@astryx.table.sort.sortBy": {
327
+ "defaultMessage": "⟦Šóřţ ƀý {label}⟧"
328
+ },
329
+ "@astryx.table.sort.sortedBy": {
330
+ "defaultMessage": "⟦Šóřţ ƀý {label}, šóřţéð {direction}⟧"
331
+ },
332
+ "@astryx.table.sort.sortedByWithPriority": {
333
+ "defaultMessage": "⟦Šóřţ ƀý {label}, šóřţéð {direction}, þříóříţý {rank, number} óƒ {total, number}⟧"
334
+ },
320
335
  "@astryx.toast.dismiss": {
321
336
  "defaultMessage": "⟦Ðíšɱíšš ñóţíƒíçàţíóñ⟧"
322
337
  },
@@ -542,6 +557,12 @@
542
557
  "@astryx.tableRowExpansion.expandAllRows": {
543
558
  "defaultMessage": "⟦Éẋþàñð àłł řóŵš⟧"
544
559
  },
560
+ "@astryx.tableTree.collapseRow": {
561
+ "defaultMessage": "⟦Çółłàþšé řóŵ⟧"
562
+ },
563
+ "@astryx.tableTree.expandRow": {
564
+ "defaultMessage": "⟦Éẋþàñð řóŵ⟧"
565
+ },
545
566
  "@astryx.textInput.clearLabel": {
546
567
  "defaultMessage": "⟦Çłéàř {label}⟧"
547
568
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.1.7",
3
+ "version": "0.1.8-canary.c5fa19b",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -636,6 +636,7 @@
636
636
  "files": [
637
637
  "dist",
638
638
  "src",
639
+ "scripts/postinstall.mjs",
639
640
  "locales",
640
641
  "docs.mjs",
641
642
  "groups.doc.mjs",
@@ -662,6 +663,7 @@
662
663
  "intl-messageformat": "^11.2.9"
663
664
  },
664
665
  "scripts": {
666
+ "postinstall": "node scripts/postinstall.mjs",
665
667
  "build": "rimraf dist && pnpm build:i18n && pnpm build:esm && tsc --project tsconfig.build.json && pnpm build:css && pnpm build:umd && node ../../scripts/check-no-dev-jsx.mjs",
666
668
  "build:i18n": "node scripts/build-pseudo-locale.mjs",
667
669
  "build:esm": "babel src --out-dir dist --extensions .ts,.tsx --out-file-extension .js --ignore \"**/*.test.ts,**/*.test.tsx,**/__tests__/**,**/*.perf.test.*,**/*.doc.mjs,**/*.d.ts,**/*.css,**/*.css.d.ts\" --config-file ./babel.config.json",
@@ -0,0 +1,93 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file @astryxdesign/core postinstall — nudge to run `npx @astryxdesign/cli init`.
5
+ *
6
+ * Enforcement layer 1 of making `astryx init` foolproof: when the design system
7
+ * is installed and the project hasn't run init yet, print a one-line next-step
8
+ * so agents/humans discover it (this is the most common fresh-install entry).
9
+ *
10
+ * Self-contained: core is a separate package and cannot import the CLI, so the
11
+ * agent-doc locations + marker below intentionally DUPLICATE the CLI's agent-doc
12
+ * discovery in packages/cli/src/commands/agent-docs.mjs — keep them aligned with
13
+ * that file. The .hermes.md / HERMES.md entries are an intentional superset (the
14
+ * CLI writes those via `--agent hermes`). Non-interactive, never fails the
15
+ * install, and quiet in the monorepo build, during npx's fetch, and once set up.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import {fileURLToPath, pathToFileURL} from 'node:url';
21
+
22
+ const HERE = fileURLToPath(import.meta.url);
23
+
24
+ // Duplicates the CLI's agent-doc locations + markers — keep aligned with
25
+ // packages/cli/src/commands/agent-docs.mjs. Hermes files are an intentional superset.
26
+ const AGENT_DOC_PATHS = [
27
+ 'AGENTS.md',
28
+ 'CLAUDE.md',
29
+ '.claude/CLAUDE.md',
30
+ '.cursorrules',
31
+ '.hermes.md',
32
+ 'HERMES.md',
33
+ ];
34
+ const MARKERS = ['<!-- ASTRYX:START -->', '<!-- XDS:START -->'];
35
+
36
+ /**
37
+ * True when the project already has the Astryx agent prompt installed (an Astryx
38
+ * marker is present in any agent-doc file) — the same check the CLI performs.
39
+ * @param {string} root @returns {boolean}
40
+ */
41
+ export function isAstryxInitialized(root) {
42
+ for (const rel of AGENT_DOC_PATHS) {
43
+ try {
44
+ const content = fs.readFileSync(path.join(root, rel), 'utf-8');
45
+ if (MARKERS.some(m => content.includes(m))) return true;
46
+ } catch {
47
+ // Unreadable/missing — keep checking the others.
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Pure decision: should the postinstall print the setup nudge? Unit-testable
55
+ * without an actual npm install.
56
+ * @returns {boolean}
57
+ */
58
+ export function shouldNudge({scriptPath, npmCommand, isSetUp} = {}) {
59
+ if (!scriptPath || !scriptPath.includes('node_modules')) return false; // monorepo/source build
60
+ if (scriptPath.includes('_npx') || npmCommand === 'exec') return false; // npx transient
61
+ if (isSetUp) return false; // already set up — stay quiet
62
+ return true;
63
+ }
64
+
65
+ function main() {
66
+ const root = process.env.INIT_CWD || process.cwd();
67
+ if (
68
+ shouldNudge({
69
+ scriptPath: HERE,
70
+ npmCommand: process.env.npm_command,
71
+ isSetUp: isAstryxInitialized(root),
72
+ })
73
+ ) {
74
+ // Scoped package form (`@astryxdesign/cli`) — always resolves to us, even
75
+ // before the CLI is installed. Bare `npx astryx` would fetch an unrelated
76
+ // look-alike package (see PR #4151). After that lands, switch this to its
77
+ // getCliInvocation() single source of truth.
78
+ process.stdout.write(
79
+ '\nNext step: run `npx @astryxdesign/cli init` to finish setup and install the Astryx agent prompt.\n\n',
80
+ );
81
+ }
82
+ }
83
+
84
+ // Run only when executed directly (`node scripts/postinstall.mjs`), never when
85
+ // imported by tests.
86
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
87
+ try {
88
+ main();
89
+ } catch {
90
+ // never break an install
91
+ }
92
+ process.exit(0);
93
+ }
@@ -13,7 +13,7 @@ export const docs = {
13
13
  'Avatar represents a person or team with a profile photo, initials, or a default icon. Use it in comment headers, contact lists, chat messages, user cards, and anywhere you need to identify someone visually.',
14
14
  bestPractices: [
15
15
  {guidance: true, description: 'Always pass a name so the avatar can show initials if the photo fails to load, and so screen readers can announce who it represents.'},
16
- {guidance: true, description: 'Pick a size that matches the context: tiny or xsmall for inline mentions, small or medium for lists and cards, large for profile headers.'},
16
+ {guidance: true, description: 'Pick a size that matches the context: xsm or sm for inline mentions, md or lg for lists and cards, xl for profile headers.'},
17
17
  {guidance: true, description: 'Add a status dot when knowing someone\'s availability matters, like in chat or team views.'},
18
18
  {guidance: false, description: 'Use Avatar for logos, product images, or anything that isn\'t a person or team. Use an image or icon instead.'},
19
19
  {guidance: false, description: 'Force a square or custom shape. Avatars are always circular to stay consistent across the system.'},
@@ -55,9 +55,9 @@ export const docs = {
55
55
  },
56
56
  {
57
57
  name: 'size',
58
- type: "'tiny' | 'xsmall' | 'small' | 'medium' | 'large' | number",
59
- description: "Avatar size. Use a named size ('tiny', 'xsmall', 'small', 'medium', 'large') or a numeric pixel value. Note: short names like 'sm', 'md', 'lg' are NOT valid; use the full words.",
60
- default: "'small'",
58
+ type: "'xsm' | 'sm' | 'md' | 'lg' | 'xl' | number",
59
+ description: "Avatar size. Use a named size ('xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px) or a numeric pixel value. Avatar shares Icon's abbreviated scale, but its tiers are larger because avatars align with media rather than glyphs.",
60
+ default: "'md'",
61
61
  },
62
62
  {
63
63
  name: 'status',
@@ -100,7 +100,7 @@ export const docsDense = {
100
100
  'Avatar represents a person or team with a profile photo, initials, or a default icon. Falls back automatically. Use in comment headers, contact lists, chat, user cards.',
101
101
  bestPractices: [
102
102
  {guidance: true, description: 'Always pass a name for initials fallback and screen reader alt text.'},
103
- {guidance: true, description: 'Match size to context: tiny/xsmall inline, small/medium in lists, large for profiles.'},
103
+ {guidance: true, description: 'Match size to context: xsm/sm inline, md/lg in lists, xl for profiles.'},
104
104
  {guidance: true, description: 'Add a status dot in chat or team views where availability matters.'},
105
105
  {guidance: false, description: 'Use for logos or product images. Use an image or icon instead.'},
106
106
  {guidance: false, description: 'Force a square or custom shape. Avatars are always circular.'},
@@ -111,7 +111,7 @@ export const docsDense = {
111
111
  fallbackSrc: 'fallback image when primary fails',
112
112
  name: 'user name for initials and alt text',
113
113
  alt: 'alt text; falls back to name',
114
- size: "avatar size. Named ('tiny', 'xsmall', 'small', 'medium', 'large') or numeric px. Short names 'sm'/'md'/'lg' are NOT valid; use full words.",
114
+ size: "avatar size. Named ('xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px) or numeric px.",
115
115
  status: 'corner content for status indicators',
116
116
  },
117
117
  components: [
@@ -55,9 +55,13 @@ const CIRCLE_EDGE_OFFSET_RATIO = (1 - 1 / Math.SQRT2) / 2;
55
55
  const INITIALS_FONT_SIZE_RATIO = 0.4;
56
56
 
57
57
  /**
58
- * Named size options
58
+ * Named size options.
59
+ *
60
+ * Avatar uses the same abbreviated scale as Icon (`xsm`/`sm`/`md`/`lg`/`xl`),
61
+ * but the values are larger because avatars align with media rather than
62
+ * glyphs. The tiers match EPS's avatar sizes.
59
63
  */
60
- type AvatarNamedSize = 'tiny' | 'xsmall' | 'small' | 'medium' | 'large';
64
+ type AvatarNamedSize = 'xsm' | 'sm' | 'md' | 'lg' | 'xl';
61
65
 
62
66
  /**
63
67
  * Numeric size options (in pixels)
@@ -78,15 +82,15 @@ export function resolveSize(size: AvatarSize): number {
78
82
  return size;
79
83
  }
80
84
  switch (size) {
81
- case 'tiny':
85
+ case 'xsm':
82
86
  return 20;
83
- case 'xsmall':
87
+ case 'sm':
84
88
  return 24;
85
- case 'small':
89
+ case 'md':
86
90
  return 36;
87
- case 'medium':
91
+ case 'lg':
88
92
  return 48;
89
- case 'large':
93
+ case 'xl':
90
94
  return 128;
91
95
  }
92
96
  }
@@ -198,8 +202,9 @@ export interface AvatarProps extends BaseProps<HTMLDivElement> {
198
202
  */
199
203
  name?: string;
200
204
  /**
201
- * The size of the avatar.
202
- * @default 'small'
205
+ * The size of the avatar. A named size (`xsm` 20px, `sm` 24px, `md` 36px,
206
+ * `lg` 48px, `xl` 128px) or a specific pixel value.
207
+ * @default 'md'
203
208
  */
204
209
  size?: AvatarSize;
205
210
  /**
@@ -258,7 +263,7 @@ function DefaultIcon({size}: {size: number}) {
258
263
  * @example
259
264
  * ```
260
265
  * <Avatar src="/user.jpg" name="John Doe" />
261
- * <Avatar name="Jane Smith" size="large" />
266
+ * <Avatar name="Jane Smith" size="xl" />
262
267
  * <Avatar src="/user.jpg" status={<OnlineIndicator />} />
263
268
  * ```
264
269
  */
@@ -267,7 +272,7 @@ export function Avatar({
267
272
  'data-testid': testId,
268
273
  fallbackSrc,
269
274
  name,
270
- size = 'small',
275
+ size = 'md',
271
276
  src,
272
277
  status,
273
278
  xstyle,
@@ -18,7 +18,7 @@ import {createContext} from 'react';
18
18
  * Context that provides the resolved numeric avatar size (in pixels)
19
19
  * to child components like AvatarStatusDot.
20
20
  *
21
- * Default value of 36 matches the default 'small' avatar size.
21
+ * Default value of 36 matches the default 'md' avatar size.
22
22
  */
23
23
  export const AvatarSizeContext = createContext<number>(36);
24
24
  AvatarSizeContext.displayName = 'AvatarSizeContext';
@@ -168,12 +168,12 @@ const variantStyleMap: Partial<
168
168
  * ```
169
169
  * <Avatar
170
170
  * name="John Doe"
171
- * size="medium"
171
+ * size="lg"
172
172
  * status={<AvatarStatusDot variant="success" label="Online" />}
173
173
  * />
174
174
  * <Avatar
175
175
  * name="Jane Smith"
176
- * size="large"
176
+ * size="xl"
177
177
  * status={<AvatarStatusDot variant="success" label="Verified" icon={<CheckIcon />} />}
178
178
  * />
179
179
  * ```
@@ -47,7 +47,7 @@ export const docs = {
47
47
  name: 'size',
48
48
  type: 'AvatarSize',
49
49
  description: 'Size applied to all avatars via context.',
50
- default: "'small'",
50
+ default: "'md'",
51
51
  },
52
52
  {
53
53
  name: 'ref',
@@ -56,14 +56,14 @@ describe('AvatarGroup', () => {
56
56
 
57
57
  it('applies size class to the group', () => {
58
58
  render(
59
- <AvatarGroup size="medium">
59
+ <AvatarGroup size="lg">
60
60
  <Avatar name="Alice" />
61
61
  </AvatarGroup>,
62
62
  );
63
63
 
64
64
  const group = screen.getByRole('group');
65
65
  expect(group.className).toContain('astryx-avatar-group');
66
- expect(group.className).toContain('medium');
66
+ expect(group.className).toContain('lg');
67
67
  });
68
68
 
69
69
  it('renders empty group when no children', () => {
@@ -134,7 +134,7 @@ describe('AvatarGroupOverflow', () => {
134
134
  const visibleCount = 3;
135
135
 
136
136
  render(
137
- <AvatarGroup size="medium">
137
+ <AvatarGroup size="lg">
138
138
  {users.slice(0, visibleCount).map(name => (
139
139
  <Avatar key={name} name={name} />
140
140
  ))}
@@ -211,4 +211,18 @@ describe('AvatarGroupOverflow — hardening', () => {
211
211
 
212
212
  expect(screen.getByText('+999')).toBeInTheDocument();
213
213
  });
214
+
215
+ it('renders the full "+N" text for wide multi-digit counts', () => {
216
+ // The indicator grows into a pill for long counts, so the entire number
217
+ // must remain present (nothing clipped away in the DOM).
218
+ render(
219
+ <AvatarGroup>
220
+ <Avatar name="Alice" />
221
+ <AvatarGroupOverflow count={4912} />
222
+ </AvatarGroup>,
223
+ );
224
+
225
+ expect(screen.getByText('+4912')).toBeInTheDocument();
226
+ expect(screen.getByLabelText('4912 more')).toBeInTheDocument();
227
+ });
214
228
  });
@@ -39,7 +39,7 @@ export interface AvatarGroupProps extends BaseProps<HTMLDivElement> {
39
39
  children: ReactNode;
40
40
  /**
41
41
  * Size applied to all avatars via context.
42
- * @default 'small'
42
+ * @default 'md'
43
43
  */
44
44
  size?: AvatarSize;
45
45
  /**
@@ -65,7 +65,7 @@ const styles = stylex.create({
65
65
  *
66
66
  * @example
67
67
  * ```
68
- * <AvatarGroup size="medium">
68
+ * <AvatarGroup size="lg">
69
69
  * {users.slice(0, 3).map(u => (
70
70
  * <Avatar key={u.id} src={u.src} name={u.name} />
71
71
  * ))}
@@ -75,7 +75,7 @@ const styles = stylex.create({
75
75
  */
76
76
  export function AvatarGroup({
77
77
  children,
78
- size = 'small',
78
+ size = 'md',
79
79
  'data-testid': testId,
80
80
  'aria-label': ariaLabelFromProps,
81
81
  xstyle,
@@ -51,7 +51,7 @@ export const docs = {
51
51
  'AvatarGroupOverflow appears at the end of an AvatarGroup to summarize people who are not shown individually. Use it when a group is sliced to a small number of visible avatars but the hidden count still matters.',
52
52
  bestPractices: [
53
53
  {guidance: true, description: 'Pass the real hidden count to `count` so the accessible label matches the visible indicator.'},
54
- {guidance: true, description: 'Use short custom text such as `+12` or `99+`; the indicator is sized like an avatar.'},
54
+ {guidance: true, description: 'Use short custom text such as `+12` or `99+`. The indicator is circular for short counts and grows into a pill for wider counts so the number always fits.'},
55
55
  {guidance: true, description: 'Provide `onClick` when the overflow opens a member list, popover, or detail view.'},
56
56
  {guidance: false, description: 'Do not use long labels inside the indicator; place longer participant details next to the group instead.'},
57
57
  ],
@@ -64,7 +64,7 @@ export const docs = {
64
64
  {
65
65
  label: 'Default overflow count',
66
66
  code: `
67
- <AvatarGroup size="medium">
67
+ <AvatarGroup size="lg">
68
68
  {users.slice(0, 3).map(user => (
69
69
  <Avatar key={user.id} src={user.src} name={user.name} />
70
70
  ))}
@@ -75,7 +75,7 @@ export const docs = {
75
75
  {
76
76
  label: 'Custom count text',
77
77
  code: `
78
- <AvatarGroup size="medium">
78
+ <AvatarGroup size="lg">
79
79
  {users.slice(0, 3).map(user => (
80
80
  <Avatar key={user.id} src={user.src} name={user.name} />
81
81
  ))}
@@ -104,7 +104,7 @@ export const docsZh = {
104
104
  'AvatarGroupOverflow 显示在 AvatarGroup 末尾,用来汇总未单独展示的成员。适用于只展示少量头像但仍需要显示隐藏数量的场景。',
105
105
  bestPractices: [
106
106
  {guidance: true, description: '向 `count` 传入真实隐藏数量,确保无障碍标签与可见指示器一致。'},
107
- {guidance: true, description: '使用 `+12` 或 `99+` 等短文本;指示器尺寸与头像相同。'},
107
+ {guidance: true, description: '使用 `+12` 或 `99+` 等短文本;指示器在计数较短时为圆形,计数较宽时会拉伸为胶囊形,确保数字始终完整显示。'},
108
108
  {guidance: true, description: '当溢出项会打开成员列表、弹出层或详情视图时提供 `onClick`。'},
109
109
  {guidance: false, description: '不要在指示器内使用长标签;更长的参与者详情应放在头像组旁边。'},
110
110
  ],
@@ -20,6 +20,7 @@ import {
20
20
  typographyVars,
21
21
  fontWeightVars,
22
22
  radiusVars,
23
+ spacingVars,
23
24
  } from '../theme/tokens.stylex';
24
25
  import {mergeProps} from '../utils';
25
26
  import {useAvatarGroup} from './AvatarGroupContext';
@@ -67,13 +68,21 @@ const styles = stylex.create({
67
68
  borderWidth: BORDER_WIDTH,
68
69
  borderStyle: 'solid',
69
70
  borderColor: colorVars['--color-background-surface'],
70
- boxSizing: 'content-box',
71
+ // border-box so the border and inline padding are included in the box
72
+ // size: a short "+N" stays a circle at exactly the avatar size, while
73
+ // longer content pushes past the min width and grows into a pill.
74
+ boxSizing: 'border-box',
75
+ // Horizontal breathing room so multi-digit "+N" counts don't crowd the
76
+ // edges once the indicator grows into a pill.
77
+ paddingInline: spacingVars['--spacing-2'],
71
78
  // Neutral tint layer (preserves opaque base underneath)
72
79
  backgroundImage: `linear-gradient(${colorVars['--color-neutral']}, ${colorVars['--color-neutral']})`,
73
80
  },
74
81
  button: {
75
82
  cursor: 'pointer',
76
- padding: 0,
83
+ // Reset the UA button's block padding only; the inline padding from `base`
84
+ // provides the pill's breathing room and must be preserved.
85
+ paddingBlock: 0,
77
86
  // Interactive overlay states layered on top via backgroundImage
78
87
  backgroundImage: {
79
88
  default: `linear-gradient(${colorVars['--color-neutral']}, ${colorVars['--color-neutral']})`,
@@ -99,8 +108,15 @@ const styles = stylex.create({
99
108
 
100
109
  const dynamicStyles = stylex.create({
101
110
  size: (s: number) => ({
102
- width: s,
103
- height: s,
111
+ // Pin height to the avatar's rendered size and enforce the same value as a
112
+ // *minimum* width, so short counts (`+5`) render a perfect circle. With
113
+ // border-box, the inline padding lives inside this size; longer content
114
+ // (`+4912`) pushes past the min width and grows into a stadium/pill.
115
+ // The border is added to the declared size (like the avatars' ring, which
116
+ // uses content-box + a 2px border) to keep the indicator the same overall
117
+ // size as its sibling avatars.
118
+ minWidth: s + BORDER_WIDTH * 2,
119
+ height: s + BORDER_WIDTH * 2,
104
120
  }),
105
121
  fontSize: (s: number) => ({
106
122
  fontSize: s * OVERFLOW_FONT_RATIO,
@@ -116,7 +132,7 @@ const dynamicStyles = stylex.create({
116
132
  *
117
133
  * @example
118
134
  * ```
119
- * <AvatarGroup size="medium">
135
+ * <AvatarGroup size="lg">
120
136
  * {users.slice(0, 3).map(u => (
121
137
  * <Avatar key={u.id} src={u.src} name={u.name} />
122
138
  * ))}
@@ -14,6 +14,7 @@ export const docs = {
14
14
  {guidance: true, description: 'Enable scroll-snap when each item should land precisely at the start edge, like a gallery or product list.'},
15
15
  {guidance: true, description: 'Always provide an aria-label that describes what the carousel contains, like "Featured products" or "Team members".'},
16
16
  {guidance: true, description: 'Use a consistent gap and item width so the carousel looks intentional, not like content overflowing by accident.'},
17
+ {guidance: true, description: 'Trust the built-in navigation: trackpad users can swipe horizontally, and mouse users can hold Shift while scrolling the wheel to move through items.'},
17
18
  {guidance: false, description: 'Use a carousel for content every user must see. Not everyone scrolls horizontally, so put critical content above the fold.'},
18
19
  {guidance: false, description: 'Auto-advance items. Let the user scroll at their own pace.'},
19
20
  {guidance: false, description: 'Nest carousels. A carousel inside a carousel is confusing and breaks keyboard navigation.'},
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  import {describe, it, expect, vi} from 'vitest';
4
- import {render, screen} from '@testing-library/react';
4
+ import {render, screen, fireEvent} from '@testing-library/react';
5
5
  import {Carousel} from './Carousel';
6
6
 
7
7
  // Mock ResizeObserver (not available in jsdom)
@@ -111,4 +111,99 @@ describe('Carousel', () => {
111
111
  expect(left).toBeDisabled();
112
112
  expect(right).toBeDisabled();
113
113
  });
114
+
115
+ describe('Shift + wheel horizontal scroll', () => {
116
+ // jsdom doesn't lay out elements, so we fake an overflowing scroll
117
+ // container and capture scrollBy calls.
118
+ function getScroller() {
119
+ const region = screen.getByRole('region');
120
+ return region.firstElementChild as HTMLElement;
121
+ }
122
+
123
+ function makeOverflowing(el: HTMLElement) {
124
+ Object.defineProperty(el, 'scrollWidth', {
125
+ value: 500,
126
+ configurable: true,
127
+ });
128
+ Object.defineProperty(el, 'clientWidth', {
129
+ value: 200,
130
+ configurable: true,
131
+ });
132
+ }
133
+
134
+ it('maps Shift + vertical wheel to horizontal scroll', () => {
135
+ render(
136
+ <Carousel aria-label="Wheel">
137
+ <div>Item 1</div>
138
+ <div>Item 2</div>
139
+ </Carousel>,
140
+ );
141
+ const scroller = getScroller();
142
+ makeOverflowing(scroller);
143
+ const scrollBy = vi.fn();
144
+ scroller.scrollBy = scrollBy;
145
+
146
+ fireEvent.wheel(scroller, {shiftKey: true, deltaY: 120, deltaX: 0});
147
+
148
+ expect(scrollBy).toHaveBeenCalledWith({left: 120, behavior: 'auto'});
149
+ });
150
+
151
+ it('does not translate wheel without Shift held', () => {
152
+ render(
153
+ <Carousel aria-label="Wheel">
154
+ <div>Item 1</div>
155
+ <div>Item 2</div>
156
+ </Carousel>,
157
+ );
158
+ const scroller = getScroller();
159
+ makeOverflowing(scroller);
160
+ const scrollBy = vi.fn();
161
+ scroller.scrollBy = scrollBy;
162
+
163
+ fireEvent.wheel(scroller, {shiftKey: false, deltaY: 120, deltaX: 0});
164
+
165
+ expect(scrollBy).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('leaves native horizontal wheel deltas alone', () => {
169
+ // Trackpads emit deltaX directly — don't double-handle those.
170
+ render(
171
+ <Carousel aria-label="Wheel">
172
+ <div>Item 1</div>
173
+ <div>Item 2</div>
174
+ </Carousel>,
175
+ );
176
+ const scroller = getScroller();
177
+ makeOverflowing(scroller);
178
+ const scrollBy = vi.fn();
179
+ scroller.scrollBy = scrollBy;
180
+
181
+ fireEvent.wheel(scroller, {shiftKey: true, deltaY: 120, deltaX: 40});
182
+
183
+ expect(scrollBy).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it('does not intercept when there is no horizontal overflow', () => {
187
+ render(
188
+ <Carousel aria-label="Wheel">
189
+ <div>Item 1</div>
190
+ </Carousel>,
191
+ );
192
+ const scroller = getScroller();
193
+ Object.defineProperty(scroller, 'scrollWidth', {
194
+ value: 200,
195
+ configurable: true,
196
+ });
197
+ Object.defineProperty(scroller, 'clientWidth', {
198
+ value: 200,
199
+ configurable: true,
200
+ });
201
+ const scrollBy = vi.fn();
202
+ scroller.scrollBy = scrollBy;
203
+
204
+ fireEvent.wheel(scroller, {shiftKey: true, deltaY: 120, deltaX: 0});
205
+
206
+ expect(scrollBy).not.toHaveBeenCalled();
207
+ });
208
+ });
114
209
  });
@@ -7,8 +7,9 @@
7
7
  * @input Uses React, StyleX, useScrollOverflow, useLayer, Button, Icon, theme tokens
8
8
  * @output Exports Carousel component
9
9
  * @position Horizontal scroll container with fade-edge overflow indication,
10
- * optional prev/next buttons on the top layer, scroll-snap, and a 1px
11
- * visual bleed allowance for child selection indicators.
10
+ * optional prev/next buttons on the top layer, scroll-snap, a 1px
11
+ * visual bleed allowance for child selection indicators, and Shift + wheel
12
+ * mapping so mouse users can scroll horizontally.
12
13
  *
13
14
  * SYNC: When modified, update:
14
15
  * - /packages/core/src/Carousel/index.ts (exports)
@@ -290,6 +291,31 @@ export function Carousel({
290
291
  [scrollRef],
291
292
  );
292
293
 
294
+ // Map Shift + vertical wheel to horizontal scroll. Trackpads emit
295
+ // horizontal deltas natively, but a standard mouse only produces deltaY —
296
+ // so mouse users can't wheel-scroll a horizontal container. Shift + wheel
297
+ // is the long-established convention for horizontal scroll containers; we
298
+ // honor it by translating the vertical delta into a horizontal scroll.
299
+ //
300
+ // Only kicks in when Shift is held and the wheel is purely vertical
301
+ // (deltaX === 0), so native trackpad horizontal scrolling is untouched.
302
+ const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
303
+ if (!event.shiftKey || event.deltaY === 0 || event.deltaX !== 0) {
304
+ return;
305
+ }
306
+ const el = scrollElRef.current;
307
+ if (!el) {
308
+ return;
309
+ }
310
+ // Nothing to scroll horizontally — let the event fall through so the page
311
+ // can scroll as it normally would.
312
+ if (el.scrollWidth <= el.clientWidth) {
313
+ return;
314
+ }
315
+ event.preventDefault();
316
+ el.scrollBy({left: event.deltaY, behavior: 'auto'});
317
+ }, []);
318
+
293
319
  const scrollBy = useCallback((direction: -1 | 1) => {
294
320
  const el = scrollElRef.current;
295
321
  if (!el) {
@@ -346,6 +372,7 @@ export function Carousel({
346
372
  <div
347
373
  ref={composedRef}
348
374
  tabIndex={0}
375
+ onWheel={handleWheel}
349
376
  {...stylex.props(
350
377
  styles.scroller,
351
378
  gapStyles[gap],
@@ -80,7 +80,7 @@ export const docs = {
80
80
  { name: 'Scroll-to-bottom button', required: false, description: 'Appears when the user scrolls up or new messages arrive. Defaults to ChatLayoutScrollButton; pass null to hide or a custom element to override.' },
81
81
  { name: 'Composer', required: true, description: 'The input area for sending messages, typically ChatComposer. Docked at the bottom inside the frosted glass layer.' },
82
82
  { name: 'Empty state', required: false, description: 'Centered placeholder shown when no messages exist. Use EmptyState for a consistent look.' },
83
- { name: 'Avatar', required: false, description: 'A sender avatar rendered beside the message. Typically Avatar with size="small". Hidden for system messages.' },
83
+ { name: 'Avatar', required: false, description: 'A sender avatar rendered beside the message. Typically Avatar with size="md". Hidden for system messages.' },
84
84
  { name: 'Name', required: false, description: 'Sender name above the message body. Place on the bubble when using bubbles, or on the message wrapper for raw content.' },
85
85
  { name: 'Content', required: true, description: 'The message body: one or more ChatMessageBubble elements, or any free-form ReactNode like images or tool calls.' },
86
86
  { name: 'Metadata', required: false, description: 'Timestamp, delivery status, and footer actions below the message. Place on the last bubble or on the message wrapper.' },