@juwel-development/design-system 4.1.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
+ };
@@ -17,7 +17,7 @@ const paneBody = cva('min-h-0 min-w-0 flex-[0_1_auto]', {
17
17
  variants: {
18
18
  scrollOwner: {
19
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: '',
20
+ child: 'flex flex-col',
21
21
  },
22
22
  },
23
23
  defaultVariants: { scrollOwner: 'body' },
@@ -145,8 +145,15 @@ const useScreenFallback = (root: {
145
145
  const gaps =
146
146
  Math.max(0, sections.length - 1) *
147
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
+ );
148
155
  setIsUnusable(
149
- frame.getBoundingClientRect().height <
156
+ allocation.height - above <
150
157
  fixed +
151
158
  gaps +
152
159
  blockEdges(element) +
@@ -254,7 +261,8 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
254
261
  * overflowing, retained while focused. It reserves the shared focus ring's room and leaves
255
262
  * native focus reveal, scrolling and child keyboard interactions intact.
256
263
  * - 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.
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.
258
266
  * Use this for a bounded Table: its wrapper must own both axes so its header stays in view.
259
267
  *
260
268
  * @CallerMustEnsure
@@ -265,7 +273,9 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
265
273
  * - In child mode supply one child that accepts its parent's allocated height and handles its
266
274
  * own overflow and focus ring. Intermediate wrappers must preserve that allocation.
267
275
  * - 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.
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.
269
279
  * - Filled ColumnLayout columns and active Stepper panels can pass the bound to Root. Keep
270
280
  * those holders and Pane directly composed so no intermediate wrapper loses the allocation.
271
281
  */
@@ -33,8 +33,8 @@ export interface IScreenProps {
33
33
  * - Use Shell's console body, optionally Sidebar.Root fill / Sidebar.Content, to allocate height
34
34
  * and own fallback scrolling. Every intermediate wrapper must pass that allocation through.
35
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.
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
38
  * - Keep the Shell bar short enough to leave a usable enclosing scrollport. This is representative
39
39
  * panel fit, not a guarantee that arbitrary content fits any particular viewport.
40
40
  */
@@ -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