@juwel-development/design-system 4.2.0 → 4.3.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.
@@ -49,7 +49,19 @@ const columnLayoutFrame = cva(
49
49
  // Chrome's layout grain, into a full switch; `0px` and `100%` are the two states, not measurements.
50
50
  const columnLayoutColumn = cva(
51
51
  'min-w-0 grow-[calc(var(--column-layout-weight)/var(--column-layout-growth-unit))] basis-[clamp(0px,(var(--column-layout-threshold)-100%)*1000000,100%)] max-w-[var(--column-layout-maximum)]',
52
+ {
53
+ variants: {
54
+ // The head keeps its content height and the next row takes what is left of a filled
55
+ // column's height; in a content-height column both rows keep their content height.
56
+ hasHead: {
57
+ true: 'grid grid-cols-[minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)] gap-[var(--column-layout-gap)]',
58
+ false: '',
59
+ },
60
+ },
61
+ defaultVariants: { hasHead: false },
62
+ },
52
63
  );
64
+ const columnLayoutColumnHead = cva('min-w-0');
53
65
 
54
66
  // The selecting holder is the box the alternatives are measured against; it takes no space of
55
67
  // its own. The probes sit out of flow and clipped, so a shortfall never becomes overflow.
@@ -173,7 +185,13 @@ export interface IColumnLayoutColumnProps {
173
185
  * The theme must supply a nonnegative CSS length at least as large as minWidth; resolved
174
186
  * lengths are not read or validated by the component. */
175
187
  maxWidth?: WidthToken;
176
- /** The column's content. Rendered unmodified: the column imposes no anatomy. */
188
+ /** Optional consumer content at content height above the column's content, spanning this
189
+ * column only. In a filled column the content below it takes the remaining height. Omitted,
190
+ * `undefined`, `null` or `false`, the column renders exactly as without it. */
191
+ head?: ReactNode;
192
+ /** The column's content, rendered unmodified. With a `head`, supply one element: one that
193
+ * accepts the remaining height (a Pane, `Alternatives fill`, `Root fill`, `Tabs.Root fill`)
194
+ * or content-height content. */
177
195
  children?: ReactNode;
178
196
  testId?: string;
179
197
  }
@@ -224,21 +242,31 @@ const ColumnLayoutRoot: FunctionComponent<IColumnLayoutRootProps> = ({
224
242
  '--column-layout-threshold': thresholdOf(tracks),
225
243
  '--column-layout-growth-unit': growthUnitOf(tracks),
226
244
  };
245
+ // `data-height-passing` tells Screen's fit check to look through a box to its children: it
246
+ // stretches to its allocation, so only what it holds has a height of its own (#159).
227
247
  const row = (
228
248
  <div
229
249
  className={columnLayout({ gap, fill })}
230
250
  style={style}
251
+ data-height-passing={fill || undefined}
231
252
  data-testid={testId}
232
253
  >
233
254
  {markColumns(children)}
234
255
  </div>
235
256
  );
236
- return fill ? <div className={columnLayoutFrame()}>{row}</div> : row;
257
+ return fill ? (
258
+ <div className={columnLayoutFrame()} data-height-passing>
259
+ {row}
260
+ </div>
261
+ ) : (
262
+ row
263
+ );
237
264
  };
238
265
 
239
266
  const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
240
267
  weight,
241
268
  maxWidth,
269
+ head,
242
270
  children,
243
271
  testId,
244
272
  }) => {
@@ -253,9 +281,18 @@ const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
253
281
  ? 'none'
254
282
  : `max(var(${maxWidth}), clamp(0px, (var(--column-layout-threshold) - 100%) * 1000000, 100%))`,
255
283
  };
