@juwel-development/design-system 3.11.0 → 4.1.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 +16 -3
- package/dist/design-system.js +1094 -546
- package/dist/styles.css +2 -0
- package/dist/styles.dark.css +2 -0
- package/dist/styles.light.css +2 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +38 -18
- package/dist/types/Display/Callout/Callout.d.ts +31 -0
- package/dist/types/Display/Icon/Icon.d.ts +20 -24
- package/dist/types/Display/Meter/Meter.d.ts +5 -6
- package/dist/types/Display/Rule/Rule.d.ts +22 -0
- package/dist/types/Display/Table/Table.d.ts +57 -7
- package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
- package/dist/types/Display/Typography/H1/H1.d.ts +1 -1
- package/dist/types/Display/Typography/H2/H2.d.ts +1 -1
- package/dist/types/Display/Typography/H3/H3.d.ts +1 -1
- package/dist/types/Display/Typography/H4/H4.d.ts +1 -1
- package/dist/types/Display/Typography/H5/H5.d.ts +1 -1
- package/dist/types/Display/Typography/H6/H6.d.ts +1 -1
- package/dist/types/Display/Typography/Note/Note.d.ts +16 -3
- package/dist/types/Display/Typography/P/P.d.ts +15 -2
- package/dist/types/Display/Typography/Prose/Prose.d.ts +14 -6
- package/dist/types/Interaction/Button/Button.d.ts +10 -4
- package/dist/types/Interaction/Slider/Slider.d.ts +3 -0
- package/dist/types/Interaction/Stepper/Stepper.d.ts +59 -0
- package/dist/types/Interaction/Stepper/StepperCompositionError.d.ts +3 -0
- package/dist/types/Interaction/Switch/Switch.d.ts +20 -0
- package/dist/types/Interaction/Tabs/Tabs.d.ts +24 -3
- package/dist/types/Layout/Dialog/Dialog.d.ts +11 -3
- package/dist/types/Layout/Form/FormConfigurationError.d.ts +3 -0
- package/dist/types/Layout/Pane/Pane.d.ts +57 -0
- package/dist/types/Layout/Screen/Screen.d.ts +31 -0
- package/dist/types/Layout/Screen/ScreenBodyAllocation.d.ts +1 -0
- package/dist/types/Layout/Shell/Shell.d.ts +39 -0
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +16 -2
- package/dist/types/Theme/Palette.d.ts +11 -4
- package/dist/types/index.d.ts +22 -1
- package/package.json +18 -8
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +86 -28
- package/src/Display/Callout/Callout.tsx +64 -0
- package/src/Display/Icon/Icon.tsx +53 -13
- package/src/Display/Meter/Meter.tsx +12 -13
- package/src/Display/Rule/Rule.tsx +31 -0
- package/src/Display/Table/Table.tsx +189 -24
- package/src/Display/Typography/Note/Note.tsx +27 -11
- package/src/Display/Typography/P/P.tsx +29 -4
- package/src/Display/Typography/Prose/Prose.tsx +44 -19
- package/src/Interaction/Button/Button.tsx +11 -4
- package/src/Interaction/Slider/Slider.tsx +3 -5
- package/src/Interaction/Stepper/Stepper.tsx +307 -0
- package/src/Interaction/Stepper/StepperCompositionError.ts +6 -0
- package/src/Interaction/Switch/Switch.tsx +64 -0
- package/src/Interaction/Tabs/Tabs.tsx +44 -10
- package/src/Layout/Dialog/Dialog.tsx +19 -6
- package/src/Layout/Form/Form.tsx +2 -1
- package/src/Layout/Form/FormConfigurationError.ts +6 -0
- package/src/Layout/Pane/Pane.tsx +272 -0
- package/src/Layout/Screen/Screen.tsx +61 -0
- package/src/Layout/Screen/ScreenBodyAllocation.ts +5 -0
- package/src/Layout/Shell/Shell.tsx +124 -0
- package/src/Layout/Sidebar/Sidebar.tsx +31 -7
- package/src/Theme/Palette.ts +17 -6
- package/src/Theme/renderTokens.ts +61 -12
- package/src/index.ts +22 -2
- package/src/styles.css +5 -6
- package/src/styles.dark.css +5 -6
- package/src/styles.light.css +5 -6
- package/src/tokens.css +54 -9
- package/src/tokens.dark.css +51 -8
- package/src/tokens.light.css +49 -6
- package/dist/index.css +0 -2
|
@@ -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 FocusEvent,
|
|
@@ -11,6 +11,16 @@ import {
|
|
|
11
11
|
import type { Subject } from 'rxjs';
|
|
12
12
|
import { TabsCompositionError } from './TabsCompositionError';
|
|
13
13
|
|
|
14
|
+
const tabsRoot = cva('', {
|
|
15
|
+
variants: {
|
|
16
|
+
fill: {
|
|
17
|
+
true: 'flex h-full min-h-0 min-w-0 flex-col',
|
|
18
|
+
false: '',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: { fill: false },
|
|
22
|
+
});
|
|
23
|
+
|
|
14
24
|
// The row is one scrolling line of controls, never a second row. The ring room is written from the
|
|
15
25
|
// two focus-ring tokens so it cannot drift from the ring it exists for: padding holds the scroll
|
|
16
26
|
// clip off the outline, the negative margin hands the room back to the page, and scroll-padding
|
|
@@ -22,6 +32,7 @@ const tabsList = cva(
|
|
|
22
32
|
'm-[calc(-1*(var(--focus-ring-width)+var(--focus-ring-offset)))]',
|
|
23
33
|
'scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
|
|
24
34
|
].join(' '),
|
|
35
|
+
{ variants: { fill: { true: 'shrink-0', false: '' } } },
|
|
25
36
|
);
|
|
26
37
|
|
|
27
38
|
// Navigation typography - the tracked grotesk label, muted at rest, foreground when current -
|
|
@@ -45,6 +56,7 @@ const tabsTab = cva(
|
|
|
45
56
|
// the ring follows focusability, not control-ness.
|
|
46
57
|
const tabsPanel = cva(
|
|
47
58
|
'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
59
|
+
{ variants: { fill: { true: 'min-h-0 min-w-0 flex-auto', false: '' } } },
|
|
48
60
|
);
|
|
49
61
|
|
|
50
62
|
type TabsContract = {
|
|
@@ -52,6 +64,7 @@ type TabsContract = {
|
|
|
52
64
|
onSelect$: Subject<string>;
|
|
53
65
|
label: string;
|
|
54
66
|
baseId: string;
|
|
67
|
+
fill: boolean;
|
|
55
68
|
};
|
|
56
69
|
|
|
57
70
|
const TabsContext = createContext<TabsContract | undefined>(undefined);
|
|
@@ -99,7 +112,11 @@ const neighbourTab = (
|
|
|
99
112
|
return tabs[(index + step + tabs.length) % tabs.length];
|
|
100
113
|
};
|
|
101
114
|
|
|
102
|
-
export interface ITabsRootProps {
|
|
115
|
+
export interface ITabsRootProps extends VariantProps<typeof tabsRoot> {
|
|
116
|
+
/** Fill the allocated parent height, keeping List at content height and giving Panel the rest.
|
|
117
|
+
* In an unsized parent, panels retain content height. Compose List and Panels directly in Root;
|
|
118
|
+
* an unsized spacing wrapper does not pass the bound. No spacing is added. */
|
|
119
|
+
fill?: VariantProps<typeof tabsRoot>['fill'];
|
|
103
120
|
/** The key of the active tab. Must name a declared tab/panel pair; the consumer owns it. */
|
|
104
121
|
active: string;
|
|
105
122
|
/** Emits the selected key on click and on arrow navigation. Tabs never selects on its own. */
|
|
@@ -141,24 +158,29 @@ const TabsRoot: FunctionComponent<ITabsRootProps> = ({
|
|
|
141
158
|
active,
|
|
142
159
|
onSelect$,
|
|
143
160
|
label,
|
|
161
|
+
fill,
|
|
144
162
|
children,
|
|
145
163
|
testId,
|
|
146
164
|
}) => {
|
|
147
165
|
const baseId = useId();
|
|
148
166
|
return (
|
|
149
|
-
<TabsContext.Provider
|
|
150
|
-
|
|
167
|
+
<TabsContext.Provider
|
|
168
|
+
value={{ active, onSelect$, label, baseId, fill: fill === true }}
|
|
169
|
+
>
|
|
170
|
+
<div className={tabsRoot({ fill })} data-testid={testId}>
|
|
171
|
+
{children}
|
|
172
|
+
</div>
|
|
151
173
|
</TabsContext.Provider>
|
|
152
174
|
);
|
|
153
175
|
};
|
|
154
176
|
|
|
155
177
|
const TabsList: FunctionComponent<ITabsListProps> = ({ children, testId }) => {
|
|
156
|
-
const { label } = useTabsContract('List');
|
|
178
|
+
const { label, fill } = useTabsContract('List');
|
|
157
179
|
return (
|
|
158
180
|
<div
|
|
159
181
|
role="tablist"
|
|
160
182
|
aria-label={label}
|
|
161
|
-
className={tabsList()}
|
|
183
|
+
className={tabsList({ fill })}
|
|
162
184
|
data-testid={testId}
|
|
163
185
|
>
|
|
164
186
|
{children}
|
|
@@ -221,7 +243,7 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
|
|
|
221
243
|
children,
|
|
222
244
|
testId,
|
|
223
245
|
}) => {
|
|
224
|
-
const { active, baseId } = useTabsContract('Panel');
|
|
246
|
+
const { active, baseId, fill } = useTabsContract('Panel');
|
|
225
247
|
const isActive = active === value;
|
|
226
248
|
return (
|
|
227
249
|
<div
|
|
@@ -230,7 +252,7 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
|
|
|
230
252
|
aria-labelledby={tabId(baseId, value)}
|
|
231
253
|
hidden={!isActive}
|
|
232
254
|
tabIndex={isActive ? 0 : undefined}
|
|
233
|
-
className={tabsPanel()}
|
|
255
|
+
className={tabsPanel({ fill })}
|
|
234
256
|
data-testid={testId}
|
|
235
257
|
>
|
|
236
258
|
{isActive ? children : undefined}
|
|
@@ -271,11 +293,23 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
|
|
|
271
293
|
* `foreground`, constant thickness in both states, so switching shifts no widths and no weights.
|
|
272
294
|
* Keyboard focus is the separate shared focus ring. Colour moves on the one motion token.
|
|
273
295
|
*
|
|
296
|
+
* - With `fill`, Root takes the allocated parent height; List keeps its content height and the
|
|
297
|
+
* active Panel takes the remainder. Its definite height bounds a direct Pane or bounded Table.
|
|
298
|
+
* Switching views changes neither Root height nor List position. No scrolling or spacing is added.
|
|
299
|
+
* In an unsized parent the panel retains content height; no bound is invented.
|
|
300
|
+
* - Without `fill` (including false or null), the existing content-height composition is unchanged.
|
|
301
|
+
*
|
|
274
302
|
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
275
303
|
* - One List with Tab elements as direct DOM children (arrays and fragments are supported),
|
|
276
304
|
* and the Panels under the same Root - as siblings of the List, or with it inside one
|
|
277
305
|
* arrangement such as a `Stack` (see the separation guidance below). Do not wrap tabs in host
|
|
278
306
|
* elements.
|
|
307
|
+
* - For `fill`, List and Panels must be direct DOM children of Root (fragments are fine), with
|
|
308
|
+
* one height-accepting child directly inside the active Panel. Supply a definite parent height
|
|
309
|
+
* and enough room for List and the child's fixed content. An unsized Stack between Root and
|
|
310
|
+
* members, or between Panel and its child, breaks that height chain. Compose content spacing
|
|
311
|
+
* inside the Pane's slots instead; Tabs adds no gap. In an unsized parent the enclosing page
|
|
312
|
+
* owns overflow. A bounded Table requires the separate #142 API.
|
|
279
313
|
* - A label is a meaningful visible text name, with non-interactive decoration at most: no link,
|
|
280
314
|
* button, input or other focusable descendant, since the tab is the one control and a nested
|
|
281
315
|
* control inside a `button` is invalid. Decorative parts - an icon that repeats the text - carry
|
|
@@ -295,8 +329,8 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
|
|
|
295
329
|
* do not fit, so a label is never the reason to shorten a name.
|
|
296
330
|
* - This is not a router: no location, no history, no deep links. Wire `onSelect$` to whatever
|
|
297
331
|
* owns the active key and pass that key back in.
|
|
298
|
-
* - Tabs sets no spacing between the row and the panel;
|
|
299
|
-
* and its members - `<Tabs.Root><Stack gap="region"><Tabs.List/>…<Tabs.Panel/>…</Stack>
|
|
332
|
+
* - In the default content-height mode, Tabs sets no spacing between the row and the panel;
|
|
333
|
+
* the separation is a `Stack` between Root and its members - `<Tabs.Root><Stack gap="region"><Tabs.List/>…<Tabs.Panel/>…</Stack>
|
|
300
334
|
* </Tabs.Root>`. `gap="stack"` holds the row and its view together as one block; `gap="region"`
|
|
301
335
|
* sets the view apart as a region of its own. Inactive panels are hidden, so the gap is exactly
|
|
302
336
|
* one whichever panel is active, from the token the consumer's other groups already use.
|
|
@@ -19,7 +19,7 @@ import { DialogNamingError } from './DialogNamingError';
|
|
|
19
19
|
// The layout classes ride the open: variant - a bare `flex` would defeat the UA's
|
|
20
20
|
// dialog:not([open]) hiding. Tailwind's preflight zeroes the UA's centering margins (m-auto
|
|
21
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 }` -
|
|
22
|
+
// overflow-hidden overrides the UA's `dialog { overflow: auto }` - Content or its delegated child owns scrolling.
|
|
23
23
|
const dialogRoot = cva(
|
|
24
24
|
[
|
|
25
25
|
'open:flex open:flex-col m-auto p-0 overflow-hidden',
|
|
@@ -67,10 +67,16 @@ const dialogDescription = cva(
|
|
|
67
67
|
// this scrolls. Its hairline exists only when a header region stands before it.
|
|
68
68
|
const dialogContent = cva(
|
|
69
69
|
[
|
|
70
|
-
'grow shrink min-h-0
|
|
70
|
+
'grow shrink min-h-0 min-w-0',
|
|
71
71
|
'px-[var(--space-region)] py-[var(--space-region)]',
|
|
72
72
|
'[&:not(:first-child)]:border-t [&:not(:first-child)]:border-border',
|
|
73
73
|
].join(' '),
|
|
74
|
+
{
|
|
75
|
+
variants: {
|
|
76
|
+
scrollOwner: { body: 'overflow-y-auto', child: 'flex flex-col' },
|
|
77
|
+
},
|
|
78
|
+
defaultVariants: { scrollOwner: 'body' },
|
|
79
|
+
},
|
|
74
80
|
);
|
|
75
81
|
|
|
76
82
|
// An end-aligned row in consumer DOM order, wrapping on narrow screens without reversing. Its
|
|
@@ -173,7 +179,8 @@ export interface IDialogDescriptionProps {
|
|
|
173
179
|
testId?: string;
|
|
174
180
|
}
|
|
175
181
|
|
|
176
|
-
export interface IDialogContentProps
|
|
182
|
+
export interface IDialogContentProps
|
|
183
|
+
extends VariantProps<typeof dialogContent> {
|
|
177
184
|
/** Arbitrary task content, composed and owned by the consumer. */
|
|
178
185
|
children: ReactNode;
|
|
179
186
|
testId?: string;
|
|
@@ -385,13 +392,14 @@ const DialogDescription: FunctionComponent<IDialogDescriptionProps> = ({
|
|
|
385
392
|
|
|
386
393
|
const DialogContent: FunctionComponent<IDialogContentProps> = ({
|
|
387
394
|
children,
|
|
395
|
+
scrollOwner,
|
|
388
396
|
testId,
|
|
389
397
|
}) => {
|
|
390
398
|
useDialogContract('Content');
|
|
391
399
|
return (
|
|
392
400
|
<div
|
|
393
401
|
data-dialog-member={'content'}
|
|
394
|
-
className={dialogContent()}
|
|
402
|
+
className={dialogContent({ scrollOwner })}
|
|
395
403
|
data-testid={testId}
|
|
396
404
|
>
|
|
397
405
|
{children}
|
|
@@ -420,7 +428,7 @@ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
|
|
|
420
428
|
* the platform `<dialog>` and `showModal()`: the browser owns the top layer, background inertness
|
|
421
429
|
* and the modal keyboard boundary; the consumer owns all wording, controls, outcomes and
|
|
422
430
|
* replacement. Composed from five members: `Root` carries the contract, `Title` the visible name,
|
|
423
|
-
* `Description` one connected line, `Content` the
|
|
431
|
+
* `Description` one connected line, `Content` the scrolling region or holder of one bounded child, `Actions` the controls.
|
|
424
432
|
*
|
|
425
433
|
* @Guarantees — enforced on every render
|
|
426
434
|
* - Presents through `showModal()`/`close()` - no portal, focus trap or polyfill - so background
|
|
@@ -443,7 +451,9 @@ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
|
|
|
443
451
|
* - `extent="screen"` (the default) fills the viewport minus the `--gutter` inline and
|
|
444
452
|
* `--space-region` block insets, header and Actions fixed, only `Content` scrolling.
|
|
445
453
|
* `extent="content"` holds a fixed 32rem capped to the viewport with content-driven height;
|
|
446
|
-
* `Content` scrolls
|
|
454
|
+
* Default `Content` scrolls as the fallback when its content exceeds available space.
|
|
455
|
+
* - `Content scrollOwner="child"` passes available height to one bounded child without scrolling.
|
|
456
|
+
* A bounded Table then owns both axes and pins its header; title, description and actions stay fixed.
|
|
447
457
|
* - Regions take `--space-region` padding with `--space-stack` between Title and Description and
|
|
448
458
|
* between Actions controls; hairlines separate header from `Content` and `Content` from
|
|
449
459
|
* `Actions` only where both neighbours exist. The surface reads `--radius-dialog`,
|
|
@@ -451,6 +461,9 @@ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
|
|
|
451
461
|
* with the theme's optional `--scrim-blur`.
|
|
452
462
|
*
|
|
453
463
|
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
464
|
+
* - In child-scroll mode provide one child that accepts the height allocation and owns overflow.
|
|
465
|
+
* Intermediate wrappers must preserve that allocation. Fixed regions must fit the viewport and
|
|
466
|
+
* leave usable room for the child; a Table needs room for its header and readable rows.
|
|
454
467
|
* - Direct children in the order `Title`, `Description`, `Content`, `Actions`, at most one of
|
|
455
468
|
* each; `Actions` is present and holds at least one explicit dismissal control, wired to the
|
|
456
469
|
* same dismissal and visibility transitions. Cancel-before-Confirm order puts initial focus on
|
package/src/Layout/Form/Form.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cva } from 'class-variance-authority';
|
|
2
2
|
import type { FunctionComponent, ReactNode } from 'react';
|
|
3
|
+
import { FormConfigurationError } from './FormConfigurationError';
|
|
3
4
|
|
|
4
5
|
/** Which of the four states the page has put the form in. Owned by the page, never by Form: the
|
|
5
6
|
* component renders the state it is given and never decides which one it is in. */
|
|
@@ -78,7 +79,7 @@ export const Form: FunctionComponent<IFormProps> = ({
|
|
|
78
79
|
note !== undefined && note !== null && typeof note !== 'boolean';
|
|
79
80
|
|
|
80
81
|
if (state === 'sent' && !hasNote) {
|
|
81
|
-
throw new
|
|
82
|
+
throw new FormConfigurationError(
|
|
82
83
|
'Form in the `sent` state must be given a `note` - it is the outcome message.',
|
|
83
84
|
);
|
|
84
85
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { ScreenBodyAllocation } from 'Layout/Screen/ScreenBodyAllocation';
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
|
+
import {
|
|
4
|
+
type FunctionComponent,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useContext,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
|
|
12
|
+
const pane = cva(
|
|
13
|
+
'box-border flex h-full min-h-0 min-w-0 flex-col gap-[var(--space-stack)] border border-solid border-border bg-surface text-foreground p-[var(--space-box-inset)] [overflow-wrap:anywhere]',
|
|
14
|
+
);
|
|
15
|
+
const paneSection = cva('min-w-0 shrink-0');
|
|
16
|
+
const paneBody = cva('min-h-0 min-w-0 flex-[0_1_auto]', {
|
|
17
|
+
variants: {
|
|
18
|
+
scrollOwner: {
|
|
19
|
+
body: '[&>div]:flow-root [&>div]:w-fit [&>div]:min-w-full [&>div]:p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))] overflow-auto overscroll-contain outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
20
|
+
child: '',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: { scrollOwner: 'body' },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const useOverflow = (
|
|
27
|
+
container: { current: HTMLDivElement | null },
|
|
28
|
+
content: { current: HTMLDivElement | null },
|
|
29
|
+
ownsScroll: boolean,
|
|
30
|
+
): boolean => {
|
|
31
|
+
const [isScrollable, setIsScrollable] = useState(true);
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
const element = container.current;
|
|
34
|
+
if (!element || !ownsScroll) return;
|
|
35
|
+
const measure = () =>
|
|
36
|
+
setIsScrollable(
|
|
37
|
+
element.scrollHeight > element.clientHeight ||
|
|
38
|
+
element.scrollWidth > element.clientWidth ||
|
|
39
|
+
element === element.ownerDocument.activeElement,
|
|
40
|
+
);
|
|
41
|
+
measure();
|
|
42
|
+
const observer =
|
|
43
|
+
typeof ResizeObserver === 'undefined'
|
|
44
|
+
? undefined
|
|
45
|
+
: new ResizeObserver(measure);
|
|
46
|
+
observer?.observe(element);
|
|
47
|
+
if (content.current) observer?.observe(content.current);
|
|
48
|
+
const mutations = new MutationObserver(measure);
|
|
49
|
+
mutations.observe(element, {
|
|
50
|
+
childList: true,
|
|
51
|
+
subtree: true,
|
|
52
|
+
characterData: true,
|
|
53
|
+
});
|
|
54
|
+
window.addEventListener('resize', measure);
|
|
55
|
+
element.addEventListener('blur', measure);
|
|
56
|
+
return () => {
|
|
57
|
+
observer?.disconnect();
|
|
58
|
+
mutations.disconnect();
|
|
59
|
+
window.removeEventListener('resize', measure);
|
|
60
|
+
element.removeEventListener('blur', measure);
|
|
61
|
+
};
|
|
62
|
+
}, [container, content, ownsScroll]);
|
|
63
|
+
return isScrollable;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface IPaneRootProps {
|
|
67
|
+
children?: ReactNode;
|
|
68
|
+
/** A non-empty name makes the enclosure an accessible group, as with Box. */
|
|
69
|
+
name?: string;
|
|
70
|
+
testId?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface IPaneSectionProps {
|
|
73
|
+
children?: ReactNode;
|
|
74
|
+
testId?: string;
|
|
75
|
+
}
|
|
76
|
+
export interface IPaneBodyProps extends VariantProps<typeof paneBody> {
|
|
77
|
+
children?: ReactNode;
|
|
78
|
+
/** Names the body while overflowing. Unused when the child owns scrolling. */
|
|
79
|
+
ariaLabel: string;
|
|
80
|
+
testId?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parseLength = (value: string): number => Number.parseFloat(value) || 0;
|
|
84
|
+
|
|
85
|
+
const blockEdges = (element: Element): number => {
|
|
86
|
+
const style = getComputedStyle(element);
|
|
87
|
+
return (
|
|
88
|
+
parseLength(style.paddingTop) +
|
|
89
|
+
parseLength(style.paddingBottom) +
|
|
90
|
+
parseLength(style.borderTopWidth) +
|
|
91
|
+
parseLength(style.borderBottomWidth)
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// HTML semantics describe the minimum usable item, including delegated tables. No Table internals
|
|
96
|
+
// or consumer selectors participate. A tall row/control must fit intact, with its focus extent.
|
|
97
|
+
const usableBodyHeight = (body: HTMLElement): number => {
|
|
98
|
+
let minimum = 0;
|
|
99
|
+
for (const element of [body, ...body.querySelectorAll('*')]) {
|
|
100
|
+
const style = getComputedStyle(element);
|
|
101
|
+
if (style.display === 'none') continue;
|
|
102
|
+
const line = parseLength(style.lineHeight) || parseLength(style.fontSize);
|
|
103
|
+
const ring = parseLength(
|
|
104
|
+
getComputedStyle(body.firstElementChild ?? body).paddingTop,
|
|
105
|
+
);
|
|
106
|
+
const item = element.matches(
|
|
107
|
+
'tr, button, input, select, textarea, [role="button"]',
|
|
108
|
+
)
|
|
109
|
+
? element.getBoundingClientRect().height
|
|
110
|
+
: line;
|
|
111
|
+
minimum = Math.max(minimum, item + 2 * ring);
|
|
112
|
+
}
|
|
113
|
+
const table = body.querySelector('table');
|
|
114
|
+
const fixedTable = table
|
|
115
|
+
? Array.from(table.children)
|
|
116
|
+
.filter((element) => element.matches('caption, thead'))
|
|
117
|
+
.reduce(
|
|
118
|
+
(height, element) => height + element.getBoundingClientRect().height,
|
|
119
|
+
0,
|
|
120
|
+
)
|
|
121
|
+
: 0;
|
|
122
|
+
return minimum + fixedTable + blockEdges(body);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const useScreenFallback = (root: {
|
|
126
|
+
current: HTMLDivElement | null;
|
|
127
|
+
}): boolean => {
|
|
128
|
+
const allocation = useContext(ScreenBodyAllocation);
|
|
129
|
+
const [isUnusable, setIsUnusable] = useState(false);
|
|
130
|
+
useLayoutEffect(() => {
|
|
131
|
+
const element = root.current;
|
|
132
|
+
const frame = allocation;
|
|
133
|
+
if (!element || !frame) return;
|
|
134
|
+
const measure = () => {
|
|
135
|
+
const sections = Array.from(element.children);
|
|
136
|
+
const body = sections.find((section) =>
|
|
137
|
+
section.hasAttribute('data-pane-body'),
|
|
138
|
+
);
|
|
139
|
+
const fixed = sections
|
|
140
|
+
.filter((section) => section !== body)
|
|
141
|
+
.reduce(
|
|
142
|
+
(height, section) => height + section.getBoundingClientRect().height,
|
|
143
|
+
0,
|
|
144
|
+
);
|
|
145
|
+
const gaps =
|
|
146
|
+
Math.max(0, sections.length - 1) *
|
|
147
|
+
parseLength(getComputedStyle(element).rowGap);
|
|
148
|
+
setIsUnusable(
|
|
149
|
+
frame.getBoundingClientRect().height <
|
|
150
|
+
fixed +
|
|
151
|
+
gaps +
|
|
152
|
+
blockEdges(element) +
|
|
153
|
+
(body instanceof HTMLElement ? usableBodyHeight(body) : 0),
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
const observer =
|
|
157
|
+
typeof ResizeObserver === 'undefined'
|
|
158
|
+
? undefined
|
|
159
|
+
: new ResizeObserver(measure);
|
|
160
|
+
const observe = () => {
|
|
161
|
+
observer?.disconnect();
|
|
162
|
+
observer?.observe(frame);
|
|
163
|
+
observer?.observe(element);
|
|
164
|
+
for (const child of element.querySelectorAll('*'))
|
|
165
|
+
observer?.observe(child);
|
|
166
|
+
measure();
|
|
167
|
+
};
|
|
168
|
+
observe();
|
|
169
|
+
const mutations = new MutationObserver(observe);
|
|
170
|
+
mutations.observe(element, {
|
|
171
|
+
childList: true,
|
|
172
|
+
subtree: true,
|
|
173
|
+
characterData: true,
|
|
174
|
+
});
|
|
175
|
+
window.addEventListener('resize', measure);
|
|
176
|
+
return () => {
|
|
177
|
+
observer?.disconnect();
|
|
178
|
+
mutations.disconnect();
|
|
179
|
+
window.removeEventListener('resize', measure);
|
|
180
|
+
};
|
|
181
|
+
}, [allocation, root]);
|
|
182
|
+
return isUnusable;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const Root: FunctionComponent<IPaneRootProps> = ({
|
|
186
|
+
children,
|
|
187
|
+
name,
|
|
188
|
+
testId,
|
|
189
|
+
}) => {
|
|
190
|
+
const root = useRef<HTMLDivElement>(null);
|
|
191
|
+
const isUnusable = useScreenFallback(root);
|
|
192
|
+
return (
|
|
193
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and group role are assigned together
|
|
194
|
+
<div
|
|
195
|
+
ref={root}
|
|
196
|
+
data-screen-unusable={isUnusable || undefined}
|
|
197
|
+
className={pane()}
|
|
198
|
+
role={name ? 'group' : undefined}
|
|
199
|
+
aria-label={name || undefined}
|
|
200
|
+
data-testid={testId}
|
|
201
|
+
>
|
|
202
|
+
{children}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
const Head: FunctionComponent<IPaneSectionProps> = ({ children, testId }) => (
|
|
207
|
+
<div className={paneSection()} data-testid={testId}>
|
|
208
|
+
{children}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
const Foot: FunctionComponent<IPaneSectionProps> = ({ children, testId }) => (
|
|
212
|
+
<div className={paneSection()} data-testid={testId}>
|
|
213
|
+
{children}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
const Body: FunctionComponent<IPaneBodyProps> = ({
|
|
217
|
+
children,
|
|
218
|
+
ariaLabel,
|
|
219
|
+
scrollOwner,
|
|
220
|
+
testId,
|
|
221
|
+
}) => {
|
|
222
|
+
const container = useRef<HTMLDivElement>(null);
|
|
223
|
+
const content = useRef<HTMLDivElement>(null);
|
|
224
|
+
const ownsScroll = scrollOwner !== 'child';
|
|
225
|
+
const isScrollable =
|
|
226
|
+
useOverflow(container, content, ownsScroll) && ownsScroll;
|
|
227
|
+
return (
|
|
228
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and group role are assigned together
|
|
229
|
+
<div
|
|
230
|
+
data-pane-body=""
|
|
231
|
+
ref={container}
|
|
232
|
+
className={paneBody({ scrollOwner: scrollOwner ?? 'body' })}
|
|
233
|
+
role={isScrollable ? 'group' : undefined}
|
|
234
|
+
aria-label={isScrollable ? ariaLabel : undefined}
|
|
235
|
+
tabIndex={isScrollable ? 0 : undefined}
|
|
236
|
+
data-testid={testId}
|
|
237
|
+
>
|
|
238
|
+
{ownsScroll ? <div ref={content}>{children}</div> : children}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A parent-bounded enclosure with optional content-height Head and Foot around a shrinking Body.
|
|
245
|
+
*
|
|
246
|
+
* @Guarantees
|
|
247
|
+
* - Root fills the allocated height, including its border and box inset. Without a definite
|
|
248
|
+
* parent height it grows with content; a stacked parent or enclosing body owns scrolling.
|
|
249
|
+
* - Head and Foot never shrink. Body scrolls both axes as needed and short content does not
|
|
250
|
+
* stretch: Foot follows it, with any unused height left in Root rather than inside Body.
|
|
251
|
+
* - Root paints surface, foreground, border and box inset, square and without elevation.
|
|
252
|
+
* The stack space role separates slots; a non-empty name exposes a group, never a landmark.
|
|
253
|
+
* - Default Body is the sole scroll region: a named group and keyboard stop only while
|
|
254
|
+
* overflowing, retained while focused. It reserves the shared focus ring's room and leaves
|
|
255
|
+
* native focus reveal, scrolling and child keyboard interactions intact.
|
|
256
|
+
* - Body scrollOwner="child" passes its allocated height directly to its child, without an
|
|
257
|
+
* inner wrapper, overflow, group or keyboard stop. The child owns scroll semantics and naming.
|
|
258
|
+
* Use this for a bounded Table: its wrapper must own both axes so its header stays in view.
|
|
259
|
+
*
|
|
260
|
+
* @CallerMustEnsure
|
|
261
|
+
* - Compose at most one Head, Body and Foot, in that order, directly inside Root; all are optional.
|
|
262
|
+
* Do not nest panes. The consumer owns all wording, facts, actions and internal arrangement.
|
|
263
|
+
* - Supply a finite parent height to bound a pane, and enough room for Head, Foot, inset and gaps.
|
|
264
|
+
* At smaller sizes use an unsized stacked arrangement in a scrolling page or Shell body.
|
|
265
|
+
* - In child mode supply one child that accepts its parent's allocated height and handles its
|
|
266
|
+
* own overflow and focus ring. Intermediate wrappers must preserve that allocation.
|
|
267
|
+
* - Inside Screen, fixed regions and the usable body minimum participate in whole-content
|
|
268
|
+
* fallback and restoration. Outside Screen the parent remains responsible for that policy.
|
|
269
|
+
* - Filled ColumnLayout columns and active Stepper panels can pass the bound to Root. Keep
|
|
270
|
+
* those holders and Pane directly composed so no intermediate wrapper loses the allocation.
|
|
271
|
+
*/
|
|
272
|
+
export const Pane = { Root, Head, Body, Foot } as const;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import { type FunctionComponent, type ReactNode, useState } from 'react';
|
|
3
|
+
import { ScreenBodyAllocation } from './ScreenBodyAllocation';
|
|
4
|
+
|
|
5
|
+
const screen = cva(
|
|
6
|
+
'box-border grid h-full min-h-0 min-w-0 grid-rows-[minmax(0,1fr)] px-[var(--gutter)] py-[var(--space-region)] [&:has(>header)]:grid-rows-[auto_minmax(0,1fr)] [&:has(>header)]:gap-[var(--space-region)]',
|
|
7
|
+
);
|
|
8
|
+
const screenHeading = cva('min-w-0');
|
|
9
|
+
const screenBody = cva(
|
|
10
|
+
'h-full min-h-0 min-w-0 [&>div]:h-full [&:has([data-screen-unusable])>div]:h-auto',
|
|
11
|
+
);
|
|
12
|
+
export interface IScreenProps {
|
|
13
|
+
/** Consumer-owned visible heading; omit when the heading belongs inside a Pane. */
|
|
14
|
+
heading?: ReactNode;
|
|
15
|
+
/** Height-passing content, normally a filling ColumnLayout with direct Panes. */
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
testId?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One semantic main with an optional heading and the remaining allocated height for its content.
|
|
21
|
+
*
|
|
22
|
+
* @Guarantees
|
|
23
|
+
* - Uses the page gutter horizontally and region spacing vertically and beneath a present heading.
|
|
24
|
+
* - Omitted, undefined, null and false headings reserve no wrapper, track or gap. Arbitrary
|
|
25
|
+
* components are not inspected for emptiness. Changing the slot never remounts content.
|
|
26
|
+
* - Adds no scrollport or tab stop. Pane bodies scroll independently while fixed regions plus a
|
|
27
|
+
* complete text line or ordinary row/control and focus room fit. Otherwise all content grows
|
|
28
|
+
* into the enclosing scroll owner. Bounds restore without probing or remounting children.
|
|
29
|
+
* - Unsized parents use natural height; ColumnLayout retains its container-fit stacking rule.
|
|
30
|
+
*
|
|
31
|
+
* @CallerMustEnsure
|
|
32
|
+
* - Supply the visible heading here or inside a panel, and exactly one main for the page.
|
|
33
|
+
* - Use Shell's console body, optionally Sidebar.Root fill / Sidebar.Content, to allocate height
|
|
34
|
+
* and own fallback scrolling. Every intermediate wrapper must pass that allocation through.
|
|
35
|
+
* - Compose filling ColumnLayout with direct Pane children; retain Pane's Head/Body/Foot order.
|
|
36
|
+
* Body can delegate to one bounded Table. Additional height-consuming wrappers need their own
|
|
37
|
+
* allocation contract and are not measured as panel fixed regions by Screen.
|
|
38
|
+
* - Keep the Shell bar short enough to leave a usable enclosing scrollport. This is representative
|
|
39
|
+
* panel fit, not a guarantee that arbitrary content fits any particular viewport.
|
|
40
|
+
*/
|
|
41
|
+
export const Screen: FunctionComponent<IScreenProps> = ({
|
|
42
|
+
heading,
|
|
43
|
+
children,
|
|
44
|
+
testId,
|
|
45
|
+
}) => {
|
|
46
|
+
const [allocation, setAllocation] = useState<HTMLDivElement | null>(null);
|
|
47
|
+
return (
|
|
48
|
+
<main className={screen()} data-testid={testId}>
|
|
49
|
+
{heading !== undefined && heading !== null && heading !== false && (
|
|
50
|
+
<header className={screenHeading()}>{heading}</header>
|
|
51
|
+
)}
|
|
52
|
+
<div ref={setAllocation} className={screenBody()}>
|
|
53
|
+
<div>
|
|
54
|
+
<ScreenBodyAllocation.Provider value={allocation}>
|
|
55
|
+
{children}
|
|
56
|
+
</ScreenBodyAllocation.Provider>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</main>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
// Screen's persistent remainder, not its growing fallback content. Pane reads this same box
|
|
4
|
+
// in both modes, so restoration never needs a temporary bound or a second render of children.
|
|
5
|
+
export const ScreenBodyAllocation = createContext<HTMLDivElement | null>(null);
|