@juwel-development/design-system 3.1.0 → 3.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.
@@ -0,0 +1,58 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ const collectionRoot = cva('m-0 list-none p-0');
5
+ const collectionItem = cva(
6
+ 'py-[var(--space-collection-item)] [&+li]:border-t [&+li]:border-solid [&+li]:border-border',
7
+ );
8
+
9
+ export interface ICollectionRootProps {
10
+ /** Collection.Item children, including mapped items and conditional omissions. */
11
+ children?: ReactNode;
12
+ testId?: string;
13
+ }
14
+
15
+ export interface ICollectionItemProps {
16
+ /** Freely composed content; its typography, arrangement and interaction remain its own. */
17
+ children?: ReactNode;
18
+ testId?: string;
19
+ }
20
+
21
+ const CollectionRoot: FunctionComponent<ICollectionRootProps> = ({
22
+ children,
23
+ testId,
24
+ }) => (
25
+ // biome-ignore lint/a11y/noRedundantRoles: list-style:none removes list semantics in Safari/VoiceOver; Checklist establishes this explicit-role precedent.
26
+ <ul className={collectionRoot()} role={'list'} data-testid={testId}>
27
+ {children}
28
+ </ul>
29
+ );
30
+
31
+ const CollectionItem: FunctionComponent<ICollectionItemProps> = ({
32
+ children,
33
+ testId,
34
+ }) => (
35
+ <li className={collectionItem()} data-testid={testId}>
36
+ {children}
37
+ </li>
38
+ );
39
+
40
+ /**
41
+ * A vertical group of freely composed items with internal hairlines and open outer edges.
42
+ * The library owns spacing and separation; the consumer owns content, arrangement and interaction.
43
+ *
44
+ * @Guarantees
45
+ * - Root is a semantic list and Item a list item, with no visual markers or horizontal indent.
46
+ * - Each item takes vertical padding from --space-collection-item; adjacent items share one
47
+ * hairline in the border colour role. Empty and single-item roots have no rules or placeholder.
48
+ * - Children render in supplied order, retaining their own typography and arrangement.
49
+ * - Neither member adds focus stops, interaction, or responsive content rearrangement.
50
+ *
51
+ * @CallerMustEnsure
52
+ * - Supply Collection.Item as Root's rendered children, directly or through fragments and maps.
53
+ * - Compose each item's content with typography, arrangements and controls for the jobs it contains.
54
+ */
55
+ export const Collection = {
56
+ Root: CollectionRoot,
57
+ Item: CollectionItem,
58
+ } as const;
@@ -0,0 +1,284 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import {
3
+ createContext,
4
+ type FocusEvent,
5
+ type FunctionComponent,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ useContext,
9
+ useId,
10
+ } from 'react';
11
+ import type { Subject } from 'rxjs';
12
+ import { TabsCompositionError } from './TabsCompositionError';
13
+
14
+ // The row is a single scrolling line, never a wrap: overflow is an accommodation, not a strip. The
15
+ // ring room is written from the two focus-ring tokens so it cannot drift from the ring it exists
16
+ // for: padding holds the scroll clip off the outline, the negative margin hands the room back to
17
+ // the page, and scroll-padding makes a nearest scrollIntoView stop with the ring inside the clip.
18
+ const tabsList = cva(
19
+ [
20
+ 'flex flex-row overflow-x-auto',
21
+ 'p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
22
+ 'm-[calc(-1*(var(--focus-ring-width)+var(--focus-ring-offset)))]',
23
+ 'scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
24
+ ].join(' '),
25
+ );
26
+
27
+ // Navigation typography - the tracked grotesk label, muted at rest, foreground when current -
28
+ // keyed on aria-selected, so the attribute the device reads is the one the paint follows. The
29
+ // marker keeps one thickness and flips only colour on the shared motion token, so selection
30
+ // shifts no geometry. The focus ring sits in the base with its colour at rest, as on Button.
31
+ const tabsTab = cva(
32
+ [
33
+ 'font-secondary text-label tracking-label',
34
+ 'text-muted hover:text-foreground aria-selected:text-foreground',
35
+ 'border-b-[length:var(--tab-marker-thickness)] border-solid border-transparent aria-selected:border-foreground',
36
+ 'shrink-0 cursor-pointer select-none text-nowrap px-[var(--tab-inset-inline)] py-[var(--tab-inset-block)]',
37
+ 'transition-colors duration-[var(--motion-duration-color)]',
38
+ 'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
39
+ ].join(' '),
40
+ );
41
+
42
+ // The panel paints nothing of its own; the recipe carries only the focus ring its Tab stop needs -
43
+ // the ring follows focusability, not control-ness.
44
+ const tabsPanel = cva(
45
+ 'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
46
+ );
47
+
48
+ type TabsContract = {
49
+ active: string;
50
+ onSelect$: Subject<string>;
51
+ label: string;
52
+ baseId: string;
53
+ };
54
+
55
+ const TabsContext = createContext<TabsContract | undefined>(undefined);
56
+
57
+ const useTabsContract = (member: string): TabsContract => {
58
+ const contract = useContext(TabsContext);
59
+ if (contract === undefined) {
60
+ throw new TabsCompositionError(member);
61
+ }
62
+ return contract;
63
+ };
64
+
65
+ // Fixed-width UTF-16 units preserve every string, including lone surrogates, without whitespace
66
+ // or collisions between escaped and literal values. Encoding changes IDs only, never selection.
67
+ const encodeValue = (value: string): string =>
68
+ value
69
+ .split('')
70
+ .map((unit) => unit.charCodeAt(0).toString(16).padStart(4, '0'))
71
+ .join('');
72
+
73
+ const keepFocusedTabVisible = (event: FocusEvent<HTMLButtonElement>): void => {
74
+ event.currentTarget.scrollIntoView({ block: 'nearest', inline: 'nearest' });
75
+ };
76
+
77
+ const tabId = (baseId: string, value: string): string =>
78
+ `${baseId}tab-${encodeValue(value)}`;
79
+ const panelId = (baseId: string, value: string): string =>
80
+ `${baseId}panel-${encodeValue(value)}`;
81
+
82
+ // The wrap-around rule on its own: the neighbouring tab in the arrow's direction, from the
83
+ // rendered row (`:scope >` keeps a nested instance's tabs out of it), wrapping at either end -
84
+ // so tab order is rendered order.
85
+ const neighbourTab = (
86
+ current: HTMLButtonElement,
87
+ step: 1 | -1,
88
+ ): HTMLButtonElement | undefined => {
89
+ const row = current.closest('[role="tablist"]');
90
+ if (row === null) {
91
+ return undefined;
92
+ }
93
+ const tabs = Array.from(
94
+ row.querySelectorAll<HTMLButtonElement>(':scope > [role="tab"]'),
95
+ );
96
+ const index = tabs.indexOf(current);
97
+ return tabs[(index + step + tabs.length) % tabs.length];
98
+ };
99
+
100
+ export interface ITabsRootProps {
101
+ /** The key of the active tab. Must name a declared tab/panel pair; the consumer owns it. */
102
+ active: string;
103
+ /** Emits the selected key on click and on arrow navigation. Tabs never selects on its own. */
104
+ onSelect$: Subject<string>;
105
+ /** The tab list's accessible name. */
106
+ label: string;
107
+ children: ReactNode;
108
+ testId?: string;
109
+ }
110
+
111
+ export interface ITabsListProps {
112
+ children: ReactNode;
113
+ testId?: string;
114
+ }
115
+
116
+ export interface ITabsTabProps {
117
+ /** The stable identity connecting this tab to its panel and emitted by selection requests.
118
+ * Not React's `key`, and never inferred from the label or the position. */
119
+ value: string;
120
+ /** The visible text label. Text only - no icons, no per-tab markup. */
121
+ children: string;
122
+ testId?: string;
123
+ }
124
+
125
+ export interface ITabsPanelProps {
126
+ /** The tab this panel belongs to - exactly one panel per tab value within a Root. */
127
+ value: string;
128
+ /** Mounted only while active; departure unmounts it, return mounts it fresh. */
129
+ children: ReactNode;
130
+ testId?: string;
131
+ }
132
+
133
+ // useId gives each Root one stable id namespace, so two instances on a page cannot collide and the
134
+ // tab/panel associations survive rerenders. That hook and the context are the component's only
135
+ // state-shaped machinery; the selection itself stays the consumer's.
136
+ const TabsRoot: FunctionComponent<ITabsRootProps> = ({
137
+ active,
138
+ onSelect$,
139
+ label,
140
+ children,
141
+ testId,
142
+ }) => {
143
+ const baseId = useId();
144
+ return (
145
+ <TabsContext.Provider value={{ active, onSelect$, label, baseId }}>
146
+ <div data-testid={testId}>{children}</div>
147
+ </TabsContext.Provider>
148
+ );
149
+ };
150
+
151
+ const TabsList: FunctionComponent<ITabsListProps> = ({ children, testId }) => {
152
+ const { label } = useTabsContract('List');
153
+ return (
154
+ <div
155
+ role="tablist"
156
+ aria-label={label}
157
+ className={tabsList()}
158
+ data-testid={testId}
159
+ >
160
+ {children}
161
+ </div>
162
+ );
163
+ };
164
+
165
+ const TabsTab: FunctionComponent<ITabsTabProps> = ({
166
+ value,
167
+ children,
168
+ testId,
169
+ }) => {
170
+ const { active, onSelect$, baseId } = useTabsContract('Tab');
171
+ const isActive = active === value;
172
+
173
+ // Left/Right move focus to the neighbouring tab and request its selection immediately -
174
+ // activation follows focus. Other keys fall through: Up/Down keep scrolling the page, Tab
175
+ // leaves the list for the active panel.
176
+ const requestNeighbour = (event: KeyboardEvent<HTMLButtonElement>): void => {
177
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
178
+ return;
179
+ }
180
+ event.preventDefault();
181
+ const neighbour = neighbourTab(
182
+ event.currentTarget,
183
+ event.key === 'ArrowRight' ? 1 : -1,
184
+ );
185
+ if (neighbour === undefined) {
186
+ return;
187
+ }
188
+ neighbour.focus();
189
+ const neighbourValue = neighbour.dataset.value;
190
+ if (neighbourValue !== undefined) {
191
+ onSelect$.next(neighbourValue);
192
+ }
193
+ };
194
+
195
+ return (
196
+ <button
197
+ type="button"
198
+ role="tab"
199
+ id={tabId(baseId, value)}
200
+ aria-selected={isActive}
201
+ aria-controls={panelId(baseId, value)}
202
+ tabIndex={isActive ? 0 : -1}
203
+ data-value={value}
204
+ data-testid={testId}
205
+ className={tabsTab()}
206
+ onClick={() => onSelect$.next(value)}
207
+ onKeyDown={requestNeighbour}
208
+ onFocus={keepFocusedTabVisible}
209
+ >
210
+ {children}
211
+ </button>
212
+ );
213
+ };
214
+
215
+ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
216
+ value,
217
+ children,
218
+ testId,
219
+ }) => {
220
+ const { active, baseId } = useTabsContract('Panel');
221
+ const isActive = active === value;
222
+ return (
223
+ <div
224
+ role="tabpanel"
225
+ id={panelId(baseId, value)}
226
+ aria-labelledby={tabId(baseId, value)}
227
+ hidden={!isActive}
228
+ tabIndex={isActive ? 0 : undefined}
229
+ className={tabsPanel()}
230
+ data-testid={testId}
231
+ >
232
+ {isActive ? children : undefined}
233
+ </div>
234
+ );
235
+ };
236
+
237
+ /**
238
+ * A few named views sharing one surface: a horizontal tab list over exactly one visible panel,
239
+ * following the WAI-ARIA tabs pattern. Controlled - the consumer owns the active key and all
240
+ * content; Tabs owns the controls and the panels that present it. Composed from four members:
241
+ * `Root` carries the contract, `List` the scrolling row, `Tab` one control, `Panel` one view.
242
+ *
243
+ * @Guarantees — enforced on every render
244
+ * - Renders `role="tablist"`/`tab`/`tabpanel` with `aria-selected`, `aria-controls` and
245
+ * `aria-labelledby` wired per pair; ids are namespaced per instance, so two Tabs on one page
246
+ * cannot collide and associations survive rerenders.
247
+ * - Selection is the value of `active`, nothing else: a selection request that goes unanswered
248
+ * changes nothing, and no fallback selection is invented or emitted - ever.
249
+ * - Only the active panel mounts its children; inactive panels stay hidden and empty, so departure
250
+ * unmounts a view and returning mounts it fresh, with no cache and no preserved state.
251
+ * - Left/Right move focus to the neighbouring tab, wrapping at either end, and request selection
252
+ * immediately; focus stays on the operated tab. Up/Down are left to the browser.
253
+ * - Roving tabindex: Tab enters the list at the active tab, then the active panel - a consistent
254
+ * Tab stop whether or not its content is focusable. Inactive panels add no stop.
255
+ * - The row scrolls horizontally on overflow - one line, no wrap, no shrink - and holds its own
256
+ * ring room, so the focused tab's ring survives the scroll clip.
257
+ * - Selection is marked by a persistent line under the active tab: `--tab-marker-thickness` in
258
+ * `foreground`, constant thickness in both states, so switching shifts no widths and no weights.
259
+ * Keyboard focus is the separate shared focus ring. Colour moves on the one motion token.
260
+ *
261
+ * @CallerMustEnsure — the component cannot see these and does not check them
262
+ * - One List with Tab elements as direct DOM children (arrays and fragments are supported),
263
+ * and sibling Panels under Root. Do not wrap tabs in host elements.
264
+ * - At least two tabs, each `value` unique and stable, each with exactly one matching `Panel`
265
+ * under the same `Root`, and `active` naming a declared pair. Invalid input is a contract
266
+ * violation, not a request for a fallback.
267
+ * - An update removing the active tab supplies a valid replacement `active` and the matching
268
+ * composition in the same update.
269
+ * - The newly selected view renders without noticeable delay - slower data belongs inside the
270
+ * immediately displayed view. Any state shared or preserved across views is the consumer's.
271
+ *
272
+ * @UXGuidelines
273
+ * - Labels are short names for views, not actions; the consuming app words and translates them.
274
+ * - This is not a router: no location, no history, no deep links. Wire `onSelect$` to whatever
275
+ * owns the active key and pass that key back in.
276
+ * - The panel is an opaque slot: compose the view's own rhythm inside it - a `Stack`, a `Prose` -
277
+ * as the view owns it; Tabs sets no spacing between the row and the panel.
278
+ */
279
+ export const Tabs = {
280
+ Root: TabsRoot,
281
+ List: TabsList,
282
+ Tab: TabsTab,
283
+ Panel: TabsPanel,
284
+ } as const;
@@ -0,0 +1,6 @@
1
+ export class TabsCompositionError extends Error {
2
+ constructor(member: string) {
3
+ super(`Tabs.${member} must be composed inside Tabs.Root`);
4
+ this.name = 'TabsCompositionError';
5
+ }
6
+ }
@@ -0,0 +1,85 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ // One recipe, no variants: both-axes centring is the component's whole job (#96), so there is nothing
5
+ // to choose - a distribution axis waits for evidence under ADR 0008's test. items-center centres the
6
+ // inline axis, the slot's auto margins (below) the block axis; alone among the composables it carries
7
+ // its own inset - the deliberate exception to "Section owns the gutter" (CONTEXT.md: Cover).
8
+ const cover = cva(
9
+ [
10
+ 'flex flex-col items-center',
11
+ 'min-h-[var(--cover-height)]',
12
+ 'px-[var(--gutter)] py-[var(--space-region)]',
13
+ ].join(' '),
14
+ );
15
+
16
+ // Not a second recipe - the slot has nothing to vary, and the standard allows one cva()
17
+ // (design-system-components.md §4), the frame's own above. Block-axis auto margins split the leftover
18
+ // space equally, so a foot after the slot still lands on the bottom edge. The full-width cap keeps a
19
+ // definite-width child - an action column asking for its bound (#99) - inside the frame's inset.
20
+ const slot = 'my-auto max-w-full';
21
+
22
+ export interface ICoverProps {
23
+ /** The screen's one opaque slot, centred on both axes. Rendered unmodified: a menu composes a title,
24
+ * a tagline and a stack of actions here; a sign-in composes a form. `Cover` imposes no anatomy. */
25
+ children: ReactNode;
26
+ /** The line on the screen's bottom edge - a version line, a legal line - centred on the inline axis.
27
+ * Omitted - or given nothing: `null`, a flag's `false` - nothing renders: no empty container
28
+ * holds its place. */
29
+ foot?: ReactNode;
30
+ testId?: string;
31
+ }
32
+
33
+ /**
34
+ * A whole screen's frame: a plain container at least the cover height tall that centres one column in
35
+ * the leftover space, on both axes, with an optional foot pinned to the bottom edge. It is the fold's
36
+ * counterpart (CONTEXT.md): a menu, a sign-in, a splash is the entire app for a moment, and nothing
37
+ * follows it - so where `Hero` deliberately stops short of the viewport to say the page continues, a
38
+ * cover reaches it, because stopping short would signal a continuation that does not exist. It renders
39
+ * no heading and no landmark: whatever the screen says is composed in the slot.
40
+ *
41
+ * @Guarantees — enforced on every render
42
+ * - It is at least `--cover-height` tall: a `min-height` floor, not a fixed height, so content longer
43
+ * than the viewport grows the frame rather than overflowing it.
44
+ * - `children` sit in the middle of the leftover space, centred on both axes; the centring is fixed,
45
+ * with no distribution to choose.
46
+ * - `foot` renders on the frame's bottom edge, centred on the inline axis, and renders nothing - not
47
+ * even an empty container - when not given or given nothing to render (`null`, a flag's `false`).
48
+ * - `children` and `foot` render unmodified; the component adds nothing to and strips nothing from
49
+ * them, and sets no colour, no heading and no landmark of its own.
50
+ * - It owns its own inset - `--gutter` on the inline axis, `--space-region` on the block axis - the
51
+ * deliberate exception to "`Section` owns the gutter": the frame equals the viewport, so it cannot
52
+ * sit inside a `Section` band without overflowing it, and there is no band around it to carry one.
53
+ * - It is never sticky or fixed and needs no JavaScript, so it renders identically server-side.
54
+ *
55
+ * @CallerMustEnsure — the component cannot see these and does not check them
56
+ * - The cover stands on its own, never inside a `Section`: the band's vertical air would push the
57
+ * frame past the viewport it exists to equal. A page that continues past its first screen wants
58
+ * `Section` and `Hero` instead - the fold, not the screen.
59
+ * - Anything the page must announce - a landmark, a heading - is composed in the slot; the frame
60
+ * declares nothing over it.
61
+ *
62
+ * @UXGuidelines
63
+ * - A cover is for a page that is the whole app for a moment - a menu, a sign-in, a splash. The
64
+ * moment content follows on the same page, the screen has become a fold and stopping short of the
65
+ * viewport is the honest signal: reach for `Hero` inside a `Section` instead.
66
+ * - The foot is a quiet line, not a footer: a version, a legal notice. Content a viewer must reach
67
+ * belongs in the slot, where it sits in the column the screen is actually about.
68
+ */
69
+ export const Cover: FunctionComponent<ICoverProps> = ({
70
+ children,
71
+ foot,
72
+ testId,
73
+ }) => {
74
+ // Absence, not falsiness, after Form's note guard: `foot={showLegal && <p/>}` hands over `false`,
75
+ // and an empty <div> would still be a flex item sitting on the bottom edge.
76
+ const hasFoot =
77
+ foot !== undefined && foot !== null && typeof foot !== 'boolean';
78
+
79
+ return (
80
+ <div className={cover()} data-testid={testId}>
81
+ <div className={slot}>{children}</div>
82
+ {hasFoot && <div>{foot}</div>}
83
+ </div>
84
+ );
85
+ };
@@ -0,0 +1,224 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode, RefCallback } from 'react';
3
+ import { Children, createContext, isValidElement, useContext } from 'react';
4
+ import type { Subject } from 'rxjs';
5
+
6
+ // One column below 64rem, the whole list above the content in normal flow; at `lg` a fixed 12rem nav
7
+ // track beside `minmax(0,1fr)`, whose zero minimum keeps wide content from displacing the track (it
8
+ // does not fix that content's own overflow). Root styles only its owned first child, leaving consumer
9
+ // navigation untouched. Padding and scroll padding reserve the focus ring's width + offset.
10
+ const sidebarRoot = cva(
11
+ [
12
+ 'grid gap-[var(--space-region)] lg:grid-cols-[12rem_minmax(0,1fr)]',
13
+ '[&>div:first-child]:border-solid [&>div:first-child]:border-border [&>div:first-child]:border-b [&>div:first-child]:pb-[var(--space-stack)]',
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',
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
+ '[&>div:first-child>nav>ul]:flex [&>div:first-child>nav>ul]:flex-col [&>div:first-child>nav>ul]:gap-[var(--space-stack)]',
18
+ ].join(' '),
19
+ );
20
+
21
+ // A percentage max-height resolves against the content-driven grid cell, not its scrollport;
22
+ // dvh alone left an 800px nav clipped by a 384px frame (#101). Measure that external boundary.
23
+ // The ref runs only after client attachment; CSS owns the breakpoint and the viewport fallback.
24
+ const observeScrollport: RefCallback<HTMLElement> = (navigation) => {
25
+ if (!navigation) return;
26
+ const ancestors: HTMLElement[] = [];
27
+ for (
28
+ let ancestor = navigation.parentElement;
29
+ ancestor && ancestor !== navigation.ownerDocument.documentElement;
30
+ ancestor = ancestor.parentElement
31
+ ) {
32
+ ancestors.push(ancestor);
33
+ }
34
+ const measure = () => {
35
+ const scrollport = ancestors.find((ancestor) =>
36
+ /^(auto|scroll|hidden|overlay)$/.test(
37
+ getComputedStyle(ancestor).overflowY,
38
+ ),
39
+ );
40
+ if (scrollport) {
41
+ navigation.style.setProperty(
42
+ '--sidebar-scrollport-height',
43
+ `${scrollport.clientHeight}px`,
44
+ );
45
+ } else {
46
+ navigation.style.removeProperty('--sidebar-scrollport-height');
47
+ }
48
+ };
49
+ measure();
50
+ // Observe ancestors so a resized wrapper or responsive change of scrollport is remeasured.
51
+ const observer =
52
+ typeof ResizeObserver === 'undefined'
53
+ ? undefined
54
+ : new ResizeObserver(measure);
55
+ for (const ancestor of ancestors) observer?.observe(ancestor);
56
+ window.addEventListener('resize', measure);
57
+ return () => {
58
+ observer?.disconnect();
59
+ window.removeEventListener('resize', measure);
60
+ navigation.style.removeProperty('--sidebar-scrollport-height');
61
+ };
62
+ };
63
+
64
+ // An entry at the label role, told apart by colour and underline alone - the agreed treatment carries
65
+ // no active-background role. Active keeps a persistent underline at the at-rest thickness; usable
66
+ // raises one on hover, instantly (underlines are off the motion allowlist, docs/adr/0001); inert is
67
+ // muted with no interactive response. The focus ring is the library's one contract (docs/adr/0002).
68
+ const sidebarEntry = cva(
69
+ [
70
+ 'w-full text-left font-secondary text-label tracking-label',
71
+ 'underline-offset-[var(--underline-offset)]',
72
+ 'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
73
+ ].join(' '),
74
+ {
75
+ variants: {
76
+ state: {
77
+ active:
78
+ 'text-foreground underline decoration-[length:var(--underline-thickness)] cursor-pointer',
79
+ usable:
80
+ 'text-foreground hover:underline hover:decoration-[length:var(--underline-thickness)] cursor-pointer',
81
+ inert: 'text-muted cursor-not-allowed',
82
+ },
83
+ },
84
+ defaultVariants: { state: 'usable' },
85
+ },
86
+ );
87
+
88
+ // What an Item derives its treatment and emission from - carried by context because the agreed surface
89
+ // gives Item no active prop and no Subject; Root alone speaks them.
90
+ type SidebarSelection = {
91
+ active: string;
92
+ onSelect$: Subject<string>;
93
+ };
94
+
95
+ const SidebarSelectionContext = createContext<SidebarSelection | undefined>(
96
+ undefined,
97
+ );
98
+
99
+ export interface ISidebarRootProps {
100
+ /** The key of the active entry. The caller supplies a key identifying one non-inert entry;
101
+ * Sidebar renders what it is given and never selects a fallback for an invalid key. */
102
+ active: string;
103
+ /** The nav landmark's accessible name. */
104
+ label: string;
105
+ /** Emits the selected entry's key when a usable inactive entry is activated. The active and inert
106
+ * entries emit nothing, and neither does any render. */
107
+ onSelect$: Subject<string>;
108
+ /** Direct `Sidebar.Item` children in display order, then one `Sidebar.Content`. */
109
+ children?: ReactNode;
110
+ testId?: string;
111
+ }
112
+
113
+ export interface ISidebarItemProps {
114
+ /** Identifies the entry to the application - what `onSelect$` emits and `active` names. React's
115
+ * reserved `key` is not the entry identifier. */
116
+ entryKey: string;
117
+ /** The entry's visible text. Text-only by type: an entry is a label, never arbitrary markup. */
118
+ children: string;
119
+ /** Present in the order but unavailable: muted, semantically disabled, skipped by Tab. */
120
+ inert?: boolean;
121
+ testId?: string;
122
+ }
123
+
124
+ export interface ISidebarContentProps {
125
+ children?: ReactNode;
126
+ testId?: string;
127
+ }
128
+
129
+ const SidebarItem: FunctionComponent<ISidebarItemProps> = ({
130
+ entryKey,
131
+ children,
132
+ inert,
133
+ testId,
134
+ }) => {
135
+ const selection = useContext(SidebarSelectionContext);
136
+ const isActive = selection !== undefined && selection.active === entryKey;
137
+ const selectEntry = () => {
138
+ if (!isActive) selection?.onSelect$.next(entryKey);
139
+ };
140
+ return (
141
+ <li>
142
+ <button
143
+ type="button"
144
+ className={sidebarEntry({
145
+ state: inert ? 'inert' : isActive ? 'active' : 'usable',
146
+ })}
147
+ disabled={inert}
148
+ aria-current={isActive ? 'true' : undefined}
149
+ data-testid={testId}
150
+ onClick={selectEntry}
151
+ >
152
+ {children}
153
+ </button>
154
+ </li>
155
+ );
156
+ };
157
+
158
+ const SidebarContent: FunctionComponent<ISidebarContentProps> = ({
159
+ children,
160
+ testId,
161
+ }) => <div data-testid={testId}>{children}</div>;
162
+
163
+ const SidebarRoot: FunctionComponent<ISidebarRootProps> = ({
164
+ active,
165
+ label,
166
+ onSelect$,
167
+ children,
168
+ testId,
169
+ }) => {
170
+ const parts = Children.toArray(children).filter(isValidElement);
171
+ return (
172
+ <div className={sidebarRoot()} data-testid={testId}>
173
+ <div>
174
+ <nav ref={observeScrollport} aria-label={label}>
175
+ <SidebarSelectionContext value={{ active, onSelect$ }}>
176
+ <ul>{parts.filter((part) => part.type === SidebarItem)}</ul>
177
+ </SidebarSelectionContext>
178
+ </nav>
179
+ </div>
180
+ {parts.filter((part) => part.type === SidebarContent)}
181
+ </div>
182
+ );
183
+ };
184
+
185
+ /**
186
+ * The standing application navigation beside the active section's content: a `nav` landmark named by
187
+ * `label`, listing text entries as plain buttons, and a `Content` track for the section the
188
+ * application shows. Selection is a request - activating a usable inactive entry emits its key through
189
+ * `onSelect$` - and the application answers by rerendering with a new `active`. Composed from `Root`,
190
+ * `Item` and `Content`; Root assembles its direct Items into the list, in order, and places Content
191
+ * beside them at 64rem or below them under it.
192
+ *
193
+ * @Guarantees — enforced on every render
194
+ * - For valid caller input, the active entry alone carries `aria-current="true"`. Sidebar owns no
195
+ * selection, URL, history or fallback: it renders the key it is given; a missing key marks nothing.
196
+ * - Activating the active entry or an inert one emits nothing; no render emits anything. Entries are
197
+ * non-submitting `type="button"` buttons with native Tab/Enter/Space behaviour - no tabs/menu model.
198
+ * - An inert entry stays visible but muted and disabled, so Tab skips it and activation is inert too.
199
+ * - At and above 64rem the nav is a fixed 12rem track, sticky at the top of the scrolling area with no
200
+ * assumed top-bar offset, capped to the screen/scrolling-area height with independent entry scrolling.
201
+ * Content sits beside it in `minmax(0,1fr)`, so wide content cannot displace the track.
202
+ * - Below 64rem the whole list lies above the content in normal flow - no stickiness, no cap, no
203
+ * drawer. Sidebar sets no viewport-height minimum and grows with its content.
204
+ * - It separates the tracks itself (the region space role and a `border` hairline) but gives the
205
+ * content track no padding and no landmark - the consumer owns everything inside `Content`.
206
+ *
207
+ * @CallerMustEnsure — the component cannot see these and does not check them
208
+ * - Entry keys are unique and `active` names one non-inert entry. Sidebar corrects nothing.
209
+ * - Items and the one Content are direct children of `Root` - Root assembles only what it can see,
210
+ * and an Item rendered outside a Root has no selection to derive its treatment from.
211
+ * - Focus after the content changes belongs to the application; Sidebar leaves it on the activated
212
+ * entry.
213
+ *
214
+ * @UXGuidelines
215
+ * - Entries are section labels: short, parallel, text-only. A destination that is a URL belongs to
216
+ * `Link` in a `Header` or `Footer`, not here - Sidebar requests sections, it does not navigate.
217
+ * - Keep inert entries listed: a section that exists but holds nothing yet keeps its place in the
218
+ * order, which is the point of inertness being a visual state rather than an absence.
219
+ */
220
+ export const Sidebar = {
221
+ Root: SidebarRoot,
222
+ Item: SidebarItem,
223
+ Content: SidebarContent,
224
+ } as const;