@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.
- package/dist/design-system.js +723 -547
- package/dist/styles.css +1 -1
- package/dist/styles.dark.css +1 -1
- package/dist/styles.light.css +1 -1
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +59 -0
- package/dist/types/Display/Table/Table.d.ts +4 -0
- package/dist/types/Interaction/Choices/Choices.d.ts +20 -8
- package/dist/types/Layout/Dialog/Dialog.d.ts +11 -4
- package/dist/types/Layout/Dialog/useDialogAllocation.d.ts +2 -0
- package/dist/types/Layout/Dialog/useModalScrollLock.d.ts +2 -0
- package/dist/types/Layout/Pane/Pane.d.ts +5 -2
- package/dist/types/Layout/Screen/Screen.d.ts +2 -2
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +9 -6
- package/package.json +1 -1
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +312 -0
- package/src/Display/Table/Table.tsx +35 -8
- package/src/Interaction/Choices/Choices.tsx +37 -19
- package/src/Layout/Dialog/Dialog.tsx +28 -7
- package/src/Layout/Dialog/useDialogAllocation.ts +131 -0
- package/src/Layout/Dialog/useModalScrollLock.ts +59 -0
- package/src/Layout/Pane/Pane.tsx +14 -4
- package/src/Layout/Screen/Screen.tsx +2 -2
- package/src/Layout/Sidebar/Sidebar.tsx +10 -7
|
@@ -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
|
-
):
|
|
329
|
-
const [
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
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
|
|
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={
|
|
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
|
|
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
|
-
/**
|
|
93
|
-
description
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
|
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
|
-
* -
|
|
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.
|