@juwel-development/design-system 3.2.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 +319 -138
- package/dist/index.css +1 -1
- package/dist/types/Arrangement/Stack/Stack.d.ts +19 -6
- 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/Sidebar/Sidebar.d.ts +69 -0
- package/dist/types/index.d.ts +3 -0
- package/package.json +1 -1
- package/src/Arrangement/Stack/Stack.tsx +30 -10
- 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 +3 -3
- package/src/Layout/Sidebar/Sidebar.tsx +224 -0
- package/src/Theme/renderTokens.ts +31 -0
- package/src/index.ts +3 -0
- package/src/tokens.css +15 -0
- package/src/tokens.dark.css +15 -0
- package/src/tokens.light.css +15 -0
|
@@ -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;
|
|
@@ -15,9 +15,9 @@ const cover = cva(
|
|
|
15
15
|
|
|
16
16
|
// Not a second recipe - the slot has nothing to vary, and the standard allows one cva()
|
|
17
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
|
|
19
|
-
//
|
|
20
|
-
const slot = 'my-auto';
|
|
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
21
|
|
|
22
22
|
export interface ICoverProps {
|
|
23
23
|
/** The screen's one opaque slot, centred on both axes. Rendered unmodified: a menu composes a title,
|
|
@@ -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;
|
|
@@ -155,6 +155,13 @@ const SPACING = `:root {
|
|
|
155
155
|
--space-band: 4em;
|
|
156
156
|
}`;
|
|
157
157
|
|
|
158
|
+
/* Collection item padding is air inside each freely composed item (#103), distinct from a sibling
|
|
159
|
+
gap or a page band. One em above and below keeps it tied to inherited type without imposing a
|
|
160
|
+
type role on the content. A separate role lets a brand tune this air without moving other groups. */
|
|
161
|
+
const COLLECTION_SPACING = `:root {
|
|
162
|
+
--space-collection-item: 1em;
|
|
163
|
+
}`;
|
|
164
|
+
|
|
158
165
|
/* The gutter is the horizontal inset holding content off the viewport edge (#9), and the one spacing role
|
|
159
166
|
measured against the screen rather than the type: it answers to how much room there is, not how large
|
|
160
167
|
the words are, so it is in rem/vw and never em. A clamp() lets it grow with the viewport with no
|
|
@@ -204,6 +211,22 @@ const TICK = `:root {
|
|
|
204
211
|
--tick-thickness: 1px;
|
|
205
212
|
}`;
|
|
206
213
|
|
|
214
|
+
/* Tab label insets separate adjacent controls and give the label vertical breathing room.
|
|
215
|
+
These are two axis-specific jobs, not a selectable scale; stack/region/band describe other jobs.
|
|
216
|
+
Like those spacing roles, em tracks the label type. Non-negative values preserve the hit area. */
|
|
217
|
+
const TAB_INSETS = `:root {
|
|
218
|
+
--tab-inset-inline: 1em;
|
|
219
|
+
--tab-inset-block: 0.5em;
|
|
220
|
+
}`;
|
|
221
|
+
|
|
222
|
+
/* Not a colour: like the tick, the thickness lives in :root only, never @theme inline, so a brand
|
|
223
|
+
can re-point the marker's weight. Not an --underline-* token - those name an anchor's line
|
|
224
|
+
(docs/adr/0006); this names the persistent mark under the active tab, drawn in `foreground`,
|
|
225
|
+
no new role. Constraint: > 0 - the line is the one persistent selection cue, zero erases it. */
|
|
226
|
+
const TAB_MARKER = `:root {
|
|
227
|
+
--tab-marker-thickness: 2px;
|
|
228
|
+
}`;
|
|
229
|
+
|
|
207
230
|
const toKebabCase = (name: string): string =>
|
|
208
231
|
name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
209
232
|
|
|
@@ -271,6 +294,9 @@ ${MEASURE}
|
|
|
271
294
|
re-pointing - so the three roles sit in :root beside the measure. */
|
|
272
295
|
${SPACING}
|
|
273
296
|
|
|
297
|
+
/* Collection's vertical item padding, measured against inherited type. */
|
|
298
|
+
${COLLECTION_SPACING}
|
|
299
|
+
|
|
274
300
|
/* The gutter has no Tailwind namespace either, so it sits in :root beside the space roles. */
|
|
275
301
|
${GUTTER}
|
|
276
302
|
|
|
@@ -282,6 +308,11 @@ ${COVER}
|
|
|
282
308
|
|
|
283
309
|
/* The checklist tick's dimensions are not colours either, and sit in :root beside the underline block. */
|
|
284
310
|
${TICK}
|
|
311
|
+
|
|
312
|
+
/* The tab marker's thickness is not a colour either, and sits in :root beside the tick block. */
|
|
313
|
+
${TAB_MARKER}
|
|
314
|
+
|
|
315
|
+
${TAB_INSETS}
|
|
285
316
|
`;
|
|
286
317
|
|
|
287
318
|
/**
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { Cluster } from 'Arrangement/Cluster/Cluster';
|
|
|
4
4
|
export { Stack } from 'Arrangement/Stack/Stack';
|
|
5
5
|
export { Brandmark } from 'Display/Brandmark/Brandmark';
|
|
6
6
|
export { Checklist } from 'Display/Checklist/Checklist';
|
|
7
|
+
export { Collection } from 'Display/Collection/Collection';
|
|
7
8
|
export { DefinitionList } from 'Display/DefinitionList/DefinitionList';
|
|
8
9
|
export { Figure } from 'Display/Figure/Figure';
|
|
9
10
|
export { Rail } from 'Display/Rail/Rail';
|
|
@@ -21,6 +22,7 @@ export { Prose } from 'Display/Typography/Prose/Prose';
|
|
|
21
22
|
export { Button } from 'Interaction/Button/Button';
|
|
22
23
|
export { Input } from 'Interaction/Input/Input';
|
|
23
24
|
export { Link } from 'Interaction/Link/Link';
|
|
25
|
+
export { Tabs } from 'Interaction/Tabs/Tabs';
|
|
24
26
|
export { TextArea } from 'Interaction/TextArea/TextArea';
|
|
25
27
|
export { Cover } from 'Layout/Cover/Cover';
|
|
26
28
|
export { Footer } from 'Layout/Footer/Footer';
|
|
@@ -30,5 +32,6 @@ export { Header } from 'Layout/Header/Header';
|
|
|
30
32
|
export { Hero } from 'Layout/Hero/Hero';
|
|
31
33
|
export { PageHead } from 'Layout/PageHead/PageHead';
|
|
32
34
|
export { Section } from 'Layout/Section/Section';
|
|
35
|
+
export { Sidebar } from 'Layout/Sidebar/Sidebar';
|
|
33
36
|
export type { PaletteTokens } from 'Theme/Palette';
|
|
34
37
|
export { dark, light } from 'Theme/Palette';
|
package/src/tokens.css
CHANGED
|
@@ -195,6 +195,11 @@
|
|
|
195
195
|
--space-band: 4em;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/* Collection's vertical item padding, measured against inherited type. */
|
|
199
|
+
:root {
|
|
200
|
+
--space-collection-item: 1em;
|
|
201
|
+
}
|
|
202
|
+
|
|
198
203
|
/* The gutter has no Tailwind namespace either, so it sits in :root beside the space roles. */
|
|
199
204
|
:root {
|
|
200
205
|
--gutter: clamp(1.5rem, 5vw, 4rem);
|
|
@@ -215,3 +220,13 @@
|
|
|
215
220
|
--tick-length: 0.9rem;
|
|
216
221
|
--tick-thickness: 1px;
|
|
217
222
|
}
|
|
223
|
+
|
|
224
|
+
/* The tab marker's thickness is not a colour either, and sits in :root beside the tick block. */
|
|
225
|
+
:root {
|
|
226
|
+
--tab-marker-thickness: 2px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
:root {
|
|
230
|
+
--tab-inset-inline: 1em;
|
|
231
|
+
--tab-inset-block: 0.5em;
|
|
232
|
+
}
|
package/src/tokens.dark.css
CHANGED
|
@@ -166,6 +166,11 @@
|
|
|
166
166
|
--space-band: 4em;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/* Collection's vertical item padding, measured against inherited type. */
|
|
170
|
+
:root {
|
|
171
|
+
--space-collection-item: 1em;
|
|
172
|
+
}
|
|
173
|
+
|
|
169
174
|
/* The gutter has no Tailwind namespace either, so it sits in :root beside the space roles. */
|
|
170
175
|
:root {
|
|
171
176
|
--gutter: clamp(1.5rem, 5vw, 4rem);
|
|
@@ -186,3 +191,13 @@
|
|
|
186
191
|
--tick-length: 0.9rem;
|
|
187
192
|
--tick-thickness: 1px;
|
|
188
193
|
}
|
|
194
|
+
|
|
195
|
+
/* The tab marker's thickness is not a colour either, and sits in :root beside the tick block. */
|
|
196
|
+
:root {
|
|
197
|
+
--tab-marker-thickness: 2px;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
:root {
|
|
201
|
+
--tab-inset-inline: 1em;
|
|
202
|
+
--tab-inset-block: 0.5em;
|
|
203
|
+
}
|