@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.
- package/dist/design-system.js +560 -464
- 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 +31 -5
- 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 +5 -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 +65 -14
- 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).
|
|
@@ -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
|
|
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,10 +9,18 @@ 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');
|
|
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<
|
|
219
|
-
|
|
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
|
|
278
|
-
* a Pane with child-owned Body and
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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 {
|
|
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}
|