@juwel-development/design-system 3.5.0 → 3.7.0

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.
@@ -0,0 +1,478 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import {
3
+ createContext,
4
+ type FunctionComponent,
5
+ type MouseEvent,
6
+ type ReactNode,
7
+ type RefObject,
8
+ useCallback,
9
+ useContext,
10
+ useEffect,
11
+ useId,
12
+ useRef,
13
+ useState,
14
+ } from 'react';
15
+ import type { Subject } from 'rxjs';
16
+ import { DialogCompositionError } from './DialogCompositionError';
17
+ import { DialogNamingError } from './DialogNamingError';
18
+
19
+ // The layout classes ride the open: variant - a bare `flex` would defeat the UA's
20
+ // dialog:not([open]) hiding. Tailwind's preflight zeroes the UA's centering margins (m-auto
21
+ // restores them) and strips ::backdrop, so the Scrim colour and blur are declared here.
22
+ // overflow-hidden overrides the UA's `dialog { overflow: auto }` - only Content may scroll.
23
+ const dialogRoot = cva(
24
+ [
25
+ 'open:flex open:flex-col m-auto p-0 overflow-hidden',
26
+ 'bg-surface text-foreground',
27
+ 'rounded-[var(--radius-dialog)] shadow-[var(--elevation-floating)]',
28
+ 'backdrop:bg-scrim backdrop:[backdrop-filter:blur(var(--scrim-blur))]',
29
+ ].join(' '),
30
+ {
31
+ variants: {
32
+ extent: {
33
+ screen: [
34
+ 'w-[calc(100vw-2*var(--gutter))] max-w-none',
35
+ 'h-[calc(100dvh-2*var(--space-region))] max-h-none',
36
+ ].join(' '),
37
+ content: [
38
+ 'w-[32rem] max-w-[calc(100vw-2*var(--gutter))]',
39
+ 'max-h-[calc(100dvh-2*var(--space-region))]',
40
+ ].join(' '),
41
+ },
42
+ },
43
+ defaultVariants: { extent: 'screen' },
44
+ },
45
+ );
46
+
47
+ // Title and Description form one header region: --space-region padding around it, --space-stack
48
+ // between the two. Each member carries its share of the padding on a wrapper at the dialog's
49
+ // inherited type size - never on the sized h1, whose em would inflate the inset. A Title
50
+ // followed by a Description hands the region's bottom padding to it.
51
+ const dialogTitle = cva(
52
+ [
53
+ 'px-[var(--space-region)] pt-[var(--space-region)] pb-[var(--space-region)]',
54
+ '[&:has(+[data-dialog-member=description])]:pb-0',
55
+ ].join(' '),
56
+ );
57
+
58
+ const dialogDescription = cva(
59
+ [
60
+ 'px-[var(--space-region)] pt-[var(--space-region)] pb-[var(--space-region)]',
61
+ '[[data-dialog-member=title]+&]:mt-[var(--space-stack)] [[data-dialog-member=title]+&]:pt-0',
62
+ ].join(' '),
63
+ );
64
+
65
+ // The one internally scrollable region: it grows into the screen extent's fixed height and
66
+ // shrinks when the emergency max-height binds, so the header and Actions stay fixed while only
67
+ // this scrolls. Its hairline exists only when a header region stands before it.
68
+ const dialogContent = cva(
69
+ [
70
+ 'grow shrink min-h-0 overflow-y-auto',
71
+ 'px-[var(--space-region)] py-[var(--space-region)]',
72
+ '[&:not(:first-child)]:border-t [&:not(:first-child)]:border-border',
73
+ ].join(' '),
74
+ );
75
+
76
+ // An end-aligned row in consumer DOM order, wrapping on narrow screens without reversing. Its
77
+ // hairline exists only when Content stands directly before it - a header alone gets no divider.
78
+ const dialogActions = cva(
79
+ [
80
+ 'flex flex-row flex-wrap items-center justify-end gap-[var(--space-stack)] shrink-0',
81
+ 'px-[var(--space-region)] pt-[var(--space-region)] pb-[var(--space-region)]',
82
+ '[[data-dialog-member=content]+&]:border-t [[data-dialog-member=content]+&]:border-border',
83
+ ].join(' '),
84
+ );
85
+
86
+ type NamePart = 'title' | 'description';
87
+
88
+ type DialogContract = {
89
+ titleId: string;
90
+ descriptionId: string;
91
+ isLabelled: boolean;
92
+ /** Title and Description announce themselves so Root writes `aria-labelledby` and
93
+ * `aria-describedby` only when the referenced element exists - an unresolved idref is an ARIA
94
+ * authoring error - because Root cannot inspect arbitrary children or fragments. */
95
+ registerNamePart: (part: NamePart) => () => void;
96
+ };
97
+
98
+ const DialogContext = createContext<DialogContract | undefined>(undefined);
99
+
100
+ const useDialogContract = (member: string): DialogContract => {
101
+ const contract = useContext(DialogContext);
102
+ if (contract === undefined) {
103
+ throw new DialogCompositionError(member);
104
+ }
105
+ return contract;
106
+ };
107
+
108
+ const FOCUSABLE_SELECTOR =
109
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
110
+
111
+ type Presentation = {
112
+ presented: RefObject<boolean>;
113
+ opener: RefObject<HTMLElement | undefined>;
114
+ };
115
+
116
+ // Module-scope so the effect below can depend on the Subject instance alone: these reach only the
117
+ // refs handed to them, never a render's closure. Opening captures the focused opener and moves
118
+ // focus to the first focusable descendant - Cancel-first compositions therefore focus Cancel.
119
+ const openDialog = (
120
+ dialog: HTMLDialogElement | undefined,
121
+ { presented, opener }: Presentation,
122
+ ): void => {
123
+ if (dialog === undefined || dialog.open) {
124
+ return;
125
+ }
126
+ // With nothing focused the browser reports `body` as active; capturing it would make closing
127
+ // focus `body`, which the contract forbids - so only a real opener is worth restoring.
128
+ const active = document.activeElement;
129
+ opener.current =
130
+ active instanceof HTMLElement && active !== document.body
131
+ ? active
132
+ : undefined;
133
+ dialog.showModal();
134
+ presented.current = true;
135
+ dialog.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)?.focus();
136
+ };
137
+
138
+ const closeDialog = (
139
+ dialog: HTMLDialogElement | undefined,
140
+ { presented }: Presentation,
141
+ ): void => {
142
+ presented.current = false;
143
+ if (dialog?.open) {
144
+ dialog.close();
145
+ }
146
+ };
147
+
148
+ export interface IDialogRootProps extends VariantProps<typeof dialogRoot> {
149
+ /** The composed regions, in the supported order: Title, Description, Content, Actions. */
150
+ children: ReactNode;
151
+ /** Emits on every dismissal - Escape and the Scrim. A consumer's own Cancel control emits its
152
+ * outcome itself; opening never emits, and neither does a consumer-emitted `false`. */
153
+ onDismiss$: Subject<void>;
154
+ /** Bidirectional visibility (docs/adr/0014): consumer booleans drive presentation without
155
+ * unmounting children, the Dialog emits only `false` after a dismissal, and a replaced Subject
156
+ * resets the Dialog to closed. Absent, the Dialog opens on mount and stays until unmounted. */
157
+ showDialog$?: Subject<boolean>;
158
+ /** Names an intentionally titleless Dialog. Exactly one naming source: never together with a
159
+ * visible `Dialog.Title`. */
160
+ ariaLabel?: string;
161
+ testId?: string;
162
+ }
163
+
164
+ export interface IDialogTitleProps {
165
+ /** The Dialog's visible name. Text only; the consuming app words and translates it. */
166
+ children: string;
167
+ testId?: string;
168
+ }
169
+
170
+ export interface IDialogDescriptionProps {
171
+ /** One concise line of phrasing content; block structure belongs in `Dialog.Content`. */
172
+ children: ReactNode;
173
+ testId?: string;
174
+ }
175
+
176
+ export interface IDialogContentProps {
177
+ /** Arbitrary task content, composed and owned by the consumer. */
178
+ children: ReactNode;
179
+ testId?: string;
180
+ }
181
+
182
+ export interface IDialogActionsProps {
183
+ /** Consumer-owned controls in consumer order; at least one explicit dismissal control. */
184
+ children: ReactNode;
185
+ testId?: string;
186
+ }
187
+
188
+ const DialogRoot: FunctionComponent<IDialogRootProps> = ({
189
+ children,
190
+ onDismiss$,
191
+ showDialog$,
192
+ extent,
193
+ ariaLabel,
194
+ testId,
195
+ }) => {
196
+ const baseId = useId();
197
+ const dialogRef = useRef<HTMLDialogElement | undefined>(undefined);
198
+ // What the component believes about its own presentation. The close handler reads it to tell a
199
+ // platform-initiated close (form method="dialog") - which must synchronize the Subject - from a
200
+ // close the component performed itself, which must not re-emit.
201
+ const presentedRef = useRef(false);
202
+ const openerRef = useRef<HTMLElement | undefined>(undefined);
203
+ // One stable bundle: the module-scope open/close reach only these refs, never a render closure.
204
+ const [presentation] = useState<Presentation>(() => ({
205
+ presented: presentedRef,
206
+ opener: openerRef,
207
+ }));
208
+ const [nameParts, setNameParts] = useState({
209
+ title: false,
210
+ description: false,
211
+ });
212
+ const registerNamePart = useCallback((part: NamePart) => {
213
+ setNameParts((current) => ({ ...current, [part]: true }));
214
+ return () => setNameParts((current) => ({ ...current, [part]: false }));
215
+ }, []);
216
+ const contract: DialogContract = {
217
+ titleId: `${baseId}title`,
218
+ descriptionId: `${baseId}description`,
219
+ isLabelled: ariaLabel !== undefined,
220
+ registerNamePart,
221
+ };
222
+
223
+ // Escape and the Scrim: the dismissal notification first, then the visibility transition the
224
+ // contract owns (docs/adr/0014). Without a Subject the notification is the consumer's signal to
225
+ // unmount, and the presentation still ends deterministically.
226
+ const dismiss = (): void => {
227
+ onDismiss$.next();
228
+ if (showDialog$ === undefined) {
229
+ closeDialog(dialogRef.current, presentation);
230
+ } else {
231
+ showDialog$.next(false);
232
+ }
233
+ };
234
+
235
+ // A backdrop click targets the dialog element itself, but so does a click on the surface's own
236
+ // padding - only coordinates outside the box are the Scrim. Keyboard-synthesized clicks target
237
+ // the operated control, so they never reach the target check.
238
+ const dismissFromScrim = (event: MouseEvent<HTMLDialogElement>): void => {
239
+ const dialog = dialogRef.current;
240
+ if (dialog === undefined || event.target !== dialog || !dialog.open) {
241
+ return;
242
+ }
243
+ const box = dialog.getBoundingClientRect();
244
+ const onSurface =
245
+ event.clientX >= box.left &&
246
+ event.clientX <= box.right &&
247
+ event.clientY >= box.top &&
248
+ event.clientY <= box.bottom;
249
+ if (!onSurface) {
250
+ dismiss();
251
+ }
252
+ };
253
+
254
+ const synchronizeClose = (): void => {
255
+ // The platform queues the close event as a task, so it can arrive after a new presentation
256
+ // has already begun - a rapid false-then-true, or a replaced Subject emitting true at once.
257
+ // An element that is open again marks the event stale: acting on it would emit `false` into
258
+ // the new presentation, corrupt the state and steal its focus.
259
+ if (dialogRef.current?.open) {
260
+ return;
261
+ }
262
+ const wasPresented = presentedRef.current;
263
+ presentedRef.current = false;
264
+ const opener = openerRef.current;
265
+ openerRef.current = undefined;
266
+ // Restore only a still-connected, still-focusable opener; no body fallback and no invented
267
+ // destination - a consumer whose confirmation removed the opener focuses its own target.
268
+ if (
269
+ opener?.isConnected &&
270
+ (opener.matches(FOCUSABLE_SELECTOR) || opener.hasAttribute('tabindex'))
271
+ ) {
272
+ opener.focus();
273
+ }
274
+ if (wasPresented) {
275
+ showDialog$?.next(false);
276
+ }
277
+ };
278
+
279
+ // The one effect: mount-open when no Subject is supplied, otherwise observe the current Subject
280
+ // instance. Replacing the instance tears the old subscription down and resets to closed
281
+ // (docs/adr/0013 - the component owns teardown, the consumer owns the stream).
282
+ useEffect(() => {
283
+ if (showDialog$ === undefined) {
284
+ openDialog(dialogRef.current, presentation);
285
+ return () => closeDialog(dialogRef.current, presentation);
286
+ }
287
+ const subscription = showDialog$.subscribe((visible) => {
288
+ if (visible) {
289
+ openDialog(dialogRef.current, presentation);
290
+ } else {
291
+ closeDialog(dialogRef.current, presentation);
292
+ }
293
+ });
294
+ return () => {
295
+ subscription.unsubscribe();
296
+ closeDialog(dialogRef.current, presentation);
297
+ };
298
+ }, [showDialog$, presentation]);
299
+
300
+ // headingreset is set through the ref because React's DOM typings do not know the attribute
301
+ // yet; it marks the h1 as the top heading of an independent task (docs/adr/0005). React hands
302
+ // the callback `null` on detach; the boundary normalizes that to the standard's `undefined`.
303
+ const attachDialog = (node: HTMLDialogElement | null): void => {
304
+ dialogRef.current = node ?? undefined;
305
+ node?.setAttribute('headingreset', '');
306
+ };
307
+
308
+ return (
309
+ <DialogContext.Provider value={contract}>
310
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: the Scrim is pointer-only by nature; the keyboard dismissal is Escape, which the platform reports as the cancel event handled by onCancel */}
311
+ <dialog
312
+ ref={attachDialog}
313
+ className={dialogRoot({ extent })}
314
+ aria-label={ariaLabel}
315
+ aria-labelledby={
316
+ ariaLabel === undefined && nameParts.title
317
+ ? contract.titleId
318
+ : undefined
319
+ }
320
+ aria-describedby={
321
+ nameParts.description ? contract.descriptionId : undefined
322
+ }
323
+ onCancel={dismiss}
324
+ onClick={dismissFromScrim}
325
+ onClose={synchronizeClose}
326
+ data-testid={testId}
327
+ >
328
+ {children}
329
+ </dialog>
330
+ </DialogContext.Provider>
331
+ );
332
+ };
333
+
334
+ const DialogTitle: FunctionComponent<IDialogTitleProps> = ({
335
+ children,
336
+ testId,
337
+ }) => {
338
+ const { titleId, isLabelled, registerNamePart } = useDialogContract('Title');
339
+ useEffect(() => registerNamePart('title'), [registerNamePart]);
340
+ if (isLabelled) {
341
+ throw new DialogNamingError();
342
+ }
343
+ return (
344
+ <div
345
+ data-dialog-member={'title'}
346
+ className={dialogTitle()}
347
+ data-testid={testId}
348
+ >
349
+ {/* The independent Dialog h1 at its own type role - not the page's display role, and not a
350
+ generic heading primitive (docs/adr/0004 and 0005, Amendments). */}
351
+ <h1
352
+ id={titleId}
353
+ className={
354
+ 'font-primary text-dialog-title leading-dialog-title text-foreground'
355
+ }
356
+ >
357
+ {children}
358
+ </h1>
359
+ </div>
360
+ );
361
+ };
362
+
363
+ const DialogDescription: FunctionComponent<IDialogDescriptionProps> = ({
364
+ children,
365
+ testId,
366
+ }) => {
367
+ const { descriptionId, registerNamePart } = useDialogContract('Description');
368
+ useEffect(() => registerNamePart('description'), [registerNamePart]);
369
+ return (
370
+ <div
371
+ data-dialog-member={'description'}
372
+ className={dialogDescription()}
373
+ data-testid={testId}
374
+ >
375
+ <p
376
+ id={descriptionId}
377
+ className={'font-primary text-body leading-body text-foreground'}
378
+ >
379
+ {children}
380
+ </p>
381
+ </div>
382
+ );
383
+ };
384
+
385
+ const DialogContent: FunctionComponent<IDialogContentProps> = ({
386
+ children,
387
+ testId,
388
+ }) => {
389
+ useDialogContract('Content');
390
+ return (
391
+ <div
392
+ data-dialog-member={'content'}
393
+ className={dialogContent()}
394
+ data-testid={testId}
395
+ >
396
+ {children}
397
+ </div>
398
+ );
399
+ };
400
+
401
+ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
402
+ children,
403
+ testId,
404
+ }) => {
405
+ useDialogContract('Actions');
406
+ return (
407
+ <div
408
+ data-dialog-member={'actions'}
409
+ className={dialogActions()}
410
+ data-testid={testId}
411
+ >
412
+ {children}
413
+ </div>
414
+ );
415
+ };
416
+
417
+ /**
418
+ * A temporary, always-named modal surface interrupting the page with one focused task, built on
419
+ * the platform `<dialog>` and `showModal()`: the browser owns the top layer, background inertness
420
+ * and the modal keyboard boundary; the consumer owns all wording, controls, outcomes and
421
+ * replacement. Composed from five members: `Root` carries the contract, `Title` the visible name,
422
+ * `Description` one connected line, `Content` the only scrollable region, `Actions` the controls.
423
+ *
424
+ * @Guarantees — enforced on every render
425
+ * - Presents through `showModal()`/`close()` - no portal, focus trap or polyfill - so background
426
+ * content is unavailable and keyboard focus is contained by the platform contract.
427
+ * - Names itself through exactly one source: a visible `Title` wired by `aria-labelledby`, or
428
+ * Root's `ariaLabel`; both at once throw. `Description` is wired by `aria-describedby` on its
429
+ * own, and arbitrary `Content` is never promoted to the accessible description.
430
+ * - The `h1` in `Title` is the top heading of the Dialog's independent task - Root carries
431
+ * `headingreset` - at the `--text-dialog-title` role, independent of the page ladder.
432
+ * - Without `showDialog$` it opens on mount and stays until unmounted. With it, it starts closed
433
+ * until the first emission, follows every boolean without unmounting children, and resets to
434
+ * closed when the Subject instance is replaced. It owns teardown of its subscription, emits only
435
+ * `false`, and never completes, validates or error-handles the consumer's stream.
436
+ * - Escape and a Scrim interaction are dismissals: `onDismiss$.next()` first, then
437
+ * `showDialog$.next(false)` where that Subject exists. A consumer-emitted `false` hides without
438
+ * a dismissal; any other native close synchronizes the Subject to `false` and invents no outcome.
439
+ * - On opening it captures the opener and moves focus to the first focusable descendant; when the
440
+ * presentation ends it restores focus to the opener only while that opener is still connected
441
+ * and focusable - no body fallback, no invented destination.
442
+ * - `extent="screen"` (the default) fills the viewport minus the `--gutter` inline and
443
+ * `--space-region` block insets, header and Actions fixed, only `Content` scrolling.
444
+ * `extent="content"` holds a fixed 32rem capped to the viewport with content-driven height;
445
+ * `Content` scrolls only as the emergency fallback when fitting is physically impossible.
446
+ * - Regions take `--space-region` padding with `--space-stack` between Title and Description and
447
+ * between Actions controls; hairlines separate header from `Content` and `Content` from
448
+ * `Actions` only where both neighbours exist. The surface reads `--radius-dialog`,
449
+ * `--elevation-floating`, `surface`/`foreground`/`border`, and the Scrim reads `--color-scrim`
450
+ * with the theme's optional `--scrim-blur`.
451
+ *
452
+ * @CallerMustEnsure — the component cannot see these and does not check them
453
+ * - Direct children in the order `Title`, `Description`, `Content`, `Actions`, at most one of
454
+ * each; `Actions` is present and holds at least one explicit dismissal control, wired to the
455
+ * same dismissal and visibility transitions. Cancel-before-Confirm order puts initial focus on
456
+ * the least destructive action.
457
+ * - Exactly one naming source is supplied - a `Title`, or `ariaLabel` for a deliberately
458
+ * titleless Dialog. Neither is optional together.
459
+ * - When completing the task removes the opener, the consumer moves focus to its own stable
460
+ * destination after hiding the Dialog.
461
+ * - One Dialog stands at a time: dismiss or unmount the current one before presenting another.
462
+ * - Routine workflows that need modal scrolling belong on a page, not in `extent="content"`.
463
+ *
464
+ * @UXGuidelines
465
+ * - Confirmation is explicit and consumer-owned: opening performs no action, dismissal reports
466
+ * cancellation and never confirmation, and the library ships no confirm/cancel controls.
467
+ * - A warning confirmation composes its wording through the existing warning status tone and
468
+ * stays understandable without colour; Dialog has no warning state, `alertdialog` mode or
469
+ * action-disabling policy, and warning content may sit beside an enabled confirmation.
470
+ * - All visible wording enters through the composition; the consuming app words and translates it.
471
+ */
472
+ export const Dialog = {
473
+ Root: DialogRoot,
474
+ Title: DialogTitle,
475
+ Description: DialogDescription,
476
+ Content: DialogContent,
477
+ Actions: DialogActions,
478
+ } as const;
@@ -0,0 +1,6 @@
1
+ export class DialogCompositionError extends Error {
2
+ constructor(member: string) {
3
+ super(`Dialog.${member} must be composed inside Dialog.Root`);
4
+ this.name = 'DialogCompositionError';
5
+ }
6
+ }
@@ -0,0 +1,8 @@
1
+ export class DialogNamingError extends Error {
2
+ constructor() {
3
+ super(
4
+ 'A Dialog takes exactly one accessible naming source: a visible Dialog.Title or ariaLabel on Dialog.Root, never both',
5
+ );
6
+ this.name = 'DialogNamingError';
7
+ }
8
+ }
@@ -39,6 +39,17 @@ export type PaletteTokens = {
39
39
  * keeps it near `surface` rather than a mid grey. */
40
40
  backing: string;
41
41
 
42
+ /** The themed veil behind a Dialog, marking the page beneath as present but unavailable. The one
43
+ * role whose alpha is part of the colour - the page must show through, which a solid hex cannot
44
+ * say - so it is an `rgb(r g b / a)` value, not a hex. Both shipped themes carry the same value;
45
+ * the Dialog's modality never depends on the treatment, so no contrast constraint applies.
46
+ *
47
+ * Migration (added with Dialog): a consumer constructing its own `PaletteTokens` object must
48
+ * add this role - `rgb(15 23 42 / 0.5)` is the shipped value. Spreading `light`/`dark` and
49
+ * overriding is unaffected, and a theme that only overrides the generated custom properties
50
+ * in CSS inherits the default. */
51
+ scrim: string;
52
+
42
53
  /** The colour of a Meter's filled share when it states an amount without judgment - neither an
43
54
  * action fill nor text ink. The depletion treatment mixes it toward `error` in OKLab, so the
44
55
  * constraint covers the whole path (WCAG 2.2 SC 1.4.11): this value, `error`, and every colour
@@ -145,6 +156,8 @@ export const light: PaletteTokens = {
145
156
  rule: '#808fa3',
146
157
  backing: '#f1f5f9',
147
158
 
159
+ scrim: 'rgb(15 23 42 / 0.5)',
160
+
148
161
  meterFill: '#0f172a',
149
162
  meterTrack: '#e2e8f0',
150
163
 
@@ -193,6 +206,8 @@ export const dark: PaletteTokens = {
193
206
  rule: '#5b6a80',
194
207
  backing: '#1e293b',
195
208
 
209
+ scrim: 'rgb(15 23 42 / 0.5)',
210
+
196
211
  meterFill: '#f8fafc',
197
212
  meterTrack: '#334155',
198
213
 
@@ -45,11 +45,12 @@ const FOCUS_RING = `:root {
45
45
  }`;
46
46
 
47
47
  /* Radius is not a colour: like motion and the focus-ring dimensions it lives in :root only, never
48
- in @theme inline. One token names the corner every control reads; 0.5rem is exactly what
49
- rounded-lg resolved to, so nothing changes visually while it keeps tracking the root font size.
50
- No structure radius, no value constraint - both deliberate; see docs/adr/0003-radius-token-contract.md. */
48
+ in @theme inline. Two roles, not a scale: --radius-control is the corner every control reads
49
+ (0.5rem, exactly what rounded-lg resolved to), --radius-dialog the corner both Dialog extents
50
+ share (0.375rem - the control's corner makes the larger surface overly round); docs/adr/0003. */
51
51
  const RADIUS = `:root {
52
52
  --radius-control: 0.5rem;
53
+ --radius-dialog: 0.375rem;
53
54
  }`;
54
55
 
55
56
  /* The control's minimum width is not a colour: like radius it lives in :root only, never in
@@ -121,6 +122,12 @@ const TYPOGRAPHY = `@theme {
121
122
  is the line height SC 1.4.12 expects text to survive, so a user stylesheet applying it moves nothing. */
122
123
  --leading-label: 1.5;
123
124
 
125
+ /* The Dialog title role: the top heading of an independent transitory task, deliberately outside
126
+ the page heading ladder (docs/adr/0005). Ships subtitle's values while staying an independent
127
+ role, so a consumer re-points Dialog titles without moving every H3 (docs/adr/0004, Amendments). */
128
+ --text-dialog-title: clamp(1.5rem, 3vw, 2.25rem);
129
+ --leading-dialog-title: 1.2;
130
+
124
131
  /* Two quantities on one property that must not collapse: --tracking-label is a fixed letter-spaced
125
132
  style, --tracking-optical a correction that varies with size. The names say which is which, so the
126
133
  distinction survives without the ADR in hand. Scope: the title role and above - H1, H2 and the page
@@ -237,6 +244,15 @@ const SLIDER = `:root {
237
244
  --slider-thumb-size: 1.5rem;
238
245
  }`;
239
246
 
247
+ /* The choice marker's two dimensions are not colours: like the tick they live in :root only,
248
+ never @theme inline, so a brand re-points the marker without Choices gaining a prop. Sized in
249
+ rem like the tick and the slider thumb. Constraints: both > 0 (a zero dot erases the one cue
250
+ that survives without colour perception), and dot < size or the dot escapes its box. */
251
+ const CHOICE_MARKER = `:root {
252
+ --choice-marker-size: 1.125rem;
253
+ --choice-marker-dot-size: 0.5rem;
254
+ }`;
255
+
240
256
  /* Not a colour: like the tick it lives in :root only, never @theme inline, so a brand can
241
257
  re-point the bar's weight. Meter reads it as h-[var(--meter-track-thickness)]. Constraint:
242
258
  > 0 - a zero-thickness track erases the display. 0.5rem is the height the accepted Negotiation
@@ -245,6 +261,21 @@ const METER_TRACK = `:root {
245
261
  --meter-track-thickness: 0.5rem;
246
262
  }`;
247
263
 
264
+ /* The scrim's blur is not a colour: it lives in :root only, so a theme can soften the page behind a
265
+ Dialog without any component prop - Dialog exposes no blur and its modality never depends on the
266
+ treatment. 0 is a genuine no-op default; blur(0) filters nothing. */
267
+ const SCRIM_BLUR = `:root {
268
+ --scrim-blur: 0;
269
+ }`;
270
+
271
+ /* One elevation for every Floating Layer the library paints - Dialog first, popup menus and drawers
272
+ may share it; standing structure such as Sidebar does not read it even when sticky. Not a scale:
273
+ a scale would leave components choosing unexplained rungs (docs/adr/0012). The default is exactly
274
+ what Tailwind's shadow-lg resolves to, read as shadow-[var(--elevation-floating)]. */
275
+ const ELEVATION = `:root {
276
+ --elevation-floating: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
277
+ }`;
278
+
248
279
  const toKebabCase = (name: string): string =>
249
280
  name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
250
281
 
@@ -334,8 +365,17 @@ ${TAB_INSETS}
334
365
 
335
366
  ${SLIDER}
336
367
 
368
+ /* The choice marker's dimensions are not colours either, and sit in :root beside the slider block. */
369
+ ${CHOICE_MARKER}
370
+
337
371
  /* The meter track's thickness is not a colour either, and sits in :root beside the tab blocks. */
338
372
  ${METER_TRACK}
373
+
374
+ /* The scrim's blur is not a colour either, and sits in :root beside the meter track. */
375
+ ${SCRIM_BLUR}
376
+
377
+ /* The floating elevation is not a colour either, and sits in :root at the end of the non-colour blocks. */
378
+ ${ELEVATION}
339
379
  `;
340
380
 
341
381
  /**
package/src/index.ts CHANGED
@@ -21,12 +21,14 @@ export { Note } from 'Display/Typography/Note/Note';
21
21
  export { P } from 'Display/Typography/P/P';
22
22
  export { Prose } from 'Display/Typography/Prose/Prose';
23
23
  export { Button } from 'Interaction/Button/Button';
24
+ export { Choices } from 'Interaction/Choices/Choices';
24
25
  export { Input } from 'Interaction/Input/Input';
25
26
  export { Link } from 'Interaction/Link/Link';
26
27
  export { Slider } from 'Interaction/Slider/Slider';
27
28
  export { Tabs } from 'Interaction/Tabs/Tabs';
28
29
  export { TextArea } from 'Interaction/TextArea/TextArea';
29
30
  export { Cover } from 'Layout/Cover/Cover';
31
+ export { Dialog } from 'Layout/Dialog/Dialog';
30
32
  export { Footer } from 'Layout/Footer/Footer';
31
33
  export type { FormState } from 'Layout/Form/Form';
32
34
  export { Form } from 'Layout/Form/Form';