284
+ const hasHead = head !== undefined && head !== null && head !== false;
285
+ // The head takes a slot of its own ahead of the content, so its arrival or departure never
286
+ // moves the content's position among the column's children and never remounts it.
256
287
  return (
257
- <div className={columnLayoutColumn()} style={style} data-testid={testId}>
288
+ <div
289
+ className={columnLayoutColumn({ hasHead })}
290
+ style={style}
291
+ data-height-passing
292
+ data-testid={testId}
293
+ >
258
294
  <ColumnLayoutMembership.Provider value={false}>
295
+ {hasHead && <div className={columnLayoutColumnHead()}>{head}</div>}
259
296
  {children}
260
297
  </ColumnLayoutMembership.Provider>
261
298
  </div>
@@ -480,7 +517,11 @@ const ColumnLayoutAlternatives: FunctionComponent<
480
517
  ? `${reserve}px`
481
518
  : '0px';
482
519
  return (
483
- <div className={columnLayoutAlternatives({ fill })} data-testid={testId}>
520
+ <div
521
+ className={columnLayoutAlternatives({ fill })}
522
+ data-height-passing={fill || undefined}
523
+ data-testid={testId}
524
+ >
484
525
  <div ref={probes} aria-hidden="true" className={columnLayoutFitProbes()}>
485
526
  {declarations.map(({ arrangement, threshold }, index) => (
486
527
  <div
@@ -516,6 +557,11 @@ const ColumnLayoutAlternatives: FunctionComponent<
516
557
  * - With `fill`, Root accepts its parent's allocated height and every horizontal column takes
517
558
  * that height, bounding a filling Pane, Table or ScrollContainer. An internal inline-size
518
559
  * frame supplies the width query; columns remain direct children of the row.
560
+ * - A Column's optional `head` renders first inside that column, at content height, separated
561
+ * from the content by the Root's gap role and spanning that column only. In a filled
562
+ * horizontal column the content takes the height left below it; otherwise both keep their
563
+ * content height. The head adds no role, name, tab stop or scrolling, and its arrival,
564
+ * change or removal never remounts the content.
519
565
  * - In an unsized parent, `fill` invents no bound: columns retain content height. When stacked,
520
566
  * columns return to content height and the holder owns scrolling. No column scrolls itself.
521
567
  * - The row holds only while every proportional share is at least its own minimum. The moment one
@@ -586,7 +632,11 @@ const ColumnLayoutAlternatives: FunctionComponent<
586
632
  * maximum below minimum has no guaranteed runtime exception. A valid maximum below the
587
633
  * original share at the threshold applies immediately, with its surplus redistributed.
588
634
  * - With `fill`, the holder supplies a definite height to bound content and owns overflow for
589
- * the stacked fallback. Filling children own their overflow; fixed content must fit the bound.
635
+ * the stacked fallback. Filling children own their overflow; fixed content must fit the bound,
636
+ * or - inside Screen - releases the whole content into the enclosing scroll owner.
637
+ * - Under a `head`, supply one child that accepts the remaining height (a Pane, `Root fill`,
638
+ * `Alternatives fill`, `Tabs.Root fill`) or content-height content. The head is the
639
+ * consumer's: its wording, controls and any heading level are not supplied by the column.
590
640
  * - The holder gives the Root a definite width, as any block does. Inside a shrink-to-fit frame
591
641
  * there is no width to measure the shares against.
592
642
  * - Content that cannot wrap - an unbroken string, a fixed-width control - needs its own overflow
@@ -89,9 +89,11 @@ const table = cva(
89
89
  highlight:
90
90
  '[&>table>*>tr[aria-selected=true]]:bg-selected-surface [&>table>*>tr[aria-selected=true]]:[--color-error:var(--color-selected-error)]',
91
91
  },
92
+ // A hidden caption is positioned against the table's own box, so it scrolls and clips with the
93
+ // table instead of extending the document below an enclosing scroll owner (#159).
92
94
  captionVisibility: {
93
95
  visible: '[&>table>caption]:pb-3',
94
- hidden: '[&>table>caption]:sr-only',
96
+ hidden: 'relative [&>table>caption]:sr-only',
95
97
  },
96
98
  // Density publishes the two cell insets the cells read, so head and body move together and the
97
99
  // theme's comfortable or compact pair is the only source (docs/adr/0008, Amendments, #115).
@@ -341,7 +343,10 @@ const useOverflow = (
341
343
  Number.parseFloat(getComputedStyle(element).paddingTop) || 0;
342
344
  element.style.scrollPaddingTop = `${headerHeight + 2 * ringRoom}px`;
343
345
  }
346
+ // Only bounded tables participate in Dialog release. Reading an absent inherited custom
347
+ // property is depth-dependent in jsdom 29.1.1, even for ordinary content tables (#157).
344
348
  const isReleased =
349
+ bounded &&
345
350
  getComputedStyle(element)
346
351
  .getPropertyValue('--_scroll-released')
347
352
  .trim() === '1';
@@ -508,6 +513,7 @@ const TableRoot: FunctionComponent<ITableRootProps> = ({
508
513
  })}
509
514
  style={style}
510
515
  data-bounded={bounded || undefined}
516
+ data-height-bounded={bounded || undefined}
511
517
  data-notes={notes}
512
518
  data-columns={columnCount}
513
519
  data-icon-actions={
@@ -641,6 +647,7 @@ const TableCell: FunctionComponent<ITableCellProps> = ({
641
647
  * - `Root` renders its required `caption` as the table's first child, so every table is named.
642
648
  * `captionVisibility` defaults to `visible`; `hidden` visually clips the caption out of flow,
643
649
  * including its spacing, while preserving its native accessible name and the scroll wrapper's name.
650
+ * The clipped caption stays inside the table's box, so it never extends an enclosing scrollport.
644
651
  * Hide it only when visible text directly above the table already names what it lists; the
645
652
  * enclosing arrangement owns the gap above the table. The consumer still supplies the caption.
646
653
  * - The block reads as a table from two rule weights alone: the heavier `rule` above the first row,
@@ -47,12 +47,22 @@ export interface ISliderProps {
47
47
  onInput$: Subject<number>;
48
48
  /**
49
49
  * Always rendered as a visible label associated with the control, the way `Input`'s is (#112);
50
- * it doubles as the accessible name.
50
+ * it is the accessible name, and stays exactly that while a `visibleValue` is shown.
51
51
  */
52
52
  label: string;
53
+ /**
54
+ * The current value shown beside the label, in the consumer's wording ("$3,542", "34 %"). The
55
+ * consumer formats it and passes new wording with each new `value`; Slider generates none. It
56
+ * shares the label's row when both fit and wraps below it when they do not. It never joins the
57
+ * accessible name and is not announced a second time: assistive technology hears it through
58
+ * `aria-valuetext`, which carries this wording whenever `valueText` is left out. Left out, only
59
+ * the label is shown.
60
+ */
61
+ visibleValue?: string;
53
62
  /**
54
63
  * How the value is announced, in the consumer's wording ("$60 a week"). Left out, assistive
55
- * technology reads the bare number: the library ships no wording of its own.
64
+ * technology reads `visibleValue`, or the bare number without one: the library ships no
65
+ * wording of its own.
56
66
  */
57
67
  valueText?: string;
58
68
  /** The explicit non-operable state: native disabled conduct, no emissions, disabled paint. */
@@ -88,11 +98,11 @@ const assertOperatingRange = (
88
98
  /**
89
99
  * A control that sets one numeric value by moving one thumb along a fixed, visible operating
90
100
  * range. Controlled: the consumer holds the value and passes it back in, and a value it does not
91
- * pass back is never adopted. It renders the value nowhere - the consumer sets any figures beside
92
- * it with typography - and it fills its container's width the way `Input` does. Its label follows
93
- * `Input` too: visible, associated, and the only text the component renders (#112). It is still
94
- * a live control and not a form field, so none of Input's annotations - hint, error, optional
95
- * marker - come with it. `disabled` is the explicit non-operable state.
101
+ * pass back is never adopted. It fills its container's width the way `Input` does. Its label
102
+ * follows `Input` too: visible, associated, and the stable accessible name (#112). The only other
103
+ * text it renders is an optional `visibleValue` in the consumer's wording, beside the label (#160).
104
+ * It is still a live control and not a form field, so none of Input's annotations - hint, error,
105
+ * optional marker - come with it. `disabled` is the explicit non-operable state.
96
106
  *
97
107
  * An off-range value renders at the nearest bound through the native control. The consumer still
98
108
  * owns the value: the clamped position is not emitted on `onInput$` until the user moves the thumb.
@@ -104,22 +114,47 @@ export const Slider: FunctionComponent<ISliderProps> = ({
104
114
  value,
105
115
  onInput$,
106
116
  label,
117
+ visibleValue,
107
118
  valueText,
108
119
  disabled,
109
120
  testId,
110
121
  }) => {
111
122
  const controlId = `${useId()}-control`;
112
123
  assertOperatingRange({ min, max, step, value });
124
+ // The label declares its face and size on itself, never on the wrapper, which would hand them
125
+ // to the control too; `body` is the control's own role (docs/adr/0004, #90, #92).
126
+ const visibleLabel = (
127
+ <label
128
+ htmlFor={controlId}
129
+ className={'font-secondary font-medium text-body text-foreground'}
130
+ >
131
+ {label}
132
+ </label>
133
+ );
113
134
  return (
114
135
  <div className={'flex flex-col gap-[var(--space-stack)]'}>
115
- {/* The label declares its face and size on itself, never on the wrapper, which would hand
116
- them to the control too; `body` is the control's own role (docs/adr/0004, #90, #92). */}
117
- <label
118
- htmlFor={controlId}
119
- className={'font-secondary font-medium text-body text-foreground'}
120
- >
121
- {label}
122
- </label>
136
+ {visibleValue === undefined ? (
137
+ visibleLabel
138
+ ) : (
139
+ // The label's face, role and leading, so a shared line keeps the label-only height even
140
+ // under a consumer face. aria-hidden because the range already announces the value;
141
+ // wrap-anywhere lets overlong wording break instead of widening the holder.
142
+ <div
143
+ className={
144
+ 'flex flex-wrap items-baseline justify-between gap-x-[var(--space-stack)]'
145
+ }
146
+ >
147
+ {visibleLabel}
148
+ <span
149
+ aria-hidden={true}
150
+ className={
151
+ 'min-w-0 wrap-anywhere font-secondary text-body text-foreground'
152
+ }
153
+ >
154
+ {visibleValue}
155
+ </span>
156
+ </div>
157
+ )}
123
158
  <input
124
159
  id={controlId}
125
160
  type={'range'}
@@ -129,7 +164,7 @@ export const Slider: FunctionComponent<ISliderProps> = ({
129
164
  step={step}
130
165
  value={value}
131
166
  disabled={disabled}
132
- aria-valuetext={valueText}
167
+ aria-valuetext={valueText ?? visibleValue}
133
168
  data-testid={testId}
134
169
  // React's onChange rides the native input event, so every movement emits through here.
135
170
  onChange={(event) => onInput$.next(event.currentTarget.valueAsNumber)}
@@ -154,7 +154,7 @@ const StepperRoot: FunctionComponent<IStepperRootProps> = ({
154
154
  }, [active$]);
155
155
  return (
156
156
  <StepperContext.Provider value={{ active, onSelect$, label, baseId }}>
157
- <div className={stepperRoot()} data-testid={testId}>
157
+ <div className={stepperRoot()} data-height-passing data-testid={testId}>
158
158
  {children}
159
159
  </div>
160
160
  </StepperContext.Provider>
@@ -272,6 +272,7 @@ const StepperPanel: FunctionComponent<IStepperPanelProps> = ({
272
272
  hidden={!isActive}
273
273
  tabIndex={isActive ? 0 : undefined}
274
274
  className={stepperPanel()}
275
+ data-height-passing
275
276
  data-testid={testId}
276
277
  >
277
278
  {isActive ? children : undefined}
@@ -167,7 +167,11 @@ const TabsRoot: FunctionComponent<ITabsRootProps> = ({
167
167
  <TabsContext.Provider
168
168
  value={{ active, onSelect$, label, baseId, fill: fill === true }}
169
169
  >
170
- <div className={tabsRoot({ fill })} data-testid={testId}>
170
+ <div
171
+ className={tabsRoot({ fill })}
172
+ data-height-passing={fill || undefined}
173
+ data-testid={testId}
174
+ >
171
175
  {children}
172
176
  </div>
173
177
  </TabsContext.Provider>
@@ -253,6 +257,7 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
253
257
  hidden={!isActive}
254
258
  tabIndex={isActive ? 0 : undefined}
255
259
  className={tabsPanel({ fill })}
260
+ data-height-passing={fill || undefined}
256
261
  data-testid={testId}
257
262
  >
258
263
  {isActive ? children : undefined}
@@ -9,9 +9,12 @@ import {
9
9
  useState,
10
10
  } from 'react';
11
11
 
12
- const pane = cva(
12
+ // A Pane inside a compact Screen follows it; a Dialog's Pane is not one of its work areas (#158).
13
+ const pane = cva([
13
14
  '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
+ '[[data-screen-spacing=compact]_&:not(dialog_*)]:gap-[var(--space-pane-slot-compact)]',
16
+ '[[data-screen-spacing=compact]_&:not(dialog_*)]:p-[max(var(--space-pane-inset-compact),calc(var(--focus-ring-width)+var(--focus-ring-offset)))]',
17
+ ]);
15
18
  const paneSection = cva('min-w-0 shrink-0');
16
19
  const paneBody = cva('min-h-0 min-w-0 flex-[0_1_auto]', {
17
20
  variants: {
@@ -164,21 +167,36 @@ const useScreenFallback = (root: {
164
167
  typeof ResizeObserver === 'undefined'
165
168
  ? undefined
166
169
  : new ResizeObserver(measure);
170
+ const mutations = new MutationObserver(() => observe());
171
+ const content = { childList: true, subtree: true, characterData: true };
172
+ // Content above the pane - a column head, a tab row - moves it without resizing it, so each
173
+ // box ahead of it up to the remainder is watched, attributes included (#159).
167
174
  const observe = () => {
168
175
  observer?.disconnect();
176
+ mutations.disconnect();
169
177
  observer?.observe(frame);
170
178
  observer?.observe(element);
171
179
  for (const child of element.querySelectorAll('*'))
172
180
  observer?.observe(child);
181
+ mutations.observe(element, content);
182
+ for (
183
+ let node: Element = element;
184
+ node !== frame && node.parentElement;
185
+ node = node.parentElement
186
+ ) {
187
+ mutations.observe(node.parentElement, { childList: true });
188
+ for (
189
+ let before = node.previousElementSibling;
190
+ before;
191
+ before = before.previousElementSibling
192
+ ) {
193
+ observer?.observe(before);
194
+ mutations.observe(before, { ...content, attributes: true });
195
+ }
196
+ }
173
197
  measure();
174
198
  };
175
199
  observe();
176
- const mutations = new MutationObserver(observe);
177
- mutations.observe(element, {
178
- childList: true,
179
- subtree: true,
180
- characterData: true,
181
- });
182
200
  window.addEventListener('resize', measure);
183
201
  return () => {
184
202
  observer?.disconnect();
@@ -201,6 +219,7 @@ const Root: FunctionComponent<IPaneRootProps> = ({
201
219
  <div
202
220
  ref={root}
203
221
  data-screen-unusable={isUnusable || undefined}
222
+ data-height-bounded
204
223
  className={pane()}
205
224
  role={name ? 'group' : undefined}
206
225
  aria-label={name || undefined}
@@ -257,6 +276,8 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
257
276
  * stretch: Foot follows it, with any unused height left in Root rather than inside Body.
258
277
  * - Root paints surface, foreground, border and box inset, square and without elevation.
259
278
  * The stack space role separates slots; a non-empty name exposes a group, never a landmark.
279
+ * Inside a Screen with spacing="compact", outside a Dialog, the inset and slot gaps follow
280
+ * that Screen's compact treatment instead; Pane offers no selector of its own.
260
281
  * - Default Body is the sole scroll region: a named group and keyboard stop only while
261
282
  * overflowing, retained while focused. It reserves the shared focus ring's room and leaves
262
283
  * native focus reveal, scrolling and child keyboard interactions intact.
@@ -274,9 +295,11 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
274
295
  * own overflow and focus ring. Intermediate wrappers must preserve that allocation.
275
296
  * - Inside Screen, fixed regions and the usable body minimum participate in whole-content
276
297
  * 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.
279
- * - Filled ColumnLayout columns and active Stepper panels can pass the bound to Root. Keep
280
- * those holders and Pane directly composed so no intermediate wrapper loses the allocation.
298
+ * above the pane - a filling Tabs row or a column head - and again whenever that changes.
299
+ * Dialog owns the corresponding whole-task fallback for a Pane with child-owned Body and
300
+ * bounded Table. Other parents own their fallback policy.
301
+ * - Filled ColumnLayout columns, with or without a head, and active Stepper panels can pass
302
+ * the bound to Root. Keep those holders and Pane directly composed so no intermediate
303
+ * wrapper loses the allocation. An unsized Pane inside a content-height Stack grows with it.
281
304
  */
282
305
  export const Pane = { Root, Head, Body, Foot } as const;
@@ -1,18 +1,108 @@
1
- import { cva } from 'class-variance-authority';
2
- import { type FunctionComponent, type ReactNode, useState } from 'react';
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import {
3
+ type FunctionComponent,
4
+ type ReactNode,
5
+ useLayoutEffect,
6
+ useState,
7
+ } from 'react';
3
8
  import { ScreenBodyAllocation } from './ScreenBodyAllocation';
4
9
 
10
+ // Compact marks the main for its Panes, which read the marker through CSS (architecture
11
+ // standard, Two rules). Its floor keeps a focus ring at the content's edge inside the scrollport.
5
12
  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)]',
13
+ 'box-border grid h-full min-h-0 min-w-0 grid-rows-[minmax(0,1fr)] px-[var(--gutter)] [&:has(>header)]:grid-rows-[auto_minmax(0,1fr)]',
14
+ {
15
+ variants: {
16
+ spacing: {
17
+ comfortable:
18
+ 'py-[var(--space-region)] [&:has(>header)]:gap-[var(--space-region)]',
19
+ compact:
20
+ 'py-[max(var(--space-screen-region-compact),calc(var(--focus-ring-width)+var(--focus-ring-offset)))] [&:has(>header)]:gap-[max(var(--space-screen-region-compact),calc(var(--focus-ring-width)+var(--focus-ring-offset)))]',
21
+ },
22
+ },
23
+ defaultVariants: { spacing: 'comfortable' },
24
+ },
7
25
  );
8
26
  const screenHeading = cva('min-w-0');
9
27
  const screenBody = cva(
10
28
  'h-full min-h-0 min-w-0 [&>div]:h-full [&:has([data-screen-unusable])>div]:h-auto',
11
29
  );
12
- export interface IScreenProps {
30
+ type Extent = { bottom: number; observed: readonly Element[] };
31
+
32
+ const isOutOfFlow = (computed: CSSStyleDeclaration): boolean =>
33
+ computed.display === 'none' ||
34
+ computed.position === 'absolute' ||
35
+ computed.position === 'fixed';
36
+
37
+ // Height-passing boxes (filled columns, Tabs, Stepper) stretch to the allocation in one mode and
38
+ // shrink to their content in the other, so they are looked through; self-bounded boxes (Pane, a
39
+ // bounded Table or ScrollContainer) answer for their own room. What remains keeps its content
40
+ // height in both modes, so its lowest edge decides fallback and restoration alike (#159).
41
+ const extentOf = (holder: Element): Extent =>
42
+ Array.from(holder.children).reduce<Extent>(
43
+ (extent, child) => {
44
+ const computed = getComputedStyle(child);
45
+ const observed = [...extent.observed, child];
46
+ if (isOutOfFlow(computed) || child.hasAttribute('data-height-bounded')) {
47
+ return { bottom: extent.bottom, observed };
48
+ }
49
+ if (child.hasAttribute('data-height-passing')) {
50
+ const inner = extentOf(child);
51
+ return {
52
+ bottom: Math.max(extent.bottom, inner.bottom),
53
+ observed: [...observed, ...inner.observed],
54
+ };
55
+ }
56
+ const bottom =
57
+ child.getBoundingClientRect().bottom +
58
+ (Number.parseFloat(computed.marginBottom) || 0);
59
+ return { bottom: Math.max(extent.bottom, bottom), observed };
60
+ },
61
+ { bottom: Number.NEGATIVE_INFINITY, observed: [] },
62
+ );
63
+
64
+ const useContentOverflow = (allocation: HTMLDivElement | null): boolean => {
65
+ const [isOverflowing, setIsOverflowing] = useState(false);
66
+ useLayoutEffect(() => {
67
+ const content = allocation?.firstElementChild;
68
+ if (!allocation || !content) return;
69
+ const measure = () =>
70
+ setIsOverflowing(
71
+ extentOf(content).bottom > allocation.getBoundingClientRect().bottom,
72
+ );
73
+ const observer =
74
+ typeof ResizeObserver === 'undefined'
75
+ ? undefined
76
+ : new ResizeObserver(measure);
77
+ // Observing a box reports it once, so the boxes are collected again only when content changes.
78
+ const observe = () => {
79
+ observer?.disconnect();
80
+ observer?.observe(allocation);
81
+ for (const box of extentOf(content).observed) observer?.observe(box);
82
+ measure();
83
+ };
84
+ observe();
85
+ const mutations = new MutationObserver(observe);
86
+ mutations.observe(content, {
87
+ childList: true,
88
+ subtree: true,
89
+ characterData: true,
90
+ });
91
+ window.addEventListener('resize', measure);
92
+ return () => {
93
+ observer?.disconnect();
94
+ mutations.disconnect();
95
+ window.removeEventListener('resize', measure);
96
+ };
97
+ }, [allocation]);
98
+ return isOverflowing;
99
+ };
100
+
101
+ export interface IScreenProps extends VariantProps<typeof screen> {
13
102
  /** Consumer-owned visible heading; omit when the heading belongs inside a Pane. */
14
103
  heading?: ReactNode;
15
- /** Height-passing content, normally a filling ColumnLayout with direct Panes. */
104
+ /** A filling ColumnLayout, a Pane or `Tabs.Root fill`, or content-height content such as a
105
+ * Stack or an unbounded Table. See the supported compositions below. */
16
106
  children: ReactNode;
17
107
  testId?: string;
18
108
  }
@@ -21,36 +111,56 @@ export interface IScreenProps {
21
111
  *
22
112
  * @Guarantees
23
113
  * - Uses the page gutter horizontally and region spacing vertically and beneath a present heading.
114
+ * - spacing="compact" selects the compact console treatment: its own vertical air and heading gap,
115
+ * and the inset and slot gaps of every Pane inside it outside a Dialog, read from the
116
+ * --space-screen-region-compact, --space-pane-inset-compact and --space-pane-slot-compact tokens.
117
+ * Gutter, type, controls, focus rings and descendant spacing stay; Pane has no selector of its own.
118
+ * Omitted or "comfortable" renders as before; switching never remounts content.
24
119
  * - Omitted, undefined, null and false headings reserve no wrapper, track or gap. Arbitrary
25
120
  * 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.
121
+ * - Adds no scrollport or tab stop, and gives no content-height region one. Bounded bodies
122
+ * scroll independently while all fixed content fits: Pane heads and feet, a column head,
123
+ * content-height columns and sole content, plus each bounded body's complete text line or
124
+ * ordinary row/control with focus room. Long bounded-body content is never fixed content.
125
+ * - Otherwise all content, the heading included, grows into the enclosing scroll owner and stays
126
+ * reachable there. Bounds restore when room returns, without probing, clipping or remounting
127
+ * children; mounted controls keep focus and selection in both directions.
29
128
  * - Unsized parents use natural height; ColumnLayout retains its container-fit stacking rule.
30
129
  *
31
130
  * @CallerMustEnsure
32
131
  * - Supply the visible heading here or inside a panel, and exactly one main for the page.
33
132
  * - Use Shell's console body, optionally Sidebar.Root fill / Sidebar.Content, to allocate height
34
133
  * 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.
134
+ * - Compose one of these as content; retain Pane's Head/Body/Foot order and never nest Panes.
135
+ * 1. Filling ColumnLayout whose columns hold a Pane, `Tabs.Root fill` or `Alternatives fill`
136
+ * directly, optionally under a `Column head`; Body can delegate to one bounded Table.
137
+ * 2. A column holding a content-height Stack - a Box and an unsized summary Pane, say.
138
+ * 3. A content-height Stack or unbounded Table as the sole content, with or without heading.
139
+ * 4. Content-height Stacks in one or more filled columns, beside Panes or each other.
140
+ * - A filling Tabs row or a column head above a Pane counts against that Pane's room. Other
141
+ * height-passing wrappers, such as a Stack meant to bound a Pane, are outside the contract.
38
142
  * - Keep the Shell bar short enough to leave a usable enclosing scrollport. This is representative
39
143
  * panel fit, not a guarantee that arbitrary content fits any particular viewport.
40
144
  */
41
145
  export const Screen: FunctionComponent<IScreenProps> = ({
42
146
  heading,
147
+ spacing,
43
148
  children,
44
149
  testId,
45
150
  }) => {
46
151
  const [allocation, setAllocation] = useState<HTMLDivElement | null>(null);
152
+ const isOverflowing = useContentOverflow(allocation);
47
153
  return (
48
- <main className={screen()} data-testid={testId}>
154
+ <main
155
+ className={screen({ spacing })}
156
+ data-screen-spacing={spacing === 'compact' ? spacing : undefined}
157
+ data-testid={testId}
158
+ >
49
159
  {heading !== undefined && heading !== null && heading !== false && (
50
160
  <header className={screenHeading()}>{heading}</header>
51
161
  )}
52
162
  <div ref={setAllocation} className={screenBody()}>
53
- <div>
163
+ <div data-screen-unusable={isOverflowing || undefined}>
54
164
  <ScreenBodyAllocation.Provider value={allocation}>
55
165
  {children}
56
166
  </ScreenBodyAllocation.Provider>
@@ -136,6 +136,7 @@ export const ScrollContainer: FunctionComponent<IScrollContainerProps> = ({
136
136
  <div
137
137
  ref={container}
138
138
  className={scrollContainer({ axis })}
139
+ data-height-bounded
139
140
  data-testid={testId}
140
141
  role={isScrollable ? 'group' : undefined}
141
142
  aria-label={isScrollable ? ariaLabel : undefined}
@@ -254,6 +254,16 @@ const BOX_INSET = `:root {
254
254
  --space-box-inset: 1em;
255
255
  }`;
256
256
 
257
+ /* Screen's compact console treatment (#158, ADR 0008): one value per enclosure position, so a
258
+ theme tunes it without retuning the generic region, box-inset and stack roles descendants read.
259
+ In em like those roles. Constraint: nonnegative; Screen and Pane floor them at the focus ring's
260
+ room. */
261
+ const CONSOLE_SPACING = `:root {
262
+ --space-screen-region-compact: 0.5em;
263
+ --space-pane-inset-compact: 0.5em;
264
+ --space-pane-slot-compact: 0.25em;
265
+ }`;
266
+
257
267
  /* Table cell insets (#115): one inline/block pair per density, in :root so a brand tunes both
258
268
  treatments without Table gaining a measurement prop. comfortable is exactly what px-4 / py-2
259
269
  resolved to, so the default moves nothing; compact halves both. In rem: cell type is fixed at the
@@ -478,6 +488,9 @@ ${COLUMN_LAYOUT_FIT}
478
488
  /* Box's inner padding, measured against inherited type, beside Collection's. */
479
489
  ${BOX_INSET}
480
490
 
491
+ /* Screen's compact console positions, beside the roles they stand in for. */
492
+ ${CONSOLE_SPACING}
493
+
481
494
  /* Callout marks an edge without enclosing or arranging its content. */
482
495
  ${CALLOUT_MARK}
483
496
 
package/src/tokens.css CHANGED
@@ -293,6 +293,13 @@
293
293
  --space-box-inset: 1em;
294
294
  }
295
295
 
296
+ /* Screen's compact console positions, beside the roles they stand in for. */
297
+ :root {
298
+ --space-screen-region-compact: 0.5em;
299
+ --space-pane-inset-compact: 0.5em;
300
+ --space-pane-slot-compact: 0.25em;
301
+ }
302
+
296
303
  /* Callout marks an edge without enclosing or arranging its content. */
297
304
  :root {
298
305
  --callout-mark-width: 0.25rem;
@@ -257,6 +257,13 @@
257
257
  --space-box-inset: 1em;
258
258
  }
259
259
 
260
+ /* Screen's compact console positions, beside the roles they stand in for. */
261
+ :root {
262
+ --space-screen-region-compact: 0.5em;
263
+ --space-pane-inset-compact: 0.5em;
264
+ --space-pane-slot-compact: 0.25em;
265
+ }
266
+
260
267
  /* Callout marks an edge without enclosing or arranging its content. */
261
268
  :root {
262
269
  --callout-mark-width: 0.25rem;