@juwel-development/design-system 4.1.0 → 4.2.1

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.
@@ -10,7 +10,11 @@ import {
10
10
  type ReactElement,
11
11
  type ReactNode,
12
12
  useContext,
13
+ useLayoutEffect,
14
+ useRef,
15
+ useState,
13
16
  } from 'react';
17
+ import { flushSync } from 'react-dom';
14
18
  import { ColumnLayoutCompositionError } from './ColumnLayoutCompositionError';
15
19
  import { ColumnLayoutConfigurationError } from './ColumnLayoutConfigurationError';
16
20
 
@@ -47,12 +51,43 @@ const columnLayoutColumn = cva(
47
51
  'min-w-0 grow-[calc(var(--column-layout-weight)/var(--column-layout-growth-unit))] basis-[clamp(0px,(var(--column-layout-threshold)-100%)*1000000,100%)] max-w-[var(--column-layout-maximum)]',
48
52
  );
49
53
 
54
+ // The selecting holder is the box the alternatives are measured against; it takes no space of
55
+ // its own. The probes sit out of flow and clipped, so a shortfall never becomes overflow.
56
+ const columnLayoutAlternatives = cva('relative min-w-0', {
57
+ variants: { fill: { false: '', true: 'h-full min-h-0' } },
58
+ defaultVariants: { fill: false },
59
+ });
60
+ const columnLayoutFitProbes = cva(
61
+ 'pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden',
62
+ );
63
+
64
+ // One probe per declaration, as wide as that declaration's shortfall: the Column's own switch
65
+ // expression against the holder's width less any reserve, so `0px` means the row would hold and
66
+ // `1px` that it would stack. Reading the width back is the whole measurement - no length is
67
+ // parsed in script.
68
+ const columnLayoutFitProbe = cva(
69
+ 'h-0 w-[clamp(0px,(var(--column-layout-threshold)-100%+var(--column-layout-reserve))*1000000,1px)]',
70
+ {
71
+ variants: {
72
+ gap: {
73
+ stack: '[--column-layout-gap:var(--space-stack)]',
74
+ region: '[--column-layout-gap:var(--space-region)]',
75
+ },
76
+ },
77
+ defaultVariants: { gap: 'region' },
78
+ },
79
+ );
80
+
50
81
  // React's CSSProperties is closed over known properties; the custom properties the recipes
51
82
  // read are declared here so the style objects stay typed without an assertion.
52
83
  type ColumnLayoutRootStyle = CSSProperties & {
53
84
  '--column-layout-threshold': string;
54
85
  '--column-layout-growth-unit': number;
55
86
  };
87
+ type ColumnLayoutFitProbeStyle = CSSProperties & {
88
+ '--column-layout-threshold': string;
89
+ '--column-layout-reserve': string;
90
+ };
56
91
  type ColumnLayoutColumnStyle = CSSProperties & {
57
92
  '--column-layout-weight': number;
58
93
  '--column-layout-maximum': string;
@@ -227,6 +262,240 @@ const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
227
262
  );
228
263
  };
229
264
 
