@juwel-development/design-system 1.1.0 → 2.0.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/dist/design-system.js +541 -36
- package/dist/index.css +1 -1
- package/dist/types/Display/Brandmark/Brandmark.d.ts +50 -0
- package/dist/types/Display/Checklist/Checklist.d.ts +26 -0
- package/dist/types/Display/DefinitionList/DefinitionList.d.ts +41 -0
- package/dist/types/Display/Figure/Figure.d.ts +52 -0
- package/dist/types/Display/Rail/Rail.d.ts +54 -0
- package/dist/types/Display/Table/Table.d.ts +61 -0
- package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +26 -0
- package/dist/types/Display/Typography/H1/H1.d.ts +27 -0
- package/dist/types/Display/Typography/H2/H2.d.ts +24 -0
- package/dist/types/Display/Typography/H3/H3.d.ts +23 -0
- package/dist/types/Display/Typography/H4/H4.d.ts +22 -0
- package/dist/types/Display/Typography/H5/H5.d.ts +23 -0
- package/dist/types/Display/Typography/H6/H6.d.ts +23 -0
- package/dist/types/Display/Typography/P/P.d.ts +25 -0
- package/dist/types/Display/Typography/Prose/Prose.d.ts +42 -0
- package/dist/types/Interaction/Button/Button.d.ts +7 -3
- package/dist/types/Interaction/Input/Input.d.ts +29 -0
- package/dist/types/Interaction/Link/Link.d.ts +27 -0
- package/dist/types/Interaction/TextArea/TextArea.d.ts +25 -0
- package/dist/types/Layout/Footer/Footer.d.ts +34 -0
- package/dist/types/Layout/Form/Form.d.ts +31 -0
- package/dist/types/Layout/Header/Header.d.ts +41 -0
- package/dist/types/Layout/Hero/Hero.d.ts +46 -0
- package/dist/types/Layout/PageHead/PageHead.d.ts +36 -0
- package/dist/types/Layout/Section/Section.d.ts +38 -0
- package/dist/types/Theme/Palette.d.ts +25 -6
- package/dist/types/index.d.ts +25 -1
- package/package.json +1 -1
- package/src/Display/.gitkeep +0 -0
- package/src/Display/Brandmark/Brandmark.tsx +97 -0
- package/src/Display/Checklist/Checklist.tsx +75 -0
- package/src/Display/DefinitionList/DefinitionList.tsx +90 -0
- package/src/Display/Figure/Figure.tsx +116 -0
- package/src/Display/Rail/Rail.tsx +108 -0
- package/src/Display/Table/Table.tsx +191 -0
- package/src/Display/Typography/Eyebrow/Eyebrow.tsx +46 -0
- package/src/Display/Typography/H1/H1.tsx +46 -0
- package/src/Display/Typography/H2/H2.tsx +43 -0
- package/src/Display/Typography/H3/H3.tsx +41 -0
- package/src/Display/Typography/H4/H4.tsx +40 -0
- package/src/Display/Typography/H5/H5.tsx +41 -0
- package/src/Display/Typography/H6/H6.tsx +41 -0
- package/src/Display/Typography/P/P.tsx +38 -0
- package/src/Display/Typography/Prose/Prose.tsx +91 -0
- package/src/Interaction/Button/Button.tsx +15 -10
- package/src/Interaction/Input/Input.tsx +103 -0
- package/src/Interaction/Link/Link.tsx +70 -0
- package/src/Interaction/TextArea/TextArea.tsx +93 -0
- package/src/Layout/.gitkeep +0 -0
- package/src/Layout/Footer/Footer.tsx +60 -0
- package/src/Layout/Form/Form.tsx +102 -0
- package/src/Layout/Header/Header.tsx +84 -0
- package/src/Layout/Hero/Hero.tsx +73 -0
- package/src/Layout/PageHead/PageHead.tsx +79 -0
- package/src/Layout/Section/Section.tsx +79 -0
- package/src/Theme/Palette.ts +38 -12
- package/src/Theme/renderTokens.ts +179 -2
- package/src/index.ts +25 -1
- package/src/tokens.css +113 -9
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
// Rules are the layout. Each Item owns its two-track grid and its hairlines. The term track is a fixed
|
|
5
|
+
// width, so per-item grids line up without a subgrid. At `lg` (64rem) a second `dt` is pinned to the
|
|
6
|
+
// term column so it cannot auto-place into the description column and break the row silently. Colours
|
|
7
|
+
// are semantic tokens re-pointed by `.dark`, so no class carries a `dark:` prefix.
|
|
8
|
+
const item = cva(
|
|
9
|
+
[
|
|
10
|
+
'grid gap-[var(--space-stack)] py-6',
|
|
11
|
+
'border-b border-solid border-border first:border-t',
|
|
12
|
+
'lg:grid-cols-[16rem_minmax(0,1fr)] lg:items-baseline lg:gap-x-12',
|
|
13
|
+
'lg:[&>dt]:col-start-1 lg:[&>dd]:col-start-2 lg:[&>dd]:row-start-1',
|
|
14
|
+
].join(' '),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// The term at the subtitle role - a step below title (docs/adr/0005 fixes the role, no size prop), so
|
|
18
|
+
// terms never tie with the heading introducing the list. Foreground, and no measure cap.
|
|
19
|
+
const term = cva('font-primary text-subtitle leading-subtitle text-foreground');
|
|
20
|
+
|
|
21
|
+
// The description at the body role, muted, capped at the reading measure in both layout modes.
|
|
22
|
+
const description = cva(
|
|
23
|
+
'font-primary text-body leading-body text-muted max-w-[var(--measure)]',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
interface IDefinitionListRootProps {
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
testId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface IDefinitionListItemProps {
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
testId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface IDefinitionListTermProps {
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface IDefinitionListDescriptionProps {
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DefinitionListRoot: FunctionComponent<IDefinitionListRootProps> = ({
|
|
45
|
+
children,
|
|
46
|
+
testId,
|
|
47
|
+
}) => <dl data-testid={testId}>{children}</dl>;
|
|
48
|
+
|
|
49
|
+
const DefinitionListItem: FunctionComponent<IDefinitionListItemProps> = ({
|
|
50
|
+
children,
|
|
51
|
+
testId,
|
|
52
|
+
}) => (
|
|
53
|
+
<div className={item()} data-testid={testId}>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const DefinitionListTerm: FunctionComponent<IDefinitionListTermProps> = ({
|
|
59
|
+
children,
|
|
60
|
+
}) => <dt className={term()}>{children}</dt>;
|
|
61
|
+
|
|
62
|
+
const DefinitionListDescription: FunctionComponent<
|
|
63
|
+
IDefinitionListDescriptionProps
|
|
64
|
+
> = ({ children }) => <dd className={description()}>{children}</dd>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A typeset list of terms and their descriptions, where the rules are the layout. The consumer
|
|
68
|
+
* composes the list from the four members; no member takes a data array. `Root` renders `<dl>`,
|
|
69
|
+
* `Item` the grouping `<div>` (valid inside `<dl>` for exactly this purpose), `Term` a `<dt>`,
|
|
70
|
+
* `Description` a `<dd>`.
|
|
71
|
+
*
|
|
72
|
+
* @Guarantees — enforced on every render
|
|
73
|
+
* - Renders semantic `dl`/`div`/`dt`/`dd`, and works with JavaScript off.
|
|
74
|
+
* - The term is fixed to the subtitle type role and exposes no size prop or variant (docs/adr/0005).
|
|
75
|
+
* - The description is body, muted, and capped at `--measure`; the term carries no measure cap.
|
|
76
|
+
* - A hairline sits above the first item and below every item, in `border`, and the block closes at
|
|
77
|
+
* the foot. No card, box, fill, icon or bullet - it reads from the rules alone.
|
|
78
|
+
* - Single column below 64rem with the term above its description; two columns at and above it with a
|
|
79
|
+
* fixed term track and baseline-aligned rows. The switch is a media query, not a prop.
|
|
80
|
+
*
|
|
81
|
+
* @UXGuidelines
|
|
82
|
+
* - Several `Term`s may share one `Description`; keep them inside one `Item` so the term column
|
|
83
|
+
* stays intact.
|
|
84
|
+
*/
|
|
85
|
+
export const DefinitionList = {
|
|
86
|
+
Root: DefinitionListRoot,
|
|
87
|
+
Item: DefinitionListItem,
|
|
88
|
+
Term: DefinitionListTerm,
|
|
89
|
+
Description: DefinitionListDescription,
|
|
90
|
+
} as const;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { FunctionComponent } from 'react';
|
|
4
|
+
|
|
5
|
+
// One recipe, on the `img` - the only element that varies. The wrapper `figure` and the `figcaption`
|
|
6
|
+
// carry literal class strings in JSX (the Input pattern, not the compound namespace: there is nothing
|
|
7
|
+
// here to compose). `ratio` has no default, so an omitted ratio emits no aspect-* class and the image
|
|
8
|
+
// keeps its intrinsic shape; `focus` defaults to center and is inert without a ratio, since object-*
|
|
9
|
+
// has nothing to crop in the base. Colours are semantic tokens re-pointed by `.dark`, so no `dark:`.
|
|
10
|
+
const figure = cva('block w-full object-cover bg-backing', {
|
|
11
|
+
variants: {
|
|
12
|
+
ratio: {
|
|
13
|
+
portrait: 'aspect-portrait',
|
|
14
|
+
square: 'aspect-square',
|
|
15
|
+
landscape: 'aspect-landscape',
|
|
16
|
+
wide: 'aspect-wide',
|
|
17
|
+
},
|
|
18
|
+
focus: {
|
|
19
|
+
center: 'object-center',
|
|
20
|
+
top: 'object-top',
|
|
21
|
+
bottom: 'object-bottom',
|
|
22
|
+
left: 'object-left',
|
|
23
|
+
right: 'object-right',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: { focus: 'center' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export interface IFigureProps extends VariantProps<typeof figure> {
|
|
30
|
+
/** The image source. Produce it through the consuming build's image pipeline - see @UXGuidelines. */
|
|
31
|
+
src: string;
|
|
32
|
+
/** The image's text alternative. Empty string when the image is decorative, or when `caption`
|
|
33
|
+
* already names it - a non-empty alt beside a caption reads the same content to a screen reader
|
|
34
|
+
* twice. */
|
|
35
|
+
alt: string;
|
|
36
|
+
/** The source's intrinsic pixel width. */
|
|
37
|
+
width: number;
|
|
38
|
+
/** The source's intrinsic pixel height. */
|
|
39
|
+
height: number;
|
|
40
|
+
/** Responsive delivery. One decision with two parts, so they are one prop: a `srcSet` with `w`
|
|
41
|
+
* descriptors and no `sizes` makes the browser assume 100vw and download the largest candidate. */
|
|
42
|
+
responsive?: { srcSet: string; sizes: string };
|
|
43
|
+
/** Names the image. Its presence is what makes this a `figure`. */
|
|
44
|
+
caption?: string;
|
|
45
|
+
/** Whether this image is visible when the page first paints. Set it on a hero. */
|
|
46
|
+
priority?: boolean;
|
|
47
|
+
testId?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An aspect-locked image frame with a focal point and an optional caption. The caption decides the
|
|
52
|
+
* 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.
|
|
54
|
+
*
|
|
55
|
+
* @Guarantees — enforced on every render
|
|
56
|
+
* - Every image reserves its box before it loads: an intrinsic `width`/`height`, and a locked
|
|
57
|
+
* `aspect-ratio` once a `ratio` is given.
|
|
58
|
+
* - `decoding="async"` always; `loading` follows `priority` (`eager`+`fetchpriority="high"` when set,
|
|
59
|
+
* `lazy` otherwise), so an above-the-fold hero is never deferred.
|
|
60
|
+
* - A `figure` element appears only where there is a caption.
|
|
61
|
+
* - `srcset` never ships without `sizes`: `responsive` bundles the two so one cannot go without the other.
|
|
62
|
+
*
|
|
63
|
+
* @UXGuidelines
|
|
64
|
+
* - Pass a `src` your build's image pipeline produced. One raw source measured 2 MB against 31.6 KB
|
|
65
|
+
* for the same crop, taking `load` from 0.95 s to 10.3 s. The component cannot check this.
|
|
66
|
+
* - Check a photograph's hue range before putting a brand colour beside it. One frame sat entirely
|
|
67
|
+
* 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. `Figure` renders no overlay, so this is about what a page puts next
|
|
69
|
+
* to a figure, not on it.
|
|
70
|
+
*/
|
|
71
|
+
export const Figure: FunctionComponent<IFigureProps> = ({
|
|
72
|
+
src,
|
|
73
|
+
alt,
|
|
74
|
+
width,
|
|
75
|
+
height,
|
|
76
|
+
ratio,
|
|
77
|
+
focus,
|
|
78
|
+
responsive,
|
|
79
|
+
caption,
|
|
80
|
+
priority,
|
|
81
|
+
testId,
|
|
82
|
+
}) => {
|
|
83
|
+
const image = (
|
|
84
|
+
<img
|
|
85
|
+
src={src}
|
|
86
|
+
alt={alt}
|
|
87
|
+
width={width}
|
|
88
|
+
height={height}
|
|
89
|
+
className={figure({ ratio, focus })}
|
|
90
|
+
srcSet={responsive?.srcSet}
|
|
91
|
+
sizes={responsive?.sizes}
|
|
92
|
+
loading={priority ? 'eager' : 'lazy'}
|
|
93
|
+
fetchPriority={priority ? 'high' : undefined}
|
|
94
|
+
decoding={'async'}
|
|
95
|
+
data-testid={caption === undefined ? testId : undefined}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (caption === undefined) {
|
|
100
|
+
return image;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<figure
|
|
105
|
+
className={'flex flex-col gap-[var(--space-stack)]'}
|
|
106
|
+
data-testid={testId}
|
|
107
|
+
>
|
|
108
|
+
{image}
|
|
109
|
+
<figcaption
|
|
110
|
+
className={'font-secondary text-label tracking-label text-muted'}
|
|
111
|
+
>
|
|
112
|
+
{caption}
|
|
113
|
+
</figcaption>
|
|
114
|
+
</figure>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
// Root owns both tracks so the sticky index has this grid as its containing block - the whole guarantee
|
|
5
|
+
// rests on Index being a direct child. One column below 64rem, index above content; at `lg` a fixed
|
|
6
|
+
// 10rem index track and `minmax(0,1fr)` beside it, with `items-start` giving the index cell the full row
|
|
7
|
+
// height to stick within. The three numbers are literals stated in prose (docs/adr/0003), not tokens.
|
|
8
|
+
const railRoot = cva(
|
|
9
|
+
[
|
|
10
|
+
'grid gap-[var(--space-stack)]',
|
|
11
|
+
'lg:grid-cols-[10rem_minmax(0,1fr)] lg:items-start lg:gap-x-12',
|
|
12
|
+
].join(' '),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// The index track: sticky with a 1.5rem offset only at and above 64rem; below it the index lies down
|
|
16
|
+
// and stays static, so it needs no opaque fill, edge or stacking decision. It is aria-hidden - it
|
|
17
|
+
// repeats the section's heading, which lives in the content track. The label role sits on each part.
|
|
18
|
+
const railIndex = cva('lg:sticky lg:top-6');
|
|
19
|
+
|
|
20
|
+
// Both parts at the label role (docs/adr/0005 binds it to no heading), told apart by colour alone. The
|
|
21
|
+
// numeral is muted with a hairline under it in `border`, not `rule` - the page-structure weight Section
|
|
22
|
+
// joins carry, which a device this small should not draw. The name is foreground. Colours are semantic
|
|
23
|
+
// tokens re-pointed by `.dark`, so no class carries a `dark:` prefix.
|
|
24
|
+
const railNumeral = cva(
|
|
25
|
+
'block border-b border-solid border-border pb-1 font-secondary text-label tracking-label text-muted',
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const railName = cva(
|
|
29
|
+
'block pt-1 font-secondary text-label tracking-label text-foreground',
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
interface IRailRootProps {
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
testId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface IRailIndexProps {
|
|
38
|
+
/** The filing numeral as a string - `02`, not `2`. Zero-padding is a brand decision, so the caller
|
|
39
|
+
* writes the exact glyphs. Omitted, the index renders its name alone with no empty numeral element. */
|
|
40
|
+
number?: string;
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
testId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface IRailContentProps {
|
|
46
|
+
children?: ReactNode;
|
|
47
|
+
testId?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const RailRoot: FunctionComponent<IRailRootProps> = ({ children, testId }) => (
|
|
51
|
+
<div className={railRoot()} data-testid={testId}>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const RailIndex: FunctionComponent<IRailIndexProps> = ({
|
|
57
|
+
number,
|
|
58
|
+
children,
|
|
59
|
+
testId,
|
|
60
|
+
}) => (
|
|
61
|
+
<div className={railIndex()} data-testid={testId} aria-hidden="true">
|
|
62
|
+
{number !== undefined && <span className={railNumeral()}>{number}</span>}
|
|
63
|
+
<span className={railName()}>{children}</span>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const RailContent: FunctionComponent<IRailContentProps> = ({
|
|
68
|
+
children,
|
|
69
|
+
testId,
|
|
70
|
+
}) => <div data-testid={testId}>{children}</div>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A section filed beside a sticky index: the section's matter in the wide track and, at and above 64rem,
|
|
74
|
+
* a persistent index in the narrow one. Composed from three members - `Root` renders the two-track grid,
|
|
75
|
+
* `Index` the sticky filing device, `Content` the opaque slot for the matter. `Root` is a `div` with no
|
|
76
|
+
* landmark; the section's heading lives in `Content`, and `Index` repeats it only as a visual marker.
|
|
77
|
+
*
|
|
78
|
+
* @Guarantees — enforced on every render
|
|
79
|
+
* - `Index` persists beside its section at and above 64rem and reads as a filed heading above the text
|
|
80
|
+
* below it. The switch is a media query, not a prop.
|
|
81
|
+
* - `Index` is decorative to assistive technology (`aria-hidden`): it repeats the heading the content
|
|
82
|
+
* track carries and does not replace it.
|
|
83
|
+
* - The index numeral is muted with a hairline under it in `border`; the name is foreground; both sit at
|
|
84
|
+
* the label role. Colours are semantic tokens re-pointed by `.dark`, so no class carries a `dark:` prefix.
|
|
85
|
+
* - `number` is optional: omitted, the index renders the name alone and no empty numeral element.
|
|
86
|
+
* - `Content` renders its children unmodified and sets no measure cap - `Prose` owns the reading measure.
|
|
87
|
+
* - No margin, max-width, fill, box or vertical rule; no `tabular-nums` or `font-variant-numeric`.
|
|
88
|
+
* - It needs no JavaScript: `position: sticky` is CSS.
|
|
89
|
+
*
|
|
90
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
91
|
+
* - `Rail.Index` is a direct child of `Rail.Root`: `position: sticky` positions against the grid
|
|
92
|
+
* container, so the index sticks for the height of its row only when Root is its parent.
|
|
93
|
+
* - The section's heading lives in `Rail.Content`. `Index` is aria-hidden and carries no heading, so a
|
|
94
|
+
* consumer that files a section only in the index leaves it without an accessible name.
|
|
95
|
+
*
|
|
96
|
+
* @UXGuidelines
|
|
97
|
+
* - An index is an information layer, not a layout remedy. Measured on the page that prompted it, the
|
|
98
|
+
* filing numeral bought 0px of width across three fallback rungs, and the column it sits in closed only
|
|
99
|
+
* +224px of a 933px deficit - it widens a composition's span, not its content. If a page sags, the
|
|
100
|
+
* answer is a column; whether that column should also be an index is a separate decision.
|
|
101
|
+
* - Do not place a rail on a hero or a full-bleed section. A bleeding section drops the gutter, so there
|
|
102
|
+
* is no inset for an index track to stand in, and a poster is not a filed section.
|
|
103
|
+
*/
|
|
104
|
+
export const Rail = {
|
|
105
|
+
Root: RailRoot,
|
|
106
|
+
Index: RailIndex,
|
|
107
|
+
Content: RailContent,
|
|
108
|
+
} as const;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
// Rules are the layout. One recipe styles the whole table from its wrapper, so the block reads as a
|
|
6
|
+
// table from two rule weights and nothing else: no cell borders, no fill, no zebra, no hover. Colours
|
|
7
|
+
// are semantic tokens re-pointed by `.dark`, so no selector carries a `dark:` class. The responsive
|
|
8
|
+
// behaviour is keyed on the wrapper's `data-notes`, so a server renders the right markup with no
|
|
9
|
+
// hydration and the mode is one attribute a stylesheet and a test can both read.
|
|
10
|
+
const table = cva(
|
|
11
|
+
[
|
|
12
|
+
// With no note column the wrapper becomes a horizontally scrollable region (see Root); the scroll
|
|
13
|
+
// sits here so the table keeps its table formatting context and the figures stay column-aligned.
|
|
14
|
+
'[&:not([data-notes])]:overflow-x-auto',
|
|
15
|
+
// The table: full width, collapsed borders so adjacent row rules meet as one line, text flush left.
|
|
16
|
+
'[&>table]:w-full [&>table]:border-collapse [&>table]:text-left',
|
|
17
|
+
// The required caption, rendered first, as the table's label in the tracked grotesk device.
|
|
18
|
+
'[&_caption]:pb-3 [&_caption]:text-left [&_caption]:font-secondary [&_caption]:text-label [&_caption]:tracking-label [&_caption]:text-muted',
|
|
19
|
+
// Every row carries a hairline top (Row); the first row - the first row group after the caption -
|
|
20
|
+
// is promoted to the heavier `rule` colour, and that heavier line is what reads as a table not a list.
|
|
21
|
+
'[&>table>*:nth-child(2)>tr:first-child]:border-rule',
|
|
22
|
+
// notes="supplementary": the note column leaves the page for everyone, sighted or not, below 48rem.
|
|
23
|
+
'[&[data-notes=supplementary]_[data-variant=note]]:max-md:hidden',
|
|
24
|
+
// notes="content": the note is the content, so each row stacks into a single column below 48rem.
|
|
25
|
+
'[&[data-notes=content]>table]:max-md:block',
|
|
26
|
+
'[&[data-notes=content]_thead]:max-md:block [&[data-notes=content]_tbody]:max-md:block [&[data-notes=content]_tfoot]:max-md:block',
|
|
27
|
+
'[&[data-notes=content]_tr]:max-md:block [&[data-notes=content]_td]:max-md:block [&[data-notes=content]_th]:max-md:block',
|
|
28
|
+
].join(' '),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// One hairline above every row, in `border`. Root promotes the first row's colour to `rule`; the last
|
|
32
|
+
// row takes no bottom rule, so the block stays open at the foot - the difference from a closed list.
|
|
33
|
+
const tableRow = cva('border-t border-solid border-border');
|
|
34
|
+
|
|
35
|
+
// A value is serif with real tabular figures; a note is the muted grotesk. Both sit at the small role,
|
|
36
|
+
// which carries the enforced 15px floor below which figures stop comparing column to column.
|
|
37
|
+
const tableCell = cva('px-4 py-2 text-small first:pl-0 last:pr-0', {
|
|
38
|
+
variants: {
|
|
39
|
+
variant: {
|
|
40
|
+
value: 'font-primary text-foreground tabular-nums',
|
|
41
|
+
note: 'font-secondary text-muted',
|
|
42
|
+
},
|
|
43
|
+
align: { left: 'text-left', right: 'text-right', center: 'text-center' },
|
|
44
|
+
},
|
|
45
|
+
defaultVariants: { variant: 'value', align: 'left' },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// The label: the tracked muted grotesk, at the label role. Carried by both scopes (column and row).
|
|
49
|
+
const tableHeaderCell = cva(
|
|
50
|
+
'px-4 py-2 font-secondary font-medium text-label text-muted tracking-label first:pl-0 last:pr-0',
|
|
51
|
+
{
|
|
52
|
+
variants: {
|
|
53
|
+
align: { left: 'text-left', right: 'text-right', center: 'text-center' },
|
|
54
|
+
},
|
|
55
|
+
defaultVariants: { align: 'left' },
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
interface ITableRootProps {
|
|
60
|
+
/** The table's accessible name. Rendered as the first child; always present. */
|
|
61
|
+
caption: string;
|
|
62
|
+
/** What the note column is. Governs narrow-viewport behaviour; omit when there is none. */
|
|
63
|
+
notes?: 'supplementary' | 'content';
|
|
64
|
+
children?: ReactNode;
|
|
65
|
+
testId?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ITableSectionProps {
|
|
69
|
+
children?: ReactNode;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ITableRowProps {
|
|
73
|
+
children?: ReactNode;
|
|
74
|
+
testId?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ITableCellProps extends VariantProps<typeof tableCell> {
|
|
78
|
+
children?: ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ITableHeaderCellProps extends VariantProps<typeof tableHeaderCell> {
|
|
82
|
+
/** Explicit, never inferred from Head/Body position - inference would need render-time context. */
|
|
83
|
+
scope: 'row' | 'col';
|
|
84
|
+
children?: ReactNode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const TableRoot: FunctionComponent<ITableRootProps> = ({
|
|
88
|
+
caption,
|
|
89
|
+
notes,
|
|
90
|
+
children,
|
|
91
|
+
testId,
|
|
92
|
+
}) => {
|
|
93
|
+
const content = (
|
|
94
|
+
<table>
|
|
95
|
+
<caption>{caption}</caption>
|
|
96
|
+
{children}
|
|
97
|
+
</table>
|
|
98
|
+
);
|
|
99
|
+
// No note column: the wrapper is a labelled region (a named `section`) so the horizontally
|
|
100
|
+
// scrolled table is keyboard-operable - WCAG 2.1.1 needs the scroll container itself focusable,
|
|
101
|
+
// there being no focusable cell content to carry it. With a note column there is nothing to
|
|
102
|
+
// scroll to, so the wrapper stays a plain grouping element.
|
|
103
|
+
if (notes === undefined) {
|
|
104
|
+
return (
|
|
105
|
+
<section
|
|
106
|
+
className={table()}
|
|
107
|
+
data-testid={testId}
|
|
108
|
+
aria-label={caption}
|
|
109
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be keyboard-operable (WCAG 2.1.1)
|
|
110
|
+
tabIndex={0}
|
|
111
|
+
>
|
|
112
|
+
{content}
|
|
113
|
+
</section>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
<div className={table()} data-notes={notes} data-testid={testId}>
|
|
118
|
+
{content}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const TableHead: FunctionComponent<ITableSectionProps> = ({ children }) => (
|
|
124
|
+
<thead>{children}</thead>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const TableBody: FunctionComponent<ITableSectionProps> = ({ children }) => (
|
|
128
|
+
<tbody>{children}</tbody>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const TableFooter: FunctionComponent<ITableSectionProps> = ({ children }) => (
|
|
132
|
+
<tfoot>{children}</tfoot>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const TableRow: FunctionComponent<ITableRowProps> = ({ children, testId }) => (
|
|
136
|
+
<tr className={tableRow()} data-testid={testId}>
|
|
137
|
+
{children}
|
|
138
|
+
</tr>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const TableHeaderCell: FunctionComponent<ITableHeaderCellProps> = ({
|
|
142
|
+
scope,
|
|
143
|
+
align,
|
|
144
|
+
children,
|
|
145
|
+
}) => (
|
|
146
|
+
<th scope={scope} className={tableHeaderCell({ align })}>
|
|
147
|
+
{children}
|
|
148
|
+
</th>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const TableCell: FunctionComponent<ITableCellProps> = ({
|
|
152
|
+
variant,
|
|
153
|
+
align,
|
|
154
|
+
children,
|
|
155
|
+
}) => (
|
|
156
|
+
<td
|
|
157
|
+
className={tableCell({ variant, align })}
|
|
158
|
+
data-variant={variant ?? 'value'}
|
|
159
|
+
>
|
|
160
|
+
{children}
|
|
161
|
+
</td>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A composable data table where the rules are the layout. The consumer composes the table from the
|
|
166
|
+
* seven members; no member takes a data array. A specification / details table is the shape it is
|
|
167
|
+
* built for - a row label, a value in tabular figures, and a note.
|
|
168
|
+
*
|
|
169
|
+
* @Guarantees — enforced on every render
|
|
170
|
+
* - Renders semantic `table`/`thead`/`tbody`/`tfoot`/`tr`/`th`/`td`, and works with JavaScript off.
|
|
171
|
+
* - `Root` renders its required `caption` as the table's first child, so every table is named.
|
|
172
|
+
* - The block reads as a table from two rule weights alone: the heavier `rule` above the first row,
|
|
173
|
+
* `border` hairlines between rows, and no bottom rule on the last. No cell borders, fill, zebra or hover.
|
|
174
|
+
* - `Cell variant="value"` sets tabular figures; `variant="note"` does not. Both at the 15px small role.
|
|
175
|
+
* - `HeaderCell` emits the `scope` it is given; none is inferred.
|
|
176
|
+
*
|
|
177
|
+
* @UXGuidelines
|
|
178
|
+
* - `align` is a cell property but reads as a column one: set the same `align` on a `HeaderCell` and
|
|
179
|
+
* every `Cell` beneath it, and keep them in sync - the component cannot align a column for you.
|
|
180
|
+
* - Choose `notes` by what the note column *is*: `"supplementary"` drops it below 48rem for everyone
|
|
181
|
+
* (out of the accessibility tree too); `"content"` stacks each row; omit it when there is no note.
|
|
182
|
+
*/
|
|
183
|
+
export const Table = {
|
|
184
|
+
Root: TableRoot,
|
|
185
|
+
Head: TableHead,
|
|
186
|
+
Body: TableBody,
|
|
187
|
+
Footer: TableFooter,
|
|
188
|
+
Row: TableRow,
|
|
189
|
+
HeaderCell: TableHeaderCell,
|
|
190
|
+
Cell: TableCell,
|
|
191
|
+
} as const;
|
|
@@ -0,0 +1,46 @@
|
|
|
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 letter-spaced eyebrow device: the secondary family, the label size and the label tracking, at
|
|
6
|
+
// weight 500 (docs/adr/0004). First consumer of --font-secondary. The tracking is a fixed style, read
|
|
7
|
+
// from --tracking-label so a site-wide optical curve cannot collapse it. It defaults to `muted`
|
|
8
|
+
// because the device is defined as muted - the one primitive to invert that default. Never sets
|
|
9
|
+
// font-variant-caps or -numeric: both fail silently on a face without the feature. Colour is a
|
|
10
|
+
// semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
11
|
+
const eyebrow = cva('font-secondary text-label tracking-label font-medium', {
|
|
12
|
+
variants: {
|
|
13
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: { color: 'muted' },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
interface IEyebrowProps extends VariantProps<typeof eyebrow> {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
testId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The eyebrow / caption device, a `p` at the label type role: the secondary family, the label size
|
|
25
|
+
* and a fixed +0.14em tracking, muted. The single primitive for the one *unowned* use of the device
|
|
26
|
+
* — a short tracked line standing above a section heading.
|
|
27
|
+
*
|
|
28
|
+
* @Guarantees — enforced on every render
|
|
29
|
+
* - Renders a `p`, reading `--font-secondary`, sized by `--text-label` and tracked by
|
|
30
|
+
* `--tracking-label`, at weight 500.
|
|
31
|
+
* - `color` selects the `muted` (default) or `foreground` role; nothing else paints text.
|
|
32
|
+
* - Sets neither `font-variant-caps` nor `font-variant-numeric` under any prop.
|
|
33
|
+
*
|
|
34
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
35
|
+
* - This is **not a form label**: it renders no `htmlFor` and labels no control. A labelled control
|
|
36
|
+
* uses `Input`/`TextArea`, which label themselves.
|
|
37
|
+
*/
|
|
38
|
+
export const Eyebrow: FunctionComponent<IEyebrowProps> = ({
|
|
39
|
+
children,
|
|
40
|
+
color,
|
|
41
|
+
testId,
|
|
42
|
+
}) => (
|
|
43
|
+
<p className={eyebrow({ color })} data-testid={testId}>
|
|
44
|
+
{children}
|
|
45
|
+
</p>
|
|
46
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
// Level fixes role: an h1 is always the display role, with no size prop to invert the ladder
|
|
6
|
+
// (docs/adr/0005). Weight inherits - Tailwind's preflight resets h1-h6 to font-weight: inherit, so
|
|
7
|
+
// the sized levels carry no weight class. --tracking-optical is the large-type correction, carried by
|
|
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
|
+
const h1 = cva('font-primary text-display leading-display tracking-optical', {
|
|
11
|
+
variants: {
|
|
12
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: { color: 'foreground' },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
interface IH1Props extends VariantProps<typeof h1> {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
testId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The page-title heading, an `h1` at the display type role.
|
|
24
|
+
*
|
|
25
|
+
* @Guarantees — enforced on every render
|
|
26
|
+
* - Renders an `h1`; its outline level and the display role are one choice, not two (docs/adr/0005).
|
|
27
|
+
* - Reads `--font-primary`, sized by `--text-display`, led by `--leading-display` and optically
|
|
28
|
+
* corrected by `--tracking-optical`, the large-type correction every role from title up carries.
|
|
29
|
+
* - `color` selects the `foreground` or `muted` role; nothing else paints text.
|
|
30
|
+
*
|
|
31
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
32
|
+
* - 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.
|
|
34
|
+
* - A subpage head is the exception: `PageHead` renders its own `h1` at the `title` role, the one
|
|
35
|
+
* sanctioned escape valve from level-fixes-role (docs/adr/0005). Reach for it where it fits.
|
|
36
|
+
* - Heading levels descend without skipping — an `h1` is followed by an `h2`, never an `h3`.
|
|
37
|
+
*/
|
|
38
|
+
export const H1: FunctionComponent<IH1Props> = ({
|
|
39
|
+
children,
|
|
40
|
+
color,
|
|
41
|
+
testId,
|
|
42
|
+
}) => (
|
|
43
|
+
<h1 className={h1({ color })} data-testid={testId}>
|
|
44
|
+
{children}
|
|
45
|
+
</h1>
|
|
46
|
+
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
// Level fixes role: an h2 is always the title role, with no size prop (docs/adr/0005). Weight
|
|
6
|
+
// inherits - Tailwind's preflight resets h1-h6 to font-weight: inherit, so the sized levels carry no
|
|
7
|
+
// weight class. --tracking-optical is the large-type correction and the title role is where it starts
|
|
8
|
+
// (#57), so an h2 and the page head's h1 - the same role - are tracked alike, and h3 down is not.
|
|
9
|
+
// Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
10
|
+
const h2 = cva('font-primary text-title leading-title tracking-optical', {
|
|
11
|
+
variants: {
|
|
12
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: { color: 'foreground' },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
interface IH2Props extends VariantProps<typeof h2> {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
testId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The section heading, an `h2` at the title type role.
|
|
24
|
+
*
|
|
25
|
+
* @Guarantees — enforced on every render
|
|
26
|
+
* - Renders an `h2`; its outline level and the title role are one choice, not two (docs/adr/0005).
|
|
27
|
+
* - Reads `--font-primary`, sized by `--text-title`, led by `--leading-title` and optically corrected
|
|
28
|
+
* by `--tracking-optical` — the title role is the smallest role that carries it, so `H3` and below
|
|
29
|
+
* take none.
|
|
30
|
+
* - `color` selects the `foreground` or `muted` role; nothing else paints text.
|
|
31
|
+
*
|
|
32
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
33
|
+
* - Heading levels descend without skipping — an `h2` sits under an `h1`, not under an `h3`.
|
|
34
|
+
*/
|
|
35
|
+
export const H2: FunctionComponent<IH2Props> = ({
|
|
36
|
+
children,
|
|
37
|
+
color,
|
|
38
|
+
testId,
|
|
39
|
+
}) => (
|
|
40
|
+
<h2 className={h2({ color })} data-testid={testId}>
|
|
41
|
+
{children}
|
|
42
|
+
</h2>
|
|
43
|
+
);
|