@astryxdesign/core 0.4.0 → 0.4.1-canary.0ad35c3

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 (282) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +11 -9
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Chat/ChatMessage.d.ts +7 -0
  28. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  29. package/dist/Chat/ChatMessageBubble.d.ts +13 -1
  30. package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
  31. package/dist/Chat/ChatMessageBubble.js +19 -1
  32. package/dist/Code/Code.d.ts.map +1 -1
  33. package/dist/Code/Code.js +1 -2
  34. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  35. package/dist/CommandPalette/CommandPalette.js +5 -0
  36. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  37. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  38. package/dist/DateInput/DateInput.d.ts.map +1 -1
  39. package/dist/DateInput/DateInput.js +23 -10
  40. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  41. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  42. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  43. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  44. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  45. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  46. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  47. package/dist/Field/PanelSearchInput.d.ts +81 -0
  48. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  49. package/dist/Field/PanelSearchInput.js +151 -0
  50. package/dist/FileInput/FileInput.d.ts.map +1 -1
  51. package/dist/FileInput/FileInput.js +21 -10
  52. package/dist/Grid/Grid.d.ts.map +1 -1
  53. package/dist/Grid/Grid.js +1 -2
  54. package/dist/Grid/index.d.ts.map +1 -1
  55. package/dist/Grid/index.js +1 -2
  56. package/dist/HoverCard/HoverCard.d.ts +1 -1
  57. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  58. package/dist/HoverCard/HoverCard.js +5 -13
  59. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  60. package/dist/HoverCard/useHoverCard.js +6 -5
  61. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  62. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  63. package/dist/Icon/globalIconRegistry.js +5 -4
  64. package/dist/Indicator/index.d.ts +1 -1
  65. package/dist/Indicator/index.d.ts.map +1 -1
  66. package/dist/Indicator/types.d.ts +10 -0
  67. package/dist/Indicator/types.d.ts.map +1 -1
  68. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  69. package/dist/InputGroup/groupStyles.js +7 -2
  70. package/dist/Layer/layerHost.d.ts +24 -0
  71. package/dist/Layer/layerHost.d.ts.map +1 -0
  72. package/dist/Layer/layerHost.js +79 -0
  73. package/dist/Layer/useLayer.d.ts +14 -6
  74. package/dist/Layer/useLayer.d.ts.map +1 -1
  75. package/dist/Layer/useLayer.js +155 -29
  76. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  77. package/dist/Lightbox/Lightbox.js +24 -50
  78. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  79. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  80. package/dist/MultiSelector/MultiSelector.js +134 -102
  81. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  82. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  83. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  84. package/dist/Popover/usePopover.d.ts +15 -0
  85. package/dist/Popover/usePopover.d.ts.map +1 -1
  86. package/dist/Popover/usePopover.js +14 -2
  87. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  88. package/dist/ProgressBar/ProgressBar.js +2 -3
  89. package/dist/Section/Section.d.ts.map +1 -1
  90. package/dist/Section/Section.js +1 -2
  91. package/dist/Section/index.d.ts.map +1 -1
  92. package/dist/Section/index.js +1 -2
  93. package/dist/Selector/Selector.d.ts +10 -0
  94. package/dist/Selector/Selector.d.ts.map +1 -1
  95. package/dist/Selector/Selector.js +135 -95
  96. package/dist/SideNav/SideNav.d.ts +7 -9
  97. package/dist/SideNav/SideNav.d.ts.map +1 -1
  98. package/dist/SideNav/SideNav.js +32 -5
  99. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  100. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  101. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  102. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  103. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  104. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  105. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  106. package/dist/SideNav/SideNavHeading.js +70 -24
  107. package/dist/SideNav/SideNavItem.d.ts +7 -2
  108. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  109. package/dist/SideNav/SideNavItem.js +114 -75
  110. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  111. package/dist/SideNav/SideNavSection.js +7 -16
  112. package/dist/SideNav/index.d.ts +1 -1
  113. package/dist/SideNav/index.d.ts.map +1 -1
  114. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  115. package/dist/Skeleton/Skeleton.js +1 -2
  116. package/dist/Skeleton/index.d.ts.map +1 -1
  117. package/dist/Skeleton/index.js +1 -2
  118. package/dist/Slider/Slider.d.ts.map +1 -1
  119. package/dist/Slider/Slider.js +2 -6
  120. package/dist/Switch/Switch.d.ts.map +1 -1
  121. package/dist/Switch/Switch.js +3 -3
  122. package/dist/Table/TableRow.d.ts +1 -1
  123. package/dist/Table/TableRow.d.ts.map +1 -1
  124. package/dist/Table/TableRow.js +4 -2
  125. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  126. package/dist/TimeInput/TimeInput.js +7 -2
  127. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  128. package/dist/Timestamp/Timestamp.js +14 -3
  129. package/dist/Timestamp/formatInstant.d.ts +11 -1
  130. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  131. package/dist/Timestamp/formatInstant.js +3 -1
  132. package/dist/TopNav/TopNavMegaMenu.js +1 -1
  133. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  134. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  135. package/dist/TopNav/TopNavMenu.js +2 -2
  136. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  137. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  138. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  139. package/dist/VisuallyHidden/index.js +1 -2
  140. package/dist/astryx.css +23 -24
  141. package/dist/astryx.umd.js +47 -47
  142. package/dist/astryx.umd.js.map +4 -4
  143. package/dist/hooks/index.d.ts +1 -1
  144. package/dist/hooks/index.d.ts.map +1 -1
  145. package/dist/hooks/index.js +1 -1
  146. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  147. package/dist/hooks/useFocusTrap.js +16 -2
  148. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  149. package/dist/hooks/useInputStatusIcon.js +17 -4
  150. package/dist/theme/defineTheme.d.ts +2 -2
  151. package/dist/theme/defineTheme.d.ts.map +1 -1
  152. package/dist/theme/defineTheme.js +2 -1
  153. package/dist/theme/index.d.ts +1 -1
  154. package/dist/theme/index.d.ts.map +1 -1
  155. package/dist/theme/index.js +1 -1
  156. package/dist/theme/tokens.stylex.d.ts +12 -0
  157. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  158. package/dist/theme/tokens.stylex.js +23 -0
  159. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  160. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  161. package/dist/utils/focusOutline.stylex.js +46 -33
  162. package/dist/utils/index.d.ts +1 -0
  163. package/dist/utils/index.d.ts.map +1 -1
  164. package/dist/utils/index.js +5 -1
  165. package/dist/utils/interactionModality.d.ts +34 -0
  166. package/dist/utils/interactionModality.d.ts.map +1 -0
  167. package/dist/utils/interactionModality.js +70 -0
  168. package/locales/en.json +44 -0
  169. package/locales/pseudo.json +33 -0
  170. package/package.json +3 -3
  171. package/src/AppShell/AppShell.doc.mjs +3 -0
  172. package/src/AspectRatio/AspectRatio.tsx +0 -2
  173. package/src/AspectRatio/index.ts +0 -2
  174. package/src/Badge/Badge.tsx +0 -2
  175. package/src/Badge/index.ts +0 -2
  176. package/src/Banner/Banner.test.tsx +58 -1
  177. package/src/Banner/Banner.tsx +24 -5
  178. package/src/Blockquote/Blockquote.tsx +0 -2
  179. package/src/Blockquote/index.ts +0 -2
  180. package/src/Button/Button.doc.mjs +2 -2
  181. package/src/Button/Button.tsx +10 -7
  182. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  183. package/src/Card/Card.tsx +0 -2
  184. package/src/Card/index.ts +0 -2
  185. package/src/Center/Center.tsx +0 -2
  186. package/src/Center/index.ts +0 -2
  187. package/src/Chat/Chat.doc.mjs +4 -1
  188. package/src/Chat/ChatMessage.doc.mjs +3 -3
  189. package/src/Chat/ChatMessage.tsx +7 -0
  190. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  191. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  192. package/src/Chat/ChatMessageBubble.tsx +25 -0
  193. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  194. package/src/Code/Code.tsx +0 -2
  195. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  196. package/src/CommandPalette/CommandPalette.tsx +5 -0
  197. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  198. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  199. package/src/DateInput/DateInput.test.tsx +19 -0
  200. package/src/DateInput/DateInput.tsx +3 -6
  201. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  202. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  203. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  204. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  205. package/src/Field/PanelSearchInput.tsx +271 -0
  206. package/src/FileInput/FileInput.tsx +21 -8
  207. package/src/Grid/Grid.tsx +0 -2
  208. package/src/Grid/index.ts +0 -2
  209. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  210. package/src/HoverCard/HoverCard.test.tsx +283 -55
  211. package/src/HoverCard/HoverCard.tsx +5 -13
  212. package/src/HoverCard/useHoverCard.tsx +8 -11
  213. package/src/Icon/globalIconRegistry.tsx +5 -4
  214. package/src/Indicator/Indicator.doc.mjs +1 -1
  215. package/src/Indicator/index.ts +1 -0
  216. package/src/Indicator/types.ts +11 -0
  217. package/src/InputGroup/groupStyles.ts +7 -8
  218. package/src/Layer/layerHost.test.ts +99 -0
  219. package/src/Layer/layerHost.ts +141 -0
  220. package/src/Layer/useLayer.doc.mjs +14 -4
  221. package/src/Layer/useLayer.test.tsx +318 -7
  222. package/src/Layer/useLayer.tsx +229 -35
  223. package/src/Lightbox/Lightbox.tsx +2 -11
  224. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  225. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  226. package/src/MultiSelector/MultiSelector.tsx +170 -127
  227. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  228. package/src/Popover/Popover.doc.mjs +2 -0
  229. package/src/Popover/usePopover.tsx +37 -4
  230. package/src/ProgressBar/ProgressBar.tsx +2 -8
  231. package/src/Section/Section.tsx +0 -2
  232. package/src/Section/index.ts +0 -2
  233. package/src/Selector/Selector.doc.mjs +10 -0
  234. package/src/Selector/Selector.test.tsx +386 -2
  235. package/src/Selector/Selector.tsx +182 -122
  236. package/src/SideNav/SideNav.doc.mjs +13 -4
  237. package/src/SideNav/SideNav.test.tsx +771 -2
  238. package/src/SideNav/SideNav.tsx +37 -16
  239. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  240. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  241. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  242. package/src/SideNav/SideNavHeading.tsx +28 -10
  243. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  244. package/src/SideNav/SideNavItem.tsx +92 -84
  245. package/src/SideNav/SideNavSection.tsx +6 -20
  246. package/src/SideNav/index.ts +2 -0
  247. package/src/Skeleton/Skeleton.tsx +0 -2
  248. package/src/Skeleton/index.ts +0 -2
  249. package/src/Slider/Slider.tsx +2 -11
  250. package/src/Switch/Switch.tsx +10 -2
  251. package/src/Table/Table.test.tsx +31 -0
  252. package/src/Table/TableRow.tsx +6 -0
  253. package/src/Text/Text.doc.mjs +1 -1
  254. package/src/TimeInput/TimeInput.test.tsx +54 -0
  255. package/src/TimeInput/TimeInput.tsx +17 -2
  256. package/src/Timestamp/Timestamp.test.tsx +125 -30
  257. package/src/Timestamp/Timestamp.tsx +14 -2
  258. package/src/Timestamp/formatInstant.ts +16 -1
  259. package/src/Toolbar/Toolbar.test.tsx +14 -14
  260. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  261. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  262. package/src/TopNav/TopNavMegaMenu.tsx +4 -1
  263. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  264. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  265. package/src/TopNav/TopNavMenu.tsx +8 -2
  266. package/src/TreeList/TreeList.test.tsx +3 -2
  267. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  268. package/src/VisuallyHidden/index.ts +0 -2
  269. package/src/hooks/index.ts +5 -1
  270. package/src/hooks/useFocusTrap.test.tsx +16 -0
  271. package/src/hooks/useFocusTrap.ts +20 -2
  272. package/src/hooks/useInputStatusIcon.tsx +8 -8
  273. package/src/serverSafeComponents.test.ts +665 -0
  274. package/src/theme/defineTheme.ts +3 -0
  275. package/src/theme/generateThemeRules.test.ts +17 -0
  276. package/src/theme/index.ts +2 -0
  277. package/src/theme/themingTargets.test.ts +152 -26
  278. package/src/theme/tokens.stylex.ts +18 -0
  279. package/src/theme/tokens.test.ts +37 -0
  280. package/src/utils/focusOutline.stylex.ts +37 -23
  281. package/src/utils/index.ts +4 -0
  282. package/src/utils/interactionModality.ts +74 -0
