@astryxdesign/core 0.4.2 → 0.4.3

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 (79) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/Avatar/Avatar.d.ts.map +1 -1
  3. package/dist/Avatar/Avatar.js +3 -19
  4. package/dist/Banner/Banner.d.ts +6 -2
  5. package/dist/Banner/Banner.d.ts.map +1 -1
  6. package/dist/Banner/Banner.js +71 -30
  7. package/dist/Banner/index.d.ts +14 -12
  8. package/dist/Banner/index.d.ts.map +1 -1
  9. package/dist/Banner/index.js +10 -8
  10. package/dist/Chat/ChatTokenizedText.js +1 -1
  11. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  12. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  13. package/dist/ComplexSelector/ComplexSelector.js +96 -35
  14. package/dist/ComplexSelector/index.d.ts +2 -2
  15. package/dist/ComplexSelector/index.d.ts.map +1 -1
  16. package/dist/ComplexSelector/index.js +1 -1
  17. package/dist/Markdown/parser.d.ts.map +1 -1
  18. package/dist/Markdown/parser.js +55 -12
  19. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  20. package/dist/PowerSearch/PowerSearch.js +4 -1
  21. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  22. package/dist/PowerSearch/formatFilterValue.js +2 -4
  23. package/dist/Table/columnUtils.d.ts.map +1 -1
  24. package/dist/Table/columnUtils.js +4 -1
  25. package/dist/TextArea/TextArea.d.ts +6 -3
  26. package/dist/TextArea/TextArea.d.ts.map +1 -1
  27. package/dist/TextArea/TextArea.js +17 -6
  28. package/dist/TreeList/TreeList.js +2 -1
  29. package/dist/astryx.css +6 -4
  30. package/dist/astryx.umd.js +51 -51
  31. package/dist/astryx.umd.js.map +4 -4
  32. package/dist/theme/defineTheme.d.ts +8 -3
  33. package/dist/theme/defineTheme.d.ts.map +1 -1
  34. package/dist/theme/defineTheme.js +36 -47
  35. package/dist/theme/mergeComponents.d.ts +20 -0
  36. package/dist/theme/mergeComponents.d.ts.map +1 -0
  37. package/dist/theme/mergeComponents.js +56 -0
  38. package/dist/theme/onMediaTokens.d.ts +6 -1
  39. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  40. package/dist/theme/onMediaTokens.js +11 -3
  41. package/dist/utils/characters.d.ts +27 -0
  42. package/dist/utils/characters.d.ts.map +1 -0
  43. package/dist/utils/characters.js +83 -0
  44. package/dist/utils/index.d.ts +1 -0
  45. package/dist/utils/index.d.ts.map +1 -1
  46. package/dist/utils/index.js +1 -0
  47. package/package.json +2 -2
  48. package/src/Avatar/Avatar.test.tsx +6 -1
  49. package/src/Avatar/Avatar.tsx +3 -21
  50. package/src/Banner/Banner.doc.mjs +9 -7
  51. package/src/Banner/Banner.test.tsx +68 -0
  52. package/src/Banner/Banner.tsx +97 -36
  53. package/src/Banner/index.ts +15 -13
  54. package/src/Chat/ChatTokenizedText.tsx +1 -1
  55. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  56. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  57. package/src/ComplexSelector/ComplexSelector.tsx +153 -32
  58. package/src/ComplexSelector/index.ts +3 -1
  59. package/src/Markdown/parser.test.ts +53 -0
  60. package/src/Markdown/parser.ts +53 -12
  61. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  62. package/src/PowerSearch/PowerSearch.tsx +4 -1
  63. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  64. package/src/PowerSearch/formatFilterValue.ts +2 -4
  65. package/src/Table/Table.test.tsx +6 -0
  66. package/src/Table/columnUtils.ts +3 -1
  67. package/src/TextArea/TextArea.doc.mjs +1 -1
  68. package/src/TextArea/TextArea.test.tsx +72 -0
  69. package/src/TextArea/TextArea.tsx +26 -8
  70. package/src/TreeList/TreeList.doc.mjs +2 -2
  71. package/src/TreeList/TreeList.tsx +1 -1
  72. package/src/theme/defineTheme.test.ts +127 -0
  73. package/src/theme/defineTheme.ts +48 -51
  74. package/src/theme/extensibleAxes.test.ts +365 -0
  75. package/src/theme/mergeComponents.ts +59 -0
  76. package/src/theme/onMediaTokens.ts +9 -2
  77. package/src/utils/characters.test.ts +141 -0
  78. package/src/utils/characters.ts +88 -0
  79. package/src/utils/index.ts +2 -0
