@juwel-development/design-system 3.9.1 → 3.11.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 +9 -298
- package/dist/design-system.js +1070 -605
- package/dist/index.css +1 -1
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +92 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayoutCompositionError.d.ts +3 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/FieldRow.d.ts +87 -0
- package/dist/types/Arrangement/FieldRow/FieldRowCompositionError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/FieldRowConfigurationError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/IControlEdge.d.ts +6 -0
- package/dist/types/Arrangement/FieldRow/alignControlEdges.d.ts +7 -0
- package/dist/types/Display/Box/Box.d.ts +41 -0
- package/dist/types/Display/Brandmark/Brandmark.d.ts +1 -1
- package/dist/types/Display/DefinitionList/DefinitionList.d.ts +59 -9
- package/dist/types/Display/DefinitionList/DefinitionListConfigurationError.d.ts +3 -0
- package/dist/types/Display/Figure/Figure.d.ts +2 -2
- package/dist/types/Display/Icon/Icon.d.ts +45 -0
- package/dist/types/Display/Table/Table.d.ts +118 -8
- package/dist/types/Display/Table/TableConfigurationError.d.ts +3 -0
- package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
- package/dist/types/Display/Typography/H1/H1.d.ts +3 -2
- package/dist/types/Display/Typography/H2/H2.d.ts +3 -2
- package/dist/types/Display/Typography/H3/H3.d.ts +3 -2
- package/dist/types/Display/Typography/H4/H4.d.ts +3 -2
- package/dist/types/Display/Typography/H5/H5.d.ts +3 -2
- package/dist/types/Display/Typography/H6/H6.d.ts +3 -2
- package/dist/types/Display/Typography/Note/Note.d.ts +1 -1
- package/dist/types/Display/Typography/P/P.d.ts +3 -2
- package/dist/types/Display/Typography/Prose/Prose.d.ts +1 -1
- package/dist/types/Interaction/Button/Button.d.ts +25 -3
- package/dist/types/Interaction/Tabs/Tabs.d.ts +31 -6
- package/dist/types/Layout/Header/Header.d.ts +67 -8
- package/dist/types/Layout/ScrollContainer/ScrollContainer.d.ts +37 -0
- package/dist/types/Layout/Section/Section.d.ts +1 -1
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +4 -0
- package/dist/types/Theme/Palette.d.ts +31 -7
- package/dist/types/index.d.ts +6 -0
- package/package.json +1 -1
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +249 -0
- package/src/Arrangement/ColumnLayout/ColumnLayoutCompositionError.ts +8 -0
- package/src/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.ts +6 -0
- package/src/Arrangement/FieldRow/FieldRow.tsx +313 -0
- package/src/Arrangement/FieldRow/FieldRowCompositionError.ts +6 -0
- package/src/Arrangement/FieldRow/FieldRowConfigurationError.ts +6 -0
- package/src/Arrangement/FieldRow/IControlEdge.ts +6 -0
- package/src/Arrangement/FieldRow/alignControlEdges.ts +35 -0
- package/src/Display/Box/Box.tsx +77 -0
- package/src/Display/Checklist/Checklist.tsx +1 -1
- package/src/Display/DefinitionList/DefinitionList.tsx +164 -22
- package/src/Display/DefinitionList/DefinitionListConfigurationError.ts +6 -0
- package/src/Display/Icon/Icon.tsx +113 -0
- package/src/Display/Table/Table.tsx +434 -44
- package/src/Display/Table/TableConfigurationError.ts +6 -0
- package/src/Display/Typography/H1/H1.tsx +3 -2
- package/src/Display/Typography/H2/H2.tsx +3 -2
- package/src/Display/Typography/H3/H3.tsx +3 -2
- package/src/Display/Typography/H4/H4.tsx +3 -2
- package/src/Display/Typography/H5/H5.tsx +3 -2
- package/src/Display/Typography/H6/H6.tsx +3 -2
- package/src/Display/Typography/P/P.tsx +3 -2
- package/src/Display/Typography/Prose/Prose.tsx +3 -3
- package/src/Interaction/Button/Button.tsx +47 -17
- package/src/Interaction/Input/Input.tsx +1 -1
- package/src/Interaction/MultiSelect/MultiSelect.tsx +1 -1
- package/src/Interaction/NumberInput/NumberInput.tsx +1 -1
- package/src/Interaction/Select/Select.tsx +1 -1
- package/src/Interaction/Tabs/Tabs.tsx +38 -11
- package/src/Interaction/TextArea/TextArea.tsx +1 -1
- package/src/Layout/Dialog/Dialog.tsx +4 -3
- package/src/Layout/Header/Header.tsx +139 -39
- package/src/Layout/PageHead/PageHead.tsx +6 -5
- package/src/Layout/ScrollContainer/ScrollContainer.tsx +149 -0
- package/src/Layout/Sidebar/Sidebar.tsx +8 -4
- package/src/Theme/Palette.ts +37 -9
- package/src/Theme/renderTokens.ts +101 -5
- package/src/index.ts +6 -0
- package/src/tokens.css +68 -4
- package/src/tokens.dark.css +66 -4
- package/src/tokens.light.css +64 -2
|
@@ -2,19 +2,15 @@ import type { VariantProps } from 'class-variance-authority';
|
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
3
|
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
-
// The recipe on the <header>. It sets the label type role -
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// sits at foreground - the colour every other item reaches only on hover. `edge` draws the bottom
|
|
12
|
-
// hairline in `rule`, the one weight a page's boundaries share with a Section join, and defaults to
|
|
13
|
-
// `rule` - the conventional header - so a ruleless shell opts out. Colours are semantic tokens re-pointed
|
|
14
|
-
// by `.dark`, so no `dark:` class.
|
|
5
|
+
// The recipe on the <header>. It sets the label type role - whose "never grows past 1rem" was a role
|
|
6
|
+
// wearing a number, so no size literal appears here (#14) - and one air value in every direction:
|
|
7
|
+
// --space-region above and below, --gutter across, so the bar aligns with every inset Section. The
|
|
8
|
+
// current-page treatment keys on the attribute, not on a component: [&_[aria-current=page]] compiles
|
|
9
|
+
// to specificity 0,2,0 and beats Link's quiet text-muted (0,1,0) with no !important and no import.
|
|
10
|
+
// Colours are semantic tokens re-pointed by `.dark`, so no `dark:` class.
|
|
15
11
|
const header = cva(
|
|
16
12
|
[
|
|
17
|
-
'flex items-baseline
|
|
13
|
+
'flex items-baseline',
|
|
18
14
|
'font-secondary text-label leading-label tracking-label text-muted',
|
|
19
15
|
'py-[var(--space-region)] px-[var(--gutter)]',
|
|
20
16
|
'[&_[aria-current=page]]:text-foreground',
|
|
@@ -25,26 +21,48 @@ const header = cva(
|
|
|
25
21
|
none: '',
|
|
26
22
|
rule: 'border-b border-solid border-rule',
|
|
27
23
|
},
|
|
24
|
+
// Read off the slots the caller filled, never set by a caller (see IHeaderProps). `navigation` is
|
|
25
|
+
// the arrangement the bar always had. `statusAction` breaks into lines instead of overflowing:
|
|
26
|
+
// --space-region along a line, the role the nav gaps its items with, and --space-stack between
|
|
27
|
+
// lines, Cluster's rule for a wrapped row (docs/adr/0008).
|
|
28
|
+
mode: {
|
|
29
|
+
navigation: 'justify-between',
|
|
30
|
+
statusAction:
|
|
31
|
+
'flex-wrap gap-x-[var(--space-region)] gap-y-[var(--space-stack)]',
|
|
32
|
+
},
|
|
28
33
|
},
|
|
29
|
-
defaultVariants: { edge: 'rule' },
|
|
34
|
+
defaultVariants: { edge: 'rule', mode: 'navigation' },
|
|
30
35
|
},
|
|
31
36
|
);
|
|
32
37
|
|
|
33
|
-
// Not a second recipe -
|
|
34
|
-
//
|
|
35
|
-
// the
|
|
36
|
-
//
|
|
37
|
-
// The height floor is the nav's own line box, written from the two tokens the recipe above sets the
|
|
38
|
-
// header from, so the declared floor and the rendered line cannot drift (#81). `shrink-0` because an
|
|
39
|
-
// explicit min-width replaces a flex item's automatic minimum - without it the bar squeezes the slot
|
|
40
|
-
// below its content and wraps the place name the floor exists to keep on one line.
|
|
38
|
+
// Not a second recipe - no slot has anything to vary, and the standard allows a component one cva()
|
|
39
|
+
// (design-system-components.md §4), which is the bar's own above. The standing floor is the nav's own
|
|
40
|
+
// line box, written from the two tokens the recipe sets the header from, so floor and rendered line
|
|
41
|
+
// cannot drift (#81); `shrink-0` because an explicit min-width replaces a flex item's automatic one.
|
|
41
42
|
const standingSlot = [
|
|
42
43
|
'inline-flex shrink-0 items-center',
|
|
43
44
|
'min-h-[calc(var(--text-label)*var(--leading-label))]',
|
|
44
45
|
'min-w-[var(--standing-min-width)]',
|
|
45
46
|
].join(' ');
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
const navSlot = 'flex flex-wrap items-baseline gap-[var(--space-region)]';
|
|
49
|
+
|
|
50
|
+
// The readout may shrink below its longest word once it has a line of its own, and an unbroken token
|
|
51
|
+
// then breaks inside the slot rather than widening the page. No role, no name, no live region.
|
|
52
|
+
const statusSlot = 'min-w-0 wrap-anywhere';
|
|
53
|
+
|
|
54
|
+
// One auto margin puts the action on the end edge whatever the line holds - beside the readout, or
|
|
55
|
+
// alone once the bar has broken. It is not `shrink-0`: a Button narrower than its label wraps the
|
|
56
|
+
// label itself (#119), where a bar refusing to shrink it would overflow the page instead.
|
|
57
|
+
const actionSlot = 'ms-auto';
|
|
58
|
+
|
|
59
|
+
interface IHeaderShellProps extends Omit<VariantProps<typeof header>, 'mode'> {
|
|
60
|
+
testId?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The navigation bar: the mode every existing caller is in, and the default. A standing link and a
|
|
64
|
+
* `<nav>`, and never a status or an action slot - the other shape is `IHeaderStatusActionProps`. */
|
|
65
|
+
export interface IHeaderNavigationProps extends IHeaderShellProps {
|
|
48
66
|
/** The standing link: a place name, a mark, or a home link. The consumer supplies the whole anchor -
|
|
49
67
|
* Header renders no link of its own. A place name uses `<Link treatment="quiet" href="/">…</Link>`; a
|
|
50
68
|
* mark uses `<Link treatment="graphic" href="/"><Brandmark …/></Link>`, whose `graphic` treatment
|
|
@@ -54,16 +72,56 @@ export interface IHeaderProps extends VariantProps<typeof header> {
|
|
|
54
72
|
navName?: string;
|
|
55
73
|
/** The nav links. */
|
|
56
74
|
children?: ReactNode;
|
|
57
|
-
|
|
75
|
+
status?: never;
|
|
76
|
+
action?: never;
|
|
58
77
|
}
|
|
59
78
|
|
|
79
|
+
/** The status/action bar: a bar that reports and acts rather than navigates, for a product whose
|
|
80
|
+
* shell carries a readout and a control and no links. It has no standing link and no `<nav>` - a
|
|
81
|
+
* button is not navigation - and the two shapes cannot be mixed: the compiler rejects a call that
|
|
82
|
+
* hands this bar a standing link, a nav name or nav children. */
|
|
83
|
+
export interface IHeaderStatusActionProps extends IHeaderShellProps {
|
|
84
|
+
/** The readout - a date, a balance, a short note, or a `Cluster` of them - at the start edge, first
|
|
85
|
+
* in reading order. A plain `div` with no role, no name and no live region: what it holds carries its
|
|
86
|
+
* own semantics, and whether a change is announced is the consumer's decision, made by wrapping its
|
|
87
|
+
* own live region. Long and unbroken wording wraps inside the slot. Omitted, nothing renders. */
|
|
88
|
+
status?: ReactNode;
|
|
89
|
+
/** The control - a `Button`, or a `Cluster` of them - at the end edge, last in reading and keyboard
|
|
90
|
+
* order, on whichever line it lands when the bar breaks. A plain `div` like `status`. Header never
|
|
91
|
+
* decides whether the control is available: pass it disabled, or withhold it (`{ready && <Button/>}`
|
|
92
|
+
* renders no box). Omitted, nothing renders. */
|
|
93
|
+
action?: ReactNode;
|
|
94
|
+
standing?: never;
|
|
95
|
+
navName?: never;
|
|
96
|
+
children?: never;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type IHeaderProps = IHeaderNavigationProps | IHeaderStatusActionProps;
|
|
100
|
+
|
|
101
|
+
// What React paints nothing for: `undefined`, `null`, a boolean and the empty string. A withheld slot
|
|
102
|
+
// gets no box, so a status-only or action-only bar carries no empty one.
|
|
103
|
+
const isPainted = (slot: ReactNode): boolean =>
|
|
104
|
+
slot !== undefined &&
|
|
105
|
+
slot !== null &&
|
|
106
|
+
typeof slot !== 'boolean' &&
|
|
107
|
+
slot !== '';
|
|
108
|
+
|
|
60
109
|
/**
|
|
61
|
-
* The shell's top edge
|
|
62
|
-
*
|
|
110
|
+
* The shell's top edge, at the label type role, in one of two mutually exclusive modes. The
|
|
111
|
+
* **navigation bar** - the default, and what every existing caller renders - is a standing link and a
|
|
112
|
+
* single `<nav>`. The **status/action bar** is a readout at the start edge and a control at the end
|
|
113
|
+
* edge, both plain boxes with no landmark of their own and no `<nav>` anywhere. The props type is a
|
|
114
|
+
* union of the two shapes, so a call that mixes them does not compile. Either bar arranges nothing
|
|
115
|
+
* beyond its slots and works with no hydration.
|
|
63
116
|
*
|
|
64
117
|
* @Guarantees — enforced on every render
|
|
65
118
|
* - The header is set at the label role and carries no size of its own: `font-secondary text-label
|
|
66
119
|
* leading-label tracking-label text-muted`, so it never grows past that role whatever the page font size.
|
|
120
|
+
* - The shell's air is one value above, below and between: `--space-region` vertically, between the
|
|
121
|
+
* nav's items and between the status/action bar's slots along a line, `--gutter` across, so the bar
|
|
122
|
+
* aligns with every inset `Section`. It is never sticky and needs no JavaScript.
|
|
123
|
+
*
|
|
124
|
+
* The navigation bar, unchanged by #125:
|
|
67
125
|
* - The nav's line box is the library's own rather than the consuming document's: the header leads
|
|
68
126
|
* itself at the label role, so the height it declares for its slot is the height it renders.
|
|
69
127
|
* - The standing slot is floored, never fixed: its minimum height is that same line box - the label
|
|
@@ -73,10 +131,24 @@ export interface IHeaderProps extends VariantProps<typeof header> {
|
|
|
73
131
|
* grows the slot, on one line, and is never clamped or wrapped.
|
|
74
132
|
* - A nav item marked `aria-current="page"` renders at `foreground` whatever supplied it - the treatment
|
|
75
133
|
* keys on the attribute, not on `Link`, so it holds against a bare anchor or any component.
|
|
76
|
-
* - The
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
134
|
+
* - The standing slot and the `<nav>` are the bar's two children whatever the caller passes, the nav
|
|
135
|
+
* flush with the end content edge and absorbing narrowing by wrapping its links. Omitting `navName`
|
|
136
|
+
* emits no `aria-label` at all, not an empty one.
|
|
137
|
+
*
|
|
138
|
+
* The status/action bar:
|
|
139
|
+
* - It renders no `<nav>`, no standing slot, no role, no name and no live region of its own. Each
|
|
140
|
+
* filled slot is one `div` directly inside the banner; an omitted or withheld slot (`false`, `null`)
|
|
141
|
+
* renders no box and reserves no space, so a status-only bar starts with its readout and an action-only
|
|
142
|
+
* bar is the control alone at the end edge.
|
|
143
|
+
* - Reading and keyboard order is status, then action, and the DOM order is the same at every width.
|
|
144
|
+
* - With both fitting, the readout sits at the start content edge and the control at the end content
|
|
145
|
+
* edge on one line. When they cannot share a line, the control drops below the readout and stays
|
|
146
|
+
* flush with the end edge; a readout then takes the whole line and wraps inside it, unbroken wording
|
|
147
|
+
* included, so the bar never widens the page and never clips.
|
|
148
|
+
* - Breaking is CSS alone: a width change re-flows the same elements, so descendant state and focus
|
|
149
|
+
* survive it.
|
|
150
|
+
* - What the consumer passes keeps its semantics: a heading stays a heading, a live region stays one, a
|
|
151
|
+
* disabled control stays disabled. Header adds, removes and announces nothing.
|
|
80
152
|
*
|
|
81
153
|
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
82
154
|
* - A mark that should *fill* the standing slot is given a **definite width** by whoever placed it -
|
|
@@ -89,6 +161,10 @@ export interface IHeaderProps extends VariantProps<typeof header> {
|
|
|
89
161
|
* width; `flex: 1 1 0; min-width: 0` only collapses the mark when the bar is already out of room, so
|
|
90
162
|
* it is not the rule to reach for. The library cannot apply either rule for you: the same width on a
|
|
91
163
|
* place name would clamp the name and wrap it.
|
|
164
|
+
* - Set the type of what fills a status/action slot: the bar sets the label role, so a bare string in
|
|
165
|
+
* `status` reads as a label, while a `P` or a `Note` wears its own role and a `Button` its own.
|
|
166
|
+
* - Whether a changing readout interrupts is yours to decide: wrap your own live region inside `status`
|
|
167
|
+
* where a change must be announced, and leave it out where it must not. Header takes neither side.
|
|
92
168
|
*
|
|
93
169
|
* @UXGuidelines
|
|
94
170
|
* - Name the nav with `navName` once the page has more than one navigation landmark - a footer nav will
|
|
@@ -97,21 +173,45 @@ export interface IHeaderProps extends VariantProps<typeof header> {
|
|
|
97
173
|
* `aria-current`, so set `current` on the `Link` and style nothing yourself.
|
|
98
174
|
* - The nav does not collapse into a menu: a disclosure needs JavaScript, so on a narrow viewport the
|
|
99
175
|
* items wrap. A mobile menu is out of scope, not a follow-up.
|
|
176
|
+
* - A bar that reports and acts is a status/action bar, never a navigation bar with a button among its
|
|
177
|
+
* links: a Balance line or a Continue button inside a `<nav>` is announced as navigation. A product
|
|
178
|
+
* whose shell needs links as well as a control has two bars, not one.
|
|
179
|
+
* - Several readouts or several actions go in a `Cluster` inside the slot: `gap="stack"` keeps them one
|
|
180
|
+
* group, and the Cluster wraps inside the slot before the bar runs out of room.
|
|
100
181
|
*/
|
|
101
182
|
export const Header: FunctionComponent<IHeaderProps> = ({
|
|
102
183
|
edge,
|
|
184
|
+
testId,
|
|
103
185
|
standing,
|
|
104
186
|
navName,
|
|
105
187
|
children,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
188
|
+
status,
|
|
189
|
+
action,
|
|
190
|
+
}) => {
|
|
191
|
+
// A slot the caller named decides the mode, even one withheld as `false`: `action={ready && …}` is a
|
|
192
|
+
// status/action bar with its control withheld, not a navigation bar with an empty nav.
|
|
193
|
+
const reportsOrActs = status !== undefined || action !== undefined;
|
|
194
|
+
return (
|
|
195
|
+
<header
|
|
196
|
+
className={header({
|
|
197
|
+
edge,
|
|
198
|
+
mode: reportsOrActs ? 'statusAction' : 'navigation',
|
|
199
|
+
})}
|
|
200
|
+
data-testid={testId}
|
|
113
201
|
>
|
|
114
|
-
{
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
202
|
+
{reportsOrActs ? (
|
|
203
|
+
<>
|
|
204
|
+
{isPainted(status) && <div className={statusSlot}>{status}</div>}
|
|
205
|
+
{isPainted(action) && <div className={actionSlot}>{action}</div>}
|
|
206
|
+
</>
|
|
207
|
+
) : (
|
|
208
|
+
<>
|
|
209
|
+
<div className={standingSlot}>{standing}</div>
|
|
210
|
+
<nav aria-label={navName} className={navSlot}>
|
|
211
|
+
{children}
|
|
212
|
+
</nav>
|
|
213
|
+
</>
|
|
214
|
+
)}
|
|
215
|
+
</header>
|
|
216
|
+
);
|
|
217
|
+
};
|
|
@@ -16,20 +16,21 @@ const pageHead = cva(
|
|
|
16
16
|
'flex flex-col gap-[var(--space-stack)] py-[var(--space-band)] px-[var(--gutter)]',
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
-
// The scale event: the title role, led and tracked as a large heading, foreground
|
|
20
|
-
// head is full-bleed and the consumer keeps titles
|
|
19
|
+
// The scale event: the title role, led and tracked as a large heading, foreground, in the heading
|
|
20
|
+
// face every heading reads (#120). No measure - the head is full-bleed and the consumer keeps titles
|
|
21
|
+
// short.
|
|
21
22
|
const pageHeadTitle = cva(
|
|
22
|
-
'font-
|
|
23
|
+
'font-heading text-title leading-title tracking-optical text-foreground',
|
|
23
24
|
);
|
|
24
25
|
|
|
25
26
|
// The standfirst: the lede role, foreground, run one measure wider than the reading column (#18).
|
|
26
27
|
const pageHeadLede = cva(
|
|
27
|
-
'font-
|
|
28
|
+
'font-body text-lede leading-lede text-foreground max-w-[var(--measure-wide)]',
|
|
28
29
|
);
|
|
29
30
|
|
|
30
31
|
// The small print: the small role, muted, held to the reading measure like Prose's tail.
|
|
31
32
|
const pageHeadIntro = cva(
|
|
32
|
-
'font-
|
|
33
|
+
'font-body text-small text-muted max-w-[var(--measure)]',
|
|
33
34
|
);
|
|
34
35
|
|
|
35
36
|
export interface IPageHeadProps {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import {
|
|
4
|
+
type FunctionComponent,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
10
|
+
|
|
11
|
+
// The container paints nothing: no fill, no border, no size of its own. `max-h-full`/`max-w-full`
|
|
12
|
+
// let a sized parent bound it and resolve to nothing under an unsized one, so no viewport bound is
|
|
13
|
+
// invented. A disabled axis is `hidden`, so overflow there is clipped rather than scrolled. The one
|
|
14
|
+
// focus ring is drawn with outline, colour at rest - docs/adr/0002.
|
|
15
|
+
const scrollContainer = cva(
|
|
16
|
+
'max-h-full max-w-full min-h-0 min-w-0 outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
17
|
+
{
|
|
18
|
+
variants: {
|
|
19
|
+
axis: {
|
|
20
|
+
both: 'overflow-auto',
|
|
21
|
+
horizontal: 'overflow-x-auto overflow-y-hidden',
|
|
22
|
+
vertical: 'overflow-y-auto overflow-x-hidden',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: { axis: 'both' },
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// The content box is `fit-content` floored at the container's width where horizontal scrolling is
|
|
30
|
+
// on, so unwrappable content grows it - which a ResizeObserver can see, since growth inside an
|
|
31
|
+
// overflow box never changes the container's size - and the container's width where only vertical
|
|
32
|
+
// is on, so a Table keeps its own horizontal scroll region. The padding is the focus ring's room.
|
|
33
|
+
const scrollContent = cva(
|
|
34
|
+
'p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
|
|
35
|
+
{
|
|
36
|
+
variants: {
|
|
37
|
+
axis: {
|
|
38
|
+
both: 'w-fit min-w-full',
|
|
39
|
+
horizontal: 'w-fit min-w-full',
|
|
40
|
+
vertical: 'w-full',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
defaultVariants: { axis: 'both' },
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
type Axis = NonNullable<VariantProps<typeof scrollContainer>['axis']>;
|
|
48
|
+
|
|
49
|
+
const isOverflowing = (element: HTMLElement, axis: Axis): boolean => {
|
|
50
|
+
const horizontally = element.scrollWidth > element.clientWidth;
|
|
51
|
+
const vertically = element.scrollHeight > element.clientHeight;
|
|
52
|
+
return (
|
|
53
|
+
(axis !== 'vertical' && horizontally) ||
|
|
54
|
+
(axis !== 'horizontal' && vertically)
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// A tab stop only while an enabled axis overflows (WCAG 2.1.1 wants the scroll container itself
|
|
59
|
+
// focusable when nothing inside is): reachable until measured, so server markup is operable before
|
|
60
|
+
// hydration, and kept reachable while it holds focus itself, since dropping tabindex from the
|
|
61
|
+
// focused element would let the browser relocate focus - the one thing a re-measure must not do.
|
|
62
|
+
const useOverflow = (
|
|
63
|
+
container: { current: HTMLElement | null },
|
|
64
|
+
content: { current: HTMLElement | null },
|
|
65
|
+
axis: Axis,
|
|
66
|
+
): boolean => {
|
|
67
|
+
const [isScrollable, setIsScrollable] = useState(true);
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
const element = container.current;
|
|
70
|
+
if (element === null) return;
|
|
71
|
+
const measure = () =>
|
|
72
|
+
setIsScrollable(
|
|
73
|
+
isOverflowing(element, axis) || element === document.activeElement,
|
|
74
|
+
);
|
|
75
|
+
measure();
|
|
76
|
+
const observer =
|
|
77
|
+
typeof ResizeObserver === 'undefined'
|
|
78
|
+
? undefined
|
|
79
|
+
: new ResizeObserver(measure);
|
|
80
|
+
observer?.observe(element);
|
|
81
|
+
if (content.current !== null) observer?.observe(content.current);
|
|
82
|
+
window.addEventListener('resize', measure);
|
|
83
|
+
element.addEventListener('blur', measure);
|
|
84
|
+
return () => {
|
|
85
|
+
observer?.disconnect();
|
|
86
|
+
window.removeEventListener('resize', measure);
|
|
87
|
+
element.removeEventListener('blur', measure);
|
|
88
|
+
};
|
|
89
|
+
}, [container, content, axis]);
|
|
90
|
+
return isScrollable;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export interface IScrollContainerProps
|
|
94
|
+
extends VariantProps<typeof scrollContainer> {
|
|
95
|
+
/** The group's accessible name while it can be scrolled. Required: a tab stop with no name is a
|
|
96
|
+
* mystery to a screen reader. The consuming app words it, usually after the heading above. */
|
|
97
|
+
ariaLabel: string;
|
|
98
|
+
children?: ReactNode;
|
|
99
|
+
testId?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Makes overflowing content reachable along the chosen axes, within the space its parent allocates.
|
|
104
|
+
* It owns the scrolling; the consumer owns the content and the allocation of space.
|
|
105
|
+
*
|
|
106
|
+
* @Guarantees — enforced on every render
|
|
107
|
+
* - Scrolls only an enabled axis and only once content overflows it; overflow on a disabled axis is
|
|
108
|
+
* clipped. Content wraps as it would anywhere else.
|
|
109
|
+
* - Keyboard-reachable - a named `group` with a tab stop - exactly while an enabled axis overflows,
|
|
110
|
+
* static content included; no stop and no group while everything fits. Scrollability is re-read on
|
|
111
|
+
* resize and on content change without moving focus.
|
|
112
|
+
* - Native scrolling: scrollbars, wheel, touch and the browser's own arrow/page keys on the focused
|
|
113
|
+
* container. No key of a control inside it is intercepted and focus is never trapped.
|
|
114
|
+
* - Invents no bound: no height, width or viewport unit of its own. The only space it adds is the
|
|
115
|
+
* focus ring's room around its content, so a focusable child flush with its edge - a Table's
|
|
116
|
+
* scroll region, a button - keeps a visible ring instead of having it clipped at the edge.
|
|
117
|
+
*
|
|
118
|
+
* @CallerMustEnsure
|
|
119
|
+
* - The parent allocates finite space on every axis the container should scroll - a sized box, or a
|
|
120
|
+
* flex/grid item allowed to shrink (`flex: 1 1 0; min-height: 0` in a column). Under an unbounded
|
|
121
|
+
* parent it simply grows with its content, as any block would.
|
|
122
|
+
* - Content fits a disabled axis. Clipping is not a way to hide essential content or controls.
|
|
123
|
+
* - `ariaLabel` is wording the viewer would recognise, typically the heading above the content.
|
|
124
|
+
*/
|
|
125
|
+
export const ScrollContainer: FunctionComponent<IScrollContainerProps> = ({
|
|
126
|
+
axis,
|
|
127
|
+
ariaLabel,
|
|
128
|
+
children,
|
|
129
|
+
testId,
|
|
130
|
+
}) => {
|
|
131
|
+
const container = useRef<HTMLDivElement>(null);
|
|
132
|
+
const content = useRef<HTMLDivElement>(null);
|
|
133
|
+
const isScrollable = useOverflow(container, content, axis ?? 'both');
|
|
134
|
+
return (
|
|
135
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and the `group` role are set together; biome cannot see the pair
|
|
136
|
+
<div
|
|
137
|
+
ref={container}
|
|
138
|
+
className={scrollContainer({ axis })}
|
|
139
|
+
data-testid={testId}
|
|
140
|
+
role={isScrollable ? 'group' : undefined}
|
|
141
|
+
aria-label={isScrollable ? ariaLabel : undefined}
|
|
142
|
+
tabIndex={isScrollable ? 0 : undefined}
|
|
143
|
+
>
|
|
144
|
+
<div ref={content} className={scrollContent({ axis })}>
|
|
145
|
+
{children}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
@@ -62,12 +62,12 @@ const observeScrollport: RefCallback<HTMLElement> = (navigation) => {
|
|
|
62
62
|
};
|
|
63
63
|
|
|
64
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
|
|
66
|
-
//
|
|
67
|
-
//
|
|
65
|
+
// no active-background role. Active keeps a persistent underline; usable raises one on hover, instantly
|
|
66
|
+
// (docs/adr/0001); inert is muted. The focus ring is the one contract (docs/adr/0002). Labels wrap with
|
|
67
|
+
// `overflow-wrap: anywhere`: unlike `break-word` it folds an unbroken word into min-content (#122).
|
|
68
68
|
const sidebarEntry = cva(
|
|
69
69
|
[
|
|
70
|
-
'w-full text-left font-secondary text-label tracking-label',
|
|
70
|
+
'w-full text-left font-secondary text-label tracking-label wrap-anywhere',
|
|
71
71
|
'underline-offset-[var(--underline-offset)]',
|
|
72
72
|
'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
73
73
|
].join(' '),
|
|
@@ -196,6 +196,10 @@ const SidebarRoot: FunctionComponent<ISidebarRootProps> = ({
|
|
|
196
196
|
* - Activating the active entry or an inert one emits nothing; no render emits anything. Entries are
|
|
197
197
|
* non-submitting `type="button"` buttons with native Tab/Enter/Space behaviour - no tabs/menu model.
|
|
198
198
|
* - An inert entry stays visible but muted and disabled, so Tab skips it and activation is inert too.
|
|
199
|
+
* - A label is rendered in full inside its entry, in either arrangement: a phrase wraps at its spaces
|
|
200
|
+
* and a word wider than the track breaks within itself. Nothing is truncated, renamed or hidden
|
|
201
|
+
* behind a tooltip, no horizontal scrolling is introduced, and a translated label neither widens
|
|
202
|
+
* the 12rem track nor displaces the content.
|
|
199
203
|
* - At and above 64rem the nav is a fixed 12rem track, sticky at the top of the scrolling area with no
|
|
200
204
|
* assumed top-bar offset, capped to the screen/scrolling-area height with independent entry scrolling.
|
|
201
205
|
* Content sits beside it in `minmax(0,1fr)`, so wide content cannot displace the track.
|
package/src/Theme/Palette.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Light and dark are two complete sets of the same roles rather than a set of `dark:` overrides
|
|
11
11
|
* scattered through the components. A component therefore carries no dark-mode classes at all:
|
|
12
|
-
* swapping the `.dark` class re-points the variables underneath it.
|
|
12
|
+
* swapping the `.dark` class re-points the variables underneath it. Complete means `Required`: the
|
|
13
|
+
* two roles the type marks optional are optional for a consumer's palette object, never here.
|
|
13
14
|
*
|
|
14
15
|
* The values below are still depot-tracker's brand (primary is its violet). They are carried over
|
|
15
16
|
* so nothing changed visually during the extraction - a starting point to replace, not a decision.
|
|
@@ -121,8 +122,28 @@ export type PaletteTokens = {
|
|
|
121
122
|
* constraint stated on `success`. */
|
|
122
123
|
warning: string;
|
|
123
124
|
/** The error status tone. Carries the general status-tone contract and 4.5:1-against-`surface`
|
|
124
|
-
* constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`.
|
|
125
|
+
* constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`. It is
|
|
126
|
+
* also Button's destructive fill (#119), identified like `primary` by the fill alone: at least 3:1
|
|
127
|
+
* against `surface` in the same theme, `errorHover` included - which the 4.5:1 text floor already
|
|
128
|
+
* clears - and constrained from the other side by the ink it carries, stated on `errorForeground`.
|
|
129
|
+
* See docs/adr/0011-status-tones-are-general-roles.md, Amendments. */
|
|
125
130
|
error: string;
|
|
131
|
+
/** The destructive fill's hover step. Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against
|
|
132
|
+
* `surface` in the same theme, and at least 4.5:1 against `errorForeground`, since a hovered
|
|
133
|
+
* control has to stay identifiable and readable too. Not a status tone: it carries no text of its
|
|
134
|
+
* own and the status-tone text floor does not apply to it. Optional in the type so a palette
|
|
135
|
+
* object written before #119 keeps compiling: the shipped stylesheet declares `--color-error-hover`,
|
|
136
|
+
* and a theme that omits the role inherits that default - which pairs with the shipped `error`,
|
|
137
|
+
* so a theme that re-points `error` re-points this too. */
|
|
138
|
+
errorHover?: string;
|
|
139
|
+
/** Text and icons drawn on top of `error` and `errorHover`. Constraint (WCAG 2.2 SC 1.4.3): at
|
|
140
|
+
* least 4.5:1 against both in the same theme, hover included. Optional in the type for the same
|
|
141
|
+
* reason as `errorHover`, with the same obligation: the shipped default is the ink for the
|
|
142
|
+
* shipped `error`. The ink inverts with the theme as `primaryForeground` does, and in light it is
|
|
143
|
+
* pure white rather than slate-50 because the shipped `error` sits exactly on the 4.5:1 floor
|
|
144
|
+
* against white (4.501:1) and slate-50 measures 4.30:1 - under it. Not required against
|
|
145
|
+
* `disabled`, which SC 1.4.3 exempts. */
|
|
146
|
+
errorForeground?: string;
|
|
126
147
|
/** The informational status tone. Carries the general status-tone contract and
|
|
127
148
|
* 4.5:1-against-`surface` constraint stated on `success`. */
|
|
128
149
|
info: string;
|
|
@@ -147,7 +168,7 @@ export type PaletteTokens = {
|
|
|
147
168
|
* against `#0f172a`, the dark set's own `surface`, sky-600 lands at 4.36 and fails. It also buys
|
|
148
169
|
* a light theme with white text on one button and black on the one beside it.
|
|
149
170
|
*/
|
|
150
|
-
export const light: PaletteTokens = {
|
|
171
|
+
export const light: Required<PaletteTokens> = {
|
|
151
172
|
surface: '#ffffff',
|
|
152
173
|
foreground: '#0f172a',
|
|
153
174
|
muted: '#64748b',
|
|
@@ -179,6 +200,8 @@ export const light: PaletteTokens = {
|
|
|
179
200
|
success: '#047857',
|
|
180
201
|
warning: '#b45309',
|
|
181
202
|
error: '#d63384',
|
|
203
|
+
errorHover: '#be185d',
|
|
204
|
+
errorForeground: '#ffffff',
|
|
182
205
|
info: '#0e7490',
|
|
183
206
|
};
|
|
184
207
|
|
|
@@ -193,11 +216,14 @@ export const light: PaletteTokens = {
|
|
|
193
216
|
* under its darker ones. The pair is the two ends of the one neutral ramp the rest of the palette is
|
|
194
217
|
* already built from - `#f8fafc` is slate-50, `surface` slate-900, `muted` slate-500 - rather than a
|
|
195
218
|
* new colour arriving for a single job. `#020617` is also the lightest slate step that still admits
|
|
196
|
-
* violet-500
|
|
197
|
-
*
|
|
198
|
-
*
|
|
219
|
+
* violet-500: slate-900 draws 4.22 against it and fails. The `secondary` pair sits one ramp step
|
|
220
|
+
* above where it first shipped - sky-500 at rest, sky-400 on hover, where it was sky-600 and sky-500 -
|
|
221
|
+
* because #119 made `secondary` an ink as well as a fill: the outlined Button draws its text and edge
|
|
222
|
+
* in it, and sky-600 measures 4.36:1 against this surface, under the 4.5:1 text floor (now 6.44:1
|
|
223
|
+
* and 8.33:1, both still clearing `secondaryForeground` at 7.28:1 and 9.42:1). Why the ink follows
|
|
224
|
+
* the theme at all, and the two routes rejected in getting here: see the light set.
|
|
199
225
|
*/
|
|
200
|
-
export const dark: PaletteTokens = {
|
|
226
|
+
export const dark: Required<PaletteTokens> = {
|
|
201
227
|
surface: '#0f172a',
|
|
202
228
|
foreground: '#f8fafc',
|
|
203
229
|
muted: '#94a3b8',
|
|
@@ -215,8 +241,8 @@ export const dark: PaletteTokens = {
|
|
|
215
241
|
primaryHover: '#a78bfa',
|
|
216
242
|
primaryForeground: '#020617',
|
|
217
243
|
|
|
218
|
-
secondary: '#
|
|
219
|
-
secondaryHover: '#
|
|
244
|
+
secondary: '#0ea5e9',
|
|
245
|
+
secondaryHover: '#38bdf8',
|
|
220
246
|
secondaryForeground: '#020617',
|
|
221
247
|
|
|
222
248
|
disabled: '#475569',
|
|
@@ -229,5 +255,7 @@ export const dark: PaletteTokens = {
|
|
|
229
255
|
success: '#34d399',
|
|
230
256
|
warning: '#fbbf24',
|
|
231
257
|
error: '#f48fb1',
|
|
258
|
+
errorHover: '#f8bbd0',
|
|
259
|
+
errorForeground: '#020617',
|
|
232
260
|
info: '#22d3ee',
|
|
233
261
|
};
|