@@ -45,6 +45,11 @@ export const docs = {
45
45
  type: "'first' | 'middle' | 'last'",
46
46
  description: 'Position within a multi-bubble group. Controls corner radius reduction on the sender side. Leave unset for standalone bubbles (full radius).',
47
47
  },
48
+ {
49
+ name: 'width',
50
+ type: 'SizeValue',
51
+ description: 'Width of the bubble (number = pixels, string = used as-is). When set, replaces the default max(80%, 280px) width cap. Combine with variant="ghost" to let custom content (an artifact card, attachments) span the full message column.',
52
+ },
48
53
  ],
49
54
  };
50
55
 
@@ -59,6 +64,7 @@ export const docsZh = {
59
64
  name: '气泡上方渲染的发送者名称,与气泡文本内边距对齐。用于消息中的第一个气泡。',
60
65
  metadata: '气泡下方渲染的元数据内容,与气泡文本内边距对齐。用于消息中的最后一个气泡。',
61
66
  group: '多气泡组中的位置。控制发送者侧的圆角缩减。',
67
+ width: '气泡宽度(数字 = 像素,字符串 = 按原样使用)。设置后替代默认的 max(80%, 280px) 宽度上限;与 ghost 变体组合可让自定义内容占满消息列宽度。',
62
68
  },