@@ -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', () => {
@@ -4,8 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file ComplexSelector.tsx
7
- * @input Uses React, StyleX, Field, usePopover
8
- * @output Exports ComplexSelector component for custom selector surfaces
7
+ * @input Uses React, StyleX, Field, Icon slots, Layer positioning, and usePopover
8
+ * @output Exports a rich-selector shell with exact token-sized input and ghost triggers, plus an imperative open/close handle
9
9
  * @position Core implementation; consumed by index.ts
10
10
  *
11
11
  * SYNC: When modified, update:
@@ -13,12 +13,15 @@
13
13
  * - /packages/core/src/ComplexSelector/ComplexSelector.test.tsx
14
14
  * - /packages/core/src/ComplexSelector/index.ts
15
15
  * - /apps/storybook/stories/ComplexSelector.stories.tsx
16
+ * - /packages/cli/assets/templates/blocks/components/ComplexSelector/ (showcase blocks)
16
17
  */
17
18
 
18
19
  import React, {
19
20
  useCallback,
20
21
  useId,
22
+ useImperativeHandle,
21
23
  useOptimistic,
24
+ useRef,
22
25
  useTransition,
23
26
  type ReactNode,
24
27
  } from 'react';
@@ -26,15 +29,17 @@ import * as stylex from '@stylexjs/stylex';
26
29
  import type {StyleXStyles} from '@stylexjs/stylex';
27
30
  import type {BaseProps} from '../BaseProps';
28
31
  import {Field, inputWrapperStyles, type FieldStatusVariant} from '../Field';
29
- import {Icon} from '../Icon';
32
+ import {Icon, renderIconSlot, type IconType} from '../Icon';
30
33
  import {Spinner} from '../Spinner';
31
34
  import {useTranslator} from '../i18n';
32
35
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
36
+ import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
33
37
  import {usePopover} from '../Popover/usePopover';
