@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.
- package/dist/design-system.js +676 -586
- package/dist/styles.css +1 -1
- package/dist/styles.dark.css +1 -1
- package/dist/styles.light.css +1 -1
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +17 -2
- package/dist/types/Display/Table/Table.d.ts +1 -0
- package/dist/types/Interaction/Slider/Slider.d.ts +17 -7
- package/dist/types/Layout/Pane/Pane.d.ts +8 -4
- package/dist/types/Layout/Screen/Screen.d.ts +28 -8
- package/package.json +1 -1
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +55 -5
- package/src/Display/Table/Table.tsx +8 -1
- package/src/Interaction/Slider/Slider.tsx +51 -16
- package/src/Interaction/Stepper/Stepper.tsx +2 -1
- package/src/Interaction/Tabs/Tabs.tsx +6 -1
- package/src/Layout/Pane/Pane.tsx +35 -12
- package/src/Layout/Screen/Screen.tsx +123 -13
- package/src/Layout/ScrollContainer/ScrollContainer.tsx +1 -0
- package/src/Theme/renderTokens.ts +13 -0
- package/src/tokens.css +7 -0
- package/src/tokens.dark.css +7 -0
- package/src/tokens.light.css +7 -0
|
@@ -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
|
-
/**
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* a live control and not a form field, so none of Input's annotations - hint, error,
|
|
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
|
-
{
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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}
|
package/src/Layout/Pane/Pane.tsx
CHANGED
|
@@ -9,9 +9,12 @@ import {
|
|
|
9
9
|
useState,
|
|
10
10
|
} from 'react';
|
|
11
11
|
|
|
12
|
-
|
|
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
|
|
278
|
-
* a Pane with child-owned Body and
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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 {
|
|
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)]
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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;
|
package/src/tokens.dark.css
CHANGED
|
@@ -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;
|