@juwel-development/design-system 3.4.0 → 3.6.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.
- package/README.md +28 -0
- package/dist/design-system.js +370 -193
- package/dist/index.css +1 -1
- package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +5 -2
- package/dist/types/Display/Typography/H1/H1.d.ts +5 -2
- package/dist/types/Display/Typography/H2/H2.d.ts +5 -2
- package/dist/types/Display/Typography/H3/H3.d.ts +5 -2
- package/dist/types/Display/Typography/H4/H4.d.ts +5 -2
- package/dist/types/Display/Typography/H5/H5.d.ts +5 -2
- package/dist/types/Display/Typography/H6/H6.d.ts +5 -2
- package/dist/types/Display/Typography/Note/Note.d.ts +5 -2
- package/dist/types/Display/Typography/P/P.d.ts +5 -2
- package/dist/types/Display/Typography/Prose/Prose.d.ts +6 -3
- package/dist/types/Layout/Dialog/Dialog.d.ts +104 -0
- package/dist/types/Layout/Dialog/DialogCompositionError.d.ts +3 -0
- package/dist/types/Layout/Dialog/DialogNamingError.d.ts +3 -0
- package/dist/types/Theme/Palette.d.ts +19 -2
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/Display/Typography/Eyebrow/Eyebrow.tsx +12 -2
- package/src/Display/Typography/H1/H1.tsx +12 -2
- package/src/Display/Typography/H2/H2.tsx +12 -2
- package/src/Display/Typography/H3/H3.tsx +12 -2
- package/src/Display/Typography/H4/H4.tsx +12 -2
- package/src/Display/Typography/H5/H5.tsx +12 -2
- package/src/Display/Typography/H6/H6.tsx +12 -2
- package/src/Display/Typography/Note/Note.tsx +12 -2
- package/src/Display/Typography/P/P.tsx +12 -2
- package/src/Display/Typography/Prose/Prose.tsx +13 -3
- package/src/Layout/Dialog/Dialog.tsx +478 -0
- package/src/Layout/Dialog/DialogCompositionError.ts +6 -0
- package/src/Layout/Dialog/DialogNamingError.ts +8 -0
- package/src/Theme/Palette.ts +27 -5
- package/src/Theme/renderTokens.ts +31 -3
- package/src/index.ts +1 -0
- package/src/tokens.css +23 -3
- package/src/tokens.dark.css +19 -0
- package/src/tokens.light.css +22 -3
|
@@ -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;
|
package/src/Theme/Palette.ts
CHANGED
|
@@ -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
|
|
@@ -102,11 +113,18 @@ export type PaletteTokens = {
|
|
|
102
113
|
* least 4.5:1 against `surface` in the same theme. */
|
|
103
114
|
link: string;
|
|
104
115
|
|
|
105
|
-
/**
|
|
106
|
-
*
|
|
116
|
+
/** The success status tone. Status tones reinforce content independently of their carrier and
|
|
117
|
+
* carry no announcement behavior. Constraint (WCAG 2.2 SC 1.4.3): at least 4.5:1 against
|
|
118
|
+
* `surface` in the same theme. */
|
|
107
119
|
success: string;
|
|
120
|
+
/** The warning status tone. Carries the general status-tone contract and 4.5:1-against-`surface`
|
|
121
|
+
* constraint stated on `success`. */
|
|
108
122
|
warning: string;
|
|
123
|
+
/** The error status tone. Carries the general status-tone contract and 4.5:1-against-`surface`
|
|
124
|
+
* constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`. */
|
|
109
125
|
error: string;
|
|
126
|
+
/** The informational status tone. Carries the general status-tone contract and
|
|
127
|
+
* 4.5:1-against-`surface` constraint stated on `success`. */
|
|
110
128
|
info: string;
|
|
111
129
|
};
|
|
112
130
|
|
|
@@ -138,6 +156,8 @@ export const light: PaletteTokens = {
|
|
|
138
156
|
rule: '#808fa3',
|
|
139
157
|
backing: '#f1f5f9',
|
|
140
158
|
|
|
159
|
+
scrim: 'rgb(15 23 42 / 0.5)',
|
|
160
|
+
|
|
141
161
|
meterFill: '#0f172a',
|
|
142
162
|
meterTrack: '#e2e8f0',
|
|
143
163
|
|
|
@@ -156,10 +176,10 @@ export const light: PaletteTokens = {
|
|
|
156
176
|
|
|
157
177
|
link: '#2563eb',
|
|
158
178
|
|
|
159
|
-
success: '#
|
|
160
|
-
warning: '#
|
|
179
|
+
success: '#047857',
|
|
180
|
+
warning: '#b45309',
|
|
161
181
|
error: '#d63384',
|
|
162
|
-
info: '#
|
|
182
|
+
info: '#0e7490',
|
|
163
183
|
};
|
|
164
184
|
|
|
165
185
|
/**
|
|
@@ -186,6 +206,8 @@ export const dark: PaletteTokens = {
|
|
|
186
206
|
rule: '#5b6a80',
|
|
187
207
|
backing: '#1e293b',
|
|
188
208
|
|
|
209
|
+
scrim: 'rgb(15 23 42 / 0.5)',
|
|
210
|
+
|
|
189
211
|
meterFill: '#f8fafc',
|
|
190
212
|
meterTrack: '#334155',
|
|
191
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.
|
|
49
|
-
rounded-lg resolved to,
|
|
50
|
-
|
|
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
|
|
@@ -245,6 +252,21 @@ const METER_TRACK = `:root {
|
|
|
245
252
|
--meter-track-thickness: 0.5rem;
|
|
246
253
|
}`;
|
|
247
254
|
|
|
255
|
+
/* The scrim's blur is not a colour: it lives in :root only, so a theme can soften the page behind a
|
|
256
|
+
Dialog without any component prop - Dialog exposes no blur and its modality never depends on the
|
|
257
|
+
treatment. 0 is a genuine no-op default; blur(0) filters nothing. */
|
|
258
|
+
const SCRIM_BLUR = `:root {
|
|
259
|
+
--scrim-blur: 0;
|
|
260
|
+
}`;
|
|
261
|
+
|
|
262
|
+
/* One elevation for every Floating Layer the library paints - Dialog first, popup menus and drawers
|
|
263
|
+
may share it; standing structure such as Sidebar does not read it even when sticky. Not a scale:
|
|
264
|
+
a scale would leave components choosing unexplained rungs (docs/adr/0012). The default is exactly
|
|
265
|
+
what Tailwind's shadow-lg resolves to, read as shadow-[var(--elevation-floating)]. */
|
|
266
|
+
const ELEVATION = `:root {
|
|
267
|
+
--elevation-floating: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
268
|
+
}`;
|
|
269
|
+
|
|
248
270
|
const toKebabCase = (name: string): string =>
|
|
249
271
|
name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
250
272
|
|
|
@@ -336,6 +358,12 @@ ${SLIDER}
|
|
|
336
358
|
|
|
337
359
|
/* The meter track's thickness is not a colour either, and sits in :root beside the tab blocks. */
|
|
338
360
|
${METER_TRACK}
|
|
361
|
+
|
|
362
|
+
/* The scrim's blur is not a colour either, and sits in :root beside the meter track. */
|
|
363
|
+
${SCRIM_BLUR}
|
|
364
|
+
|
|
365
|
+
/* The floating elevation is not a colour either, and sits in :root at the end of the non-colour blocks. */
|
|
366
|
+
${ELEVATION}
|
|
339
367
|
`;
|
|
340
368
|
|
|
341
369
|
/**
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ export { Slider } from 'Interaction/Slider/Slider';
|
|
|
27
27
|
export { Tabs } from 'Interaction/Tabs/Tabs';
|
|
28
28
|
export { TextArea } from 'Interaction/TextArea/TextArea';
|
|
29
29
|
export { Cover } from 'Layout/Cover/Cover';
|
|
30
|
+
export { Dialog } from 'Layout/Dialog/Dialog';
|
|
30
31
|
export { Footer } from 'Layout/Footer/Footer';
|
|
31
32
|
export type { FormState } from 'Layout/Form/Form';
|
|
32
33
|
export { Form } from 'Layout/Form/Form';
|