34
38
  import {
35
39
  colorVars,
36
40
  durationVars,
37
41
  easeVars,
42
+ fontWeightVars,
38
43
  radiusVars,
39
44
  sizeVars,
40
45
  spacingVars,
@@ -99,6 +104,39 @@ const styles = stylex.create({
99
104
  placeholder: {
100
105
  color: colorVars['--color-text-secondary'],
101
106
  },
107
+ triggerGhost: {
108
+ width: 'auto',
109
+ borderWidth: 0,
110
+ backgroundColor: 'transparent',
111
+ backgroundImage: {
112
+ default: null,
113
+ ':hover': {
114
+ '@media (hover: hover)': `linear-gradient(${colorVars['--color-overlay-hover']}, ${colorVars['--color-overlay-hover']})`,
115
+ },
116
+ ':active': `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`,
117
+ },
118
+ boxShadow: {
119
+ default: 'none',
120
+ ':hover:not(:focus-within)': {
121
+ '@media (hover: hover)': 'none',
122
+ },
123
+ ':focus-within': 'none',
124
+ },
125
+ fontWeight: fontWeightVars['--font-weight-medium'],
126
+ transitionProperty:
127
+ 'background-image, background-color, color, opacity, transform',
128
+ transform: {
129
+ default: 'scale(1)',
130
+ ':active': 'scale(0.98)',
131
+ },
132
+ },
133
+ triggerGhostDisabled: {
134
+ backgroundImage: 'none',
135
+ transform: {
136
+ default: 'none',
137
+ ':active': 'none',
138
+ },
139
+ },
102
140
  // Only what Icon does not already provide: `sm` gives the 16px box and
103
141
  // `color="secondary"` the color, but the glyph still must not shrink inside
104
142
  // the flex trigger.
@@ -129,19 +167,21 @@ const styles = stylex.create({
129
167
  padding: spacingVars['--spacing-3'],
130
168
  },
131
169
  sm: {
132
- minHeight: sizeVars['--size-element-sm'],
170
+ height: sizeVars['--size-element-sm'],
133
171
  },
134
172
  md: {
135
- minHeight: sizeVars['--size-element-md'],
173
+ height: sizeVars['--size-element-md'],
136
174
  },
137
175
  lg: {
138
- minHeight: sizeVars['--size-element-lg'],
176
+ height: sizeVars['--size-element-lg'],
139
177
  },
140
178
  disabled: {
141
179
  cursor: 'not-allowed',
142
180
  },
143
181
  });
144
182
 
183
+ export type ComplexSelectorVariant = 'input' | 'ghost';
184
+
145
185
  export type ComplexSelectorSize = 'sm' | 'md' | 'lg';
146
186
 
147
187
  export interface ComplexSelectorRenderState {
@@ -155,6 +195,25 @@ export interface ComplexSelectorRenderState {
155
195
  contentId: string;
156
196
  }
157
197
 
198
+ /**
199
+ * Imperative control surface for ComplexSelector, accessed via the `handleRef`
200
+ * prop. Methods drive the same popover machinery as the built-in trigger, so
201
+ * they respect focus restoration, light dismiss, and Escape. Prefer these
202
+ * callbacks over mirroring open state in the parent — the selector owns its
203
+ * visibility, and imperative calls avoid the focus-management pitfalls of
204
+ * syncing an external `isOpen` prop.
205
+ */
206
+ export interface ComplexSelectorHandle {
207
+ /** Open the selector surface. No-op when disabled or already open. */
208
+ open(): void;
209
+ /** Close the selector surface. Restores focus to the trigger. */
210
+ close(): void;
211
+ /** Toggle the selector surface open or closed. */
212
+ toggle(): void;
213
+ /** Whether the selector surface is currently open. Reads live state. */
214
+ isOpen(): boolean;
215
+ }
216
+
158
217
  export interface ComplexSelectorStatus {
159
218
  type: 'warning' | 'error' | 'success';
160
219
  message?: string;
@@ -203,10 +262,22 @@ export interface ComplexSelectorProps<Value> extends Omit<
203
262
  labelTooltip?: string;
204
263
  /** Trigger and field size. */
205
264
  size?: ComplexSelectorSize;
265
+ /** Visual trigger style. Ghost matches toolbar buttons. */
266
+ variant?: ComplexSelectorVariant;
267
+ /** Icon displayed at the start of the trigger. */
268
+ startIcon?: ReactNode | IconType;
206
269
  /** Width of the field. */
207
270
  width?: SizeValue;
208
271
  /** Popup placement. */
209
- placement?: 'above' | 'below' | 'start' | 'end';
272
+ placement?: LayerPlacement;
273
+ /** Popup alignment along the placement axis. */
274
+ alignment?: LayerAlignment;
275
+ /**
276
+ * Imperative handle for programmatic open/close control. Exposes open,
277
+ * close, toggle, and the isOpen query. Use this instead of mirroring open
278
+ * state in the parent — the selector owns its visibility.
279
+ */
280
+ handleRef?: React.Ref<ComplexSelectorHandle>;
210
281
  /** StyleX styles for the popup content container. */
211
282
  contentXstyle?: StyleXStyles;
212
283
  /** Test ID for the trigger container. */
@@ -258,8 +329,12 @@ export function ComplexSelector<Value>({
258
329
  statusVariant = 'attached',
259
330
  labelTooltip,
260
331
  size = 'md',
332
+ variant = 'input',
333
+ startIcon,
261
334
  width,
262
335
  placement = 'below',
336
+ alignment = 'start',
337
+ handleRef,
263
338
  contentXstyle,
264
339
  xstyle,
265
340
  className,
@@ -270,6 +345,10 @@ export function ComplexSelector<Value>({
270
345
  }: ComplexSelectorProps<Value>) {
271
346
  const t = useTranslator();
272
347
  const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder');
348
+ const effectiveStatusVariant =
349
+ variant === 'ghost' && statusVariant === 'attached'
350
+ ? 'detached'
351
+ : statusVariant;
273
352
 
274
353
  const triggerId = useId();
275
354
  const labelId = useId();
@@ -284,24 +363,67 @@ export function ComplexSelector<Value>({
284
363
  .filter((id): id is string => id != null)
285
364
  .join(' ') || undefined;
286
365
 
366
+ const triggerRef = useRef<HTMLButtonElement>(null);
367
+ const lastHideTimeRef = useRef(0);
368
+
287
369
  const [isPending, startTransition] = useTransition();
288
370
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
289
371
  const isBusy = isLoading || isPending;
290
372
 
373
+ const handlePopoverHide = useCallback(() => {
374
+ lastHideTimeRef.current = Date.now();
375
+ triggerRef.current?.focus();
376
+ }, []);
377
+
291
378
  const popover = usePopover({
292
379
  dialogLabel: label,
293
380
  hasCloseButton: false,
294
381
  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
382
  surfaceTarget: 'complex-selector-popup',
300
- onHide: () => {
301
- document.getElementById(triggerId)?.focus();
302
- },
383
+ onHide: handlePopoverHide,
303
384
  });
304
385
 
386
+ const isOpen = popover.isOpen;
387
+
388
+ const handleTriggerClick = useCallback(() => {
389
+ if (isDisabled || Date.now() - lastHideTimeRef.current < 50) {
390
+ return;
391
+ }
392
+ if (popover.isOpen) {
393
+ popover.hide();
394
+ } else {
395
+ popover.show();
396
+ }
397
+ }, [isDisabled, popover]);
398
+
399
+ const close = useCallback(() => {
400
+ popover.hide();
401
+ }, [popover]);
402
+
403
+ useImperativeHandle(
404
+ handleRef,
405
+ () => ({
406
+ open: () => {
407
+ if (!isDisabled) {
408
+ popover.show();
409
+ }
410
+ },
411
+ close: () => popover.hide(),
412
+ toggle: () => {
413
+ if (isDisabled) {
414
+ return;
415
+ }
416
+ if (popover.isOpen) {
417
+ popover.hide();
418
+ } else {
419
+ popover.show();
420
+ }
421
+ },
422
+ isOpen: () => popover.isOpen,
423
+ }),
424
+ [isDisabled, popover],
425
+ );
426
+
305
427
  const commitValue = useCallback(
306
428
  (nextValue: Value) => {
307
429
  onChange?.(nextValue);
@@ -319,8 +441,8 @@ export function ComplexSelector<Value>({
319
441
 
320
442
  const content = (
321
443
  <div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
322
- {children(optimisticValue, commitValue, popover.hide, {
323
- isOpen: popover.isOpen,
444
+ {children(optimisticValue, commitValue, close, {
445
+ isOpen,
324
446
  isBusy,
325
447
  triggerId,
326
448
  contentId,
@@ -334,13 +456,10 @@ export function ComplexSelector<Value>({
334
456
  ref={popover.triggerRef}
335
457
  data-testid={testId}
336
458
  {...props}
337
- onClick={composeEventHandlers(onClickProp, () => {
338
- if (!isDisabled) {
339
- popover.toggle();
340
- }
341
- })}
459
+ onClick={composeEventHandlers(onClickProp, handleTriggerClick)}
342
460
  {...mergeProps(
343
461
  themeProps('complex-selector', {
462
+ variant,
344
463
  size,
345
464
  status: status?.type ?? null,
346
465
  }),
@@ -348,12 +467,11 @@ export function ComplexSelector<Value>({
348
467
  inputWrapperStyles.base,
349
468
  styles.triggerContainer,
350
469
  styles[size],
351
- // The ring belongs to the wrapper (the focusable `<button>` sits
352
- // inside it), but it must still be a KEYBOARD ring: `:focus-within`
353
- // matched a mouse click on the trigger and drew the outline for
354
- // pointer users too. `focusWithin` here is `:has(:focus-visible)`.
355
- focusOutlineStyles.focusWithin,
470
+ variant === 'input' && focusOutlineStyles.focusWithin,
471
+ variant === 'ghost' && styles.triggerGhost,
472
+ variant === 'ghost' && focusOutlineStyles.focusWithin,
356
473
  isDisabled && inputWrapperStyles.disabled,
474
+ variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
357
475
  isDisabled && styles.disabled,
358
476
  triggerLabel == null && styles.placeholder,
359
477
  xstyle,
@@ -361,11 +479,14 @@ export function ComplexSelector<Value>({
361
479
  className,
362
480
  style,
363
481
  )}>
482
+ {startIcon &&
483
+ renderIconSlot(startIcon, {size: 'sm', color: 'secondary'})}
364
484
  <button
485
+ ref={triggerRef}
365
486
  id={triggerId}
366
487
  type="button"
367
488
  aria-haspopup="dialog"
368
- aria-expanded={popover.isOpen}
489
+ aria-expanded={isOpen}
369
490
  aria-controls={contentId}
370
491
  aria-describedby={ariaDescribedBy}
371
492
  aria-labelledby={labelId}
@@ -374,7 +495,7 @@ export function ComplexSelector<Value>({
374
495
  aria-busy={isBusy || undefined}
375
496
  disabled={isDisabled}
376
497
  onKeyDown={event => {
377
- if (event.key === 'ArrowDown' && !popover.isOpen && !isDisabled) {
498
+ if (event.key === 'ArrowDown' && !isOpen && !isDisabled) {
378
499
  event.preventDefault();
379
500
  popover.show();
380
501
  }
@@ -394,17 +515,17 @@ export function ComplexSelector<Value>({
394
515
  xstyle={[
395
516
  styles.triggerIcon,
396
517
  styles.triggerIconRotation,
397
- popover.isOpen && styles.triggerIconOpen,
518
+ isOpen && styles.triggerIconOpen,
398
519
  ]}
399
520
  {...themeProps('complex-selector-indicator-icon', {
400
- state: popover.isOpen ? 'expanded' : 'collapsed',
521
+ state: isOpen ? 'expanded' : 'collapsed',
401
522
  })}
402
523
  />
403
524
  </div>
404
525
 
405
526
  {popover.render(content, {
406
527
  placement,
407
- alignment: 'start',
528
+ alignment,
408
529
  offset: spacingVars['--spacing-1'],
409
530
  xstyle: [styles.popover, layerAnimations[placement]],
410
531
  })}
@@ -431,7 +552,7 @@ export function ComplexSelector<Value>({
431
552
  }
432
553
  : undefined
433
554
  }
434
- statusVariant={statusVariant}
555
+ statusVariant={effectiveStatusVariant}
435
556
  labelTooltip={labelTooltip}
436
557
  width={width}>
437
558
  {selectorContent}
@@ -4,14 +4,16 @@
4
4
 
5
5
  /**
6
6
  * @file index.ts
7
- * @output Exports ComplexSelector and types
7
+ * @output Exports ComplexSelector and its trigger-size contract types
8
8
  * @position Public API entry point
9
9
  */
10
10
 
11
11
  export {
12
12
  ComplexSelector,
13
+ type ComplexSelectorHandle,
13
14
  type ComplexSelectorProps,
14
15
  type ComplexSelectorRenderState,
15
16
  type ComplexSelectorSize,
16
17
  type ComplexSelectorStatus,
18
+ type ComplexSelectorVariant,
17
19
  } from './ComplexSelector';
@@ -51,6 +51,48 @@ describe('parseInline', () => {
51
51
  expect(result).toEqual([{type: 'image', src: 'img.png', alt: 'alt'}]);
52
52
  });
53
53
 
54
+ it('rejects javascript: links as plain text (XSS prevention)', () => {
55
+ const result = parseInline('[click](javascript:alert(1))');
56
+ // Should be emitted as plain text, NOT as a link node
57
+ expect(result).toEqual([
58
+ {type: 'text', content: '[click](javascript:alert(1))'},
59
+ ]);
60
+ });
61
+
62
+ it('rejects javascript: with mixed case and whitespace (XSS prevention)', () => {
63
+ const result = parseInline('[click](JaVaScRiPt:alert(1))');
64
+ expect(result).toEqual([
65
+ {type: 'text', content: '[click](JaVaScRiPt:alert(1))'},
66
+ ]);
67
+ });
68
+
69
+ it('rejects vbscript: links (XSS prevention)', () => {
70
+ const result = parseInline('[click](vbscript:MsgBox(1))');
71
+ expect(result).toEqual([
72
+ {type: 'text', content: '[click](vbscript:MsgBox(1))'},
73
+ ]);
74
+ });
75
+
76
+ it('rejects data:text/html image src (XSS prevention)', () => {
77
+ const result = parseInline(
78
+ '![xss](data:text/html,<script>alert(1)</script>)',
79
+ );
80
+ expect(result).toEqual([
81
+ {
82
+ type: 'text',
83
+ content: '![xss](data:text/html,<script>alert(1)</script>)',
84
+ },
85
+ ]);
86
+ });
87
+
88
+ it('allows normal http/https links', () => {
89
+ const result = parseInline('[safe](https://example.com)');
90
+ expect(result[0].type).toBe('link');
91
+ if (result[0].type === 'link') {
92
+ expect(result[0].href).toBe('https://example.com');
93
+ }
94
+ });
95
+
54
96
  it('parses strikethrough', () => {
55
97
  const result = parseInline('~~deleted~~');
56
98
  expect(result[0].type).toBe('strikethrough');
@@ -727,6 +769,17 @@ describe('citation parsing', () => {
727
769
  });
728
770
  });
729
771
 
772
+ it('rejects <javascript:...> angle-bracket autolinks (XSS prevention)', () => {
773
+ const result = parseInline('see <javascript:alert(1)> ok', {
774
+ autolink: 'gfm',
775
+ });
776
+ // Should NOT produce a link node with javascript: href
777
+ const linkNodes = result.filter(
778
+ (n): n is Extract<typeof n, {type: 'link'}> => n.type === 'link',
779
+ );
780
+ expect(linkNodes).toHaveLength(0);
781
+ });
782
+
730
783
  it('parses <email> angle-bracket autolinks', () => {
731
784
  const result = parseInline('mail <user@example.com> please', {
732
785
  autolink: 'gfm',