265
+ export interface IColumnLayoutAlternativesProps
266
+ extends VariantProps<typeof columnLayoutAlternatives> {
267
+ /** Accept the parent's allocated height and pass it to the mounted arrangement or fallback,
268
+ * which must accept it in turn (`ColumnLayout.Root fill`, `Tabs.Root fill`). */
269
+ fill?: VariantProps<typeof columnLayoutAlternatives>['fill'];
270
+ /** Mounted when none of the alternatives fits. Omitted, `undefined`, `null` or `false`, the
271
+ * last declared arrangement stays mounted instead, under its own stacking contract. */
272
+ fallback?: ReactNode;
273
+ /** The alternative arrangements in order of preference, as `ColumnLayout.Root` elements with
274
+ * at least one column each: direct children, or arrays and fragments of them. A conditional
275
+ * that renders nothing declares nothing. */
276
+ children?: ReactNode;
277
+ testId?: string;
278
+ }
279
+
280
+ type Arrangement = ReactElement<IColumnLayoutRootProps>;
281
+
282
+ // What an alternative contributes to selection: the Root to mount and the width its row holds at.
283
+ type Declaration = { arrangement: Arrangement; threshold: string };
284
+
285
+ // The outcome of one measurement, kept with the declarations it was taken for: an index into
286
+ // them, or NONE_FITS.
287
+ type Selection = { measured: string; firstFit: number };
288
+
289
+ // A selection change: the choice it mounted and the holder width that asked for it.
290
+ type Change = { to: number; width: number };
291
+
292
+ const NONE_FITS = -1;
293
+
294
+ // Changes further apart than this are separate events, not one mount answering another: a skipped
295
+ // observation is delivered in the next frame, and a content update the frame after.
296
+ const FEEDBACK_FRAMES = 3;
297
+
298
+ // The width the holder loses to `asked`, where the trail shows it: two round trips, to `asked` at
299
+ // this width and away at one other width. Only a holder whose width answers to what is mounted
300
+ // repeats itself this exactly.
301
+ const lossTo = (
302
+ trail: readonly Change[],
303
+ asked: number,
304
+ width: number,
305
+ ): number | undefined => {
306
+ const [first, away, second, awayAgain] = trail;
307
+ const isRepeated =
308
+ first?.to === asked &&
309
+ second?.to === asked &&
310
+ first.width === width &&
311
+ second.width === width &&
312
+ away?.to === awayAgain?.to &&
313
+ away?.width === awayAgain?.width;
314
+ return isRepeated && awayAgain !== undefined && awayAgain.width !== width
315
+ ? Math.abs(width - awayAgain.width)
316
+ : undefined;
317
+ };
318
+
319
+ const isRoot = (node: ReactNode): node is Arrangement =>
320
+ isValidElement(node) && node.type === ColumnLayoutRoot;
321
+
322
+ const collectArrangements = (children: ReactNode): Arrangement[] =>
323
+ Children.toArray(children).flatMap((child) => {
324
+ if (isFragment(child)) {
325
+ return collectArrangements(child.props.children);
326
+ }
327
+ if (!isRoot(child)) {
328
+ throw new ColumnLayoutConfigurationError(
329
+ 'Alternatives takes ColumnLayout.Root elements only (arrays and fragments are fine; other content or a wrapping component is not)',
330
+ );
331
+ }
332
+ return [child];
333
+ });
334
+
335
+ const toDeclaration = (
336
+ arrangement: Arrangement,
337
+ index: number,
338
+ ): Declaration => {
339
+ const tracks = collectColumns(arrangement.props.children).map(toTrack);
340
+ if (tracks.length === 0) {
341
+ throw new ColumnLayoutConfigurationError(
342
+ `alternative ${index + 1} declares no columns, so it has no minimum to fit`,
343
+ );
344
+ }
345
+ return { arrangement, threshold: thresholdOf(tracks) };
346
+ };
347
+
348
+ const probeStyleOf = (
349
+ threshold: string,
350
+ reserve: string,
351
+ ): ColumnLayoutFitProbeStyle => ({
352
+ '--column-layout-threshold': threshold,
353
+ '--column-layout-reserve': reserve,
354
+ });
355
+
356
+ // A probe is as wide as its declaration's shortfall, so a width of zero is a fit. No declaration
357
+ // at all answers NONE_FITS, which is also what "none fits" reads as.
358
+ const firstFitOf = (probes: HTMLElement): number =>
359
+ Array.from(probes.children).findIndex(
360
+ (probe) => probe.getBoundingClientRect().width === 0,
361
+ );
362
+
363
+ // Declaration order is the preference; the fallback and the retained last declaration come after
364
+ // every declaration that fits.
365
+ const preferenceOf = (firstFit: number): number =>
366
+ firstFit === NONE_FITS ? Number.POSITIVE_INFINITY : firstFit;
367
+
368
+ const ColumnLayoutAlternatives: FunctionComponent<
369
+ IColumnLayoutAlternativesProps
370
+ > = ({ fill, fallback, children, testId }) => {
371
+ const declarations = collectArrangements(children).map(toDeclaration);
372
+ const declared = declarations
373
+ .map(
374
+ ({ arrangement, threshold }) => `${arrangement.props.gap} ${threshold}`,
375
+ )
376
+ .join('\n');
377
+ const probes = useRef<HTMLDivElement>(null);
378
+ const mounted = useRef<ReactNode>(null);
379
+ const [selection, setSelection] = useState<Selection | undefined>(undefined);
380
+ const [reserve, setReserve] = useState(0);
381
+
382
+ // The first selection is made before the first paint; from then on a probe changes size only
383
+ // when its declaration's fit flips - on a holder resize or a re-pointed token alike. flushSync
384
+ // commits the new arrangement inside the observation, so no frame paints the outgoing one.
385
+ useLayoutEffect(() => {
386
+ const holder = probes.current;
387
+ if (holder === null) {
388
+ return;
389
+ }
390
+ let selected: number | undefined;
391
+ let trail: readonly Change[] = [];
392
+ const keep = (change: Change): void => {
393
+ const kept = [...trail.slice(-3), change];
394
+ let frames = FEEDBACK_FRAMES;
395
+ const tick = (): void => {
396
+ if (trail !== kept) {
397
+ return;
398
+ }
399
+ frames -= 1;
400
+ if (frames === 0) {
401
+ trail = [];
402
+ } else {
403
+ requestAnimationFrame(tick);
404
+ }
405
+ };
406
+ trail = kept;
407
+ requestAnimationFrame(tick);
408
+ };
409
+ const select = (): void => {
410
+ const asked = firstFitOf(holder);
411
+ if (asked === selected) {
412
+ return;
413
+ }
414
+ const { width } = holder.getBoundingClientRect();
415
+ const lost = lossTo(trail, asked, width);
416
+ if (lost !== undefined) {
417
+ // Hold the loss back from the preferred of the two, and settle on the later declaration.
418
+ setReserve((reserved) => Math.max(reserved, lost));
419
+ trail = [];
420
+ if (
421
+ selected !== undefined &&
422
+ preferenceOf(asked) < preferenceOf(selected)
423
+ ) {
424
+ return;
425
+ }
426
+ }
427
+ keep({ to: asked, width });
428
+ selected = asked;
429
+ setSelection({ measured: declared, firstFit: asked });
430
+ };
431
+ setReserve(0);
432
+ select();
433
+ if (typeof ResizeObserver === 'undefined') {
434
+ return;
435
+ }
436
+ const observer = new ResizeObserver(() => flushSync(select));
437
+ for (const probe of holder.children) {
438
+ observer.observe(probe);
439
+ }
440
+ return () => {
441
+ trail = [];
442
+ observer.disconnect();
443
+ };
444
+ }, [declared]);
445
+
446
+ const hasFallback =
447
+ fallback !== undefined && fallback !== null && fallback !== false;
448
+ // A selection measured for other declarations says nothing about these: what it mounted stays
449
+ // until the effect above has measured them, so no unselected arrangement mounts in between.
450
+ const current = selection?.measured === declared ? selection : undefined;
451
+ const fitting =
452
+ current === undefined
453
+ ? undefined
454
+ : declarations[current.firstFit]?.arrangement;
455
+ const chosen =
456
+ fitting ?? (hasFallback ? undefined : declarations.at(-1)?.arrangement);
457
+ const content: ReactNode =
458
+ current === undefined ? (
459
+ mounted.current
460
+ ) : chosen === undefined ? (
461
+ hasFallback && <Fragment key="fallback">{fallback}</Fragment>
462
+ ) : (
463
+ <ColumnLayoutRoot
464
+ key="arrangement"
465
+ gap={chosen.props.gap}
466
+ fill={chosen.props.fill}
467
+ testId={chosen.props.testId}
468
+ >
469
+ {chosen.props.children}
470
+ </ColumnLayoutRoot>
471
+ );
472
+ useLayoutEffect(() => {
473
+ mounted.current = content;
474
+ });
475
+
476
+ // Only a declaration preferred to the mounted one is judged with the reserve: the mounted one
477
+ // and those after it are measured at the width the holder has.
478
+ const reserveOf = (index: number): string =>
479
+ current !== undefined && index < preferenceOf(current.firstFit)
480
+ ? `${reserve}px`
481
+ : '0px';
482
+ return (
483
+ <div className={columnLayoutAlternatives({ fill })} data-testid={testId}>
484
+ <div ref={probes} aria-hidden="true" className={columnLayoutFitProbes()}>
485
+ {declarations.map(({ arrangement, threshold }, index) => (
486
+ <div
487
+ // biome-ignore lint/suspicious/noArrayIndexKey: a probe's identity is its place in the order of preference
488
+ key={index}
489
+ className={columnLayoutFitProbe({ gap: arrangement.props.gap })}
490
+ style={probeStyleOf(threshold, reserveOf(index))}
491
+ />
492
+ ))}
493
+ </div>
494
+ {content}
495
+ </div>
496
+ );
497
+ };
498
+
230
499
  /**
231
500
  * An arrangement of weighted columns that becomes one column when the space it is given cannot
232
501
  * satisfy every column's minimum at the requested proportions. It owns the arrangement and nothing
@@ -275,6 +544,38 @@ const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
275
544
  * `Root` - loose, nested in another column, or reached through a wrapping component - throws
276
545
  * {@link ColumnLayoutCompositionError}. Both are programmer errors, raised loud and early.
277
546
  *
547
+ * @Guarantees — `Alternatives`, the consumer's choice between arrangements
548
+ * - Its children are `Root`s in the consumer's order of preference. It mounts the first whose
549
+ * row would hold at its own width - the same threshold, tokens and gap that Root switches at,
550
+ * compared by the browser, with equality fitting - also when later ones fit too. Nothing is
551
+ * ranked, inferred or keyed to the viewport; two instances on a page select independently.
552
+ * - When none fits it mounts `fallback` if one was supplied, and otherwise keeps the last
553
+ * declared Root, which stacks under its own contract. Selection never floors a width, so it
554
+ * causes no horizontal overflow. With nothing declared it mounts the fallback, or nothing.
555
+ * - Selection follows the holder's width and every token the thresholds read - a re-pointed
556
+ * minimum, a space role, a font-relative unit - without a resize, and is committed before the
557
+ * next paint. Until the first measurement (a server render, the first client render) no
558
+ * arrangement is mounted. Without layout, as in jsdom, every declaration measures as fitting.
559
+ * - Only the selected arrangement is mounted: no second copy of an area, an id or a control, and
560
+ * nothing kept hidden. A change that leaves the selection alone touches no content. When the
561
+ * declarations themselves change, what is mounted stays until the new ones are measured, in
562
+ * the same update: nothing they do not select is mounted in between.
563
+ * - A holder that loses width to what is mounted in it - a scroll body whose scrollbar shows for
564
+ * one arrangement only - settles. Once the same two selections have alternated at the same two
565
+ * widths twice, no more than three frames apart, the later declaration stays and the width lost
566
+ * is held back from every declaration preferred to the mounted one until the declarations
567
+ * change: an earlier one returns once it fits with that much to spare. Otherwise nothing is
568
+ * held back, and a width that merely drops and returns is followed exactly.
569
+ * - On a selection change a column declared in both arrangements under the same React `key`
570
+ * (unkeyed: the same position) with the same element types inside stays mounted, with its
571
+ * state and focus. Everything else unmounts; content that returns mounts fresh, and no focus,
572
+ * text selection, scroll position or local state is cached or restored for it.
573
+ * - It adds no role, name, tab stop, spacing, scrolling or selection request of its own. With
574
+ * `fill` it passes its parent's allocated height to what it mounts.
575
+ * - A child that is not a `Root` - text, another element, a component wrapping a Root - or a
576
+ * Root without columns throws {@link ColumnLayoutConfigurationError}. Every declaration is
577
+ * validated, whether or not it is the one mounted.
578
+ *
278
579
  * @CallerMustEnsure — the component cannot see these and does not check them
279
580
  * - Every `minWidth` and supplied `maxWidth` token is declared, on the Root or an ancestor of it,
280
581
  * as a valid nonnegative CSS length: `24rem`, `20em`, `40ch`, `320px`. A `rem` resolves against
@@ -293,6 +594,13 @@ const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
293
594
  * - `Root`'s children are `Column`s. Anything else placed beside them is rendered as given but is
294
595
  * not a column: it takes no weight, counts for no gap or threshold, and sits in the row as
295
596
  * content of its own width, which breaks the proportions. Content belongs inside a `Column`.
597
+ * - For `Alternatives`: state that must survive a removal - a draft, the active tab - is kept
598
+ * by the application and supplied to the fresh mount; Tabs inside an arrangement receive a
599
+ * valid `active` whenever they mount. An area that should stay mounted carries the same `key`
600
+ * in each arrangement. With `fill`, the Roots and the fallback accept the height themselves.
601
+ * A holder whose width answers to the mounted arrangement more than three frames after the
602
+ * mount, or differently each time, is outside the contract: that dependency is not recognised
603
+ * and can alternate.
296
604
  *
297
605
  * @UXGuidelines
298
606
  * - A minimum is the width below which the column's content stops being readable or operable -
@@ -300,8 +608,12 @@ const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
300
608
  * lines - not the width the designer would like. The stack is the readable fallback.
301
609
  * - Weights are structural relationships: `2` and `1` say the table is the subject and the summary
302
610
  * its support. A ratio chosen to hit a pixel width is a measurement, and the theme owns those.
611
+ * - Alternatives are for one screen arranged differently, not for different screens: declare the
612
+ * same areas in the same reading order and let the narrower arrangement group some of them,
613
+ * in `Tabs` for instance. Which areas exist and how they group is the application's decision.
303
614
  */
