@juwel-development/design-system 3.10.0 → 4.0.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.
Files changed (66) hide show
  1. package/README.md +16 -3
  2. package/dist/design-system.js +1038 -528
  3. package/dist/styles.css +2 -0
  4. package/dist/styles.dark.css +2 -0
  5. package/dist/styles.light.css +2 -0
  6. package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +38 -18
  7. package/dist/types/Display/Callout/Callout.d.ts +31 -0
  8. package/dist/types/Display/Icon/Icon.d.ts +31 -16
  9. package/dist/types/Display/Meter/Meter.d.ts +5 -6
  10. package/dist/types/Display/Rule/Rule.d.ts +22 -0
  11. package/dist/types/Display/Table/Table.d.ts +57 -7
  12. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
  13. package/dist/types/Display/Typography/H1/H1.d.ts +1 -1
  14. package/dist/types/Display/Typography/H2/H2.d.ts +1 -1
  15. package/dist/types/Display/Typography/H3/H3.d.ts +1 -1
  16. package/dist/types/Display/Typography/H4/H4.d.ts +1 -1
  17. package/dist/types/Display/Typography/H5/H5.d.ts +1 -1
  18. package/dist/types/Display/Typography/H6/H6.d.ts +1 -1
  19. package/dist/types/Display/Typography/Note/Note.d.ts +16 -3
  20. package/dist/types/Display/Typography/P/P.d.ts +15 -2
  21. package/dist/types/Display/Typography/Prose/Prose.d.ts +14 -6
  22. package/dist/types/Interaction/Button/Button.d.ts +13 -5
  23. package/dist/types/Interaction/Slider/Slider.d.ts +3 -0
  24. package/dist/types/Interaction/Stepper/Stepper.d.ts +59 -0
  25. package/dist/types/Interaction/Stepper/StepperCompositionError.d.ts +3 -0
  26. package/dist/types/Interaction/Switch/Switch.d.ts +20 -0
  27. package/dist/types/Interaction/Tabs/Tabs.d.ts +24 -3
  28. package/dist/types/Layout/Dialog/Dialog.d.ts +11 -3
  29. package/dist/types/Layout/Form/FormConfigurationError.d.ts +3 -0
  30. package/dist/types/Layout/Pane/Pane.d.ts +55 -0
  31. package/dist/types/Layout/Shell/Shell.d.ts +39 -0
  32. package/dist/types/Layout/Sidebar/Sidebar.d.ts +16 -2
  33. package/dist/types/Theme/Palette.d.ts +11 -4
  34. package/dist/types/index.d.ts +21 -1
  35. package/package.json +18 -8
  36. package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +86 -28
  37. package/src/Display/Callout/Callout.tsx +64 -0
  38. package/src/Display/Icon/Icon.tsx +111 -21
  39. package/src/Display/Meter/Meter.tsx +12 -13
  40. package/src/Display/Rule/Rule.tsx +31 -0
  41. package/src/Display/Table/Table.tsx +189 -24
  42. package/src/Display/Typography/Note/Note.tsx +27 -11
  43. package/src/Display/Typography/P/P.tsx +29 -4
  44. package/src/Display/Typography/Prose/Prose.tsx +44 -19
  45. package/src/Interaction/Button/Button.tsx +14 -5
  46. package/src/Interaction/Slider/Slider.tsx +3 -5
  47. package/src/Interaction/Stepper/Stepper.tsx +307 -0
  48. package/src/Interaction/Stepper/StepperCompositionError.ts +6 -0
  49. package/src/Interaction/Switch/Switch.tsx +64 -0
  50. package/src/Interaction/Tabs/Tabs.tsx +44 -10
  51. package/src/Layout/Dialog/Dialog.tsx +19 -6
  52. package/src/Layout/Form/Form.tsx +2 -1
  53. package/src/Layout/Form/FormConfigurationError.ts +6 -0
  54. package/src/Layout/Pane/Pane.tsx +159 -0
  55. package/src/Layout/Shell/Shell.tsx +124 -0
  56. package/src/Layout/Sidebar/Sidebar.tsx +31 -7
  57. package/src/Theme/Palette.ts +17 -6
  58. package/src/Theme/renderTokens.ts +61 -12
  59. package/src/index.ts +21 -2
  60. package/src/styles.css +5 -6
  61. package/src/styles.dark.css +5 -6
  62. package/src/styles.light.css +5 -6
  63. package/src/tokens.css +54 -9
  64. package/src/tokens.dark.css +51 -8
  65. package/src/tokens.light.css +49 -6
  66. package/dist/index.css +0 -2