63
69
  };
64
70
 
@@ -74,5 +80,6 @@ export const docsDense = {
74
80
  displayName: 'sender name above bubble, aligned w/ bubble padding',
75
81
  metadata: 'metadata below bubble, aligned w/ bubble padding',
76
82
  group: 'position in multi-bubble group; controls corner radius reduction',
83
+ width: 'bubble width (number=px, string=as-is); replaces default max(80%,280px) cap; ghost+width="100%" spans full message column',
77
84
  },
78
85
  };
@@ -7,6 +7,16 @@ import {ChatMessageBubble} from './ChatMessageBubble';
7
7
  import {ChatMessageList} from './ChatMessageList';
8
8
  import {ChatMessageMetadata} from './ChatMessageMetadata';
9
9
 
10
+ /**
11
+ * Classes shared between two elements. StyleX emits one atomic class per
12
+ * style declaration, so two elements share a class exactly when they share
13
+ * a declaration — e.g. the same density paddingInline.
14
+ */
15
+ function sharedClasses(a: Element, b: Element): string[] {
16
+ const bClasses = new Set(Array.from(b.classList));
17
+ return Array.from(a.classList).filter(c => bClasses.has(c));
18
+ }
19
+
10
20
  describe('ChatMessageBubble', () => {
11
21
  it('renders children', () => {
12
22
  render(
@@ -29,9 +39,7 @@ describe('ChatMessageBubble', () => {
29
39
 
30
40
  it('defaults to assistant when no context', () => {
31
41
  render(
32
- <ChatMessageBubble data-testid="bubble">
33
- Standalone
34
- </ChatMessageBubble>,
42
+ <ChatMessageBubble data-testid="bubble">Standalone</ChatMessageBubble>,
35
43
  );
36
44
  const el = screen.getByTestId('bubble');
37
45
  expect(el.className).toContain('assistant');
@@ -41,9 +49,7 @@ describe('ChatMessageBubble', () => {
41
49
  render(
42
50
  <ChatMessageList density="compact">
43
51
  <ChatMessage sender="assistant">
44
- <ChatMessageBubble data-testid="bubble">
45
- Compact
46
- </ChatMessageBubble>
52
+ <ChatMessageBubble data-testid="bubble">Compact</ChatMessageBubble>
47
53
  </ChatMessage>
48
54
  </ChatMessageList>,
49
55
  );
@@ -59,6 +65,89 @@ describe('ChatMessageBubble', () => {
59
65
  );
60
66
  expect(screen.getByTestId('my-bubble')).toBeTruthy();
61
67
  });
68
+
69
+ it('ghost variant aligns custom content with the bubble text column (#2574)', () => {
70
+ // Repro from the issue: a raw child renders flush with the message
71
+ // edge — it carries none of the inset the bubble's name slot gets.
72
+ const {container} = render(
73
+ <ChatMessage sender="assistant">
74
+ <ChatMessageBubble name="Navi">Hello</ChatMessageBubble>
75
+ <div data-testid="raw">Artifact card</div>
76
+ <ChatMessageBubble variant="ghost" data-testid="ghost">
77
+ Artifact card
78
+ </ChatMessageBubble>
79
+ </ChatMessage>,
80
+ );
81
+ const nameSlot = container.querySelector('[data-chat-name]')!;
82
+ expect(nameSlot).toBeTruthy();
83
+
84
+ // Unwrapped custom content: no shared inset — the misalignment case.
85
+ expect(sharedClasses(screen.getByTestId('raw'), nameSlot)).toEqual([]);
86
+
87
+ // Ghost-wrapped content: shares the bubble slot's paddingInline
88
+ // declaration, so its text column matches the filled bubble exactly.
89
+ expect(
90
+ sharedClasses(screen.getByTestId('ghost'), nameSlot).length,
91
+ ).toBeGreaterThan(0);
92
+ });
93
+
94
+ it('ghost inset tracks message density', () => {
95
+ const renderAtDensity = (density: 'balanced' | 'spacious') => {
96
+ const {container} = render(
97
+ <ChatMessage sender="assistant" density={density}>
98
+ <ChatMessageBubble name="Navi">Hello</ChatMessageBubble>
99
+ <ChatMessageBubble variant="ghost" data-testid={`ghost-${density}`}>
100
+ Card
101
+ </ChatMessageBubble>
102
+ </ChatMessage>,
103
+ );
104
+ return sharedClasses(
105
+ screen.getByTestId(`ghost-${density}`),
106
+ container.querySelector('[data-chat-name]')!,
107
+ );
108
+ };
109
+
110
+ const balancedInset = renderAtDensity('balanced');
111
+ const spaciousInset = renderAtDensity('spacious');
112
+
113
+ // Both densities align with their own bubble's slot padding...
114
+ expect(balancedInset.length).toBeGreaterThan(0);
115
+ expect(spaciousInset.length).toBeGreaterThan(0);
116
+ // ...and spacious uses a wider inset than balanced.
117
+ expect(spaciousInset).not.toEqual(balancedInset);
118
+ });
119
+
120
+ it('width prop replaces the default width cap', () => {
121
+ render(
122
+ <ChatMessage sender="assistant">
123
+ <ChatMessageBubble data-testid="capped">Text</ChatMessageBubble>
124
+ <ChatMessageBubble variant="ghost" width="100%" data-testid="full">
125
+ Card
126
+ </ChatMessageBubble>
127
+ </ChatMessage>,
128
+ );
129
+ const capped = screen.getByTestId('capped');
130
+ const full = screen.getByTestId('full');
131
+
132
+ // Default bubbles keep the cap; a width bubble replaces it with none.
133
+ expect(getComputedStyle(capped).maxWidth).toMatch(/^max\(80%,\s*280px\)$/);
134
+ expect(getComputedStyle(full).maxWidth).toBe('none');
135
+ // The dynamic width value is set on the element (string passes through).
136
+ expect(full.getAttribute('style')).toContain('100%');
137
+ });
138
+
139
+ it('numeric width is treated as pixels', () => {
140
+ render(
141
+ <ChatMessage sender="assistant">
142
+ <ChatMessageBubble width={420} data-testid="fixed">
143
+ Artifact
144
+ </ChatMessageBubble>
145
+ </ChatMessage>,
146
+ );
147
+ expect(screen.getByTestId('fixed').getAttribute('style')).toContain(
148
+ '420px',
149
+ );
150
+ });
62
151
  });
63
152
 
64
153
  describe('ChatMessageMetadata', () => {
@@ -15,6 +15,9 @@
15
15
  * - If you use bubbles on one side (e.g. assistant), use them consistently
16
16
  * for all messages on that side. Use `ghost` variant for content that
17
17
  * needs alignment without a visual boundary.
18
+ * - For custom content (cards, attachments, citations) that should span
19
+ * the full message column instead of the default width cap, combine
20
+ * `ghost` with `width="100%"` (#2574).
18
21
  * - Put `name` on the first bubble in a message, `metadata` on the last.
19
22
  * - For unbubbled messages, use ChatMessage's `name` and `metadata`
20
23
  * props instead.
@@ -36,6 +39,7 @@ import {
36
39
  } from '../theme/tokens.stylex';
37
40
  import {useChatMessageContext} from './ChatContext';
38
41
  import {mergeProps} from '../utils';
42
+ import type {SizeValue} from '../utils/types';
39
43
  import type {BaseProps} from '../BaseProps';
40
44
  import {themeProps} from '../utils/themeProps';
41
45
 
@@ -83,6 +87,15 @@ export interface ChatMessageBubbleProps extends BaseProps<HTMLDivElement> {
83
87
  * Leave unset for standalone bubbles (full radius).
84
88
  */
85
89
  group?: 'first' | 'middle' | 'last';
90
+
91
+ /**
92
+ * Width of the bubble.
93
+ * Numbers are treated as pixels, strings are used as-is (e.g. "100%").
94
+ * When set, replaces the default `max(80%, 280px)` width cap; leave unset
95
+ * to keep the cap. Combine with `variant="ghost"` to let custom content
96
+ * (an artifact card, attachments) span the full message column.
97
+ */
98
+ width?: SizeValue;
86
99
  }
87
100
 
88
101
  // =============================================================================
@@ -189,6 +202,16 @@ const styles = stylex.create({
189
202
  },
190
203
  });
191
204
 
205
+ // Dynamic styles for sizing props
206
+ const dynamicStyles = stylex.create({
207
+ sizing: (width: SizeValue) => ({
208
+ width,
209
+ // An explicit width replaces the default cap — a full-column or
210
+ // fixed-width bubble shouldn't also be clamped by max(80%, 280px).
211
+ maxWidth: 'none',
212
+ }),
213
+ });
214
+
192
215
  // =============================================================================
193
216
  // Component
194
217
  // =============================================================================
@@ -216,6 +239,7 @@ export function ChatMessageBubble({
216
239
  name,
217
240
  metadata,
218
241
  group,
242
+ width,
219
243
  xstyle,
220
244
  className,
221
245
  style: styleProp,
@@ -292,6 +316,7 @@ export function ChatMessageBubble({
292
316
  paddingStyle,
293
317
  variant === 'ghost' && styles.paddingBlockNone,
294
318
  groupStyle,
319
+ width != null && dynamicStyles.sizing(width),
295
320
  xstyle,
296
321
  ),
297
322
  className,
@@ -17,6 +17,7 @@ import {Theme} from '../theme/Theme';
17
17
  import {defineTheme} from '../theme/defineTheme';
18
18
  import {getForcedColorsRules} from '../__tests__/forcedColors';
19
19
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
20
+ import {FOCUS_OUTLINE_PARTS} from '../utils/focusOutline.stylex';
20
21
 
21
22
  afterEach(() => {
22
23
  __resetLiveRegionsForTest();
@@ -686,7 +687,9 @@ describe('focus ring ownership (WCAG 2.4.7)', () => {
686
687
  <CheckboxInput label="Accept" value={false} onChange={() => {}} />,
687
688
  );
688
689
  focusInput(container);
689
- expect(indicatorOf(container).style.outlineStyle).toBe('solid');
690
+ expect(indicatorOf(container).style.outlineStyle).toBe(
691
+ FOCUS_OUTLINE_PARTS.outlineStyle,
692
+ );
690
693
  });
691
694
 
692
695
  it('paints it on a replacement that forwards nothing', () => {
@@ -701,7 +704,7 @@ describe('focus ring ownership (WCAG 2.4.7)', () => {
701
704
  expect(replaced.className).toBe('');
702
705
  // ...and it still gets a ring, because the owner drew it.
703
706
  focusInput(container);
704
- expect(replaced.style.outlineStyle).toBe('solid');
707
+ expect(replaced.style.outlineStyle).toBe(FOCUS_OUTLINE_PARTS.outlineStyle);
705
708
  });
706
709
 
707
710
  it('clears the ring on blur', () => {
@@ -709,7 +712,9 @@ describe('focus ring ownership (WCAG 2.4.7)', () => {
709
712
  <CheckboxInput label="Accept" value={false} onChange={() => {}} />,
710
713
  );
711
714
  const input = focusInput(container);
712
- expect(indicatorOf(container).style.outlineStyle).toBe('solid');
715
+ expect(indicatorOf(container).style.outlineStyle).toBe(
716
+ FOCUS_OUTLINE_PARTS.outlineStyle,
717
+ );
713
718
  fireEvent.blur(input);
714
719
  expect(indicatorOf(container).style.outlineStyle).toBe('');
715
720
  });
package/src/Code/Code.tsx CHANGED
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Code.tsx
7
5
  * @input Uses React, StyleX, theme tokens
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {describe, it, expect, vi, beforeEach} from 'vitest';
11
11
  import {render, screen, waitFor, fireEvent, act} from '@testing-library/react';
12
+ import {useState} from 'react';
12
13
  import {CommandPalette} from './CommandPalette';
13
14
  import {createStaticSource} from '@astryxdesign/core/Typeahead';
14
15
  import type {SearchSource, SearchableItem} from '@astryxdesign/core/Typeahead';
@@ -274,6 +275,59 @@ describe('CommandPalette', () => {
274
275
  );
275
276
  });
276
277
 
278
+ it('discards a search response that resolves after the palette closed', async () => {
279
+ // A source whose search resolves only when released, and no cancel()
280
+ // implementation — closing the palette must invalidate the request itself.
281
+ const resolvers: ((items: SearchableItem[]) => void)[] = [];
282
+ const source: SearchSource = {
283
+ bootstrap: () => [],
284
+ async search(): Promise<SearchableItem[]> {
285
+ return new Promise<SearchableItem[]>(resolve => {
286
+ resolvers.push(resolve);
287
+ });
288
+ },
289
+ };
290
+
291
+ function Harness() {
292
+ const [isOpen, setIsOpen] = useState(true);
293
+ return (
294
+ <CommandPalette
295
+ isOpen={isOpen}
296
+ onOpenChange={setIsOpen}
297
+ searchSource={source}
298
+ />
299
+ );
300
+ }
301
+ render(<Harness />);
302
+
303
+ const input = screen.getByRole('combobox');
304
+ fireEvent.change(input, {target: {value: 'a'}});
305
+ await waitFor(() => expect(resolvers).toHaveLength(1));
306
+
307
+ // Close while the search is still in flight.
308
+ const dialog = screen.getByRole('dialog');
309
+ const escapeEvent = new KeyboardEvent('keydown', {
310
+ key: 'Escape',
311
+ bubbles: true,
312
+ cancelable: true,
313
+ });
314
+ dialog.dispatchEvent(escapeEvent);
315
+ await waitFor(() =>
316
+ expect(screen.getByRole('dialog', {hidden: true})).not.toHaveAttribute(
317
+ 'open',
318
+ ),
319
+ );
320
+
321
+ // The stale response arrives after close — it must not re-commit the
322
+ // abandoned query/results into the closed palette.
323
+ await act(async () => {
324
+ resolvers[0]([{id: 'stale', label: 'Stale item'}]);
325
+ });
326
+
327
+ expect(screen.getByRole('combobox', {hidden: true})).toHaveValue('');
328
+ expect(screen.queryByText('Stale item')).not.toBeInTheDocument();
329
+ });
330
+
277
331
  it('does not move the highlight while typing in the search input', async () => {
278
332
  // The palette's input already filters; letting the shared combobox
279
333
  // typeahead also chase prefixes would drag the highlight — and Enter —
@@ -319,6 +319,11 @@ export function CommandPalette<T extends SearchableItem = SearchableItem>({
319
319
  );
320
320
 
321
321
  const handleClose = useCallback(() => {
322
+ // Invalidate any in-flight search. Most sources don't implement cancel(),
323
+ // and a response that resolves after close would still pass runSearch's
324
+ // version check and re-commit the stale query/results into the closed
325
+ // palette (visible as a ghost query on reopen while bootstrap is pending).
326
+ searchVersionRef.current++;
322
327
  // Reset both committed and optimistic search on close
323
328
  setSearch('');
324
329
  setSearchResults([]);
@@ -177,7 +177,7 @@ describe('ComplexSelector', () => {
177
177
  });
178
178
 
179
179
  describe('ComplexSelector popup theme target', () => {
180
- it('puts astryx-complex-selector-popup on the content box inside the layer', async () => {
180
+ it('puts astryx-complex-selector-popup on the surface that paints, not the content box', async () => {
181
181
  const user = userEvent.setup();
182
182
  render(
183
183
  <ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple">
@@ -186,18 +186,27 @@ describe('ComplexSelector popup theme target', () => {
186
186
  );
187
187
  await user.click(screen.getByRole('button', {name: 'Fruit blend'}));
188
188
 
189
- const layer = document.querySelector('[popover]') as HTMLElement;
190
189
  const popup = document.querySelector(
191
190
  '.astryx-complex-selector-popup',
192
191
  ) as HTMLElement;
193
192
  expect(popup).not.toBeNull();
194
- // The layer is a bare positioning box; the target must be the content box
195
- // it contains, which is what actually paints.
196
- expect(popup).not.toBe(layer);
197
- expect(layer.contains(popup)).toBe(true);
193
+
194
+ // The surface is the element usePopover renders: it carries the dialog
195
+ // role and the shared surface class, and the component's content box —
196
+ // the one with the padding and the scroll — sits INSIDE it. A target on
197
+ // that inner box cannot paint the popup's background or radius, which is
198
+ // what a theme reaches for this class to do.
199
+ expect(popup).toHaveAttribute('role', 'dialog');
200
+ expect(popup).toHaveClass('astryx-popover-surface');
201
+ expect(popup.querySelector('[id]')).not.toBeNull();
198
202
  expect(popup).toContainElement(
199
203
  screen.getByRole('button', {name: 'Done', ...h}),
200
204
  );
205
+
206
+ // And it is not the bare positioning layer either.
207
+ const layer = document.querySelector('[popover]') as HTMLElement;
208
+ expect(popup).not.toBe(layer);
209
+ expect(layer.contains(popup)).toBe(true);
201
210
  });
202
211
 
203
212
  it('keeps the target when the consumer also passes contentXstyle', async () => {
@@ -292,6 +292,11 @@ export function ComplexSelector<Value>({
292
292
  dialogLabel: label,
293
293
  hasCloseButton: false,
294
294
  hasAutoFocus: true,
295
+ // The popup's theme target belongs on the SURFACE — the element painting
296
+ // background, radius and elevation — which `usePopover` owns. Rendered on
297
+ // the content box below it, a theme's background or radius rule paints the
298
+ // wrong box.
299
+ surfaceTarget: 'complex-selector-popup',
295
300
  onHide: () => {
296
301
  document.getElementById(triggerId)?.focus();
297
302
  },
@@ -313,16 +318,7 @@ export function ComplexSelector<Value>({
313
318
  const triggerContent = triggerLabel ?? placeholder;
314
319
 
315
320
  const content = (
316
- // The theme target sits here, not on the layer: the layer element is a
317
- // bare positioning box (useLayer zeroes its borders, padding and
318
- // background), so this content box is the surface a theme has to reach to
319
- // paint the popup.
320
- <div
321
- id={contentId}
322
- {...mergeProps(
323
- themeProps('complex-selector-popup'),
324
- stylex.props(styles.content, contentXstyle),
325
- )}>
321
+ <div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
326
322
  {children(optimisticValue, commitValue, popover.hide, {
327
323
  isOpen: popover.isOpen,
328
324
  isBusy,
@@ -25,6 +25,7 @@ import {InputGroup} from '../InputGroup';
25
25
  import {InputGroupText} from '../InputGroup/InputGroupText';
26
26
  import {defineTheme} from '../theme/defineTheme';
27
27
  import {generateThemeCSS} from '../theme/generateThemeRules';
28
+ import {InternationalizationProvider} from '../i18n';
28
29
 
29
30
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
30
31
  const {prose, component} = generateThemeCSS(theme);
@@ -170,6 +171,24 @@ describe('DateInput', () => {
170
171
  expect(screen.queryByText('Invalid date')).not.toBeInTheDocument();
171
172
  });
172
173
 
174
+ it('resolves the invalid-date announcement from the i18n catalog', () => {
175
+ const {container} = render(
176
+ <InternationalizationProvider
177
+ locale="en"
178
+ overrides={{en: {'@astryx.dateInput.invalidDate': 'Ungültiges Datum'}}}>
179
+ <DateInput label="Date" onChange={() => {}} />
180
+ </InternationalizationProvider>,
181
+ );
182
+
183
+ fireEvent.change(screen.getByRole('combobox'), {
184
+ target: {value: '13/45/2024'},
185
+ });
186
+
187
+ expect(within(container).getByRole('alert')).toHaveTextContent(
188
+ 'Ungültiges Datum',
189
+ );
190
+ });
191
+
173
192
  it('reverts to previous value on blur when input is invalid', async () => {
174
193
  const onChange = vi.fn();
175
194
  render(<DateInput label="Date" value="2026-01-25" onChange={onChange} />);
@@ -80,11 +80,6 @@ const styles = stylex.create({
80
80
  backgroundColor: 'transparent',
81
81
  cursor: 'pointer',
82
82
  borderRadius: radiusVars['--radius-element'],
83
- outline: {
84
- default: 'none',
85
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
86
- },
87
- outlineOffset: 1,
88
83
  },
89
84
  iconButtonDisabled: {
90
85
  cursor: 'not-allowed',
@@ -163,6 +158,7 @@ import {mergeProps, mergeRefs, isFocusDetached} from '../utils';
163
158
  import type {BaseProps} from '../BaseProps';
164
159
  import type {SizeValue} from '../utils/types';
165
160
  import {themeProps} from '../utils/themeProps';
161
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
166
162
  import {stableClassName} from '../naming';
167
163
  import {useTranslator} from '../i18n';
168
164
 
@@ -694,6 +690,7 @@ export function DateInput({
694
690
  : t('@astryx.dateInput.openCalendar')
695
691
  }
696
692
  {...stylex.props(
693
+ focusOutlineStyles.focusVisible,
697
694
  styles.iconButton,
698
695
  isEffectivelyDisabled && styles.iconButtonDisabled,
699
696
  )}>
@@ -754,7 +751,7 @@ export function DateInput({
754
751
  user would get no feedback that their entry was rejected (WCAG 3.3.1).
755
752
  */}
756
753
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
757
- {!isInputValid ? 'Invalid date' : ''}
754
+ {!isInputValid ? t('@astryx.dateInput.invalidDate') : ''}
758
755
  </VisuallyHidden>
759
756
  {hasClear && value !== undefined && !isEffectivelyDisabled && (
760
757
  <InputClearButton
@@ -61,6 +61,7 @@ import type {SizeValue} from '../utils/types';
61
61
  import {useSize} from '../SizeContext/SizeContext';
62
62
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
63
63
  import {themeProps} from '../utils/themeProps';
64
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
64
65
  import {stableClassName} from '../naming';
65
66
  import {useTranslator} from '../i18n';
66
67
 
@@ -118,11 +119,6 @@ const styles = stylex.create({
118
119
  backgroundColor: 'transparent',
119
120
  cursor: 'pointer',
120
121
  borderRadius: radiusVars['--radius-element'],
121
- outline: {
122
- default: 'none',
123
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
124
- },
125
- outlineOffset: 1,
126
122
  },
127
123
  iconButtonDisabled: {
128
124
  cursor: 'not-allowed',
@@ -160,10 +156,6 @@ const styles = stylex.create({
160
156
  color: colorVars['--color-text-primary'],
161
157
  cursor: 'pointer',
162
158
  textAlign: 'start',
163
- outline: {
164
- default: 'none',
165
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
166
- },
167
159
  },
168
160
  presetButtonActive: {
169
161
  backgroundColor: colorVars['--color-accent-muted'],
@@ -592,6 +584,7 @@ export function DateRangeInput({
592
584
  }
593
585
  tabIndex={-1}
594
586
  {...stylex.props(
587
+ focusOutlineStyles.focusVisible,
595
588
  styles.iconButton,
596
589
  isEffectivelyDisabled && styles.iconButtonDisabled,
597
590
  )}>
@@ -661,6 +654,7 @@ export function DateRangeInput({
661
654
  aria-current={isActive ? 'true' : undefined}
662
655
  onClick={() => handlePresetClick(preset)}
663
656
  {...stylex.props(
657
+ focusOutlineStyles.focusVisible,
664
658
  styles.presetButton,
665
659
  isActive && styles.presetButtonActive,
666
660
  )}>
@@ -9,7 +9,7 @@
9
9
  * SYNC: When DateTimeInput.tsx changes, update tests to match new behavior
10
10
  */
11
11
 
12
- import {describe, it, expect, vi, beforeEach} from 'vitest';
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
13
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {getButton, queryButton} from '../__tests__/fastRoleQueries';
@@ -17,6 +17,16 @@ import {DateTimeInput} from './DateTimeInput';
17
17
  import type {ISODateTimeString} from './DateTimeInput';
18
18
  import {defineTheme} from '../theme/defineTheme';
19
19
  import {generateThemeCSS} from '../theme/generateThemeRules';
20
+ import {InternationalizationProvider} from '../i18n';
21
+ import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
22
+
23
+ function politeRegion(): HTMLElement | null {
24
+ return document.querySelector('[data-astryx-live-region="polite"]');
25
+ }
26
+
27
+ afterEach(() => {
28
+ __resetLiveRegionsForTest();
29
+ });
20
30
 
21
31
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
22
32
  const {prose, component} = generateThemeCSS(theme);
@@ -626,6 +636,35 @@ describe('DateTimeInput', () => {
626
636
 
627
637
  expect(screen.getByText('Invalid time')).toBeInTheDocument();
628
638
  });
639
+
640
+ it('resolves the invalid date and time announcements from the i18n catalog', () => {
641
+ render(
642
+ <InternationalizationProvider
643
+ locale="en"
644
+ overrides={{
645
+ en: {
646
+ '@astryx.dateInput.invalidDate': 'Ungültiges Datum',
647
+ '@astryx.timeInput.invalidTime': 'Ungültige Zeit',
648
+ },
649
+ }}>
650
+ <DateTimeInput
651
+ label="Meeting"
652
+ value={'2026-03-15T10:00' as ISODateTimeString}
653
+ onChange={() => {}}
654
+ />
655
+ </InternationalizationProvider>,
656
+ );
657
+
658
+ fireEvent.change(screen.getByRole('combobox'), {
659
+ target: {value: '13/45/2024'},
660
+ });
661
+ expect(screen.getByText('Ungültiges Datum')).toBeInTheDocument();
662
+
663
+ fireEvent.change(screen.getByLabelText('Meeting time'), {
664
+ target: {value: '99:99 zz'},
665
+ });
666
+ expect(screen.getByText('Ungültige Zeit')).toBeInTheDocument();
667
+ });
629
668
  });
630
669
  describe('disabledMessage', () => {
631
670
  // jsdom does not implement the Popover API used by the tooltip, so mock
@@ -819,6 +858,45 @@ describe('DateTimeInput', () => {
819
858
  expect(onChange).toHaveBeenCalledTimes(1);
820
859
  expect(onChange.mock.calls[0][0]).toContain('14:31');
821
860
  });
861
+
862
+ // Arrow-key stepping mutates a plain textbox programmatically, and screen
863
+ // readers do not announce programmatic textbox changes — the new value
864
+ // must be announced through the polite live region (WCAG 4.1.2).
865
+ it('politely announces the new time after ArrowUp stepping', async () => {
866
+ render(
867
+ <DateTimeInput
868
+ label="Meeting"
869
+ value={'2026-03-15T14:30' as ISODateTimeString}
870
+ onChange={() => {}}
871
+ />,
872
+ );
873
+
874
+ fireEvent.keyDown(screen.getByLabelText('Meeting time'), {
875
+ key: 'ArrowUp',
876
+ });
877
+
878
+ await waitFor(() => {
879
+ expect(politeRegion()).toHaveTextContent('2:31 PM');
880
+ });
881
+ });
882
+
883
+ it('politely announces the new time after ArrowDown stepping', async () => {
884
+ render(
885
+ <DateTimeInput
886
+ label="Meeting"
887
+ value={'2026-03-15T14:30' as ISODateTimeString}
888
+ onChange={() => {}}
889
+ />,
890
+ );
891
+
892
+ fireEvent.keyDown(screen.getByLabelText('Meeting time'), {
893
+ key: 'ArrowDown',
894
+ });
895
+
896
+ await waitFor(() => {
897
+ expect(politeRegion()).toHaveTextContent('2:29 PM');
898
+ });
899
+ });
822
900
  });
823
901
 
824
902
  describe('weekStartsOn', () => {