304
615
  export const ColumnLayout = {
305
616
  Root: ColumnLayoutRoot,
306
617
  Column: ColumnLayoutColumn,
618
+ Alternatives: ColumnLayoutAlternatives,
307
619
  } as const;
@@ -325,8 +325,8 @@ const useOverflow = (
325
325
  table: { current: HTMLElement | null },
326
326
  caption: { current: HTMLElement | null },
327
327
  bounded: boolean,
328
- ): boolean => {
329
- const [isOverflowing, setIsOverflowing] = useState(true);
328
+ ): number | undefined => {
329
+ const [scrollTabIndex, setScrollTabIndex] = useState<number | undefined>(0);
330
330
  useLayoutEffect(() => {
331
331
  const element = wrapper.current;
332
332
  if (element === null) return;
@@ -341,10 +341,27 @@ const useOverflow = (
341
341
  Number.parseFloat(getComputedStyle(element).paddingTop) || 0;
342
342
  element.style.scrollPaddingTop = `${headerHeight + 2 * ringRoom}px`;
343
343
  }
344
- setIsOverflowing(
344
+ // Only bounded tables participate in Dialog release. Reading an absent inherited custom
345
+ // property is depth-dependent in jsdom 29.1.1, even for ordinary content tables (#157).
346
+ const isReleased =
347
+ bounded &&
348
+ getComputedStyle(element)
349
+ .getPropertyValue('--_scroll-released')
350
+ .trim() === '1';
351
+ const isFocused = element === document.activeElement;
352
+ const overflows =
345
353
  element.scrollWidth > element.clientWidth ||
346
- (bounded && element.scrollHeight > element.clientHeight) ||
347
- element === document.activeElement,
354
+ (bounded && element.scrollHeight > element.clientHeight);
355
+ // Removing tabindex from an active element blurs it in Chromium. A retained -1 keeps
356
+ // that focus through release while removing the extra scrolling stop from Tab order.
357
+ setScrollTabIndex(
358
+ isReleased
359
+ ? isFocused
360
+ ? -1
361
+ : undefined
362
+ : overflows || isFocused
363
+ ? 0
364
+ : undefined,
348
365
  );
349
366
  };
350
367
  measure();
@@ -376,11 +393,16 @@ const useOverflow = (
376
393
  element.removeEventListener('blur', measure);
377
394
  };
378
395
  }, [wrapper, table, caption, bounded]);
379
- return isOverflowing;
396
+ return scrollTabIndex;
380
397
  };
