@juwel-development/design-system 3.0.0 → 3.1.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 +31 -0
- package/dist/design-system.js +202 -126
- package/dist/index.css +1 -1
- package/dist/types/Arrangement/Cluster/Cluster.d.ts +50 -0
- package/dist/types/Arrangement/Stack/Stack.d.ts +46 -0
- package/dist/types/Display/Figure/Figure.d.ts +21 -4
- package/dist/types/Display/Rail/Rail.d.ts +6 -0
- package/dist/types/Display/Typography/H1/H1.d.ts +7 -1
- package/dist/types/Display/Typography/Note/Note.d.ts +35 -0
- package/dist/types/Interaction/Link/Link.d.ts +4 -4
- package/dist/types/Layout/Form/Form.d.ts +8 -4
- package/dist/types/Layout/Header/Header.d.ts +20 -1
- package/dist/types/Layout/Hero/Hero.d.ts +9 -7
- package/dist/types/Layout/Section/Section.d.ts +7 -0
- package/dist/types/Theme/Palette.d.ts +55 -6
- package/dist/types/index.d.ts +3 -0
- package/package.json +1 -1
- package/src/Arrangement/Cluster/Cluster.tsx +80 -0
- package/src/Arrangement/Stack/Stack.tsx +75 -0
- package/src/Display/Figure/Figure.tsx +48 -7
- package/src/Display/Rail/Rail.tsx +6 -0
- package/src/Display/Typography/H1/H1.tsx +17 -7
- package/src/Display/Typography/Note/Note.tsx +56 -0
- package/src/Interaction/Button/Button.tsx +4 -1
- package/src/Interaction/Input/Input.tsx +17 -6
- package/src/Interaction/Link/Link.tsx +9 -5
- package/src/Interaction/TextArea/TextArea.tsx +17 -6
- package/src/Layout/Form/Form.tsx +24 -8
- package/src/Layout/Header/Header.tsx +37 -4
- package/src/Layout/Hero/Hero.tsx +10 -6
- package/src/Layout/PageHead/PageHead.tsx +2 -0
- package/src/Layout/Section/Section.tsx +11 -0
- package/src/Theme/Palette.ts +63 -14
- package/src/Theme/renderTokens.ts +18 -1
- package/src/index.ts +3 -0
- package/src/tokens.css +20 -9
- package/src/tokens.dark.css +16 -5
- package/src/tokens.light.css +16 -5
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
// One recipe on a plain <div>. The two axes are two positions and take different roles, so the base
|
|
6
|
+
// sets `gap-y` and the variant sets `gap-x` - a shorthand would hand the caller both. `gap` holds the
|
|
7
|
+
// two roles attested along a line and defaults to `region`, the wider one every clustered site uses;
|
|
8
|
+
// the wrapped-line gap and the baseline alignment have one answer each in the evidence, so the recipe
|
|
9
|
+
// fixes them and no prop reaches them (docs/adr/0008). Header hand-writes a near-identical row and
|
|
10
|
+
// cannot import this one (architecture standard, the import test); it deliberately differs on gap-y.
|
|
11
|
+
const cluster = cva(
|
|
12
|
+
'flex flex-wrap items-baseline gap-y-[var(--space-stack)]',
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
gap: {
|
|
16
|
+
stack: 'gap-x-[var(--space-stack)]',
|
|
17
|
+
region: 'gap-x-[var(--space-region)]',
|
|
18
|
+
},
|
|
19
|
+
justify: {
|
|
20
|
+
start: '',
|
|
21
|
+
between: 'justify-between',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: { gap: 'region', justify: 'start' },
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export interface IClusterProps extends VariantProps<typeof cluster> {
|
|
29
|
+
/** The clustered matter. Rendered unmodified: `Cluster` imposes no anatomy and wraps nothing in an
|
|
30
|
+
* item element. */
|
|
31
|
+
children?: ReactNode;
|
|
32
|
+
testId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A horizontal arrangement that wraps: children in a row, aligned on their baselines, with a wider
|
|
37
|
+
* gap along a line than between wrapped lines. It owns one arrangement and nothing else - no
|
|
38
|
+
* landmark, no band, no join, no gutter, no fill - and takes no outer space, so whatever holds it
|
|
39
|
+
* owns the rhythm around it.
|
|
40
|
+
*
|
|
41
|
+
* @Guarantees — enforced on every render
|
|
42
|
+
* - It renders a `div` with no landmark role, no `nav` of its own and no margin.
|
|
43
|
+
* - Children wrap onto further lines rather than overflowing or shrinking, at every prop combination.
|
|
44
|
+
* - Children are always aligned on their baselines. These rows mix type at different roles — a nav
|
|
45
|
+
* link at the label role beside a credit line at the small role — and centring them makes the type
|
|
46
|
+
* look mis-set, so this is the arrangement rather than a default to override (docs/adr/0008).
|
|
47
|
+
* - `gap` selects which space role separates items **along a line**: `region` (the default), the gap
|
|
48
|
+
* between one group and the next, or `stack`, the sibling gap between items that belong together.
|
|
49
|
+
* Nothing else — neither the band role, which is vertical padding and never a gap, nor the gutter,
|
|
50
|
+
* which is measured against the screen where these roles are measured against the type.
|
|
51
|
+
* - The gap between **wrapped lines** is always `--space-stack` and no prop can change it: a wrapped
|
|
52
|
+
* line sitting as far from its neighbour as its items sit from each other stops reading as one group.
|
|
53
|
+
* - `justify="start"` (the default) runs the row from the start edge; `justify="between"` distributes
|
|
54
|
+
* it end to end, which is the row with a group at each end.
|
|
55
|
+
* - No literal length and no numbered spacing rung appears in the recipe: every value it emits is a
|
|
56
|
+
* role the token layer already names, so a second brand re-points all of them.
|
|
57
|
+
* - `children` render unmodified, and it needs no JavaScript.
|
|
58
|
+
*
|
|
59
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
60
|
+
* - A cluster that navigates is wrapped in — or wraps — the caller's own `<nav aria-label="…">`.
|
|
61
|
+
* This component declares no landmark, exactly as `Footer`'s guidance already directs.
|
|
62
|
+
* - The gutter and the vertical band belong to whatever holds it — a `Section`, a `Footer` — and not
|
|
63
|
+
* to a prop here, or two components own one job.
|
|
64
|
+
*
|
|
65
|
+
* @UXGuidelines
|
|
66
|
+
* - `gap="stack"` is the row whose items belong together, as a form's actions do. `gap="region"`
|
|
67
|
+
* separates one group from the next; reach for it when the row's items are not a single set.
|
|
68
|
+
* - `justify="between"` needs two groups to distribute. A row of loose items pushed end to end reads
|
|
69
|
+
* as a gap in the middle rather than as a distribution.
|
|
70
|
+
*/
|
|
71
|
+
export const Cluster: FunctionComponent<IClusterProps> = ({
|
|
72
|
+
gap,
|
|
73
|
+
justify,
|
|
74
|
+
children,
|
|
75
|
+
testId,
|
|
76
|
+
}) => (
|
|
77
|
+
<div className={cluster({ gap, justify })} data-testid={testId}>
|
|
78
|
+
{children}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
// One recipe on a plain <div>, its variants declared in the order the prop table reads. Six existing
|
|
6
|
+
// components hand-write this same set of utilities and the spec pins each against it - they cannot
|
|
7
|
+
// import this one (architecture standard, the import test) - but that pinning compares the utilities
|
|
8
|
+
// as a set, so nothing here is ordered to satisfy a test. The roster is the evidence and not the
|
|
9
|
+
// token family: `band` is vertical padding and never a gap, and only `--measure` has ever bounded a
|
|
10
|
+
// container, so the bound is on-or-off rather than a set of named measures (docs/adr/0008).
|
|
11
|
+
// `split` turns at `lg`, the threshold Rail and DefinitionList already use.
|
|
12
|
+
const stack = cva('flex', {
|
|
13
|
+
variants: {
|
|
14
|
+
gap: {
|
|
15
|
+
stack: 'gap-[var(--space-stack)]',
|
|
16
|
+
region: 'gap-[var(--space-region)]',
|
|
17
|
+
},
|
|
18
|
+
measure: { true: 'max-w-[var(--measure)]', false: '' },
|
|
19
|
+
direction: {
|
|
20
|
+
column: 'flex-col',
|
|
21
|
+
split: 'flex-col lg:flex-row lg:items-start',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: { gap: 'stack', measure: false, direction: 'column' },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export interface IStackProps extends VariantProps<typeof stack> {
|
|
28
|
+
/** The stacked matter. Rendered unmodified: `Stack` imposes no anatomy. */
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
testId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A vertical arrangement: children in a column, separated by one named space role and optionally
|
|
35
|
+
* bounded by the reading measure. It owns one axis and one gap and nothing else - no landmark, no
|
|
36
|
+
* heading, no band, no join, no gutter, no fill - and takes no outer space, so whatever holds it
|
|
37
|
+
* owns the rhythm around it.
|
|
38
|
+
*
|
|
39
|
+
* @Guarantees — enforced on every render
|
|
40
|
+
* - It renders a `div` with no landmark role, no heading and no margin of its own.
|
|
41
|
+
* - `gap` selects which space role separates the children: `stack` (the default), the gap between
|
|
42
|
+
* siblings within one block, or `region`, the gap between groups of blocks. Nothing else — a
|
|
43
|
+
* spacing neither role expresses is a request for a measurement (docs/adr/0003, docs/adr/0004).
|
|
44
|
+
* - `measure` bounds the element to `--measure`, the reading column; omitted, the column is
|
|
45
|
+
* unbounded, which is right wherever the children are not running text. It sets no font-size, so
|
|
46
|
+
* a `ch`-counted measure keeps resolving against inherited body type.
|
|
47
|
+
* - `direction="column"` (the default) never changes axis. `direction="split"` is the same column
|
|
48
|
+
* turning into a row at and above 64rem, its children aligned to their start edge rather than
|
|
49
|
+
* stretched. It is the only viewport-dependent behaviour here, and it is named for the job: the
|
|
50
|
+
* breakpoint is an implementation detail and is not part of the vocabulary.
|
|
51
|
+
* - No literal length and no numbered spacing rung appears in the recipe: every value it emits is a
|
|
52
|
+
* role the token layer already names, so a second brand re-points all of them.
|
|
53
|
+
* - `children` render unmodified, and it needs no JavaScript.
|
|
54
|
+
*
|
|
55
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
56
|
+
* - The gutter and the vertical band belong to the `Section` around it —
|
|
57
|
+
* `<Section><Stack>…</Stack></Section>` — and not to a prop here, or two components own one job.
|
|
58
|
+
* - A stack holding running text is a reading block: reach for `Prose` instead, which is this
|
|
59
|
+
* arrangement plus the contract that says the content is prose.
|
|
60
|
+
*
|
|
61
|
+
* @UXGuidelines
|
|
62
|
+
* - `gap="region"` separates groups of blocks, not blocks. A region-sized gap between two paragraphs
|
|
63
|
+
* reads as missing content — the argument `Section` makes for joining rather than gapping.
|
|
64
|
+
*/
|
|
65
|
+
export const Stack: FunctionComponent<IStackProps> = ({
|
|
66
|
+
gap,
|
|
67
|
+
measure,
|
|
68
|
+
direction,
|
|
69
|
+
children,
|
|
70
|
+
testId,
|
|
71
|
+
}) => (
|
|
72
|
+
<div className={stack({ gap, measure, direction })} data-testid={testId}>
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { VariantProps } from 'class-variance-authority';
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
|
-
import type { FunctionComponent } from 'react';
|
|
3
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
// One recipe, on the `img` - the only element that varies. The wrapper `figure` and the `figcaption`
|
|
6
6
|
// carry literal class strings in JSX (the Input pattern, not the compound namespace: there is nothing
|
|
@@ -42,6 +42,10 @@ export interface IFigureProps extends VariantProps<typeof figure> {
|
|
|
42
42
|
responsive?: { srcSet: string; sizes: string };
|
|
43
43
|
/** Names the image. Its presence is what makes this a `figure`. */
|
|
44
44
|
caption?: string;
|
|
45
|
+
/** A slot over the image frame - the consumer's own decoration, rendered unmodified as a sibling of
|
|
46
|
+
* the image inside a positioning context. Having something to render is what makes that context
|
|
47
|
+
* exist; where inside it the overlay sits is expressed on the consumer's own element. */
|
|
48
|
+
overlay?: ReactNode;
|
|
45
49
|
/** Whether this image is visible when the page first paints. Set it on a hero. */
|
|
46
50
|
priority?: boolean;
|
|
47
51
|
testId?: string;
|
|
@@ -50,7 +54,9 @@ export interface IFigureProps extends VariantProps<typeof figure> {
|
|
|
50
54
|
/**
|
|
51
55
|
* An aspect-locked image frame with a focal point and an optional caption. The caption decides the
|
|
52
56
|
* element: with one, the image is wrapped in a `figure` with a `figcaption`; without one, the image
|
|
53
|
-
* renders alone, making no self-contained claim a thumbnail or logo should not make.
|
|
57
|
+
* renders alone, making no self-contained claim a thumbnail or logo should not make. An `overlay`
|
|
58
|
+
* decides a second element the same way: with one, the image sits in a positioning context the
|
|
59
|
+
* overlay can be placed against; without one, there is no wrapper at all.
|
|
54
60
|
*
|
|
55
61
|
* @Guarantees — enforced on every render
|
|
56
62
|
* - Every image reserves its box before it loads: an intrinsic `width`/`height`, and a locked
|
|
@@ -59,14 +65,25 @@ export interface IFigureProps extends VariantProps<typeof figure> {
|
|
|
59
65
|
* `lazy` otherwise), so an above-the-fold hero is never deferred.
|
|
60
66
|
* - A `figure` element appears only where there is a caption.
|
|
61
67
|
* - `srcset` never ships without `sizes`: `responsive` bundles the two so one cannot go without the other.
|
|
68
|
+
* - An `overlay` renders unmodified: the component adds no class and no ARIA attribute to it, strips
|
|
69
|
+
* nothing from it, and paints nothing on the frame - the wrapper it renders carries the positioning
|
|
70
|
+
* context and no other property, so the image fills its container exactly as it does without one.
|
|
71
|
+
*
|
|
72
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
73
|
+
* - A decorative overlay carries the consumer's own `aria-hidden="true"`. The library will not add it:
|
|
74
|
+
* a slot it modifies is no longer a slot.
|
|
62
75
|
*
|
|
63
76
|
* @UXGuidelines
|
|
64
77
|
* - Pass a `src` your build's image pipeline produced. One raw source measured 2 MB against 31.6 KB
|
|
65
78
|
* for the same crop, taking `load` from 0.95 s to 10.3 s. The component cannot check this.
|
|
66
79
|
* - Check a photograph's hue range before putting a brand colour beside it. One frame sat entirely
|
|
67
80
|
* between hue 14° and 40°, so an amber call-to-action muddied against a terracotta wall - the accent
|
|
68
|
-
* lives where the photo isn't.
|
|
69
|
-
* to a figure
|
|
81
|
+
* lives where the photo isn't. The component paints nothing over the frame itself, so this is about
|
|
82
|
+
* what a page puts next to a figure - and about what it puts in `overlay`, which is the consumer's
|
|
83
|
+
* own decoration and answers to the same hue range.
|
|
84
|
+
* - `overlay` is a place, not a treatment. The library provides somewhere legal for a rim, a ring or a
|
|
85
|
+
* plate to go, and provides none of them; positioning it inside the frame is the consumer's, expressed
|
|
86
|
+
* on their own absolutely-positioned element.
|
|
70
87
|
*/
|
|
71
88
|
export const Figure: FunctionComponent<IFigureProps> = ({
|
|
72
89
|
src,
|
|
@@ -77,9 +94,18 @@ export const Figure: FunctionComponent<IFigureProps> = ({
|
|
|
77
94
|
focus,
|
|
78
95
|
responsive,
|
|
79
96
|
caption,
|
|
97
|
+
overlay,
|
|
80
98
|
priority,
|
|
81
99
|
testId,
|
|
82
100
|
}) => {
|
|
101
|
+
// Absence, not falsiness, and not `undefined` alone: `overlay={showRing && <Ring />}` hands over
|
|
102
|
+
// `false` when the decoration is off, and that consumer asked for no overlay - wrapping anyway
|
|
103
|
+
// would put a `div` where their flex or grid item used to be, which is the one thing this slot
|
|
104
|
+
// promises never to do. Absence is React's own set of nothing-to-render nodes, so a bare
|
|
105
|
+
// truthiness test is wrong in the other direction: `0` renders as `"0"`.
|
|
106
|
+
const hasOverlay =
|
|
107
|
+
overlay !== undefined && overlay !== null && typeof overlay !== 'boolean';
|
|
108
|
+
|
|
83
109
|
const image = (
|
|
84
110
|
<img
|
|
85
111
|
src={src}
|
|
@@ -92,12 +118,27 @@ export const Figure: FunctionComponent<IFigureProps> = ({
|
|
|
92
118
|
loading={priority ? 'eager' : 'lazy'}
|
|
93
119
|
fetchPriority={priority ? 'high' : undefined}
|
|
94
120
|
decoding={'async'}
|
|
95
|
-
data-testid={caption === undefined ? testId : undefined}
|
|
121
|
+
data-testid={caption === undefined && !hasOverlay ? testId : undefined}
|
|
96
122
|
/>
|
|
97
123
|
);
|
|
98
124
|
|
|
125
|
+
// The positioning context exists only where there is something to position, so an overlay-less
|
|
126
|
+
// `Figure` renders exactly what it rendered before this slot existed - which matters most in the
|
|
127
|
+
// caption-less shape, where the image itself is the flex or grid item its container sees.
|
|
128
|
+
const frame = !hasOverlay ? (
|
|
129
|
+
image
|
|
130
|
+
) : (
|
|
131
|
+
<div
|
|
132
|
+
className={'relative'}
|
|
133
|
+
data-testid={caption === undefined ? testId : undefined}
|
|
134
|
+
>
|
|
135
|
+
{image}
|
|
136
|
+
{overlay}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
|
|
99
140
|
if (caption === undefined) {
|
|
100
|
-
return
|
|
141
|
+
return frame;
|
|
101
142
|
}
|
|
102
143
|
|
|
103
144
|
return (
|
|
@@ -105,7 +146,7 @@ export const Figure: FunctionComponent<IFigureProps> = ({
|
|
|
105
146
|
className={'flex flex-col gap-[var(--space-stack)]'}
|
|
106
147
|
data-testid={testId}
|
|
107
148
|
>
|
|
108
|
-
{
|
|
149
|
+
{frame}
|
|
109
150
|
<figcaption
|
|
110
151
|
className={'font-secondary text-label tracking-label text-muted'}
|
|
111
152
|
>
|
|
@@ -84,6 +84,8 @@ const RailContent: FunctionComponent<IRailContentProps> = ({
|
|
|
84
84
|
* the label role. Colours are semantic tokens re-pointed by `.dark`, so no class carries a `dark:` prefix.
|
|
85
85
|
* - `number` is optional: omitted, the index renders the name alone and no empty numeral element.
|
|
86
86
|
* - `Content` renders its children unmodified and sets no measure cap - `Prose` owns the reading measure.
|
|
87
|
+
* - `Content` sets no spacing between those children either - a composed `Stack` owns the gap. The slot
|
|
88
|
+
* is opaque, so it cannot see the pieces whose rhythm it would set: the same reason it sets no measure.
|
|
87
89
|
* - No margin, max-width, fill, box or vertical rule; no `tabular-nums` or `font-variant-numeric`.
|
|
88
90
|
* - It needs no JavaScript: `position: sticky` is CSS.
|
|
89
91
|
*
|
|
@@ -94,6 +96,10 @@ const RailContent: FunctionComponent<IRailContentProps> = ({
|
|
|
94
96
|
* consumer that files a section only in the index leaves it without an accessible name.
|
|
95
97
|
*
|
|
96
98
|
* @UXGuidelines
|
|
99
|
+
* - The heading `Content` is required to carry and the section's matter beneath it are two pieces in one
|
|
100
|
+
* opaque slot, and the slot separates nothing. Compose the column inside it -
|
|
101
|
+
* `<Rail.Content><Stack gap="…"><H2>Work</H2>{matter}</Stack></Rail.Content>` - and take the space role
|
|
102
|
+
* from `Stack`'s own guidance: `Rail` holds no opinion on which of the two roles this position wants.
|
|
97
103
|
* - An index is an information layer, not a layout remedy. Measured on the page that prompted it, the
|
|
98
104
|
* filing numeral bought 0px of width across three fallback rungs, and the column it sits in closed only
|
|
99
105
|
* +224px of a 933px deficit - it widens a composition's span, not its content. If a page sags, the
|
|
@@ -6,13 +6,17 @@ import type { FunctionComponent, ReactNode } from 'react';
|
|
|
6
6
|
// (docs/adr/0005). Weight inherits - Tailwind's preflight resets h1-h6 to font-weight: inherit, so
|
|
7
7
|
// the sized levels carry no weight class. --tracking-optical is the large-type correction, carried by
|
|
8
8
|
// the title role and above (#57), so the biggest type on the page is the first to take it. Colour is a
|
|
9
|
-
// semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// semantic token re-pointed by `.dark`, so no variant carries a `dark:` class. The measure is base and
|
|
10
|
+
// not a variant because the role fixes it and a caller picks nothing (docs/adr/0008, #87).
|
|
11
|
+
const h1 = cva(
|
|
12
|
+
'font-primary text-display leading-display tracking-optical max-w-[var(--measure-display)]',
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: { color: 'foreground' },
|
|
13
18
|
},
|
|
14
|
-
|
|
15
|
-
});
|
|
19
|
+
);
|
|
16
20
|
|
|
17
21
|
interface IH1Props extends VariantProps<typeof h1> {
|
|
18
22
|
children: ReactNode;
|
|
@@ -26,11 +30,17 @@ interface IH1Props extends VariantProps<typeof h1> {
|
|
|
26
30
|
* - Renders an `h1`; its outline level and the display role are one choice, not two (docs/adr/0005).
|
|
27
31
|
* - Reads `--font-primary`, sized by `--text-display`, led by `--leading-display` and optically
|
|
28
32
|
* corrected by `--tracking-optical`, the large-type correction every role from title up carries.
|
|
33
|
+
* - Bounded at `--measure-display`, the display role's own measure — narrower than the reading column
|
|
34
|
+
* because bigger type wants fewer characters per line (docs/adr/0004). The bound is the recipe's,
|
|
35
|
+
* not a caller's: the level fixes the role and the role fixes the measure, so there is no `measure`
|
|
36
|
+
* prop to select between roles (docs/adr/0008). It holds under every `color`.
|
|
29
37
|
* - `color` selects the `foreground` or `muted` role; nothing else paints text.
|
|
30
38
|
*
|
|
31
39
|
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
32
40
|
* - This is an ordinary page title, and it is also a hero's lead - `display` is the hero role, so a
|
|
33
|
-
* poster hero's `h1` is this one, placed inside a `Hero` (#17), which renders no heading of its own
|
|
41
|
+
* poster hero's `h1` is this one, placed inside a `Hero` (#17), which renders no heading of its own
|
|
42
|
+
* and leaves its slot uncapped — the display measure arrives with this heading rather than with the
|
|
43
|
+
* slot, which would cap the mark beside it too (#87).
|
|
34
44
|
* - A subpage head is the exception: `PageHead` renders its own `h1` at the `title` role, the one
|
|
35
45
|
* sanctioned escape valve from level-fixes-role (docs/adr/0005). Reach for it where it fits.
|
|
36
46
|
* - Heading levels descend without skipping — an `h1` is followed by an `h2`, never an `h3`.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
// The annotation device: the secondary family at the small role, because docs/adr/0004 fixes
|
|
6
|
+
// --font-secondary as the face labels are set in and an annotation labels rather than reads. It
|
|
7
|
+
// defaults to `foreground` where Eyebrow defaults to `muted`: Eyebrow inverts the ordinary default
|
|
8
|
+
// because its device is defined as muted, and an annotation is not - it is foreground *or* muted -
|
|
9
|
+
// so Note follows `P` and keeps the ordinary default. No tracking and no weight: those two
|
|
10
|
+
// are what make an Eyebrow. No measure and no margin: the annotation sits in no reading column, and
|
|
11
|
+
// the page owns the rhythm around it. Colour is re-pointed by `.dark`, so no variant carries `dark:`.
|
|
12
|
+
const note = cva('font-secondary text-small', {
|
|
13
|
+
variants: {
|
|
14
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: { color: 'foreground' },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export interface INoteProps extends VariantProps<typeof note> {
|
|
20
|
+
/** The annotation. A `ReactNode` rather than a string, because these lines carry inline links. */
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
testId?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A short annotation at the small type role in the secondary family — a routing line above a submit
|
|
27
|
+
* button, a privacy line at the point of submission, a line beside a control. Not reading matter:
|
|
28
|
+
* it takes no measure and sits in no reading column, which is what separates it from `Prose.Tail`
|
|
29
|
+
* at the same size, and it carries neither the tracking nor the weight that make an `Eyebrow`.
|
|
30
|
+
*
|
|
31
|
+
* @Guarantees — enforced on every render
|
|
32
|
+
* - Renders a `p`, reading `--font-secondary` and sized by `--text-small`.
|
|
33
|
+
* - `color` selects the `foreground` (default) or `muted` role; nothing else paints text.
|
|
34
|
+
* - Emits no tracking, no font-weight, no measure and no margin under any prop.
|
|
35
|
+
* - Carries no ARIA role and no live region under any prop.
|
|
36
|
+
*
|
|
37
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
38
|
+
* - The device has three carriers and this primitive is only one: use it for an annotation no other
|
|
39
|
+
* component owns. A form's own message belongs to `Form`'s `note` and a table's to its note cell,
|
|
40
|
+
* each painted by the component that owns it.
|
|
41
|
+
* - Do **not** put a `Note` in `Form`'s `note` slot: that nests a `p` in a `p` and paints the text
|
|
42
|
+
* twice. The slot takes a node, so pass the wording straight to `Form` and let the links ride
|
|
43
|
+
* inside it — inline content only, never a block. `Form` cannot delegate here either — the
|
|
44
|
+
* architecture standard's one-way dependency rule is why `Prose` restates `P`'s utilities.
|
|
45
|
+
* - Where the annotation is a form's status message, `Form` owns `role="status"`/`role="alert"` by
|
|
46
|
+
* state; a `Note` announces nothing.
|
|
47
|
+
*/
|
|
48
|
+
export const Note: FunctionComponent<INoteProps> = ({
|
|
49
|
+
children,
|
|
50
|
+
color,
|
|
51
|
+
testId,
|
|
52
|
+
}) => (
|
|
53
|
+
<p className={note({ color })} data-testid={testId}>
|
|
54
|
+
{children}
|
|
55
|
+
</p>
|
|
56
|
+
);
|
|
@@ -10,8 +10,11 @@ import type { Subject } from 'rxjs';
|
|
|
10
10
|
// base too: identical across variants, drawn with outline, colour at rest so it never fades in -
|
|
11
11
|
// see docs/adr/0002-focus-ring-token-contract.md. The corner is in the base as well, one radius
|
|
12
12
|
// token every variant shares, so none can disagree - see docs/adr/0003-radius-token-contract.md.
|
|
13
|
+
// The face is in the base for the same reason - see docs/adr/0004-typography-token-contract.md (#90).
|
|
14
|
+
// The size is in the base for the same reason, and here it is load-bearing: the recipe fixes
|
|
15
|
+
// vertical padding and sets no height, so the font-size is what drives it (docs/adr/0004, #92).
|
|
13
16
|
const button = cva(
|
|
14
|
-
'transition-colors duration-[var(--motion-duration-color)] rounded-[var(--radius-control)] py-2 sm:py-2 disabled:bg-disabled disabled:hover:bg-disabled-hover cursor-pointer disabled:cursor-not-allowed select-none text-nowrap inline-flex flex-row items-center justify-center gap-2 outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
17
|
+
'font-primary text-body transition-colors duration-[var(--motion-duration-color)] rounded-[var(--radius-control)] py-2 sm:py-2 disabled:bg-disabled disabled:hover:bg-disabled-hover cursor-pointer disabled:cursor-not-allowed select-none text-nowrap inline-flex flex-row items-center justify-center gap-2 outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
15
18
|
{
|
|
16
19
|
variants: {
|
|
17
20
|
variant: {
|
|
@@ -8,9 +8,11 @@ import type { Subject } from 'rxjs';
|
|
|
8
8
|
// `.dark`, so no variant carries a `dark:` class. The border is the only boundary of a transparent
|
|
9
9
|
// control, drawn in `controlBorder` (>=3:1 against surface) and turned `error` on both `:user-invalid`
|
|
10
10
|
// and `aria-invalid` so a server-rendered and a browser-validated invalid state paint identically.
|
|
11
|
-
// Focus adds only the shared ring - the border never changes on focus (docs/adr/0002).
|
|
11
|
+
// Focus adds only the shared ring - the border never changes on focus (docs/adr/0002). The
|
|
12
|
+
// control's face, and the placeholder that follows it, are docs/adr/0004's (#90); so is its size
|
|
13
|
+
// (#92) - `body` is the role clearing the 16px below which iOS Safari zooms a focused control.
|
|
12
14
|
const input = cva(
|
|
13
|
-
'block w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
|
|
15
|
+
'block w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
|
|
14
16
|
{
|
|
15
17
|
variants: {
|
|
16
18
|
// text/email/url are visually identical; the axis only selects the control's `type`
|
|
@@ -91,7 +93,14 @@ export const Input: FunctionComponent<IInputProps> = ({
|
|
|
91
93
|
|
|
92
94
|
return (
|
|
93
95
|
<div className={'flex flex-col gap-[var(--space-stack)]'}>
|
|
94
|
-
|
|
96
|
+
{/* Each of these declares `font-secondary` on itself, never on the wrapper above - the
|
|
97
|
+
wrapper would hand the labelling face to the control too (docs/adr/0004, #90). The
|
|
98
|
+
label's size is declared here for the same reason, and it is `body`, the control's own
|
|
99
|
+
role, rather than `label` (docs/adr/0004, #92). */}
|
|
100
|
+
<label
|
|
101
|
+
htmlFor={controlId}
|
|
102
|
+
className={'font-secondary font-medium text-body text-foreground'}
|
|
103
|
+
>
|
|
95
104
|
{label}
|
|
96
105
|
</label>
|
|
97
106
|
<input
|
|
@@ -111,15 +120,17 @@ export const Input: FunctionComponent<IInputProps> = ({
|
|
|
111
120
|
onInput={(event) => onInput$?.next(event.currentTarget.value)}
|
|
112
121
|
/>
|
|
113
122
|
{!required && optionalLabel && (
|
|
114
|
-
<span className={'text-muted text-
|
|
123
|
+
<span className={'font-secondary text-muted text-small'}>
|
|
124
|
+
{optionalLabel}
|
|
125
|
+
</span>
|
|
115
126
|
)}
|
|
116
127
|
{hint && (
|
|
117
|
-
<p id={hintId} className={'text-muted text-
|
|
128
|
+
<p id={hintId} className={'font-secondary text-muted text-small'}>
|
|
118
129
|
{hint}
|
|
119
130
|
</p>
|
|
120
131
|
)}
|
|
121
132
|
{invalid && (
|
|
122
|
-
<p id={errorId} className={'text-error text-
|
|
133
|
+
<p id={errorId} className={'font-secondary text-error text-small'}>
|
|
123
134
|
{errorMessage}
|
|
124
135
|
</p>
|
|
125
136
|
)}
|
|
@@ -8,6 +8,10 @@ import type { FunctionComponent, ReactNode } from 'react';
|
|
|
8
8
|
// semantic tokens re-pointed by `.dark`, so no treatment carries a `dark:` class. Underlines read the
|
|
9
9
|
// library's --underline-* tokens and arrive instantly, off the transition allowlist (docs/adr/0001):
|
|
10
10
|
// prose thickens its on hover, quiet and label-link raise one at rest thickness, graphic has none.
|
|
11
|
+
// Only `quiet` declares a face; the other three decline one deliberately (docs/adr/0004, #90).
|
|
12
|
+
// No treatment declares a type role either, `quiet` included, and that silence is a decision, not
|
|
13
|
+
// the omission #92 closed elsewhere: a face is constant across placements, a size is not
|
|
14
|
+
// (docs/adr/0004, #92; docs/adr/0006 for label-link).
|
|
11
15
|
const link = cva(
|
|
12
16
|
'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
13
17
|
{
|
|
@@ -16,7 +20,7 @@ const link = cva(
|
|
|
16
20
|
prose:
|
|
17
21
|
'text-link underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] hover:decoration-[length:var(--underline-thickness-hover)]',
|
|
18
22
|
quiet:
|
|
19
|
-
'text-muted no-underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] transition-colors duration-[var(--motion-duration-color)] hover:text-foreground hover:underline',
|
|
23
|
+
'font-secondary text-muted no-underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] transition-colors duration-[var(--motion-duration-color)] hover:text-foreground hover:underline',
|
|
20
24
|
'label-link':
|
|
21
25
|
'text-inherit no-underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] hover:underline',
|
|
22
26
|
graphic: 'text-inherit no-underline',
|
|
@@ -42,10 +46,10 @@ interface ILinkProps extends VariantProps<typeof link> {
|
|
|
42
46
|
|
|
43
47
|
/**
|
|
44
48
|
* A link in one of four treatments. `prose` for running text (told apart by its underline, never by
|
|
45
|
-
* hue), `quiet` for standing navigation (muted, and foreground with an
|
|
46
|
-
* `label-link` for a link acting as a label (inherits its colour, underlines
|
|
47
|
-
* of its own), and `graphic` for an anchor whose child is not text (paints
|
|
48
|
-
* its own colour). It renders a plain `<a>`, so it works with no hydration.
|
|
49
|
+
* hue), `quiet` for standing navigation (muted, set in the labelling face, and foreground with an
|
|
50
|
+
* underline on hover), `label-link` for a link acting as a label (inherits its colour, underlines
|
|
51
|
+
* on hover, sets no type of its own), and `graphic` for an anchor whose child is not text (paints
|
|
52
|
+
* nothing, so a mark keeps its own colour). It renders a plain `<a>`, so it works with no hydration.
|
|
49
53
|
*/
|
|
50
54
|
export const Link: FunctionComponent<ILinkProps> = ({
|
|
51
55
|
href,
|
|
@@ -8,9 +8,11 @@ import type { Subject } from 'rxjs';
|
|
|
8
8
|
// drawn in `controlBorder` (>=3:1 against surface) and turned `error` on both `:user-invalid` and
|
|
9
9
|
// `aria-invalid` so a server-rendered and a browser-validated invalid state paint identically. Focus
|
|
10
10
|
// adds only the shared ring - the border never changes on focus (docs/adr/0002). Height is the
|
|
11
|
-
// recipe's, not a `rows` prop.
|
|
11
|
+
// recipe's, not a `rows` prop. The control's face, and the placeholder that follows it, are
|
|
12
|
+
// docs/adr/0004's (#90); so is its size (#92) - `body` is the role clearing the 16px below which
|
|
13
|
+
// iOS Safari zooms a focused control.
|
|
12
14
|
const textArea = cva(
|
|
13
|
-
'block min-h-24 w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
|
|
15
|
+
'block min-h-24 w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
|
|
14
16
|
);
|
|
15
17
|
|
|
16
18
|
interface ITextAreaProps {
|
|
@@ -82,7 +84,14 @@ export const TextArea: FunctionComponent<ITextAreaProps> = ({
|
|
|
82
84
|
|
|
83
85
|
return (
|
|
84
86
|
<div className={'flex flex-col gap-[var(--space-stack)]'}>
|
|
85
|
-
|
|
87
|
+
{/* Each of these declares `font-secondary` on itself, never on the wrapper above - the
|
|
88
|
+
wrapper would hand the labelling face to the control too (docs/adr/0004, #90). The
|
|
89
|
+
label's size is declared here for the same reason, and it is `body`, the control's own
|
|
90
|
+
role, rather than `label` (docs/adr/0004, #92). */}
|
|
91
|
+
<label
|
|
92
|
+
htmlFor={controlId}
|
|
93
|
+
className={'font-secondary font-medium text-body text-foreground'}
|
|
94
|
+
>
|
|
86
95
|
{label}
|
|
87
96
|
</label>
|
|
88
97
|
<textarea
|
|
@@ -101,15 +110,17 @@ export const TextArea: FunctionComponent<ITextAreaProps> = ({
|
|
|
101
110
|
onInput={(event) => onInput$?.next(event.currentTarget.value)}
|
|
102
111
|
/>
|
|
103
112
|
{!required && optionalLabel && (
|
|
104
|
-
<span className={'text-muted text-
|
|
113
|
+
<span className={'font-secondary text-muted text-small'}>
|
|
114
|
+
{optionalLabel}
|
|
115
|
+
</span>
|
|
105
116
|
)}
|
|
106
117
|
{hint && (
|
|
107
|
-
<p id={hintId} className={'text-muted text-
|
|
118
|
+
<p id={hintId} className={'font-secondary text-muted text-small'}>
|
|
108
119
|
{hint}
|
|
109
120
|
</p>
|
|
110
121
|
)}
|
|
111
122
|
{invalid && (
|
|
112
|
-
<p id={errorId} className={'text-error text-
|
|
123
|
+
<p id={errorId} className={'font-secondary text-error text-small'}>
|
|
113
124
|
{errorMessage}
|
|
114
125
|
</p>
|
|
115
126
|
)}
|
package/src/Layout/Form/Form.tsx
CHANGED
|
@@ -8,8 +8,9 @@ export type FormState = 'idle' | 'sending' | 'sent' | 'failed';
|
|
|
8
8
|
// The one recipe, and it paints the note only (issue #6): `state` selects the note's tone - muted
|
|
9
9
|
// while the form stands, success on the outcome, error on the failure. Region selection is the
|
|
10
10
|
// NOTE_ROLE map below, not classes, so this axis never grows a layout job. Colours are semantic
|
|
11
|
-
// tokens re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
12
|
-
|
|
11
|
+
// tokens re-pointed by `.dark`, so no variant carries a `dark:` class. The face joins the size in
|
|
12
|
+
// the base, so no state can disagree with it (docs/adr/0004, #90).
|
|
13
|
+
const form = cva('font-secondary text-small', {
|
|
13
14
|
variants: {
|
|
14
15
|
state: {
|
|
15
16
|
idle: 'text-muted',
|
|
@@ -37,8 +38,12 @@ interface IFormProps {
|
|
|
37
38
|
children?: ReactNode;
|
|
38
39
|
/** The actions row - the consumer supplies its own submit Button. */
|
|
39
40
|
actions?: ReactNode;
|
|
40
|
-
/** Standing note in `idle`/`sending`; the outcome message in `sent`/`failed`.
|
|
41
|
-
note
|
|
41
|
+
/** Standing note in `idle`/`sending`; the outcome message in `sent`/`failed`. Inline content: a
|
|
42
|
+
* sentence, with a Link or emphasis inside it. Form renders the paragraph the note sits in and
|
|
43
|
+
* owns its ARIA role, so a node that brings its own block element - a second `<p>` - is unnested
|
|
44
|
+
* by the parser and the text leaves the region carrying that role. A node that renders nothing
|
|
45
|
+
* (`null`, `false`, an omitted prop) is no note: the paragraph is not rendered at all. */
|
|
46
|
+
note?: ReactNode;
|
|
42
47
|
testId?: string;
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -51,8 +56,8 @@ interface IFormProps {
|
|
|
51
56
|
* `sent` drops the fields and actions so a completed submission cannot be resubmitted, while the
|
|
52
57
|
* `<form>` itself is retained in every state so the DOM shape is stable across a runtime change.
|
|
53
58
|
*
|
|
54
|
-
* The note's
|
|
55
|
-
* the outcome message, so a `sent` with no `note` is a programmer error and throws.
|
|
59
|
+
* The note's content is always the consumer's; Form chooses only its element and ARIA role. `sent`
|
|
60
|
+
* is the outcome message, so a `sent` with no `note` is a programmer error and throws.
|
|
56
61
|
*/
|
|
57
62
|
export const Form: FunctionComponent<IFormProps> = ({
|
|
58
63
|
action,
|
|
@@ -63,7 +68,16 @@ export const Form: FunctionComponent<IFormProps> = ({
|
|
|
63
68
|
note,
|
|
64
69
|
testId,
|
|
65
70
|
}) => {
|
|
66
|
-
|
|
71
|
+
// Absence, not falsiness: a ReactNode may be falsy and still render (`0` renders as `"0"`), so a
|
|
72
|
+
// truthiness test would both refuse a legitimate note in `sent` and drop a paragraph React had
|
|
73
|
+
// written a value into. Absence is React's own set of nothing-to-render nodes rather than
|
|
74
|
+
// `undefined` alone, because `note={showPrivacy && <>...</>}` hands over `false` when the line is
|
|
75
|
+
// off - and an empty `<p>` is still a flex item, so it would cost a region gap and, in `sent`, a
|
|
76
|
+
// status region announcing nothing.
|
|
77
|
+
const hasNote =
|
|
78
|
+
note !== undefined && note !== null && typeof note !== 'boolean';
|
|
79
|
+
|
|
80
|
+
if (state === 'sent' && !hasNote) {
|
|
67
81
|
throw new Error(
|
|
68
82
|
'Form in the `sent` state must be given a `note` - it is the outcome message.',
|
|
69
83
|
);
|
|
@@ -87,12 +101,14 @@ export const Form: FunctionComponent<IFormProps> = ({
|
|
|
87
101
|
{children}
|
|
88
102
|
</div>
|
|
89
103
|
)}
|
|
104
|
+
{/* Not pinned against Stack (#75) as the two columns above are: the actions row runs along the
|
|
105
|
+
other axis, which is Cluster's arrangement (#76) and not a stack with a different gap. */}
|
|
90
106
|
{showFields && actions && (
|
|
91
107
|
<div className={'flex flex-row gap-[var(--space-stack)]'}>
|
|
92
108
|
{actions}
|
|
93
109
|
</div>
|
|
94
110
|
)}
|
|
95
|
-
{
|
|
111
|
+
{hasNote && (
|
|
96
112
|
<p role={noteRole} className={form({ state })}>
|
|
97
113
|
{note}
|
|
98
114
|
</p>
|