@juwel-development/design-system 4.2.1 → 4.4.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).
@@ -511,6 +513,7 @@ const TableRoot: FunctionComponent<ITableRootProps> = ({
511
513
  })}
512
514
  style={style}
513
515
  data-bounded={bounded || undefined}
516
+ data-height-bounded={bounded || undefined}
514
517
  data-notes={notes}
515
518
  data-columns={columnCount}
516
519
  data-icon-actions={
@@ -644,6 +647,7 @@ const TableCell: FunctionComponent<ITableCellProps> = ({
644
647
  * - `Root` renders its required `caption` as the table's first child, so every table is named.
645
648
  * `captionVisibility` defaults to `visible`; `hidden` visually clips the caption out of flow,
646
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.
647
651
  * Hide it only when visible text directly above the table already names what it lists; the
648
652
  * enclosing arrangement owns the gap above the table. The consumer still supplies the caption.
649
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,10 +9,18 @@ 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');
19
+ // The auto margin takes only spare allocated height, so it resolves to zero wherever there is none.
20
+ const paneFoot = cva('min-w-0 shrink-0', {
21
+ variants: { placement: { 'after-body': '', bottom: 'mt-auto' } },
22
+ defaultVariants: { placement: 'after-body' },
23
+ });
16
24
  const paneBody = cva('min-h-0 min-w-0 flex-[0_1_auto]', {
17
25
  variants: {
18
26
  scrollOwner: {
@@ -73,6 +81,15 @@ export interface IPaneSectionProps {
73
81
  children?: ReactNode;
74
82
  testId?: string;
75
83
  }
84
+ export interface IPaneFootProps extends VariantProps<typeof paneFoot> {
85
+ children?: ReactNode;
86
+ /**
87
+ * `after-body` (the default) follows the body, leaving spare height in Root. `bottom` places
88
+ * the foot at Root's physical inner bottom whenever Root has spare allocated height.
89
+ */
90
+ placement?: VariantProps<typeof paneFoot>['placement'];
91
+ testId?: string;
92
+ }
76
93
  export interface IPaneBodyProps extends VariantProps<typeof paneBody> {
77
94
  children?: ReactNode;
78
95
  /** Names the body while overflowing. Unused when the child owns scrolling. */
@@ -164,21 +181,36 @@ const useScreenFallback = (root: {
164
181
  typeof ResizeObserver === 'undefined'
165
182
  ? undefined
166
183
  : new ResizeObserver(measure);
184
+ const mutations = new MutationObserver(() => observe());
185
+ const content = { childList: true, subtree: true, characterData: true };
186
+ // Content above the pane - a column head, a tab row - moves it without resizing it, so each
187
+ // box ahead of it up to the remainder is watched, attributes included (#159).
167
188
  const observe = () => {
168
189
  observer?.disconnect();
190
+ mutations.disconnect();
169
191
  observer?.observe(frame);
170
192
  observer?.observe(element);
171
193
  for (const child of element.querySelectorAll('*'))
172
194
  observer?.observe(child);
195
+ mutations.observe(element, content);
196
+ for (
197
+ let node: Element = element;
198
+ node !== frame && node.parentElement;
199
+ node = node.parentElement
200
+ ) {
201
+ mutations.observe(node.parentElement, { childList: true });
202
+ for (
203
+ let before = node.previousElementSibling;
204
+ before;
205
+ before = before.previousElementSibling
206
+ ) {
207
+ observer?.observe(before);
208
+ mutations.observe(before, { ...content, attributes: true });
209
+ }
210
+ }
173
211
  measure();
174
212
  };
175
213
  observe();
176
- const mutations = new MutationObserver(observe);
177
- mutations.observe(element, {
178
- childList: true,
179
- subtree: true,
180
- characterData: true,
181
- });
182
214
  window.addEventListener('resize', measure);
183
215
  return () => {
184
216
  observer?.disconnect();
@@ -201,6 +233,7 @@ const Root: FunctionComponent<IPaneRootProps> = ({
201
233
  <div
202
234
  ref={root}
203
235
  data-screen-unusable={isUnusable || undefined}
236
+ data-height-bounded
204
237
  className={pane()}
205
238
  role={name ? 'group' : undefined}
206
239
  aria-label={name || undefined}
@@ -215,8 +248,12 @@ const Head: FunctionComponent<IPaneSectionProps> = ({ children, testId }) => (
215
248
  {children}
216
249
  </div>
217
250
  );
218
- const Foot: FunctionComponent<IPaneSectionProps> = ({ children, testId }) => (
219
- <div className={paneSection()} data-testid={testId}>
251
+ const Foot: FunctionComponent<IPaneFootProps> = ({
252
+ children,
253
+ placement,
254
+ testId,
255
+ }) => (
256
+ <div className={paneFoot({ placement })} data-testid={testId}>
220
257
  {children}
221
258
  </div>
222
259
  );
@@ -255,8 +292,16 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
255
292
  * parent height it grows with content; a stacked parent or enclosing body owns scrolling.
256
293
  * - Head and Foot never shrink. Body scrolls both axes as needed and short content does not
257
294
  * stretch: Foot follows it, with any unused height left in Root rather than inside Body.
295
+ * - Foot placement="bottom" moves that unused height above Foot instead, so Foot's bottom edge
296
+ * meets Root's inner bottom. Body still keeps its content height, gains no overflow, group or
297
+ * keyboard stop, and scrolls exactly as before once it overflows. Without spare height - an
298
+ * unsized Root, an overflowing Body, a content-height Dialog or Screen's whole-content
299
+ * fallback - it has no effect: those fallbacks take precedence and decide as before.
300
+ * Switching it keeps every node, focus, value and selection.
258
301
  * - Root paints surface, foreground, border and box inset, square and without elevation.
259
302
  * The stack space role separates slots; a non-empty name exposes a group, never a landmark.
303
+ * Inside a Screen with spacing="compact", outside a Dialog, the inset and slot gaps follow
304
+ * that Screen's compact treatment instead; Pane offers no selector of its own.
260
305
  * - Default Body is the sole scroll region: a named group and keyboard stop only while
261
306
  * overflowing, retained while focused. It reserves the shared focus ring's room and leaves
262
307
  * native focus reveal, scrolling and child keyboard interactions intact.
@@ -268,15 +313,21 @@ const Body: FunctionComponent<IPaneBodyProps> = ({
268
313
  * @CallerMustEnsure
269
314
  * - Compose at most one Head, Body and Foot, in that order, directly inside Root; all are optional.
270
315
  * Do not nest panes. The consumer owns all wording, facts, actions and internal arrangement.
316
+ * - Foot placement positions Foot's box only. For the final action itself to meet the inner
317
+ * bottom-right corner, compose it as the foot's last and lowest box, such as the Button ending
318
+ * a Cluster justify="between" whose groups stay on one line at the supported widths. If the
319
+ * foot wraps, the action's line still meets the bottom but no longer the right edge.
271
320
  * - Supply a finite parent height to bound a pane, and enough room for Head, Foot, inset and gaps.
272
321
  * At smaller sizes use an unsized stacked arrangement in a scrolling page or Shell body.
273
322
  * - In child mode supply one child that accepts its parent's allocated height and handles its
274
323
  * own overflow and focus ring. Intermediate wrappers must preserve that allocation.
275
324
  * - Inside Screen, fixed regions and the usable body minimum participate in whole-content
276
325
  * 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.
326
+ * above the pane - a filling Tabs row or a column head - and again whenever that changes.
327
+ * Dialog owns the corresponding whole-task fallback for a Pane with child-owned Body and
328
+ * bounded Table. Other parents own their fallback policy.
329
+ * - Filled ColumnLayout columns, with or without a head, and active Stepper panels can pass
330
+ * the bound to Root. Keep those holders and Pane directly composed so no intermediate
331
+ * wrapper loses the allocation. An unsized Pane inside a content-height Stack grows with it.
281
332
  */
282
333
  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}