@@ -1,4 +1,4 @@
1
- import { cva } from 'class-variance-authority';
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
2
  import {
3
3
  createContext,
4
4
  type FocusEvent,
@@ -11,6 +11,16 @@ import {
11
11
  import type { Subject } from 'rxjs';
12
12
  import { TabsCompositionError } from './TabsCompositionError';
13
13
 
14
+ const tabsRoot = cva('', {
15
+ variants: {
16
+ fill: {
17
+ true: 'flex h-full min-h-0 min-w-0 flex-col',
18
+ false: '',
19
+ },
20
+ },
21
+ defaultVariants: { fill: false },
22
+ });
23
+
14
24
  // The row is one scrolling line of controls, never a second row. The ring room is written from the
15
25
  // two focus-ring tokens so it cannot drift from the ring it exists for: padding holds the scroll
16
26
  // clip off the outline, the negative margin hands the room back to the page, and scroll-padding
@@ -22,6 +32,7 @@ const tabsList = cva(
22
32
  'm-[calc(-1*(var(--focus-ring-width)+var(--focus-ring-offset)))]',
23
33
  'scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
24
34
  ].join(' '),
35
+ { variants: { fill: { true: 'shrink-0', false: '' } } },
25
36
  );
26
37
 
27
38
  // Navigation typography - the tracked grotesk label, muted at rest, foreground when current -
@@ -45,6 +56,7 @@ const tabsTab = cva(
45
56
  // the ring follows focusability, not control-ness.
46
57
  const tabsPanel = cva(
47
58
  'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
59
+ { variants: { fill: { true: 'min-h-0 min-w-0 flex-auto', false: '' } } },
48
60
  );
49
61
 
50
62
  type TabsContract = {
@@ -52,6 +64,7 @@ type TabsContract = {
52
64
  onSelect$: Subject<string>;
53
65
  label: string;
54
66
  baseId: string;
67
+ fill: boolean;
55
68
  };
56
69
 
57
70
  const TabsContext = createContext<TabsContract | undefined>(undefined);
@@ -99,7 +112,11 @@ const neighbourTab = (
99
112
  return tabs[(index + step + tabs.length) % tabs.length];
100
113
  };
101
114
 
102
- export interface ITabsRootProps {
115
+ export interface ITabsRootProps extends VariantProps<typeof tabsRoot> {
116
+ /** Fill the allocated parent height, keeping List at content height and giving Panel the rest.
117
+ * In an unsized parent, panels retain content height. Compose List and Panels directly in Root;
118
+ * an unsized spacing wrapper does not pass the bound. No spacing is added. */
119
+ fill?: VariantProps<typeof tabsRoot>['fill'];
103
120
  /** The key of the active tab. Must name a declared tab/panel pair; the consumer owns it. */
104
121
  active: string;
105
122
  /** Emits the selected key on click and on arrow navigation. Tabs never selects on its own. */
@@ -141,24 +158,29 @@ const TabsRoot: FunctionComponent<ITabsRootProps> = ({
141
158
  active,
142
159
  onSelect$,
143
160
  label,
161
+ fill,
144
162
  children,
145
163
  testId,
146
164
  }) => {
147
165
  const baseId = useId();
148
166
  return (
149
- <TabsContext.Provider value={{ active, onSelect$, label, baseId }}>
150
- <div data-testid={testId}>{children}</div>
167
+ <TabsContext.Provider
168
+ value={{ active, onSelect$, label, baseId, fill: fill === true }}
169
+ >
170
+ <div className={tabsRoot({ fill })} data-testid={testId}>
171
+ {children}
172
+ </div>
151
173
  </TabsContext.Provider>
152
174
  );
153
175
  };
154
176
 
155
177
  const TabsList: FunctionComponent<ITabsListProps> = ({ children, testId }) => {
156
- const { label } = useTabsContract('List');
178
+ const { label, fill } = useTabsContract('List');
157
179
  return (
158
180
  <div
159
181
  role="tablist"
160
182
  aria-label={label}
161
- className={tabsList()}
183
+ className={tabsList({ fill })}
162
184
  data-testid={testId}
163
185
  >
164
186
  {children}
@@ -221,7 +243,7 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
221
243
  children,
222
244
  testId,
223
245
  }) => {
224
- const { active, baseId } = useTabsContract('Panel');
246
+ const { active, baseId, fill } = useTabsContract('Panel');
225
247
  const isActive = active === value;
226
248
  return (
227
249
  <div
@@ -230,7 +252,7 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
230
252
  aria-labelledby={tabId(baseId, value)}
231
253
  hidden={!isActive}
232
254
  tabIndex={isActive ? 0 : undefined}
233
- className={tabsPanel()}
255
+ className={tabsPanel({ fill })}
234
256
  data-testid={testId}
235
257
  >
236
258
  {isActive ? children : undefined}
@@ -271,11 +293,23 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
271
293
  * `foreground`, constant thickness in both states, so switching shifts no widths and no weights.
272
294
  * Keyboard focus is the separate shared focus ring. Colour moves on the one motion token.
273
295
  *
296
+ * - With `fill`, Root takes the allocated parent height; List keeps its content height and the
297
+ * active Panel takes the remainder. Its definite height bounds a direct Pane or bounded Table.
298
+ * Switching views changes neither Root height nor List position. No scrolling or spacing is added.
299
+ * In an unsized parent the panel retains content height; no bound is invented.
300
+ * - Without `fill` (including false or null), the existing content-height composition is unchanged.
301
+ *
274
302
  * @CallerMustEnsure — the component cannot see these and does not check them
275
303
  * - One List with Tab elements as direct DOM children (arrays and fragments are supported),
276
304
  * and the Panels under the same Root - as siblings of the List, or with it inside one
277
305
  * arrangement such as a `Stack` (see the separation guidance below). Do not wrap tabs in host
278
306
  * elements.
307
+ * - For `fill`, List and Panels must be direct DOM children of Root (fragments are fine), with
308
+ * one height-accepting child directly inside the active Panel. Supply a definite parent height
309
+ * and enough room for List and the child's fixed content. An unsized Stack between Root and
310
+ * members, or between Panel and its child, breaks that height chain. Compose content spacing
311
+ * inside the Pane's slots instead; Tabs adds no gap. In an unsized parent the enclosing page
312
+ * owns overflow. A bounded Table requires the separate #142 API.
279
313
  * - A label is a meaningful visible text name, with non-interactive decoration at most: no link,
280
314
  * button, input or other focusable descendant, since the tab is the one control and a nested
281
315
  * control inside a `button` is invalid. Decorative parts - an icon that repeats the text - carry
@@ -295,8 +329,8 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
295
329
  * do not fit, so a label is never the reason to shorten a name.
296
330
  * - This is not a router: no location, no history, no deep links. Wire `onSelect$` to whatever
297
331
  * owns the active key and pass that key back in.
298
- * - Tabs sets no spacing between the row and the panel; the separation is a `Stack` between Root
299
- * and its members - `<Tabs.Root><Stack gap="region"><Tabs.List/>…<Tabs.Panel/>…</Stack>
332
+ * - In the default content-height mode, Tabs sets no spacing between the row and the panel;
333
+ * the separation is a `Stack` between Root and its members - `<Tabs.Root><Stack gap="region"><Tabs.List/>…<Tabs.Panel/>…</Stack>
300
334
  * </Tabs.Root>`. `gap="stack"` holds the row and its view together as one block; `gap="region"`
301
335
  * sets the view apart as a region of its own. Inactive panels are hidden, so the gap is exactly
302
336
  * one whichever panel is active, from the token the consumer's other groups already use.
@@ -19,7 +19,7 @@ import { DialogNamingError } from './DialogNamingError';
19
19
  // The layout classes ride the open: variant - a bare `flex` would defeat the UA's
20
20
  // dialog:not([open]) hiding. Tailwind's preflight zeroes the UA's centering margins (m-auto
21
21
  // restores them) and strips ::backdrop, so the Scrim colour and blur are declared here.
22
- // overflow-hidden overrides the UA's `dialog { overflow: auto }` - only Content may scroll.
22
+ // overflow-hidden overrides the UA's `dialog { overflow: auto }` - Content or its delegated child owns scrolling.
23
23
  const dialogRoot = cva(
24
24
  [
25
25
  'open:flex open:flex-col m-auto p-0 overflow-hidden',
@@ -67,10 +67,16 @@ const dialogDescription = cva(
67
67
  // this scrolls. Its hairline exists only when a header region stands before it.
68
68
  const dialogContent = cva(
69
69
  [
70
- 'grow shrink min-h-0 overflow-y-auto',
70
+ 'grow shrink min-h-0 min-w-0',
71
71
  'px-[var(--space-region)] py-[var(--space-region)]',
72
72
  '[&:not(:first-child)]:border-t [&:not(:first-child)]:border-border',
73
73
  ].join(' '),
74
+ {
75
+ variants: {
76
+ scrollOwner: { body: 'overflow-y-auto', child: 'flex flex-col' },
77
+ },
78
+ defaultVariants: { scrollOwner: 'body' },
79
+ },
74
80
  );
75
81
 
76
82
  // An end-aligned row in consumer DOM order, wrapping on narrow screens without reversing. Its
@@ -173,7 +179,8 @@ export interface IDialogDescriptionProps {
173
179
  testId?: string;
174
180
  }
175
181
 
176
- export interface IDialogContentProps {
182
+ export interface IDialogContentProps
183
+ extends VariantProps<typeof dialogContent> {
177
184
  /** Arbitrary task content, composed and owned by the consumer. */
178
185
  children: ReactNode;
179
186
  testId?: string;
@@ -385,13 +392,14 @@ const DialogDescription: FunctionComponent<IDialogDescriptionProps> = ({
385
392
 
386
393
  const DialogContent: FunctionComponent<IDialogContentProps> = ({
387
394
  children,
395
+ scrollOwner,
388
396
  testId,
389
397
  }) => {
390
398
  useDialogContract('Content');
391
399
  return (
392
400
  <div
393
401
  data-dialog-member={'content'}
394
- className={dialogContent()}
402
+ className={dialogContent({ scrollOwner })}
395
403
  data-testid={testId}
396
404
  >
397
405
  {children}
@@ -420,7 +428,7 @@ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
420
428
  * the platform `<dialog>` and `showModal()`: the browser owns the top layer, background inertness
421
429
  * and the modal keyboard boundary; the consumer owns all wording, controls, outcomes and
422
430
  * replacement. Composed from five members: `Root` carries the contract, `Title` the visible name,
423
- * `Description` one connected line, `Content` the only scrollable region, `Actions` the controls.
431
+ * `Description` one connected line, `Content` the scrolling region or holder of one bounded child, `Actions` the controls.
424
432
  *
425
433
  * @Guarantees — enforced on every render
426
434
  * - Presents through `showModal()`/`close()` - no portal, focus trap or polyfill - so background
@@ -443,7 +451,9 @@ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
443
451
  * - `extent="screen"` (the default) fills the viewport minus the `--gutter` inline and
444
452
  * `--space-region` block insets, header and Actions fixed, only `Content` scrolling.
445
453
  * `extent="content"` holds a fixed 32rem capped to the viewport with content-driven height;
446
- * `Content` scrolls only as the emergency fallback when fitting is physically impossible.
454
+ * Default `Content` scrolls as the fallback when its content exceeds available space.
455
+ * - `Content scrollOwner="child"` passes available height to one bounded child without scrolling.
456
+ * A bounded Table then owns both axes and pins its header; title, description and actions stay fixed.
447
457
  * - Regions take `--space-region` padding with `--space-stack` between Title and Description and
448
458
  * between Actions controls; hairlines separate header from `Content` and `Content` from
449
459
  * `Actions` only where both neighbours exist. The surface reads `--radius-dialog`,
@@ -451,6 +461,9 @@ const DialogActions: FunctionComponent<IDialogActionsProps> = ({
451
461
  * with the theme's optional `--scrim-blur`.
452
462
  *
453
463
  * @CallerMustEnsure — the component cannot see these and does not check them
464
+ * - In child-scroll mode provide one child that accepts the height allocation and owns overflow.
465
+ * Intermediate wrappers must preserve that allocation. Fixed regions must fit the viewport and
466
+ * leave usable room for the child; a Table needs room for its header and readable rows.
454
467
  * - Direct children in the order `Title`, `Description`, `Content`, `Actions`, at most one of
455
468
  * each; `Actions` is present and holds at least one explicit dismissal control, wired to the
456
469
  * same dismissal and visibility transitions. Cancel-before-Confirm order puts initial focus on
@@ -1,5 +1,6 @@
1
1
  import { cva } from 'class-variance-authority';
2
2
  import type { FunctionComponent, ReactNode } from 'react';
3
+ import { FormConfigurationError } from './FormConfigurationError';
3
4
 
4
5
  /** Which of the four states the page has put the form in. Owned by the page, never by Form: the
5
6
  * component renders the state it is given and never decides which one it is in. */
@@ -78,7 +79,7 @@ export const Form: FunctionComponent<IFormProps> = ({
78
79
  note !== undefined && note !== null && typeof note !== 'boolean';
79
80
 
80
81
  if (state === 'sent' && !hasNote) {
81
- throw new Error(
82
+ throw new FormConfigurationError(
82
83
  'Form in the `sent` state must be given a `note` - it is the outcome message.',
83
84
  );
84
85
  }
@@ -0,0 +1,6 @@
1
+ export class FormConfigurationError extends Error {
2
+ constructor(reason: string) {
3
+ super(`Form configuration is invalid: ${reason}`);
4
+ this.name = 'FormConfigurationError';
5
+ }
6
+ }
@@ -0,0 +1,159 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import {
3
+ type FunctionComponent,
4
+ type ReactNode,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+
10
+ const pane = cva(
11
+ '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]',
12
+ );
13
+ const paneSection = cva('min-w-0 shrink-0');
14
+ const paneBody = cva('min-h-0 min-w-0 flex-[0_1_auto]', {
15
+ variants: {
16
+ scrollOwner: {
17
+ body: '[&>div]:flow-root [&>div]:w-fit [&>div]:min-w-full [&>div]:p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))] overflow-auto overscroll-contain outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
18
+ child: '',
19
+ },
20
+ },
21
+ defaultVariants: { scrollOwner: 'body' },
22
+ });
23
+
24
+ const useOverflow = (
25
+ container: { current: HTMLDivElement | null },
26
+ content: { current: HTMLDivElement | null },
27
+ ownsScroll: boolean,
28
+ ): boolean => {
29
+ const [isScrollable, setIsScrollable] = useState(true);
30
+ useLayoutEffect(() => {
31
+ const element = container.current;
32
+ if (!element || !ownsScroll) return;
33
+ const measure = () =>
34
+ setIsScrollable(
35
+ element.scrollHeight > element.clientHeight ||
36
+ element.scrollWidth > element.clientWidth ||
37
+ element === element.ownerDocument.activeElement,
38
+ );
39
+ measure();
40
+ const observer =
41
+ typeof ResizeObserver === 'undefined'
42
+ ? undefined
43
+ : new ResizeObserver(measure);
44
+ observer?.observe(element);
45
+ if (content.current) observer?.observe(content.current);
46
+ const mutations = new MutationObserver(measure);
47
+ mutations.observe(element, {
48
+ childList: true,
49
+ subtree: true,
50
+ characterData: true,
51
+ });
52
+ window.addEventListener('resize', measure);
53
+ element.addEventListener('blur', measure);
54
+ return () => {
55
+ observer?.disconnect();
56
+ mutations.disconnect();
57
+ window.removeEventListener('resize', measure);
58
+ element.removeEventListener('blur', measure);
59
+ };
60
+ }, [container, content, ownsScroll]);
61
+ return isScrollable;
62
+ };
63
+
64
+ export interface IPaneRootProps {
65
+ children?: ReactNode;
66
+ /** A non-empty name makes the enclosure an accessible group, as with Box. */
67
+ name?: string;
68
+ testId?: string;
69
+ }
70
+ export interface IPaneSectionProps {
71
+ children?: ReactNode;
72
+ testId?: string;
73
+ }
74
+ export interface IPaneBodyProps extends VariantProps<typeof paneBody> {
75
+ children?: ReactNode;
76
+ /** Names the body while overflowing. Unused when the child owns scrolling. */
77
+ ariaLabel: string;
78
+ testId?: string;
79
+ }
80
+
81
+ const Root: FunctionComponent<IPaneRootProps> = ({
82
+ children,
83
+ name,
84
+ testId,
85
+ }) => (
86
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and group role are assigned together
87
+ <div
88
+ className={pane()}
89
+ role={name ? 'group' : undefined}
90
+ aria-label={name || undefined}
91
+ data-testid={testId}
92
+ >
93
+ {children}
94
+ </div>
95
+ );
96
+ const Head: FunctionComponent<IPaneSectionProps> = ({ children, testId }) => (
97
+ <div className={paneSection()} data-testid={testId}>
98
+ {children}
99
+ </div>
100
+ );
101
+ const Foot: FunctionComponent<IPaneSectionProps> = ({ children, testId }) => (
102
+ <div className={paneSection()} data-testid={testId}>
103
+ {children}
104
+ </div>
105
+ );
106
+ const Body: FunctionComponent<IPaneBodyProps> = ({
107
+ children,
108
+ ariaLabel,
109
+ scrollOwner,
110
+ testId,
111
+ }) => {
112
+ const container = useRef<HTMLDivElement>(null);
113
+ const content = useRef<HTMLDivElement>(null);
114
+ const ownsScroll = scrollOwner !== 'child';
115
+ const isScrollable =
116
+ useOverflow(container, content, ownsScroll) && ownsScroll;
117
+ return (
118
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and group role are assigned together
119
+ <div
120
+ ref={container}
121
+ className={paneBody({ scrollOwner: scrollOwner ?? 'body' })}
122
+ role={isScrollable ? 'group' : undefined}
123
+ aria-label={isScrollable ? ariaLabel : undefined}
124
+ tabIndex={isScrollable ? 0 : undefined}
125
+ data-testid={testId}
126
+ >
127
+ {ownsScroll ? <div ref={content}>{children}</div> : children}
128
+ </div>
129
+ );
130
+ };
131
+
132
+ /**
133
+ * A parent-bounded enclosure with optional content-height Head and Foot around a shrinking Body.
134
+ *
135
+ * @Guarantees
136
+ * - Root fills the allocated height, including its border and box inset. Without a definite
137
+ * parent height it grows with content; a stacked parent or enclosing body owns scrolling.
138
+ * - Head and Foot never shrink. Body scrolls both axes as needed and short content does not
139
+ * stretch: Foot follows it, with any unused height left in Root rather than inside Body.
140
+ * - Root paints surface, foreground, border and box inset, square and without elevation.
141
+ * The stack space role separates slots; a non-empty name exposes a group, never a landmark.
142
+ * - Default Body is the sole scroll region: a named group and keyboard stop only while
143
+ * overflowing, retained while focused. It reserves the shared focus ring's room and leaves
144
+ * native focus reveal, scrolling and child keyboard interactions intact.
145
+ * - Body scrollOwner="child" passes its allocated height directly to its child, without an
146
+ * inner wrapper, overflow, group or keyboard stop. The child owns scroll semantics and naming.
147
+ * Use this for a bounded Table: its wrapper must own both axes so its header stays in view.
148
+ *
149
+ * @CallerMustEnsure
150
+ * - Compose at most one Head, Body and Foot, in that order, directly inside Root; all are optional.
151
+ * Do not nest panes. The consumer owns all wording, facts, actions and internal arrangement.
152
+ * - Supply a finite parent height to bound a pane, and enough room for Head, Foot, inset and gaps.
153
+ * At smaller sizes use an unsized stacked arrangement in a scrolling page or Shell body.
154
+ * - In child mode supply one child that accepts its parent's allocated height and handles its
155
+ * own overflow and focus ring. Intermediate wrappers must preserve that allocation.
156
+ * - Filled ColumnLayout columns and active Stepper panels can pass the bound to Root. Keep
157
+ * those holders and Pane directly composed so no intermediate wrapper loses the allocation.
158
+ */
159
+ export const Pane = { Root, Head, Body, Foot } as const;
@@ -0,0 +1,124 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import {
3
+ type FunctionComponent,
4
+ type ReactNode,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+
10
+ const shell = cva(
11
+ 'h-dvh min-w-0 overflow-clip grid grid-rows-[auto_minmax(0,1fr)]',
12
+ );
13
+ const shellBody = cva(
14
+ 'min-h-0 min-w-0 overflow-auto overscroll-contain p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))] scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))] outline-focus-ring focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[calc(-1*var(--focus-ring-width))]',
15
+ {
16
+ variants: {
17
+ mode: { console: '[&>div]:h-full', page: '[&>div]:min-h-full' },
18
+ },
19
+ defaultVariants: { mode: 'console' },
20
+ },
21
+ );
22
+
23
+ // Geometry is read only to expose a keyboard stop; CSS alone allocates the height.
24
+ const useScrollableBody = (body: {
25
+ current: HTMLDivElement | null;
26
+ }): boolean => {
27
+ const [isScrollable, setIsScrollable] = useState(true);
28
+ useLayoutEffect(() => {
29
+ const element = body.current;
30
+ if (!element) return;
31
+ const measure = () =>
32
+ setIsScrollable(
33
+ element.scrollHeight > element.clientHeight ||
34
+ element.scrollWidth > element.clientWidth ||
35
+ element === element.ownerDocument.activeElement,
36
+ );
37
+ measure();
38
+ const resize =
39
+ typeof ResizeObserver === 'undefined'
40
+ ? undefined
41
+ : new ResizeObserver(measure);
42
+ const observeContent = () => {
43
+ resize?.disconnect();
44
+ resize?.observe(element);
45
+ for (const child of element.querySelectorAll('*')) resize?.observe(child);
46
+ measure();
47
+ };
48
+ observeContent();
49
+ const mutations = new MutationObserver(observeContent);
50
+ mutations.observe(element, {
51
+ childList: true,
52
+ subtree: true,
53
+ characterData: true,
54
+ });
55
+ window.addEventListener('resize', measure);
56
+ element.addEventListener('blur', measure);
57
+ return () => {
58
+ resize?.disconnect();
59
+ mutations.disconnect();
60
+ window.removeEventListener('resize', measure);
61
+ element.removeEventListener('blur', measure);
62
+ };
63
+ }, [body]);
64
+ return isScrollable;
65
+ };
66
+
67
+ export interface IShellProps extends VariantProps<typeof shellBody> {
68
+ /** Consumer-owned top bar; its content determines its height. */
69
+ bar: ReactNode;
70
+ /** Body content; receives the remaining height in console mode. */
71
+ children: ReactNode;
72
+ /** The body scroll group's name, exposed only while overflowing or focused. */
73
+ ariaLabel: string;
74
+ testId?: string;
75
+ }
76
+
77
+ /**
78
+ * A viewport-owning frame with a content-height bar and the remaining height for its body.
79
+ *
80
+ * @Guarantees
81
+ * - CSS allocates both tracks, including when bar content wraps. Header is unchanged.
82
+ * - Console (default) passes a finite height to its child; page lets body content grow.
83
+ * Both keep overflow inside the body, with the bar visible and document still.
84
+ * - Console body scrolling is a reachability fallback for content that cannot fit; bounded
85
+ * panels normally own their scrolling. Page deliberately scrolls the body as a whole.
86
+ * - Overflow makes the body a named, keyboard-reachable group, never a landmark. Removing
87
+ * overflow does not relocate focus. Native focus reveal and scroll keys remain intact.
88
+ * - Body padding reserves the shared focus ring's room, with an inset ring on the body itself.
89
+ *
90
+ * @CallerMustEnsure
91
+ * - Place one Shell at the viewport origin, with no outside margins, padding or siblings that
92
+ * add document extent. This is the application frame, not a nested fullscreen section.
93
+ * - The bar must leave usable body space. An arbitrarily tall bar cannot fit every viewport.
94
+ * - Supply the main landmark inside the body (inside Sidebar.Content when using Sidebar).
95
+ * - Intermediate consumer wrappers must preserve the allocated height for bounded panels;
96
+ * wrappers with height: 100% and min-height: 0, or display: contents, can pass it through.
97
+ * - Use Sidebar.Root fill in a console; below 64rem its list stacks and the body scrolls.
98
+ * - Name the body in the viewer's language. Content and its panel arrangements remain yours.
99
+ */
100
+ export const Shell: FunctionComponent<IShellProps> = ({
101
+ bar,
102
+ children,
103
+ ariaLabel,
104
+ mode,
105
+ testId,
106
+ }) => {
107
+ const body = useRef<HTMLDivElement>(null);
108
+ const isScrollable = useScrollableBody(body);
109
+ return (
110
+ <div className={shell()} data-testid={testId}>
111
+ <div>{bar}</div>
112
+ {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: the group and its name are conditional together */}
113
+ <div
114
+ ref={body}
115
+ className={shellBody({ mode })}
116
+ role={isScrollable ? 'group' : undefined}
117
+ aria-label={isScrollable ? ariaLabel : undefined}
118
+ tabIndex={isScrollable ? 0 : undefined}
119
+ >
120
+ <div>{children}</div>
121
+ </div>
122
+ </div>
123
+ );
124
+ };
@@ -1,4 +1,4 @@
1
- import { cva } from 'class-variance-authority';
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
2
  import type { FunctionComponent, ReactNode, RefCallback } from 'react';
3
3
  import { Children, createContext, isValidElement, useContext } from 'react';
4
4
  import type { Subject } from 'rxjs';
@@ -12,10 +12,20 @@ const sidebarRoot = cva(
12
12
  'grid gap-[var(--space-region)] lg:grid-cols-[12rem_minmax(0,1fr)]',
13
13
  '[&>div:first-child]:border-solid [&>div:first-child]:border-border [&>div:first-child]:border-b [&>div:first-child]:pb-[var(--space-stack)]',
14
14
  'lg:[&>div:first-child]:border-b-0 lg:[&>div:first-child]:border-r lg:[&>div:first-child]:pb-0 lg:[&>div:first-child]:pr-[var(--space-stack)]',
15
- 'lg:[&>div:first-child>nav]:sticky lg:[&>div:first-child>nav]:top-0 lg:[&>div:first-child>nav]:max-h-[min(100dvh,var(--sidebar-scrollport-height,100dvh))] lg:[&>div:first-child>nav]:overflow-y-auto',
15
+ 'lg:[&>div:first-child>nav]:sticky lg:[&>div:first-child>nav]:top-0 lg:[&>div:first-child>nav]:overflow-y-auto',
16
16
  '[&>div:first-child>nav]:p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))] lg:[&>div:first-child>nav]:scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
17
17
  '[&>div:first-child>nav>ul]:flex [&>div:first-child>nav>ul]:flex-col [&>div:first-child>nav>ul]:gap-[var(--space-stack)]',
18
18
  ].join(' '),
19
+ {
20
+ variants: {
21
+ fill: {
22
+ false:
23
+ 'lg:[&>div:first-child>nav]:max-h-[min(100dvh,var(--sidebar-scrollport-height,100dvh))]',
24
+ true: 'min-h-full lg:h-full lg:min-h-0 lg:grid-rows-[minmax(0,1fr)] lg:[&>div]:min-h-0 lg:[&>div]:h-full lg:[&>div:first-child>nav]:max-h-full',
25
+ },
26
+ },
27
+ defaultVariants: { fill: false },
28
+ },
19
29
  );
20
30
 
21
31
  // A percentage max-height resolves against the content-driven grid cell, not its scrollport;
@@ -38,9 +48,13 @@ const observeScrollport: RefCallback<HTMLElement> = (navigation) => {
38
48
  ),
39
49
  );
40
50
  if (scrollport) {
51
+ const scrollportStyle = getComputedStyle(scrollport);
52
+ const padding =
53
+ (Number.parseFloat(scrollportStyle.paddingTop) || 0) +
54
+ (Number.parseFloat(scrollportStyle.paddingBottom) || 0);
41
55
  navigation.style.setProperty(
42
56
  '--sidebar-scrollport-height',
43
- `${scrollport.clientHeight}px`,
57
+ `${Math.max(0, scrollport.clientHeight - padding)}px`,
44
58
  );
45
59
  } else {
46
60
  navigation.style.removeProperty('--sidebar-scrollport-height');
@@ -96,7 +110,10 @@ const SidebarSelectionContext = createContext<SidebarSelection | undefined>(
96
110
  undefined,
97
111
  );
98
112
 
99
- export interface ISidebarRootProps {
113
+ export interface ISidebarRootProps extends VariantProps<typeof sidebarRoot> {
114
+ /** Pass a finite parent height through to Content at 64rem and above, using CSS alone.
115
+ * Below 64rem the enclosing scrollable body owns the stacked list and content. */
116
+ fill?: VariantProps<typeof sidebarRoot>['fill'];
100
117
  /** The key of the active entry. The caller supplies a key identifying one non-inert entry;
101
118
  * Sidebar renders what it is given and never selects a fallback for an invalid key. */
102
119
  active: string;
@@ -166,12 +183,13 @@ const SidebarRoot: FunctionComponent<ISidebarRootProps> = ({
166
183
  onSelect$,
167
184
  children,
168
185
  testId,
186
+ fill,
169
187
  }) => {
170
188
  const parts = Children.toArray(children).filter(isValidElement);
171
189
  return (
172
- <div className={sidebarRoot()} data-testid={testId}>
190
+ <div className={sidebarRoot({ fill })} data-testid={testId}>
173
191
  <div>
174
- <nav ref={observeScrollport} aria-label={label}>
192
+ <nav ref={fill ? undefined : observeScrollport} aria-label={label}>
175
193
  <SidebarSelectionContext value={{ active, onSelect$ }}>
176
194
  <ul>{parts.filter((part) => part.type === SidebarItem)}</ul>
177
195
  </SidebarSelectionContext>
@@ -204,11 +222,17 @@ const SidebarRoot: FunctionComponent<ISidebarRootProps> = ({
204
222
  * assumed top-bar offset, capped to the screen/scrolling-area height with independent entry scrolling.
205
223
  * Content sits beside it in `minmax(0,1fr)`, so wide content cannot displace the track.
206
224
  * - Below 64rem the whole list lies above the content in normal flow - no stickiness, no cap, no
207
- * drawer. Sidebar sets no viewport-height minimum and grows with its content.
225
+ * drawer. Without fill, Sidebar grows with its content.
226
+ * - With fill, a finite parent height passes through Root and Content at 64rem and above;
227
+ * navigation is capped to that allocated height by CSS, with no scrollport measurement.
228
+ * Below 64rem the list stacks and the enclosing body scrolls. The DOM never switches.
208
229
  * - It separates the tracks itself (the region space role and a `border` hairline) but gives the
209
230
  * content track no padding and no landmark - the consumer owns everything inside `Content`.
210
231
  *
211
232
  * @CallerMustEnsure — the component cannot see these and does not check them
233
+ * - Fill requires a parent with a finite height and a scrollable enclosing body for narrow
234
+ * viewports or oversized fixed content. Consumer wrappers inside Content preserve that bound.
235
+ * It works with any bounded parent; no Shell identity, context or offset is required.
212
236
  * - Entry keys are unique and `active` names one non-inert entry. Sidebar corrects nothing.
213
237
  * - Items and the one Content are direct children of `Root` - Root assembles only what it can see,
214
238
  * and an Item rendered outside a Root has no selection to derive its treatment from.