@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.
@@ -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
- // overflow-hidden overrides the UA's `dialog { overflow: auto }` - Content or its delegated child owns scrolling.
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` scrolls as the fallback when its content exceeds available space.
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 then owns both axes and pins its header; title, description and actions stay fixed.
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. Fixed regions must fit the viewport and
466
- * leave usable room for the child; a Table needs room for its header and readable rows.
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
+ };
@@ -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
- // biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and group role are assigned together
87
- <div
88
- className={pane()}
89
- role={name ? 'group' : undefined}
90
- aria-label={name || undefined}
91
- data-testid={testId}
92
- >
93
- {children}
94
- </div>
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. The child owns scroll semantics and naming.
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 the selected entry's key when a usable inactive entry is activated. The active and inert
123
- * entries emit nothing, and neither does any render. */
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
- if (!isActive) selection?.onSelect$.next(entryKey);
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. Selection is a request - activating a usable inactive entry emits its key through
207
- * `onSelect$` - and the application answers by rerendering with a new `active`. Composed from `Root`,
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
- * - Activating the active entry or an inert one emits nothing; no render emits anything. Entries are
215
- * non-submitting `type="button"` buttons with native Tab/Enter/Space behaviour - no tabs/menu model.
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';