381
398
 
382
399
  const revealFocus = (event: FocusEvent<HTMLDivElement>): void => {
383
400
  const wrapper = event.currentTarget;
401
+ if (
402
+ getComputedStyle(wrapper).getPropertyValue('--_scroll-released').trim() ===
403
+ '1'
404
+ )
405
+ return;
384
406
  const target = event.target;
385
407
  if (target === wrapper) return;
386
408
  const bounds = wrapper.getBoundingClientRect();
@@ -428,12 +450,13 @@ const TableRoot: FunctionComponent<ITableRootProps> = ({
428
450
  const tableElement = useRef<HTMLTableElement>(null);
429
451
  const captionElement = useRef<HTMLTableCaptionElement>(null);
430
452
  const trackList = trackListOf(columns);
431
- const isOverflowing = useOverflow(
453
+ const scrollTabIndex = useOverflow(
432
454
  wrapper,
433
455
  tableElement,
434
456
  captionElement,
435
457
  bounded === true,
436
458
  );
459
+ const isOverflowing = scrollTabIndex !== undefined;
437
460
  const style: TableRootStyle | undefined =
438
461
  trackList === undefined ? undefined : { '--table-columns': trackList };
439
462
  const columnCount = trackList === undefined ? undefined : columns?.length;
@@ -500,7 +523,7 @@ const TableRoot: FunctionComponent<ITableRootProps> = ({
500
523
  data-testid={testId}
501
524
  role={isOverflowing ? 'group' : undefined}
502
525
  aria-label={isOverflowing ? caption : undefined}
503
- tabIndex={isOverflowing ? 0 : undefined}
526
+ tabIndex={scrollTabIndex}
504
527
  >
505
528
  {content}
506
529
  </div>
@@ -683,6 +706,10 @@ const TableCell: FunctionComponent<ITableCellProps> = ({
683
706
  * table holding an interactive row makes ring room around itself, so a focused row's ring is never
684
707
  * clipped by the scroll region. A table with neither keeps its static geometry exactly.
685
708
  *
709
+ * - In Dialog's insufficient-height fallback the enclosing Dialog releases this wrapper's
710
+ * bound and pinned header. It contributes no independent scroll region or scroll tab stop;
711
+ * content and focus stay mounted while sufficient space restores bounded scrolling.
712
+ *
686
713
  * @CallerMustEnsure — the component cannot see these and does not check them
687
714
  * - For bounded mode supply finite usable parent height, with room for the header and readable rows.
688
715
  * Compose directly inside `Pane.Body scrollOwner="child"` or `Dialog.Content scrollOwner="child"`:
@@ -1,4 +1,4 @@
1
- import { cva } from 'class-variance-authority';
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
2
  import {
3
3
  createContext,
4
4
  type FunctionComponent,
@@ -10,7 +10,15 @@ import {
10
10
  import type { Subject } from 'rxjs';
11
11
  import { ChoicesCompositionError } from './ChoicesCompositionError';
12
12
 
13
- const choicesGroup = cva('flex flex-col gap-[var(--space-stack)]');
13
+ const choicesGroup = cva('flex min-w-0 gap-[var(--space-stack)]', {
14
+ variants: {
15
+ arrangement: {
16
+ vertical: 'flex-col [&>[role=radio]]:w-full',
17
+ wrapping: 'flex-row flex-wrap items-start',
18
+ },
19
+ },
20
+ defaultVariants: { arrangement: 'vertical' },
21
+ });
14
22
 
15
23
  // The selected row is told apart by the marker dot and a boundary flip from `controlBorder` to
16
24
  // `foreground` - Tabs' marker treatment, keyed on aria-checked so the attribute the device reads
@@ -18,7 +26,7 @@ const choicesGroup = cva('flex flex-col gap-[var(--space-stack)]');
18
26
  // geometry. Inert mutes only the name's ink (the Sidebar treatment); the selection stays visible.
19
27
  const choicesChoice = cva(
20
28
  [
21
- 'group/choice flex w-full flex-row items-start gap-3 text-left',
29
+ 'group/choice flex min-w-0 max-w-full flex-none flex-row items-start gap-3 text-left wrap-anywhere',
22
30
  'rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent aria-checked:border-foreground',
23
31
  'px-3 py-2',
24
32
  'cursor-pointer disabled:cursor-not-allowed',
@@ -68,7 +76,10 @@ const ARROW_STEP: Record<string, 1 | -1> = {
68
76
  ArrowLeft: -1,
69
77
  };
70
78
 
71
- export interface IChoicesRootProps {
79
+ export interface IChoicesRootProps extends VariantProps<typeof choicesGroup> {
80
+ /** Full-width vertical rows by default; wrapping uses content-sized choices that flow onto
81
+ * further lines. Both arrangements keep declared-order keyboard movement. */
82
+ arrangement?: VariantProps<typeof choicesGroup>['arrangement'];
72
83
  /** The key of the selected choice. Must name a declared choice; the consumer owns it. */
73
84
  selected: string;
74
85
  /** Emits a choice's key when an available non-selected row is activated by pointer or arrow
@@ -76,7 +87,7 @@ export interface IChoicesRootProps {
76
87
  onSelect$: Subject<string>;
77
88
  /** The radio group's accessible name. */
78
89
  label: string;
79
- /** Present with its retained selection and every description visible, but unavailable:
90
+ /** Present with its retained selection and every supplied description visible, but unavailable:
80
91
  * semantically disabled, skipped by Tab, and deaf to pointer and keyboard input. */
81
92
  inert?: boolean;
82
93
  children: ReactNode;
@@ -89,12 +100,13 @@ export interface IChoicesChoiceProps {
89
100
  value: string;
90
101
  /** The visible name. Text only - no icons, no per-row markup. */
91
102
  children: string;
92
- /** The visible description under the name, exposed as the row's accessible description. */
93
- description: string;
103
+ /** Optional visible text under the name, exposed as the choice's accessible description. */
104
+ description?: string;
94
105
  testId?: string;
95
106
  }
96
107
 
97
108
  const ChoicesRoot: FunctionComponent<IChoicesRootProps> = ({
109
+ arrangement,
98
110
  selected,
99
111
  onSelect$,
100
112
  label,
@@ -109,7 +121,7 @@ const ChoicesRoot: FunctionComponent<IChoicesRootProps> = ({
109
121
  role={'radiogroup'}
110
122
  aria-label={label}
111
123
  aria-disabled={inert === true ? 'true' : undefined}
112
- className={choicesGroup()}
124
+ className={choicesGroup({ arrangement })}
113
125
  data-testid={testId}
114
126
  >
115
127
  {children}
@@ -157,7 +169,7 @@ const ChoicesChoice: FunctionComponent<IChoicesChoiceProps> = ({
157
169
  role={'radio'}
158
170
  aria-checked={isSelected}
159
171
  aria-labelledby={nameId}
160
- aria-describedby={descriptionId}
172
+ aria-describedby={description ? descriptionId : undefined}
161
173
  tabIndex={isSelected && !inert ? 0 : -1}
162
174
  disabled={inert}
163
175
  data-value={value}
@@ -200,19 +212,21 @@ const ChoicesChoice: FunctionComponent<IChoicesChoiceProps> = ({
200
212
  >
201
213
  {children}
202
214
  </span>
203
- <span
204
- id={descriptionId}
205
- className={'font-secondary text-muted text-small'}
206
- >
207
- {description}
208
- </span>
215
+ {description && (
216
+ <span
217
+ id={descriptionId}
218
+ className={'font-secondary text-muted text-small'}
219
+ >
220
+ {description}
221
+ </span>
222
+ )}
209
223
  </span>
210
224
  </button>
211
225
  );
212
226
  };
213
227
 
214
228
  /**
215
- * A single selection from a small set of described options: vertically arranged choice rows,
229
+ * A single selection from a small set of named options with optional descriptions: choice rows,
216
230
  * exactly one selected, following the WAI-ARIA radio-group pattern. Controlled - the consumer owns
217
231
  * the selected key and every word; Choices owns the rows, the marker and the highlight. Composed
218
232
  * from two members: `Root` carries the contract, `Choice` one row.
@@ -220,7 +234,8 @@ const ChoicesChoice: FunctionComponent<IChoicesChoiceProps> = ({
220
234
  * @Guarantees — enforced on every render
221
235
  * - Renders `role="radiogroup"` named by `label`, with each row a `role="radio"` button carrying
222
236
  * `aria-checked`, its visible name as accessible name and its description as accessible
223
- * description; ids are minted per row, so two groups on a page cannot collide.
237
+ * description when supplied; omitted or empty descriptions render no content or association.
238
+ * Ids are minted per row, so two groups on a page cannot collide.
224
239
  * - Selection is the value of `selected`, nothing else: activating an available non-selected row
225
240
  * emits its key on `onSelect$`; the selected row, an unanswered request and every render emit
226
241
  * nothing, and no fallback selection is invented - ever.
@@ -232,7 +247,10 @@ const ChoicesChoice: FunctionComponent<IChoicesChoiceProps> = ({
232
247
  * - An inert group keeps the selected marker, highlight and every name and description visible,
233
248
  * mutes only the names' ink, exposes `aria-disabled` on the group with every row disabled, adds
234
249
  * no Tab stop, and emits nothing for any pointer or keyboard input.
235
- * - Names and descriptions wrap at narrow widths; a row never widens its group.
250
+ * - `arrangement="vertical"` (the default) gives every choice the group's width. `wrapping`
251
+ * sizes choices to content without sharing surplus width and flows whole choices onto further
252
+ * lines. Names, unbroken words and supplied descriptions wrap within the available group width.
253
+ * - Arrow order is declared order even across visual line breaks, never spatial row navigation.
236
254
  *
237
255
  * @CallerMustEnsure — the component cannot see these and does not check them
238
256
  * - Choice rows are direct children of `Root` (arrays and fragments are supported), each `value`
@@ -243,7 +261,7 @@ const ChoicesChoice: FunctionComponent<IChoicesChoiceProps> = ({
243
261
  * a consumer that ignores a request has decided the selection stays.
244
262
  *
245
263
  * @UXGuidelines
246
- * - Names are short nouns for options and descriptions one or two sentences under them; the
264
+ * - Names are short nouns for options and optional descriptions one or two sentences under them; the
247
265
  * consuming app words and translates both - the library ships no text of its own.
248
266
  * - This is not a form field: no `<label>` pairing, no validation, no submission. Wire `onSelect$`
249
267
  * to whatever owns the selected key and pass that key back in.