@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.
- package/README.md +32 -0
- package/dist/design-system.js +330 -134
- package/dist/index.css +1 -1
- package/dist/types/Arrangement/Stack/Stack.d.ts +23 -5
- package/dist/types/Display/Collection/Collection.d.ts +30 -0
- package/dist/types/Interaction/Tabs/Tabs.d.ts +79 -0
- package/dist/types/Interaction/Tabs/TabsCompositionError.d.ts +3 -0
- package/dist/types/Layout/Cover/Cover.d.ts +48 -0
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +69 -0
- package/dist/types/index.d.ts +4 -0
- package/package.json +1 -1
- package/src/Arrangement/Stack/Stack.tsx +39 -13
- package/src/Display/Collection/Collection.tsx +58 -0
- package/src/Interaction/Tabs/Tabs.tsx +284 -0
- package/src/Interaction/Tabs/TabsCompositionError.ts +6 -0
- package/src/Layout/Cover/Cover.tsx +85 -0
- package/src/Layout/Sidebar/Sidebar.tsx +224 -0
- package/src/Theme/renderTokens.ts +50 -6
- package/src/index.ts +4 -0
- package/src/tokens.css +24 -1
- package/src/tokens.dark.css +24 -1
- package/src/tokens.light.css +24 -1
|
@@ -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,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;
|