@juwel-development/design-system 4.0.0 → 4.2.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/dist/design-system.js +780 -545
- package/dist/styles.css +1 -1
- package/dist/styles.dark.css +1 -1
- package/dist/styles.light.css +1 -1
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +59 -0
- package/dist/types/Display/Table/Table.d.ts +4 -0
- package/dist/types/Interaction/Choices/Choices.d.ts +20 -8
- package/dist/types/Layout/Dialog/Dialog.d.ts +11 -4
- package/dist/types/Layout/Dialog/useDialogAllocation.d.ts +2 -0
- package/dist/types/Layout/Dialog/useModalScrollLock.d.ts +2 -0
- package/dist/types/Layout/Pane/Pane.d.ts +6 -1
- package/dist/types/Layout/Screen/Screen.d.ts +31 -0
- package/dist/types/Layout/Screen/ScreenBodyAllocation.d.ts +1 -0
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +9 -6
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +312 -0
- package/src/Display/Table/Table.tsx +32 -8
- package/src/Interaction/Choices/Choices.tsx +37 -19
- package/src/Layout/Dialog/Dialog.tsx +28 -7
- package/src/Layout/Dialog/useDialogAllocation.ts +131 -0
- package/src/Layout/Dialog/useModalScrollLock.ts +59 -0
- package/src/Layout/Pane/Pane.tsx +136 -13
- package/src/Layout/Screen/Screen.tsx +61 -0
- package/src/Layout/Screen/ScreenBodyAllocation.ts +5 -0
- package/src/Layout/Sidebar/Sidebar.tsx +10 -7
- package/src/index.ts +1 -0
|
@@ -15,14 +15,25 @@ import {
|
|
|
15
15
|
import type { Subject } from 'rxjs';
|
|
16
16
|
import { DialogCompositionError } from './DialogCompositionError';
|
|
17
17
|
import { DialogNamingError } from './DialogNamingError';
|
|
18
|
+
import { useDialogAllocation } from './useDialogAllocation';
|
|
19
|
+
import { useModalScrollLock } from './useModalScrollLock';
|
|
18
20
|
|
|
19
21
|
// The layout classes ride the open: variant - a bare `flex` would defeat the UA's
|
|
20
22
|
// dialog:not([open]) hiding. Tailwind's preflight zeroes the UA's centering margins (m-auto
|
|
21
23
|
// restores them) and strips ::backdrop, so the Scrim colour and blur are declared here.
|
|
22
|
-
//
|
|
24
|
+
// Content or its delegated child owns scrolling while usable; insufficient height releases the whole task.
|
|
23
25
|
const dialogRoot = cva(
|
|
24
26
|
[
|
|
25
27
|
'open:flex open:flex-col m-auto p-0 overflow-hidden',
|
|
28
|
+
'[&[data-dialog-unusable]]:overflow-auto [&[data-dialog-unusable]]:[--_scroll-released:1]',
|
|
29
|
+
'[&[data-dialog-unusable]]:scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
|
|
30
|
+
'[&[data-dialog-unusable]>*]:shrink-0',
|
|
31
|
+
'[&[data-dialog-unusable]>[data-dialog-member=content]]:flex-none',
|
|
32
|
+
'[&[data-dialog-unusable]>[data-dialog-member=content]]:overflow-visible',
|
|
33
|
+
'[&[data-dialog-unusable]>[data-dialog-member=content]>*]:h-auto',
|
|
34
|
+
'[&[data-dialog-unusable]_div:has(>table)]:max-h-none',
|
|
35
|
+
'[&[data-dialog-unusable]_div:has(>table)]:overflow-visible',
|
|
36
|
+
'[&[data-dialog-unusable]_thead]:static',
|
|
26
37
|
'bg-surface text-foreground',
|
|
27
38
|
'rounded-[var(--radius-dialog)] shadow-[var(--elevation-floating)]',
|
|
28
39
|
'backdrop:bg-scrim backdrop:[backdrop-filter:blur(var(--scrim-blur))]',
|
|
@@ -50,14 +61,14 @@ const dialogRoot = cva(
|
|
|
50
61
|
// followed by a Description hands the region's bottom padding to it.
|
|
51
62
|
const dialogTitle = cva(
|
|
52
63
|
[
|
|
53
|
-
'px-[var(--space-region)] pt-[var(--space-region)] pb-[var(--space-region)]',
|
|
64
|
+
'shrink-0 px-[var(--space-region)] pt-[var(--space-region)] pb-[var(--space-region)]',
|
|
54
65
|
'[&:has(+[data-dialog-member=description])]:pb-0',
|
|
55
66
|
].join(' '),
|
|
56
67
|
);
|
|
57
68
|
|
|
58
69
|
const dialogDescription = cva(
|
|
59
70
|
[
|
|
60
|
-
'px-[var(--space-region)] pt-[var(--space-region)] pb-[var(--space-region)]',
|
|
71
|
+
'shrink-0 px-[var(--space-region)] pt-[var(--space-region)] pb-[var(--space-region)]',
|
|
61
72
|
'[[data-dialog-member=title]+&]:mt-[var(--space-stack)] [[data-dialog-member=title]+&]:pt-0',
|
|
62
73
|
].join(' '),
|
|
63
74
|
);
|
|
@@ -202,6 +213,8 @@ const DialogRoot: FunctionComponent<IDialogRootProps> = ({
|
|
|
202
213
|
}) => {
|
|
203
214
|
const baseId = useId();
|
|
204
215
|
const dialogRef = useRef<HTMLDialogElement | undefined>(undefined);
|
|
216
|
+
const isUnusable = useDialogAllocation(dialogRef);
|
|
217
|
+
useModalScrollLock(dialogRef);
|
|
205
218
|
// What the component believes about its own presentation. The close handler reads it to tell a
|
|
206
219
|
// platform-initiated close (form method="dialog") - which must synchronize the Subject - from a
|
|
207
220
|
// close the component performed itself, which must not re-emit.
|
|
@@ -318,6 +331,7 @@ const DialogRoot: FunctionComponent<IDialogRootProps> = ({
|
|
|
318
331
|
<dialog
|
|
319
332
|
ref={attachDialog}
|
|
320
333
|
className={dialogRoot({ extent })}
|
|
334
|
+
data-dialog-unusable={isUnusable || undefined}
|
|
321
335
|
aria-label={ariaLabel}
|
|
322
336
|
aria-labelledby={
|
|
323
337
|
ariaLabel === undefined && nameParts.title
|
|
@@ -451,9 +465,14 @@ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
|
|
|
451
465
|
* - `extent="screen"` (the default) fills the viewport minus the `--gutter` inline and
|
|
452
466
|
* `--space-region` block insets, header and Actions fixed, only `Content` scrolling.
|
|
453
467
|
* `extent="content"` holds a fixed 32rem capped to the viewport with content-driven height;
|
|
454
|
-
* Default `Content`
|
|
468
|
+
* Default `Content` owns overflow while a usable viewport fits.
|
|
455
469
|
* - `Content scrollOwner="child"` passes available height to one bounded child without scrolling.
|
|
456
|
-
* A bounded Table
|
|
470
|
+
* A bounded Table, directly or through Pane's child-owned Body, owns both axes and pins its header;
|
|
471
|
+
* title, description, Pane.Foot summary and actions stay fixed while they and a complete row fit.
|
|
472
|
+
* - When fixed regions plus a complete row/control or text line and focus room cannot fit, the
|
|
473
|
+
* whole task scrolls in Dialog. Content and Table contribute no separate scroll region and the
|
|
474
|
+
* Table header is released. Bounds restore on resize or content changes without remounting.
|
|
475
|
+
* The background document is locked while presented; its prior overflow is restored on close.
|
|
457
476
|
* - Regions take `--space-region` padding with `--space-stack` between Title and Description and
|
|
458
477
|
* between Actions controls; hairlines separate header from `Content` and `Content` from
|
|
459
478
|
* `Actions` only where both neighbours exist. The surface reads `--radius-dialog`,
|
|
@@ -462,8 +481,10 @@ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
|
|
|
462
481
|
*
|
|
463
482
|
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
464
483
|
* - In child-scroll mode provide one child that accepts the height allocation and owns overflow.
|
|
465
|
-
* Intermediate wrappers must preserve that allocation.
|
|
466
|
-
*
|
|
484
|
+
* Intermediate consumer wrappers must preserve that allocation. Pane.Root with child-owned
|
|
485
|
+
* Pane.Body and a fixed Pane.Foot is supported. The fallback measures the composed table and
|
|
486
|
+
* surrounding fixed content at their current width; arbitrary height-consuming wrappers need
|
|
487
|
+
* their own allocation contract.
|
|
467
488
|
* - Direct children in the order `Title`, `Description`, `Content`, `Actions`, at most one of
|
|
468
489
|
* each; `Actions` is present and holds at least one explicit dismissal control, wired to the
|
|
469
490
|
* same dismissal and visibility transitions. Cancel-before-Confirm order puts initial focus on
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const length = (value: string): number => Number.parseFloat(value) || 0;
|
|
4
|
+
const edges = (style: CSSStyleDeclaration): number =>
|
|
5
|
+
length(style.paddingTop) +
|
|
6
|
+
length(style.paddingBottom) +
|
|
7
|
+
length(style.borderTopWidth) +
|
|
8
|
+
length(style.borderBottomWidth);
|
|
9
|
+
const margins = (element: Element): number => {
|
|
10
|
+
const style = getComputedStyle(element);
|
|
11
|
+
return length(style.marginTop) + length(style.marginBottom);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Measure semantic content at its current width, never by temporarily rebounding fallback content.
|
|
15
|
+
// A table needs its caption, header and one complete row; surrounding fixed siblings keep their height.
|
|
16
|
+
const minimumHeight = (element: Element): number => {
|
|
17
|
+
const style = getComputedStyle(element);
|
|
18
|
+
if (style.display === 'none' || style.position === 'absolute') return 0;
|
|
19
|
+
if (element.matches('table')) {
|
|
20
|
+
const fixed = Array.from(element.children)
|
|
21
|
+
.filter((child) => child.matches('caption, thead'))
|
|
22
|
+
.reduce(
|
|
23
|
+
(height, child) => height + child.getBoundingClientRect().height,
|
|
24
|
+
0,
|
|
25
|
+
);
|
|
26
|
+
const row = Math.max(
|
|
27
|
+
0,
|
|
28
|
+
...Array.from(element.querySelectorAll('tbody>tr')).map(
|
|
29
|
+
(child) => child.getBoundingClientRect().height,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
return fixed + row;
|
|
33
|
+
}
|
|
34
|
+
if (element.querySelector('table')) {
|
|
35
|
+
const children = Array.from(element.children);
|
|
36
|
+
return (
|
|
37
|
+
edges(style) +
|
|
38
|
+
Math.max(0, children.length - 1) * length(style.rowGap) +
|
|
39
|
+
children.reduce(
|
|
40
|
+
(height, child) =>
|
|
41
|
+
height +
|
|
42
|
+
margins(child) +
|
|
43
|
+
(child.matches('table') || child.querySelector('table')
|
|
44
|
+
? minimumHeight(child)
|
|
45
|
+
: child.getBoundingClientRect().height),
|
|
46
|
+
0,
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const item = Math.max(
|
|
51
|
+
length(style.lineHeight) || length(style.fontSize),
|
|
52
|
+
...Array.from(
|
|
53
|
+
element.querySelectorAll(
|
|
54
|
+
'button, input, select, textarea, [role="button"]',
|
|
55
|
+
),
|
|
56
|
+
).map((child) => child.getBoundingClientRect().height),
|
|
57
|
+
);
|
|
58
|
+
const ring =
|
|
59
|
+
length(style.getPropertyValue('--focus-ring-width')) +
|
|
60
|
+
length(style.getPropertyValue('--focus-ring-offset'));
|
|
61
|
+
return edges(style) + item + 2 * ring;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const useDialogAllocation = (
|
|
65
|
+
root: RefObject<HTMLDialogElement | undefined>,
|
|
66
|
+
): boolean => {
|
|
67
|
+
const [isUnusable, setIsUnusable] = useState(false);
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
const dialog = root.current;
|
|
70
|
+
if (!dialog) return;
|
|
71
|
+
const measure = () => {
|
|
72
|
+
if (!dialog.open) return;
|
|
73
|
+
const style = getComputedStyle(dialog);
|
|
74
|
+
const available =
|
|
75
|
+
style.maxHeight === 'none'
|
|
76
|
+
? length(style.height)
|
|
77
|
+
: length(style.maxHeight);
|
|
78
|
+
const required = Array.from(dialog.children).reduce(
|
|
79
|
+
(height, child) =>
|
|
80
|
+
height +
|
|
81
|
+
margins(child) +
|
|
82
|
+
(child.getAttribute('data-dialog-member') === 'content'
|
|
83
|
+
? minimumHeight(child)
|
|
84
|
+
: child.getBoundingClientRect().height),
|
|
85
|
+
edges(style),
|
|
86
|
+
);
|
|
87
|
+
setIsUnusable(available < required);
|
|
88
|
+
};
|
|
89
|
+
const observer =
|
|
90
|
+
typeof ResizeObserver === 'undefined'
|
|
91
|
+
? undefined
|
|
92
|
+
: new ResizeObserver(measure);
|
|
93
|
+
const observe = () => {
|
|
94
|
+
observer?.disconnect();
|
|
95
|
+
observer?.observe(dialog);
|
|
96
|
+
for (const child of dialog.querySelectorAll('*'))
|
|
97
|
+
observer?.observe(child);
|
|
98
|
+
measure();
|
|
99
|
+
};
|
|
100
|
+
observe();
|
|
101
|
+
const mutations = new MutationObserver(observe);
|
|
102
|
+
mutations.observe(dialog, {
|
|
103
|
+
childList: true,
|
|
104
|
+
subtree: true,
|
|
105
|
+
characterData: true,
|
|
106
|
+
attributes: true,
|
|
107
|
+
attributeFilter: ['open'],
|
|
108
|
+
});
|
|
109
|
+
window.addEventListener('resize', measure);
|
|
110
|
+
return () => {
|
|
111
|
+
observer?.disconnect();
|
|
112
|
+
mutations.disconnect();
|
|
113
|
+
window.removeEventListener('resize', measure);
|
|
114
|
+
};
|
|
115
|
+
}, [root]);
|
|
116
|
+
// Releasing or restoring the allocation can move the focused control outside the scrollport.
|
|
117
|
+
// Reveal it after that layout commit, retaining the same element and native keyboard behavior.
|
|
118
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the allocation transition is the trigger
|
|
119
|
+
useLayoutEffect(() => {
|
|
120
|
+
const dialog = root.current;
|
|
121
|
+
const focused = dialog?.ownerDocument.activeElement;
|
|
122
|
+
if (
|
|
123
|
+
dialog?.open &&
|
|
124
|
+
focused instanceof HTMLElement &&
|
|
125
|
+
dialog.contains(focused)
|
|
126
|
+
) {
|
|
127
|
+
focused.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
128
|
+
}
|
|
129
|
+
}, [root, isUnusable]);
|
|
130
|
+
return isUnusable;
|
|
131
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
type PageScrollLock = {
|
|
4
|
+
overflow: string;
|
|
5
|
+
priority: string;
|
|
6
|
+
dialogs: Set<HTMLDialogElement>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const PAGE_SCROLL_LOCKS = new WeakMap<HTMLElement, PageScrollLock>();
|
|
10
|
+
|
|
11
|
+
// Mutation observers can report the replacement opening before the previous dialog closing,
|
|
12
|
+
// even when the consumer closes first. Share the snapshot until both observers have settled.
|
|
13
|
+
const acquireScrollLock = (dialog: HTMLDialogElement): (() => void) => {
|
|
14
|
+
const page = dialog.ownerDocument.documentElement;
|
|
15
|
+
let lock = PAGE_SCROLL_LOCKS.get(page);
|
|
16
|
+
if (lock === undefined) {
|
|
17
|
+
lock = {
|
|
18
|
+
overflow: page.style.getPropertyValue('overflow'),
|
|
19
|
+
priority: page.style.getPropertyPriority('overflow'),
|
|
20
|
+
dialogs: new Set<HTMLDialogElement>(),
|
|
21
|
+
};
|
|
22
|
+
PAGE_SCROLL_LOCKS.set(page, lock);
|
|
23
|
+
page.style.setProperty('overflow', 'hidden');
|
|
24
|
+
}
|
|
25
|
+
const acquired = lock;
|
|
26
|
+
acquired.dialogs.add(dialog);
|
|
27
|
+
return () => {
|
|
28
|
+
acquired.dialogs.delete(dialog);
|
|
29
|
+
if (acquired.dialogs.size === 0) {
|
|
30
|
+
page.style.setProperty('overflow', acquired.overflow, acquired.priority);
|
|
31
|
+
PAGE_SCROLL_LOCKS.delete(page);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const useModalScrollLock = (
|
|
37
|
+
root: RefObject<HTMLDialogElement | undefined>,
|
|
38
|
+
): void => {
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
const dialog = root.current;
|
|
41
|
+
if (!dialog) return;
|
|
42
|
+
let release: (() => void) | undefined;
|
|
43
|
+
const synchronize = () => {
|
|
44
|
+
if (dialog.open && release === undefined) {
|
|
45
|
+
release = acquireScrollLock(dialog);
|
|
46
|
+
} else if (!dialog.open && release !== undefined) {
|
|
47
|
+
release();
|
|
48
|
+
release = undefined;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
synchronize();
|
|
52
|
+
const observer = new MutationObserver(synchronize);
|
|
53
|
+
observer.observe(dialog, { attributes: true, attributeFilter: ['open'] });
|
|
54
|
+
return () => {
|
|
55
|
+
observer.disconnect();
|
|
56
|
+
release?.();
|
|
57
|
+
};
|
|
58
|
+
}, [root]);
|
|
59
|
+
};
|
package/src/Layout/Pane/Pane.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { ScreenBodyAllocation } from 'Layout/Screen/ScreenBodyAllocation';
|
|
1
2
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
3
|
import {
|
|
3
4
|
type FunctionComponent,
|
|
4
5
|
type ReactNode,
|
|
6
|
+
useContext,
|
|
5
7
|
useLayoutEffect,
|
|
6
8
|
useRef,
|
|
7
9
|
useState,
|
|
@@ -15,7 +17,7 @@ const paneBody = cva('min-h-0 min-w-0 flex-[0_1_auto]', {
|
|
|
15
17
|
variants: {
|
|
16
18
|
scrollOwner: {
|
|
17
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)]',
|
|
18
|
-
child: '',
|
|
20
|
+
child: 'flex flex-col',
|
|
19
21
|
},
|
|
20
22
|
},
|
|
21
23
|
defaultVariants: { scrollOwner: 'body' },
|
|
@@ -78,21 +80,136 @@ export interface IPaneBodyProps extends VariantProps<typeof paneBody> {
|
|
|
78
80
|
testId?: string;
|
|
79
81
|
}
|
|
80
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
|
+
// A height-passing holder may keep a row of its own above the pane (Tabs.Root fill); the
|
|
149
|
+
// pane is offered the remainder less that row, bounded or released alike (#153).
|
|
150
|
+
const allocation = frame.getBoundingClientRect();
|
|
151
|
+
const above = Math.max(
|
|
152
|
+
0,
|
|
153
|
+
element.getBoundingClientRect().top - allocation.top,
|
|
154
|
+
);
|
|
155
|
+
setIsUnusable(
|
|
156
|
+
allocation.height - above <
|
|
157
|
+
fixed +
|
|
158
|
+
gaps +
|
|
159
|
+
blockEdges(element) +
|
|
160
|
+
(body instanceof HTMLElement ? usableBodyHeight(body) : 0),
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
const observer =
|
|
164
|
+
typeof ResizeObserver === 'undefined'
|
|
165
|
+
? undefined
|
|
166
|
+
: new ResizeObserver(measure);
|
|
167
|
+
const observe = () => {
|
|
168
|
+
observer?.disconnect();
|
|
169
|
+
observer?.observe(frame);
|
|
170
|
+
observer?.observe(element);
|
|
171
|
+
for (const child of element.querySelectorAll('*'))
|
|
172
|
+
observer?.observe(child);
|
|
173
|
+
measure();
|
|
174
|
+
};
|
|
175
|
+
observe();
|
|
176
|
+
const mutations = new MutationObserver(observe);
|
|
177
|
+
mutations.observe(element, {
|
|
178
|
+
childList: true,
|
|
179
|
+
subtree: true,
|
|
180
|
+
characterData: true,
|
|
181
|
+
});
|
|
182
|
+
window.addEventListener('resize', measure);
|
|
183
|
+
return () => {
|
|
184
|
+
observer?.disconnect();
|
|
185
|
+
mutations.disconnect();
|
|
186
|
+
window.removeEventListener('resize', measure);
|
|
187
|
+
};
|
|
188
|
+
}, [allocation, root]);
|
|
189
|
+
return isUnusable;
|
|
190
|
+
};
|
|
191
|
+
|
|
81
192
|
const Root: FunctionComponent<IPaneRootProps> = ({
|
|
82
193
|
children,
|
|
83
194
|
name,
|
|
84
195
|
testId,
|
|
85
|
-
}) =>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
196
|
+
}) => {
|
|
197
|
+
const root = useRef<HTMLDivElement>(null);
|
|
198
|
+
const isUnusable = useScreenFallback(root);
|
|
199
|
+
return (
|
|
200
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and group role are assigned together
|
|
201
|
+
<div
|
|
202
|
+
ref={root}
|
|
203
|
+
data-screen-unusable={isUnusable || undefined}
|
|
204
|
+
className={pane()}
|
|
205
|
+
role={name ? 'group' : undefined}
|
|
206
|
+
aria-label={name || undefined}
|
|
207
|
+
data-testid={testId}
|
|
208
|
+
>
|
|
209
|
+
{children}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
96
213
|
const Head: FunctionComponent<IPaneSectionProps> = ({ children, testId }) => (
|
|
97
214
|
<div className={paneSection()} data-testid={testId}>
|
|
98
215
|
{children}
|
|
@@ -117,6 +234,7 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
|
|
|
117
234
|
return (
|
|
118
235
|
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and group role are assigned together
|
|
119
236
|
<div
|
|
237
|
+
data-pane-body=""
|
|
120
238
|
ref={container}
|
|
121
239
|
className={paneBody({ scrollOwner: scrollOwner ?? 'body' })}
|
|
122
240
|
role={isScrollable ? 'group' : undefined}
|
|
@@ -143,7 +261,8 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
|
|
|
143
261
|
* overflowing, retained while focused. It reserves the shared focus ring's room and leaves
|
|
144
262
|
* native focus reveal, scrolling and child keyboard interactions intact.
|
|
145
263
|
* - Body scrollOwner="child" passes its allocated height directly to its child, without an
|
|
146
|
-
* inner wrapper, overflow, group or keyboard stop.
|
|
264
|
+
* inner wrapper, overflow, group or keyboard stop. Its flex allocation permits a bounded Table
|
|
265
|
+
* to shrink even in a content-height Dialog. The child owns scroll semantics and naming.
|
|
147
266
|
* Use this for a bounded Table: its wrapper must own both axes so its header stays in view.
|
|
148
267
|
*
|
|
149
268
|
* @CallerMustEnsure
|
|
@@ -153,6 +272,10 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
|
|
|
153
272
|
* At smaller sizes use an unsized stacked arrangement in a scrolling page or Shell body.
|
|
154
273
|
* - In child mode supply one child that accepts its parent's allocated height and handles its
|
|
155
274
|
* own overflow and focus ring. Intermediate wrappers must preserve that allocation.
|
|
275
|
+
* - Inside Screen, fixed regions and the usable body minimum participate in whole-content
|
|
276
|
+
* fallback and restoration, measured against what Screen's remainder leaves below anything
|
|
277
|
+
* above the pane - a filling Tabs row. Dialog owns the corresponding whole-task fallback for
|
|
278
|
+
* a Pane with child-owned Body and bounded Table. Other parents own their fallback policy.
|
|
156
279
|
* - Filled ColumnLayout columns and active Stepper panels can pass the bound to Root. Keep
|
|
157
280
|
* those holders and Pane directly composed so no intermediate wrapper loses the allocation.
|
|
158
281
|
*/
|
|
@@ -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. A filling Tabs row above a Pane counts against that
|
|
37
|
+
* Pane's room; other height-consuming wrappers need their own allocation contract.
|
|
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);
|
|
@@ -119,8 +119,8 @@ export interface ISidebarRootProps extends VariantProps<typeof sidebarRoot> {
|
|
|
119
119
|
active: string;
|
|
120
120
|
/** The nav landmark's accessible name. */
|
|
121
121
|
label: string;
|
|
122
|
-
/** Emits
|
|
123
|
-
*
|
|
122
|
+
/** Emits a navigation request once per non-inert entry activation, including the current entry.
|
|
123
|
+
* Rendering, focus and active-key changes emit nothing. The caller owns the Subject's lifetime. */
|
|
124
124
|
onSelect$: Subject<string>;
|
|
125
125
|
/** Direct `Sidebar.Item` children in display order, then one `Sidebar.Content`. */
|
|
126
126
|
children?: ReactNode;
|
|
@@ -152,7 +152,7 @@ const SidebarItem: FunctionComponent<ISidebarItemProps> = ({
|
|
|
152
152
|
const selection = useContext(SidebarSelectionContext);
|
|
153
153
|
const isActive = selection !== undefined && selection.active === entryKey;
|
|
154
154
|
const selectEntry = () => {
|
|
155
|
-
|
|
155
|
+
selection?.onSelect$.next(entryKey);
|
|
156
156
|
};
|
|
157
157
|
return (
|
|
158
158
|
<li>
|
|
@@ -203,16 +203,17 @@ const SidebarRoot: FunctionComponent<ISidebarRootProps> = ({
|
|
|
203
203
|
/**
|
|
204
204
|
* The standing application navigation beside the active section's content: a `nav` landmark named by
|
|
205
205
|
* `label`, listing text entries as plain buttons, and a `Content` track for the section the
|
|
206
|
-
* application shows.
|
|
207
|
-
*
|
|
206
|
+
* application shows. Every non-inert activation requests that section through `onSelect$`, including
|
|
207
|
+
* the current section; the application decides whether to change content or `active`. Composed from `Root`,
|
|
208
208
|
* `Item` and `Content`; Root assembles its direct Items into the list, in order, and places Content
|
|
209
209
|
* beside them at 64rem or below them under it.
|
|
210
210
|
*
|
|
211
211
|
* @Guarantees — enforced on every render
|
|
212
212
|
* - For valid caller input, the active entry alone carries `aria-current="true"`. Sidebar owns no
|
|
213
213
|
* selection, URL, history or fallback: it renders the key it is given; a missing key marks nothing.
|
|
214
|
-
* -
|
|
215
|
-
*
|
|
214
|
+
* - Each non-inert mouse/Enter/Space activation emits its key exactly once, including the current
|
|
215
|
+
* entry. This supersedes #101's current-entry silence. Inert entries, render, focus and active-key
|
|
216
|
+
* changes emit nothing. Entries are non-submitting native buttons, with no tabs/menu model.
|
|
216
217
|
* - An inert entry stays visible but muted and disabled, so Tab skips it and activation is inert too.
|
|
217
218
|
* - A label is rendered in full inside its entry, in either arrangement: a phrase wraps at its spaces
|
|
218
219
|
* and a word wider than the track breaks within itself. Nothing is truncated, renamed or hidden
|
|
@@ -238,6 +239,8 @@ const SidebarRoot: FunctionComponent<ISidebarRootProps> = ({
|
|
|
238
239
|
* and an Item rendered outside a Root has no selection to derive its treatment from.
|
|
239
240
|
* - Focus after the content changes belongs to the application; Sidebar leaves it on the activated
|
|
240
241
|
* entry.
|
|
242
|
+
* - Navigation requests carry no routing, history, draft disposal, payment or content state policy.
|
|
243
|
+
* Ordinary content may ignore a current-section request; subordinate content may return through it.
|
|
241
244
|
*
|
|
242
245
|
* @UXGuidelines
|
|
243
246
|
* - Entries are section labels: short, parallel, text-only. A destination that is a URL belongs to
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,7 @@ export { Header } from 'Layout/Header/Header';
|
|
|
61
61
|
export { Hero } from 'Layout/Hero/Hero';
|
|
62
62
|
export { PageHead } from 'Layout/PageHead/PageHead';
|
|
63
63
|
export { Pane } from 'Layout/Pane/Pane';
|
|
64
|
+
export { Screen } from 'Layout/Screen/Screen';
|
|
64
65
|
export { ScrollContainer } from 'Layout/ScrollContainer/ScrollContainer';
|
|
65
66
|
export { Section } from 'Layout/Section/Section';
|
|
66
67
|
export { Shell } from 'Layout/Shell/Shell';
|