@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
@@ -455,4 +455,72 @@ describe('Banner', () => {
455
455
  );
456
456
  });
457
457
  });
458
+
459
+ describe('dismiss focus handoff', () => {
460
+ it('returns focus to where it came from instead of dropping it to body', async () => {
461
+ const user = userEvent.setup();
462
+ render(
463
+ <>
464
+ <button type="button">Before</button>
465
+ <Banner status="info" title="Heads up" isDismissable />
466
+ </>,
467
+ );
468
+ const before = screen.getByRole('button', {name: 'Before'});
469
+ before.focus();
470
+
471
+ await user.tab();
472
+ expect(screen.getByRole('button', {name: 'Dismiss'})).toHaveFocus();
473
+
474
+ await user.keyboard('{Enter}');
475
+
476
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
477
+ expect(before).toHaveFocus();
478
+ expect(document.activeElement).not.toBe(document.body);
479
+ });
480
+
481
+ it('leaves focus alone when it never entered the banner', async () => {
482
+ const user = userEvent.setup();
483
+ render(
484
+ <>
485
+ <button type="button">Elsewhere</button>
486
+ <Banner status="info" title="Heads up" isDismissable />
487
+ </>,
488
+ );
489
+ await user.click(screen.getByRole('button', {name: 'Dismiss'}));
490
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
491
+ });
492
+ });
493
+
494
+ describe('empty slots', () => {
495
+ it('does not show the expand affordance for children that render nothing', () => {
496
+ render(
497
+ <Banner status="info" title="Heads up">
498
+ {false}
499
+ </Banner>,
500
+ );
501
+ expect(
502
+ screen.queryByRole('button', {name: 'Expand'}),
503
+ ).not.toBeInTheDocument();
504
+ });
505
+
506
+ it('still shows the expand affordance for real children', () => {
507
+ render(
508
+ <Banner status="info" title="Heads up">
509
+ <p>Detail</p>
510
+ </Banner>,
511
+ );
512
+ expect(
513
+ screen.getByRole('button', {name: 'Expand'}),
514
+ ).toBeInTheDocument();
515
+ });
516
+
517
+ it('renders no description node for a description that renders nothing', () => {
518
+ const {container} = render(
519
+ <Banner status="info" title="Heads up" description="" />,
520
+ );
521
+ const header = container.firstElementChild!.firstElementChild!;
522
+ // icon wrapper + text column, and the text column holds the title alone
523
+ expect(header.children[1].children).toHaveLength(1);
524
+ });
525
+ });
458
526
  });
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Banner.tsx
7
- * @input Uses React useState, Button, Icon (with registry string names), StyleX
7
+ * @input Uses React useState/useRef/useId, Button, Icon (with registry string names), StyleX
8
8
  * @output Exports Banner component, BannerProps, BannerStatus, BannerContainer types
9
9
  * @position Core implementation; consumed by index.ts, tested by Banner.test.tsx
10
10
  *
@@ -19,6 +19,10 @@
19
19
  * - Each visual area owns its own border-radius (no overflow:clip on the container)
20
20
  * - When children are provided, a collapse/expand toggle button appears in the end area
21
21
  *
22
+ * A status added through `BannerStatusMap` augmentation has no entry in the
23
+ * status lookups, so it renders with no status fill, no default glyph and the
24
+ * polite `role="status"` rather than losing its ARIA role entirely.
25
+ *
22
26
  * Title and description render as <div> (not <p>): they accept arbitrary
23
27
  * ReactNode content, and <p> cannot legally contain block-level children
