@astryxdesign/core 0.4.2 → 0.4.3-canary.07f9998

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 (203) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +79 -45
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +3 -19
  5. package/dist/Banner/Banner.d.ts +9 -2
  6. package/dist/Banner/Banner.d.ts.map +1 -1
  7. package/dist/Banner/Banner.js +84 -33
  8. package/dist/Banner/index.d.ts +14 -12
  9. package/dist/Banner/index.d.ts.map +1 -1
  10. package/dist/Banner/index.js +10 -8
  11. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  12. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  13. package/dist/BottomSheet/BottomSheet.js +381 -0
  14. package/dist/BottomSheet/BottomSheetPanel.d.ts +57 -0
  15. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  16. package/dist/BottomSheet/BottomSheetPanel.js +395 -0
  17. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  18. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  19. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  20. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  21. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  22. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  23. package/dist/BottomSheet/index.d.ts +11 -0
  24. package/dist/BottomSheet/index.d.ts.map +1 -0
  25. package/dist/BottomSheet/index.js +12 -0
  26. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  27. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  28. package/dist/BottomSheet/snapOffsets.js +130 -0
  29. package/dist/BottomSheet/useMobileKeyboard.d.ts +25 -0
  30. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  31. package/dist/BottomSheet/useMobileKeyboard.js +384 -0
  32. package/dist/BottomSheet/useSheetGestures.d.ts +170 -0
  33. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  34. package/dist/BottomSheet/useSheetGestures.js +881 -0
  35. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  36. package/dist/Chat/ChatComposerInput.js +3 -4
  37. package/dist/Chat/ChatTokenizedText.js +1 -1
  38. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  39. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  40. package/dist/ComplexSelector/ComplexSelector.js +98 -31
  41. package/dist/ComplexSelector/index.d.ts +2 -2
  42. package/dist/ComplexSelector/index.d.ts.map +1 -1
  43. package/dist/ComplexSelector/index.js +1 -1
  44. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  45. package/dist/ContextMenu/ContextMenu.js +4 -2
  46. package/dist/DateInput/DateInput.d.ts.map +1 -1
  47. package/dist/DateInput/DateInput.js +9 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  50. package/dist/Dialog/Dialog.d.ts.map +1 -1
  51. package/dist/Dialog/Dialog.js +2 -1
  52. package/dist/Markdown/parser.d.ts.map +1 -1
  53. package/dist/Markdown/parser.js +55 -12
  54. package/dist/MobileNav/MobileNav.d.ts +12 -0
  55. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  56. package/dist/MobileNav/MobileNav.js +102 -30
  57. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  58. package/dist/MultiSelector/MultiSelector.js +10 -1
  59. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  60. package/dist/NumberInput/NumberInput.js +9 -1
  61. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  62. package/dist/PowerSearch/PowerSearch.js +4 -1
  63. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  64. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  65. package/dist/PowerSearch/formatFilterValue.js +2 -4
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +10 -1
  68. package/dist/Switch/Switch.d.ts.map +1 -1
  69. package/dist/Switch/Switch.js +5 -1
  70. package/dist/Table/columnUtils.d.ts.map +1 -1
  71. package/dist/Table/columnUtils.js +4 -1
  72. package/dist/TextArea/TextArea.d.ts +6 -3
  73. package/dist/TextArea/TextArea.d.ts.map +1 -1
  74. package/dist/TextArea/TextArea.js +17 -6
  75. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  76. package/dist/TimeInput/TimeInput.js +8 -1
  77. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  78. package/dist/Tooltip/useTooltip.js +4 -1
  79. package/dist/TreeList/TreeList.js +2 -1
  80. package/dist/Typeahead/BaseTypeahead.js +1 -1
  81. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  82. package/dist/Typeahead/Typeahead.js +10 -1
  83. package/dist/astryx.css +25 -2
  84. package/dist/hooks/index.d.ts +8 -1
  85. package/dist/hooks/index.d.ts.map +1 -1
  86. package/dist/hooks/index.js +8 -1
  87. package/dist/hooks/useFocusTrap.d.ts +0 -11
  88. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  89. package/dist/hooks/useFocusTrap.js +1 -11
  90. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  91. package/dist/hooks/useInputStatusIcon.js +1 -0
  92. package/dist/index.d.ts +1 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +1 -0
  95. package/dist/theme/defineTheme.d.ts +30 -7
  96. package/dist/theme/defineTheme.d.ts.map +1 -1
  97. package/dist/theme/defineTheme.js +36 -47
  98. package/dist/theme/expandColorScale.d.ts +17 -2
  99. package/dist/theme/expandColorScale.d.ts.map +1 -1
  100. package/dist/theme/expandColorScale.js +63 -33
  101. package/dist/theme/mergeComponents.d.ts +20 -0
  102. package/dist/theme/mergeComponents.d.ts.map +1 -0
  103. package/dist/theme/mergeComponents.js +56 -0
  104. package/dist/theme/onMediaTokens.d.ts +6 -1
  105. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  106. package/dist/theme/onMediaTokens.js +11 -3
  107. package/dist/utils/characters.d.ts +27 -0
  108. package/dist/utils/characters.d.ts.map +1 -0
  109. package/dist/utils/characters.js +83 -0
  110. package/dist/utils/ime.d.ts +5 -0
  111. package/dist/utils/ime.d.ts.map +1 -0
  112. package/dist/utils/ime.js +63 -0
  113. package/dist/utils/index.d.ts +2 -0
  114. package/dist/utils/index.d.ts.map +1 -1
  115. package/dist/utils/index.js +3 -1
  116. package/package.json +9 -8
  117. package/src/Avatar/Avatar.test.tsx +6 -1
  118. package/src/Avatar/Avatar.tsx +3 -21
  119. package/src/Banner/Banner.doc.mjs +12 -10
  120. package/src/Banner/Banner.test.tsx +108 -0
  121. package/src/Banner/Banner.tsx +124 -38
  122. package/src/Banner/index.ts +15 -13
  123. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  124. package/src/BottomSheet/BottomSheet.test.tsx +1865 -0
  125. package/src/BottomSheet/BottomSheet.tsx +539 -0
  126. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  127. package/src/BottomSheet/BottomSheetPanel.tsx +542 -0
  128. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  129. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  130. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  131. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  132. package/src/BottomSheet/index.ts +15 -0
  133. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  134. package/src/BottomSheet/snapOffsets.ts +159 -0
  135. package/src/BottomSheet/useMobileKeyboard.ts +566 -0
  136. package/src/BottomSheet/useSheetGestures.test.ts +833 -0
  137. package/src/BottomSheet/useSheetGestures.ts +1333 -0
  138. package/src/Chat/ChatComposerInput.tsx +3 -4
  139. package/src/Chat/ChatTokenizedText.tsx +1 -1
  140. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  141. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  142. package/src/ComplexSelector/ComplexSelector.tsx +156 -28
  143. package/src/ComplexSelector/index.ts +3 -1
  144. package/src/ContextMenu/ContextMenu.tsx +4 -2
  145. package/src/DateInput/DateInput.test.tsx +21 -0
  146. package/src/DateInput/DateInput.tsx +9 -1
  147. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  148. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  149. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  150. package/src/Dialog/Dialog.tsx +2 -1
  151. package/src/Markdown/parser.test.ts +53 -0
  152. package/src/Markdown/parser.ts +53 -12
  153. package/src/MobileNav/MobileNav.tsx +128 -37
  154. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  155. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  156. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  157. package/src/MultiSelector/MultiSelector.test.tsx +31 -0
  158. package/src/MultiSelector/MultiSelector.tsx +10 -1
  159. package/src/NumberInput/NumberInput.test.tsx +18 -0
  160. package/src/NumberInput/NumberInput.tsx +9 -1
  161. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  162. package/src/PowerSearch/PowerSearch.tsx +4 -1
  163. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  164. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  165. package/src/PowerSearch/formatFilterValue.ts +2 -4
  166. package/src/Selector/Selector.test.tsx +33 -0
  167. package/src/Selector/Selector.tsx +10 -1
  168. package/src/Switch/Switch.test.tsx +16 -0
  169. package/src/Switch/Switch.tsx +7 -0
  170. package/src/Table/Table.test.tsx +6 -0
  171. package/src/Table/columnUtils.ts +3 -1
  172. package/src/TextArea/TextArea.doc.mjs +1 -1
  173. package/src/TextArea/TextArea.test.tsx +72 -0
  174. package/src/TextArea/TextArea.tsx +26 -8
  175. package/src/TimeInput/TimeInput.test.tsx +23 -0
  176. package/src/TimeInput/TimeInput.tsx +8 -0
  177. package/src/Tooltip/useTooltip.tsx +4 -1
  178. package/src/TreeList/TreeList.doc.mjs +2 -2
  179. package/src/TreeList/TreeList.tsx +1 -1
  180. package/src/Typeahead/BaseTypeahead.tsx +1 -1
  181. package/src/Typeahead/Typeahead.test.tsx +35 -0
  182. package/src/Typeahead/Typeahead.tsx +10 -1
  183. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  184. package/src/__tests__/stubMatchMedia.ts +95 -0
  185. package/src/hooks/index.ts +9 -5
  186. package/src/hooks/useFocusTrap.ts +1 -14
  187. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  188. package/src/hooks/useInputStatusIcon.tsx +7 -0
  189. package/src/index.ts +1 -0
  190. package/src/theme/defineTheme.test.ts +127 -0
  191. package/src/theme/defineTheme.ts +70 -55
  192. package/src/theme/expandColorScale.test.ts +138 -0
  193. package/src/theme/expandColorScale.ts +89 -44
  194. package/src/theme/extensibleAxes.test.ts +365 -0
  195. package/src/theme/mergeComponents.ts +59 -0
  196. package/src/theme/onMediaTokens.ts +9 -2
  197. package/src/utils/characters.test.ts +141 -0
  198. package/src/utils/characters.ts +88 -0
  199. package/src/utils/ime.test.ts +32 -0
  200. package/src/utils/ime.ts +69 -0
  201. package/src/utils/index.ts +4 -0
  202. package/dist/astryx.umd.js +0 -147
  203. package/dist/astryx.umd.js.map +0 -7