24
28
  * (the HTML parser reparents them, desyncing SSR markup from the hydrated
@@ -34,12 +38,12 @@
34
38
  * - /packages/cli/assets/templates/blocks/components/Banner/ (showcase blocks)
35
39
  */
36
40
 
37
- import {useId, useState, type ReactNode} from 'react';
41
+ import {useId, useRef, useState, type ReactNode} from 'react';
38
42
  import * as stylex from '@stylexjs/stylex';
39
43
  import type {BaseProps} from '../BaseProps';
40
44
  import {Button} from '../Button';
41
45
  import {Icon} from '../Icon';
42
- import type {IconName} from '../Icon';
46
+ import type {IconName, IconColor} from '../Icon';
43
47
  import {
44
48
  colorVars,
45
49
  spacingVars,
@@ -51,7 +55,7 @@ import {
51
55
  easeVars,
52
56
  shadowVars,
53
57
  } from '../theme/tokens.stylex';
54
- import {mergeProps} from '../utils';
58
+ import {composeEventHandlers, isRenderable, mergeProps} from '../utils';
55
59
  import type {Elevation} from '../utils/types';
56
60
  import {edgeCompSlot} from '../Layout/edgeCompensation.stylex';
57
61
  import {themeProps} from '../utils/themeProps';
@@ -147,37 +151,38 @@ export interface BannerProps extends BaseProps<HTMLDivElement> {
147
151
  }
148
152
 
149
153
  // =============================================================================
150
- // Status → Icon mapping
154
+ // Status lookups
151
155
  // =============================================================================
152
156
 
153
- const defaultIconNames: Record<BannerStatus, IconName> = {
157
+ // `BannerStatus` is `keyof BannerStatusMap`, and index.ts documents augmenting
158
+ // that interface to add a status. Every lookup below is therefore partial: an
159
+ // augmented status the library has never heard of falls through to the base
160
+ // treatment (no status fill, no glyph, the polite role) instead of resolving to
161
+ // `undefined` and dropping the ARIA role along with it.
162
+
163
+ const defaultIconNames: Partial<Record<BannerStatus, IconName>> = {
154
164
  info: 'info',
155
165
  warning: 'warning',
156
166
  error: 'error',
157
167
  success: 'success',
158
168
  };
159
169
 
160
- // =============================================================================
161
- // Status → ARIA role mapping
162
- // =============================================================================
163
-
164
- const statusRole: Record<BannerStatus, 'alert' | 'status'> = {
170
+ const statusRole: Partial<Record<BannerStatus, 'alert' | 'status'>> = {
165
171
  info: 'status',
166
172
  warning: 'alert',
167
173
  error: 'alert',
168
174
  success: 'status',
169
175
  };
170
176
 
171
- // =============================================================================
172
- // Status Icon color mapping
173
- // =============================================================================
177
+ /** An unknown status is not urgent by definition, so it announces politely. */
178
+ const FALLBACK_ROLE = 'status';
174
179
 
175
- const statusIconColor = {
180
+ const statusIconColor: Partial<Record<BannerStatus, IconColor>> = {
176
181
  info: 'accent',
177
182
  warning: 'warning',
178
183
  error: 'error',
179
184
  success: 'success',
180
- } as const;
185
+ };
181
186
 
182
187
  // =============================================================================
183
188
  // Styles
@@ -193,7 +198,7 @@ const styles = stylex.create({
193
198
  // When elevated, a card-container banner rounds its root so the shadow
194
199
  // follows the same silhouette as the header/content border-radius.
195
200
  rootElevatedCard: {
196
- borderRadius: radiusVars['--radius-container'],
201
+ borderRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
197
202
  },
198
203
  // Header area — colored status background with icon, title, description, actions
199
204
  // This is the primary theme target ('banner')
@@ -206,11 +211,11 @@ const styles = stylex.create({
206
211
  },
207
212
  // Border-radius for card container: all corners when standalone, top-only when content is visible
208
213
  headerCardStandalone: {
209
- borderRadius: radiusVars['--radius-container'],
214
+ borderRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
210
215
  },
211
216
  headerCardWithContent: {
212
- borderStartStartRadius: radiusVars['--radius-container'],
213
- borderStartEndRadius: radiusVars['--radius-container'],
217
+ borderStartStartRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
218
+ borderStartEndRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
214
219
  borderEndStartRadius: 0,
215
220
  borderEndEndRadius: 0,
216
221
  },
@@ -233,6 +238,10 @@ const styles = stylex.create({
233
238
  fontWeight: fontWeightVars['--font-weight-semibold'],
234
239
  lineHeight: typeScaleVars['--text-label-leading'],
235
240
  color: colorVars['--color-text-primary'],
241
+ // A single unbroken token (a URL, an ID, a German compound) otherwise sets
242
+ // the flex item's min-content width and pushes the page into horizontal
243
+ // scrolling at 320px, which is a WCAG 1.4.10 reflow failure.
244
+ overflowWrap: 'anywhere',
236
245
  },
237
246
  description: {
238
247
  margin: 0,
@@ -241,6 +250,7 @@ const styles = stylex.create({
241
250
  fontWeight: fontWeightVars['--font-weight-normal'],
242
251
  lineHeight: typeScaleVars['--text-supporting-leading'],
243
252
  color: colorVars['--color-text-secondary'],
253
+ overflowWrap: 'anywhere',
244
254
  },
245
255
  iconWrapper: {
246
256
  display: 'flex',
@@ -262,17 +272,17 @@ const styles = stylex.create({
262
272
  paddingInline: spacingVars['--spacing-4'],
263
273
  borderInlineStartWidth: borderVars['--border-width'],
264
274
  borderInlineEndWidth: borderVars['--border-width'],
265
- borderBottomWidth: borderVars['--border-width'],
275
+ borderBlockEndWidth: borderVars['--border-width'],
266
276
  borderInlineStartStyle: 'solid',
267
277
  borderInlineEndStyle: 'solid',
268
- borderBottomStyle: 'solid',
278
+ borderBlockEndStyle: 'solid',
269
279
  borderInlineStartColor: colorVars['--color-border'],
270
280
  borderInlineEndColor: colorVars['--color-border'],
271
- borderBottomColor: colorVars['--color-border'],
281
+ borderBlockEndColor: colorVars['--color-border'],
272
282
  },
273
283
  contentAreaCard: {
274
- borderEndStartRadius: radiusVars['--radius-container'],
275
- borderEndEndRadius: radiusVars['--radius-container'],
284
+ borderEndStartRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
285
+ borderEndEndRadius: `var(--_banner-radius, ${radiusVars['--radius-container']})`,
276
286
  },
277
287
  // Applied to the chevron <Icon> itself (via `xstyle`) rather than a wrapper,
278
288
  // so the element that rotates is the element a theme targets.
@@ -304,6 +314,17 @@ const statusStyles = stylex.create({
304
314
  },
305
315
  });
306
316
 
317
+ /**
318
+ * Narrows an augmented `BannerStatus` to one the style map actually carries,
319
+ * so an unknown status renders with no status fill rather than crashing the
320
+ * lookup. StyleX style maps cannot be declared `Partial`, hence the guard.
321
+ */
322
+ function hasStatusStyle(
323
+ status: BannerStatus,
324
+ ): status is BannerStatus & keyof typeof statusStyles {
325
+ return Object.prototype.hasOwnProperty.call(statusStyles, status);
326
+ }
327
+
307
328
  // Resting elevation for the banner. Applied to the root so the shadow wraps
308
329
  // the whole banner (header + optional content). 'none' is the default and
309
330
  // leaves the layout-only root untouched. For the `card` container the root is
@@ -381,28 +402,63 @@ export function Banner({
381
402
  className,
382
403
  style,
383
404
  ref,
405
+ onFocusCapture,
406
+ onPointerDownCapture,
384
407
  ...rest
385
408
  }: BannerProps) {
386
409
  const t = useTranslator();
387
410
  const [isDismissed, setIsDismissed] = useState(false);
388
411
  const [isExpanded, setIsExpanded] = useState(defaultIsExpanded);
412
+ // The element focus came from before it entered the banner. Dismissing
413
+ // unmounts the whole banner, dismiss button included, so without a handoff
414
+ // the browser drops focus to <body> and a keyboard user loses their place.
415
+ // ToastViewport makes the same handoff when a focused toast is dismissed.
416
+ const focusOriginRef = useRef<HTMLElement | null>(null);
389
417
  // Links the expand/collapse toggle to the content region it shows/hides so
390
418
  // assistive tech can move from the button to its controlled content
391
419
  // (disclosure pattern). The region is conditionally rendered, so aria-controls
392
420
  // below is set only while it's mounted to avoid a dangling reference.
393
421
  const contentId = useId();
394
422
  const defaultIconName = defaultIconNames[status];
395
- const role = statusRole[status];
423
+ const role = statusRole[status] ?? FALLBACK_ROLE;
396
424
  const iconColor = statusIconColor[status];
397
- const hasChildren = children != null;
425
+ const hasChildren = isRenderable(children);
398
426
 
399
427
  if (isDismissed) {
400
428
  return null;
401
429
  }
402
430
 
431
+ // `focusin` reports the element focus came *from* as relatedTarget; on
432
+ // pointerdown focus has not moved yet, so document.activeElement is it.
433
+ const rememberFocusOrigin = (candidate: EventTarget | null, root: Node) => {
434
+ if (
435
+ candidate instanceof HTMLElement &&
436
+ candidate !== document.body &&
437
+ !root.contains(candidate)
438
+ ) {
439
+ focusOriginRef.current = candidate;
440
+ }
441
+ };
442
+
443
+ const handleFocusCapture = (event: React.FocusEvent<HTMLDivElement>) => {
444
+ rememberFocusOrigin(event.relatedTarget, event.currentTarget);
445
+ };
446
+
447
+ const handlePointerDownCapture = (
448
+ event: React.PointerEvent<HTMLDivElement>,
449
+ ) => {
450
+ rememberFocusOrigin(document.activeElement, event.currentTarget);
451
+ };
452
+
403
453
  const handleDismiss = () => {
454
+ const origin = focusOriginRef.current;
404
455
  setIsDismissed(true);
405
456
  onDismiss?.();
457
+ // Move focus before React removes the subtree, so the browser never has a
458
+ // frame where the focused node is gone.
459
+ if (origin?.isConnected) {
460
+ origin.focus();
461
+ }
406
462
  };
407
463
 
408
464
  const handleToggleExpand = () => {
@@ -410,11 +466,11 @@ export function Banner({
410
466
  };
411
467
 
412
468
  // Show the end area if there are actions, dismiss, or a collapsible toggle
413
- const showEndArea = endContent != null || isDismissable || hasChildren;
469
+ const showEndArea = isRenderable(endContent) || isDismissable || hasChildren;
414
470
  // Center items vertically when there's only a title (no description)
415
471
  // and the banner has action buttons
416
- const hasActions = endContent != null || isDismissable;
417
- const isSingleLine = description == null && hasActions;
472
+ const hasActions = isRenderable(endContent) || isDismissable;
473
+ const isSingleLine = !isRenderable(description) && hasActions;
418
474
 
419
475
  const showContent = hasChildren && isExpanded;
420
476
  const isCard = container === 'card';
@@ -423,6 +479,11 @@ export function Banner({
423
479
  <div
424
480
  ref={ref}
425
481
  role={role}
482
+ onFocusCapture={composeEventHandlers(onFocusCapture, handleFocusCapture)}
483
+ onPointerDownCapture={composeEventHandlers(
484
+ onPointerDownCapture,
485
+ handlePointerDownCapture,
486
+ )}
426
487
  {...mergeProps(
427
488
  stylex.props(
428
489
  styles.root,
@@ -441,7 +502,7 @@ export function Banner({
441
502
  stylex.props(
442
503
  styles.header,
443
504
  isSingleLine && styles.headerCentered,
444
- statusStyles[status],
505
+ hasStatusStyle(status) && statusStyles[status],
445
506
  isCard &&
446
507
  (showContent
447
508
  ? styles.headerCardWithContent
@@ -454,16 +515,16 @@ export function Banner({
454
515
  overrides reach it via inheritance). The wrapper itself stays
455
516
  layout-only. */}
456
517
  <div
457
- {...(icon != null
518
+ {...(isRenderable(icon)
458
519
  ? mergeProps(
459
520
  themeProps('banner-icon', {status}),
460
521
  stylex.props(styles.iconWrapper),
461
522
  )
462
523
  : stylex.props(styles.iconWrapper))}
463
524
  aria-hidden="true">
464
- {icon != null ? (
525
+ {isRenderable(icon) ? (
465
526
  icon
466
- ) : (
527
+ ) : defaultIconName != null ? (
467
528
  // Applied to the status <Icon> itself rather than the wrapper, so
468
529
  // the element that paints the glyph is the element a theme
469
530
  // targets — a 'banner-icon' 'status:X' color override beats the
@@ -474,11 +535,11 @@ export function Banner({
474
535
  color={iconColor}
475
536
  {...themeProps('banner-icon', {status})}
476
537
  />
477
- )}
538
+ ) : null}
478
539
  </div>
479
540
  <div {...stylex.props(styles.headerContent)}>
480
541
  <div {...stylex.props(styles.title)}>{title}</div>
481
- {description != null && (
542
+ {isRenderable(description) && (
482
543
  <div {...stylex.props(styles.description)}>{description}</div>
483
544
  )}
484
545
  </div>
@@ -11,19 +11,6 @@
11
11
  * SYNC: When modified, update this header and /packages/core/src/Banner/Banner.doc.mjs
12
12
  */
13
13
 
14
- /**
15
- * Extensible status map for Banner.
16
- *
17
- * Theme packages can add custom statuses via TypeScript module augmentation:
18
- * @example
19
- * ```
20
- * declare module '@astryxdesign/core/Banner' {
21
- * interface BannerStatusMap {
22
- * 'neutral': true;
23
- * }
24
- * }
25
- * ```
26
- */
27
14
  /**
28
15
  * Extensible container map for Banner.
29
16
  *
@@ -42,6 +29,21 @@ export interface BannerContainerMap {
42
29
  section: true;
43
30
  }
44
31
 
32
+ /**
33
+ * Extensible status map for Banner.
34
+ *
35
+ * Theme packages can add custom statuses via TypeScript module augmentation.
36
+ * A status the library does not know falls through to the base treatment: no
37
+ * status fill, no default glyph, and `role="status"`.
38
+ * @example
39
+ * ```
40
+ * declare module '@astryxdesign/core/Banner' {
41
+ * interface BannerStatusMap {
42
+ * 'neutral': true;
43
+ * }
44
+ * }
45
+ * ```
46
+ */
45
47
  export interface BannerStatusMap {
46
48
  info: true;
47
49
  warning: true;
@@ -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
  },