@@ -43,7 +43,7 @@ import {
43
43
  typeScaleVars,
44
44
  typographyVars,
45
45
  } from '../theme/tokens.stylex';
46
- import {mergeProps} from '../utils';
46
+ import {mergeProps, isImeKeyEvent} from '../utils';
47
47
  import {useTriggerMenu} from './useTriggerMenu';
48
48
  import {useChatComposerTokens, isCustomToken} from './useChatComposerTokens';
49
49
  import {ensureCaretInside, insertTextAtCursor} from './chatComposerSelection';
@@ -559,9 +559,8 @@ export function ChatComposerInput(props: ChatComposerInputProps) {
559
559
 
560
560
  if (e.key === 'Enter' && !e.shiftKey) {
561
561
  // Never submit mid-composition — an IME uses Enter to commit a
562
- // candidate (e.g. Japanese/Chinese/Korean input), and browsers may
563
- // also surface the legacy keyCode 229 for composing keystrokes.
564
- if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) {
562
+ // candidate. See utils/ime.ts for the full rationale.
563
+ if (isImeKeyEvent(e.nativeEvent)) {
565
564
  return;
566
565
  }
567
566
 
@@ -74,7 +74,7 @@ function isCustomToken(
74
74
  }
75
75
 
76
76
  function escapeRegExp(str: string): string {
77
- return str.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
77
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
78
78
  }
79
79
 
80
80
  // =============================================================================
@@ -1,5 +1,14 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
+ /**
4
+ * @file ComplexSelector.doc.mjs
5
+ * @input ComplexSelector public API and composition contract
6
+ * @output Exports full and dense component documentation
7
+ * @position Core documentation consumed by CLI and Storybook autodocs
8
+ *
9
+ * SYNC: When modified, update ComplexSelector.tsx, tests, and stories.
10
+ */
11
+
3
12
  /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
4
13
 
5
14
  export const docs = {
@@ -19,7 +28,10 @@ export const docs = {
19
28
  ],
20
29
  theming: {
21
30
  targets: [
22
- {className: 'astryx-complex-selector', visualProps: ['size', 'status']},
31
+ {
32
+ className: 'astryx-complex-selector',
33
+ visualProps: ['variant', 'size', 'status'],
34
+ },
23
35
  {
24
36
  className: 'astryx-complex-selector-indicator-icon',
25
37
  states: ['state'],
@@ -32,7 +44,7 @@ export const docs = {
32
44
  name: 'ComplexSelector',
33
45
  displayName: 'Complex Selector',
34
46
  description:
35
- 'A field and dialog-popover shell for custom selector content.',
47
+ 'An input or toolbar trigger and dialog-popover shell for custom selector content.',
36
48
  props: [
37
49
  {
38
50
  name: 'label',
@@ -93,9 +105,21 @@ export const docs = {
93
105
  {
94
106
  name: 'size',
95
107
  type: "'sm' | 'md' | 'lg'",
96
- description: 'Trigger and field size.',
108
+ description: 'Exact trigger height: sm 28px, md 32px, or lg 36px.',
97
109
  default: "'md'",
98
110
  },
111
+ {
112
+ name: 'variant',
113
+ type: "'input' | 'ghost'",
114
+ description:
115
+ 'Visual trigger style. Input is the bordered form treatment; ghost matches toolbar buttons.',
116
+ default: "'input'",
117
+ },
118
+ {
119
+ name: 'startIcon',
120
+ type: 'ReactNode | IconType',
121
+ description: 'Icon displayed at the start of the trigger.',
122
+ },
99
123
  {
100
124
  name: 'width',
101
125
  type: 'SizeValue',
@@ -107,6 +131,18 @@ export const docs = {
107
131
  description: 'Popup placement.',
108
132
  default: "'below'",
109
133
  },
134
+ {
135
+ name: 'alignment',
136
+ type: "'start' | 'center' | 'end'",
137
+ description: 'Popup alignment along the placement axis.',
138
+ default: "'start'",
139
+ },
140
+ {
141
+ name: 'handleRef',
142
+ type: 'React.Ref<ComplexSelectorHandle>',
143
+ description:
144
+ 'Imperative handle for programmatic control. Exposes open(), close(), toggle(), and isOpen().',
145
+ },
110
146
  {
111
147
  name: 'contentXstyle',
112
148
  type: 'StyleXStyles',
@@ -119,6 +155,16 @@ export const docs = {
119
155
  description:
120
156
  'Use ComplexSelector when a selection needs richer custom content than a Selector option row. It is intentionally one component: ComplexSelector owns the field, trigger, popover, focus restore, and changeAction flow, while the content render prop owns the selector-specific accessible structure.',
121
157
  bestPractices: [
158
+ {
159
+ guidance: true,
160
+ description:
161
+ 'Use variant="ghost" with a startIcon when the selector is triggered from a toolbar. Use alignment="end" when a wide surface should align its end edge to the trigger.',
162
+ },
163
+ {
164
+ guidance: true,
165
+ description:
166
+ 'For staged editors, keep draft state in the composed content and call the provided onChange helper only from Apply. Cancel or dismiss without committing.',
167
+ },
122
168
  {
123
169
  guidance: true,
124
170
  description:
@@ -164,7 +210,7 @@ export const docsDense = {
164
210
  group: 'Selector',
165
211
  category: 'Data Input',
166
212
  description:
167
- 'Field+dialog-popover shell for rich custom selectors. Content gets value/onChange/close/state; content owns semantics. Use focus hooks and evaluate custom content against WCAG 2.2.',
213
+ 'Input/ghost trigger + dialog-popover shell for rich custom selectors. Content gets value/onChange/close/state; content owns semantics. Use focus hooks and evaluate custom content against WCAG 2.2.',
168
214
  usage: {
169
215
  description:
170
216
  'Use when a selection needs richer custom content than a Selector row. One component: it owns field, trigger, popover, focus restore, and changeAction; the render prop owns the selector-specific accessible structure.',
@@ -211,10 +257,13 @@ export const docsDense = {
211
257
  value: 'Controlled value.',
212
258
  onChange: 'Commit value.',
213
259
  changeAction: 'Async action after onChange; drives optimistic value/busy.',
214
- children:
215
- 'Render custom dialog content from (value,onChange,close,state).',
260
+ children: 'Render custom dialog content from (value,onChange,close,state).',
216
261
  triggerLabel: 'Closed trigger label/content.',
262
+ variant: 'input for forms; ghost for toolbar triggers.',
263
+ startIcon: 'Leading trigger icon.',
217
264
  placement: 'Popup placement.',
265
+ alignment: 'Popup alignment.',
266
+ handleRef: 'Imperative open/close/toggle handle.',
218
267
  accessibility:
219
268
  'Custom content must provide its own accessible structure. Use focus hooks and evaluate against WCAG 2.2.',
220
269
  },
@@ -2,17 +2,45 @@
2
2
 
3
3
  /**
4
4
  * @file ComplexSelector.test.tsx
5
- * @input Uses vitest, Testing Library, user-event
6
- * @output Unit tests for ComplexSelector
7
- * @position Tests; validates custom content, async actions, and dialog composition
5
+ * @input Uses vitest, Testing Library, user-event, and ComplexSelector
6
+ * @output Unit tests for selection, trigger variants, positioning, and the imperative handle
7
+ * @position Tests; validates the ComplexSelector public interaction contract
8
8
  *
9
9
  * SYNC: When ComplexSelector.tsx API changes, update these tests.
10
10
  */
11
11
 
12
- import {describe, expect, it, vi} from 'vitest';
13
- import {render, screen, waitFor} from '@testing-library/react';
12
+ import React from 'react';
13
+ import {beforeEach, describe, expect, it, vi} from 'vitest';
14
+ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
14
15
  import userEvent from '@testing-library/user-event';
15
- import {ComplexSelector} from './ComplexSelector';
16
+ import {ComplexSelector, type ComplexSelectorHandle} from './ComplexSelector';
17
+
18
+ const originalMatches = HTMLElement.prototype.matches;
19
+
20
+ // Mock the Popover API, which jsdom does not implement.
21
+ beforeEach(() => {
22
+ HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
23
+ this.setAttribute('popover-open', '');
24
+ const event = new Event('toggle');
25
+ Object.defineProperty(event, 'newState', {value: 'open'});
26
+ this.dispatchEvent(event);
27
+ });
28
+ HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
29
+ this.removeAttribute('popover-open');
30
+ const event = new Event('toggle');
31
+ Object.defineProperty(event, 'newState', {value: 'closed'});
32
+ this.dispatchEvent(event);
33
+ });
34
+ Object.defineProperty(HTMLElement.prototype, 'matches', {
35
+ configurable: true,
36
+ value: function (this: HTMLElement, selector: string): boolean {
37
+ if (selector === ':popover-open') {
38
+ return this.hasAttribute('popover-open');
39
+ }
40
+ return originalMatches.call(this, selector);
41
+ },
42
+ });
43
+ });
16
44
 
17
45
  type FruitValue = {
18
46
  fruit: 'Apple' | 'Banana';
@@ -83,6 +111,35 @@ function FruitComplexSelector({
83
111
  }
84
112
 
85
113
  describe('ComplexSelector', () => {
114
+ it('defaults to md and reflects explicit trigger sizes', () => {
115
+ const {container, rerender} = render(
116
+ <ComplexSelector label="Fruit blend" value="Apple">
117
+ {() => <div>Options</div>}
118
+ </ComplexSelector>,
119
+ );
120
+
121
+ const getSelector = () =>
122
+ container.querySelector('.astryx-complex-selector');
123
+
124
+ expect(getSelector()).toHaveAttribute('data-size', 'md');
125
+
126
+ rerender(
127
+ <ComplexSelector label="Fruit blend" value="Apple" size="sm">
128
+ {() => <div>Options</div>}
129
+ </ComplexSelector>,
130
+ );
131
+
132
+ expect(getSelector()).toHaveAttribute('data-size', 'sm');
133
+
134
+ rerender(
135
+ <ComplexSelector label="Fruit blend" value="Apple" size="lg">
136
+ {() => <div>Options</div>}
137
+ </ComplexSelector>,
138
+ );
139
+
140
+ expect(getSelector()).toHaveAttribute('data-size', 'lg');
141
+ });
142
+
86
143
  it('renders custom content with value and commits through onChange', async () => {
87
144
  const user = userEvent.setup();
88
145
  const onChange = vi.fn();
@@ -174,6 +231,140 @@ describe('ComplexSelector', () => {
174
231
  await user.click(screen.getByRole('button', {name: 'Done', ...h}));
175
232
  expect(trigger).toHaveAttribute('aria-expanded', 'false');
176
233
  });
234
+
235
+ it('renders a ghost toolbar trigger with a start icon', () => {
236
+ const {container} = render(
237
+ <ComplexSelector
238
+ label="View options"
239
+ value={['name']}
240
+ variant="ghost"
241
+ startIcon="viewColumns"
242
+ status={{type: 'warning', message: 'Unsaved changes'}}
243
+ data-testid="view-options">
244
+ {() => <div>Columns</div>}
245
+ </ComplexSelector>,
246
+ );
247
+
248
+ expect(container.querySelector('.astryx-complex-selector')).toHaveAttribute(
249
+ 'data-variant',
250
+ 'ghost',
251
+ );
252
+ expect(container.querySelector('.astryx-field-status')).toHaveAttribute(
253
+ 'data-variant',
254
+ 'detached',
255
+ );
256
+ expect(
257
+ screen.getByTestId('view-options').querySelectorAll('svg'),
258
+ ).toHaveLength(2);
259
+ });
260
+
261
+ it('supports end-aligned popup positioning', () => {
262
+ render(
263
+ <ComplexSelector label="View options" value={[]} alignment="end">
264
+ {() => <div>Columns</div>}
265
+ </ComplexSelector>,
266
+ );
267
+
268
+ const popover = screen
269
+ .getByRole('dialog', {hidden: true})
270
+ .closest('[popover]');
271
+ expect(popover?.getAttribute('style')).toContain(
272
+ 'position-area: self-block-end span-self-inline-start',
273
+ );
274
+ });
275
+
276
+ it('exposes imperative open, close, toggle, and isOpen via handleRef', async () => {
277
+ const handleRef = React.createRef<ComplexSelectorHandle>();
278
+ render(
279
+ <ComplexSelector label="View options" value={[]} handleRef={handleRef}>
280
+ {() => <button type="button">Apply</button>}
281
+ </ComplexSelector>,
282
+ );
283
+ const trigger = screen.getByRole('button', {name: 'View options'});
284
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
285
+ expect(handleRef.current?.isOpen()).toBe(false);
286
+
287
+ act(() => {
288
+ handleRef.current?.open();
289
+ });
290
+ await waitFor(() => {
291
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
292
+ });
293
+ expect(handleRef.current?.isOpen()).toBe(true);
294
+
295
+ act(() => {
296
+ handleRef.current?.close();
297
+ });
298
+ await waitFor(() => {
299
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
300
+ });
301
+ expect(handleRef.current?.isOpen()).toBe(false);
302
+
303
+ act(() => {
304
+ handleRef.current?.toggle();
305
+ });
306
+ await waitFor(() => {
307
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
308
+ });
309
+
310
+ act(() => {
311
+ handleRef.current?.toggle();
312
+ });
313
+ await waitFor(() => {
314
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
315
+ });
316
+ });
317
+
318
+ it('does not open via the imperative handle when disabled', async () => {
319
+ const handleRef = React.createRef<ComplexSelectorHandle>();
320
+ render(
321
+ <ComplexSelector
322
+ label="View options"
323
+ value={[]}
324
+ isDisabled
325
+ handleRef={handleRef}>
326
+ {() => <button type="button">Apply</button>}
327
+ </ComplexSelector>,
328
+ );
329
+ const trigger = screen.getByRole('button', {name: 'View options'});
330
+
331
+ act(() => {
332
+ handleRef.current?.open();
333
+ });
334
+ act(() => {
335
+ handleRef.current?.toggle();
336
+ });
337
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
338
+ expect(handleRef.current?.isOpen()).toBe(false);
339
+ });
340
+
341
+ it('does not reopen from the trigger click that follows light dismiss', async () => {
342
+ const user = userEvent.setup();
343
+ render(
344
+ <ComplexSelector label="View options" value={[]}>
345
+ {() => <button type="button">Apply</button>}
346
+ </ComplexSelector>,
347
+ );
348
+ const trigger = screen.getByRole('button', {name: 'View options'});
349
+
350
+ await user.click(trigger);
351
+ const popover = screen
352
+ .getByRole('dialog', {hidden: true})
353
+ .closest('[popover]');
354
+ expect(popover).not.toBeNull();
355
+
356
+ const closeEvent = new Event('toggle');
357
+ Object.defineProperty(closeEvent, 'newState', {value: 'closed'});
358
+ fireEvent(popover as HTMLElement, closeEvent);
359
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
360
+
361
+ const showCallCount = vi.mocked(HTMLElement.prototype.showPopover).mock
362
+ .calls.length;
363
+ fireEvent.click(trigger);
364
+ expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(
365
+ showCallCount,
366
+ );
367
+ });
177
368
  });
178
369
 
179
370
  describe('ComplexSelector popup theme target', () => {