@juwel-development/design-system 1.1.0 → 2.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 +19 -3
- package/dist/design-system.js +560 -43
- 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 +31 -0
- package/dist/types/Interaction/Link/Link.d.ts +27 -0
- package/dist/types/Interaction/TextArea/TextArea.d.ts +27 -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/Theme/renderTokens.d.ts +16 -3
- package/dist/types/index.d.ts +25 -1
- package/package.json +5 -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 +120 -0
- package/src/Interaction/Link/Link.tsx +70 -0
- package/src/Interaction/TextArea/TextArea.tsx +110 -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 +255 -18
- package/src/index.ts +25 -1
- package/src/styles.dark.css +9 -0
- package/src/styles.light.css +9 -0
- package/src/tokens.css +118 -9
- package/src/tokens.dark.css +169 -0
- package/src/tokens.light.css +169 -0
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
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 h3 is always the subtitle 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. Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:`
|
|
8
|
+
// class.
|
|
9
|
+
const h3 = cva('font-primary text-subtitle leading-subtitle', {
|
|
10
|
+
variants: {
|
|
11
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { color: 'foreground' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
interface IH3Props extends VariantProps<typeof h3> {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
testId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The subsection heading, an `h3` at the subtitle type role. The last of the three sized steps;
|
|
23
|
+
* levels below it share the body size and separate by weight.
|
|
24
|
+
*
|
|
25
|
+
* @Guarantees — enforced on every render
|
|
26
|
+
* - Renders an `h3`; its outline level and the subtitle role are one choice, not two (docs/adr/0005).
|
|
27
|
+
* - Reads `--font-primary`, sized by `--text-subtitle` and led by `--leading-subtitle`.
|
|
28
|
+
* - `color` selects the `foreground` or `muted` role; nothing else paints text.
|
|
29
|
+
*
|
|
30
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
31
|
+
* - Heading levels descend without skipping — an `h3` sits under an `h2`, not under an `h1`.
|
|
32
|
+
*/
|
|
33
|
+
export const H3: FunctionComponent<IH3Props> = ({
|
|
34
|
+
children,
|
|
35
|
+
color,
|
|
36
|
+
testId,
|
|
37
|
+
}) => (
|
|
38
|
+
<h3 className={h3({ color })} data-testid={testId}>
|
|
39
|
+
{children}
|
|
40
|
+
</h3>
|
|
41
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
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 h4 shares the body size with h5, h6 and p, told apart by weight alone
|
|
6
|
+
// (docs/adr/0005). The `font-bold` is load-bearing - Tailwind's preflight resets h1-h6 to
|
|
7
|
+
// font-weight: inherit, so without it an h4 renders identically to a paragraph. Colour is a semantic
|
|
8
|
+
// token re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
9
|
+
const h4 = cva('font-primary text-body leading-body font-bold', {
|
|
10
|
+
variants: {
|
|
11
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { color: 'foreground' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
interface IH4Props extends VariantProps<typeof h4> {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
testId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The first of the weight-separated headings, an `h4` at the body size, rendered bold.
|
|
23
|
+
*
|
|
24
|
+
* @Guarantees — enforced on every render
|
|
25
|
+
* - Renders an `h4`; its outline level and the body role are one choice, not two (docs/adr/0005).
|
|
26
|
+
* - Reads `--font-primary`, sized by `--text-body`, and is bold so it stands apart from a paragraph.
|
|
27
|
+
* - `color` selects the `foreground` or `muted` role; nothing else paints text.
|
|
28
|
+
*
|
|
29
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
30
|
+
* - Heading levels descend without skipping — an `h4` sits under an `h3`, not under an `h2`.
|
|
31
|
+
*/
|
|
32
|
+
export const H4: FunctionComponent<IH4Props> = ({
|
|
33
|
+
children,
|
|
34
|
+
color,
|
|
35
|
+
testId,
|
|
36
|
+
}) => (
|
|
37
|
+
<h4 className={h4({ color })} data-testid={testId}>
|
|
38
|
+
{children}
|
|
39
|
+
</h4>
|
|
40
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
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 h5 shares the body size with h4, h6 and p, told apart by weight alone
|
|
6
|
+
// (docs/adr/0005). The `font-semibold` is load-bearing - Tailwind's preflight resets h1-h6 to
|
|
7
|
+
// font-weight: inherit, so without it an h5 renders identically to a paragraph. Colour is a semantic
|
|
8
|
+
// token re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
9
|
+
const h5 = cva('font-primary text-body leading-body font-semibold', {
|
|
10
|
+
variants: {
|
|
11
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { color: 'foreground' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
interface IH5Props extends VariantProps<typeof h5> {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
testId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A weight-separated heading, an `h5` at the body size, rendered semibold — one step lighter than
|
|
23
|
+
* `h4`.
|
|
24
|
+
*
|
|
25
|
+
* @Guarantees — enforced on every render
|
|
26
|
+
* - Renders an `h5`; its outline level and the body role are one choice, not two (docs/adr/0005).
|
|
27
|
+
* - Reads `--font-primary`, sized by `--text-body`, semibold so it stands apart from a paragraph.
|
|
28
|
+
* - `color` selects the `foreground` or `muted` role; nothing else paints text.
|
|
29
|
+
*
|
|
30
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
31
|
+
* - Heading levels descend without skipping — an `h5` sits under an `h4`, not under an `h3`.
|
|
32
|
+
*/
|
|
33
|
+
export const H5: FunctionComponent<IH5Props> = ({
|
|
34
|
+
children,
|
|
35
|
+
color,
|
|
36
|
+
testId,
|
|
37
|
+
}) => (
|
|
38
|
+
<h5 className={h5({ color })} data-testid={testId}>
|
|
39
|
+
{children}
|
|
40
|
+
</h5>
|
|
41
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
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 h6 shares the body size with h4, h5 and p, told apart by weight alone
|
|
6
|
+
// (docs/adr/0005). The `font-medium` is load-bearing - Tailwind's preflight resets h1-h6 to
|
|
7
|
+
// font-weight: inherit, so without it an h6 renders identically to a paragraph. Colour is a semantic
|
|
8
|
+
// token re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
9
|
+
const h6 = cva('font-primary text-body leading-body font-medium', {
|
|
10
|
+
variants: {
|
|
11
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { color: 'foreground' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
interface IH6Props extends VariantProps<typeof h6> {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
testId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The lightest heading, an `h6` at the body size, rendered medium — the deepest level the ladder
|
|
23
|
+
* offers, because the weight lever runs out here.
|
|
24
|
+
*
|
|
25
|
+
* @Guarantees — enforced on every render
|
|
26
|
+
* - Renders an `h6`; its outline level and the body role are one choice, not two (docs/adr/0005).
|
|
27
|
+
* - Reads `--font-primary`, sized by `--text-body`, medium so it stands apart from a paragraph.
|
|
28
|
+
* - `color` selects the `foreground` or `muted` role; nothing else paints text.
|
|
29
|
+
*
|
|
30
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
31
|
+
* - Heading levels descend without skipping — an `h6` sits under an `h5`, not under an `h4`.
|
|
32
|
+
*/
|
|
33
|
+
export const H6: FunctionComponent<IH6Props> = ({
|
|
34
|
+
children,
|
|
35
|
+
color,
|
|
36
|
+
testId,
|
|
37
|
+
}) => (
|
|
38
|
+
<h6 className={h6({ color })} data-testid={testId}>
|
|
39
|
+
{children}
|
|
40
|
+
</h6>
|
|
41
|
+
);
|
|
@@ -0,0 +1,38 @@
|
|
|
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 body role, shared with h4-h6 which add a weight to stand apart from it (docs/adr/0005). Weight
|
|
6
|
+
// inherits. Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
7
|
+
// P owns no reading measure - that belongs to whatever owns the reading column (Prose #21).
|
|
8
|
+
const p = cva('font-primary text-body leading-body', {
|
|
9
|
+
variants: {
|
|
10
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
11
|
+
},
|
|
12
|
+
defaultVariants: { color: 'foreground' },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
interface IPProps extends VariantProps<typeof p> {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
testId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A paragraph of body copy, a `p` at the body type role. Unbounded — usable in a form, a card or a
|
|
22
|
+
* table cell — because the reading measure belongs to whatever owns the reading column (Prose #21).
|
|
23
|
+
*
|
|
24
|
+
* @Guarantees — enforced on every render
|
|
25
|
+
* - Renders a `p`, reading `--font-primary`, sized by `--text-body` and led by `--leading-body`.
|
|
26
|
+
* - `color` selects the `foreground` or `muted` role; nothing else paints text.
|
|
27
|
+
*
|
|
28
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
29
|
+
* - Where line length matters, place the paragraph inside whatever bounds the reading measure; `P`
|
|
30
|
+
* does not constrain its own width.
|
|
31
|
+
* - For a paragraph inside a reading column, reach for `Prose.Body`, which is measure-bounded by its
|
|
32
|
+
* `Prose.Root`; `P` is for a paragraph with no reading column around it.
|
|
33
|
+
*/
|
|
34
|
+
export const P: FunctionComponent<IPProps> = ({ children, color, testId }) => (
|
|
35
|
+
<p className={p({ color })} data-testid={testId}>
|
|
36
|
+
{children}
|
|
37
|
+
</p>
|
|
38
|
+
);
|
|
@@ -0,0 +1,91 @@
|
|
|
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 reading column. It caps every child at --measure and sets no font-size, so the ch measure
|
|
6
|
+
// keeps resolving against inherited body type - the same rule Form follows when it takes the measure.
|
|
7
|
+
// The sibling gap is the type-scale --space-stack, never a one-idea-per-screen gap: the air the block
|
|
8
|
+
// wants is at its edges (the 66ch measure) and inside the type, not between the blocks. It sets no
|
|
9
|
+
// margin, so the page - not the component - owns the vertical rhythm around the column.
|
|
10
|
+
const proseRoot = cva(
|
|
11
|
+
'flex max-w-[var(--measure)] flex-col gap-[var(--space-stack)]',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
// The opening paragraph, at the new lede role: larger than body and led tighter (docs/adr/0004), but
|
|
15
|
+
// still running-text leading, not a heading's. Colour is the foreground token, re-pointed by `.dark`.
|
|
16
|
+
const proseLede = cva('font-primary text-lede leading-lede text-foreground');
|
|
17
|
+
|
|
18
|
+
// The body role inside the column - the same utilities P emits, restated because the no-cross-import
|
|
19
|
+
// rule forbids reusing P's recipe. A test pins the two equal so they cannot drift. Colour is a
|
|
20
|
+
// semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
|
|
21
|
+
const proseBody = cva('font-primary text-body leading-body', {
|
|
22
|
+
variants: {
|
|
23
|
+
color: { foreground: 'text-foreground', muted: 'text-muted' },
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: { color: 'foreground' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// The stepped-down closing note: the small role, always muted. small carries the enforced 15px floor.
|
|
29
|
+
const proseTail = cva('font-primary text-small text-muted');
|
|
30
|
+
|
|
31
|
+
interface IProseRootProps {
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
testId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface IProseLedeProps {
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface IProseBodyProps extends VariantProps<typeof proseBody> {
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface IProseTailProps {
|
|
45
|
+
children?: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ProseRoot: FunctionComponent<IProseRootProps> = ({
|
|
49
|
+
children,
|
|
50
|
+
testId,
|
|
51
|
+
}) => (
|
|
52
|
+
<div className={proseRoot()} data-testid={testId}>
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const ProseLede: FunctionComponent<IProseLedeProps> = ({ children }) => (
|
|
58
|
+
<p className={proseLede()}>{children}</p>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const ProseBody: FunctionComponent<IProseBodyProps> = ({ color, children }) => (
|
|
62
|
+
<p className={proseBody({ color })}>{children}</p>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const ProseTail: FunctionComponent<IProseTailProps> = ({ children }) => (
|
|
66
|
+
<p className={proseTail()}>{children}</p>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A measure-bounded reading block: a lede, body paragraphs and an optional stepped-down muted tail,
|
|
71
|
+
* all capped at the reading measure - including inside a wider grid cell, which follows from `Root`
|
|
72
|
+
* bounding the column. Composed from the four members; the consumer supplies the copy.
|
|
73
|
+
*
|
|
74
|
+
* @Guarantees — enforced on every render
|
|
75
|
+
* - `Root` renders a `div` (never a `section`: it owns no landmark and no heading), capped at
|
|
76
|
+
* `--measure`, setting no font-size so the `ch` measure resolves against inherited body type.
|
|
77
|
+
* - `Root` stacks its children on `--space-stack` and takes no outer margin: the page owns the
|
|
78
|
+
* rhythm around the block, and the air the block wants is at its edges and inside the type.
|
|
79
|
+
* - `Lede` renders a `p` at the lede role; `Body` at the body role with a `foreground`/`muted`
|
|
80
|
+
* `color`; `Tail` at the small role, muted. No drop cap or other invented device.
|
|
81
|
+
*
|
|
82
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
83
|
+
* - Use `Prose.Body` for a paragraph inside this reading column; for a paragraph with no reading
|
|
84
|
+
* column around it - in a form, a card, a table cell - use `P` instead.
|
|
85
|
+
*/
|
|
86
|
+
export const Prose = {
|
|
87
|
+
Root: ProseRoot,
|
|
88
|
+
Lede: ProseLede,
|
|
89
|
+
Body: ProseBody,
|
|
90
|
+
Tail: ProseTail,
|
|
91
|
+
} as const;
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import type { VariantProps } from 'class-variance-authority';
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
|
-
import type { FunctionComponent,
|
|
3
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
4
|
import type { Subject } from 'rxjs';
|
|
5
5
|
|
|
6
6
|
// No `dark:` classes here by design: every colour below is a semantic token whose value is
|
|
7
7
|
// re-pointed by the `.dark` class in tokens.css, so one set of classes serves both themes.
|
|
8
8
|
// The colour transition is stated once in the base, on the motion token, so no variant can
|
|
9
|
-
// disagree with it - see docs/adr/0001-motion-token-contract.md.
|
|
9
|
+
// disagree with it - see docs/adr/0001-motion-token-contract.md. The one focus ring is in the
|
|
10
|
+
// base too: identical across variants, drawn with outline, colour at rest so it never fades in -
|
|
11
|
+
// see docs/adr/0002-focus-ring-token-contract.md. The corner is in the base as well, one radius
|
|
12
|
+
// token every variant shares, so none can disagree - see docs/adr/0003-radius-token-contract.md.
|
|
10
13
|
const button = cva(
|
|
11
|
-
'transition-colors duration-[var(--motion-duration-color)] 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 focus-
|
|
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)]',
|
|
12
15
|
{
|
|
13
16
|
variants: {
|
|
14
17
|
variant: {
|
|
15
18
|
primary:
|
|
16
|
-
'px-4 sm:px-6 min-w-
|
|
19
|
+
'px-4 sm:px-6 min-w-[var(--control-min-width)] bg-primary text-primary-foreground hover:bg-primary-hover',
|
|
17
20
|
secondary:
|
|
18
|
-
'px-4 sm:px-6 min-w-
|
|
21
|
+
'px-4 sm:px-6 min-w-[var(--control-min-width)] bg-secondary text-secondary-foreground hover:bg-secondary-hover',
|
|
19
22
|
ghost:
|
|
20
|
-
'px-2 min-w-0 bg-transparent text-foreground hover:underline
|
|
23
|
+
'px-2 min-w-0 bg-transparent text-foreground hover:underline hover:decoration-[length:var(--underline-thickness)] hover:underline-offset-[var(--underline-offset)]',
|
|
21
24
|
},
|
|
22
25
|
},
|
|
23
26
|
defaultVariants: {
|
|
@@ -26,9 +29,9 @@ const button = cva(
|
|
|
26
29
|
},
|
|
27
30
|
);
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
interface IButtonProps extends VariantProps<typeof button> {
|
|
33
|
+
/** Optional: an icon-only button renders none, and names itself with `ariaLabel` instead. */
|
|
34
|
+
children?: ReactNode;
|
|
32
35
|
onClick$?: Subject<void>;
|
|
33
36
|
disabled?: boolean;
|
|
34
37
|
testId?: string;
|
|
@@ -53,13 +56,15 @@ export interface IButtonProps
|
|
|
53
56
|
* - Provide visual feedback on hover/active states
|
|
54
57
|
* - Ensure sufficient touch target size (minimum 44x44px) for mobile users
|
|
55
58
|
* - Position primary actions on the right for multi-button layouts
|
|
59
|
+
* - A submit button's busy state is a label swap ("Send" to "Sending…"), never a spinner: it costs
|
|
60
|
+
* nothing to render server-side and keeps a Form's `sending` state driver-agnostic
|
|
56
61
|
*
|
|
57
62
|
* @Accessibility
|
|
58
63
|
* - Ensure adequate color contrast (4.5:1 minimum ratio)
|
|
59
64
|
* - Provide focus styles for keyboard navigation
|
|
60
65
|
* - Use appropriate ARIA attributes when needed
|
|
61
66
|
*/
|
|
62
|
-
export const Button: FunctionComponent<
|
|
67
|
+
export const Button: FunctionComponent<IButtonProps> = ({
|
|
63
68
|
children,
|
|
64
69
|
disabled,
|
|
65
70
|
testId,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import { type FunctionComponent, useEffect, useId, useRef } from 'react';
|
|
4
|
+
import type { Subject } from 'rxjs';
|
|
5
|
+
|
|
6
|
+
// One recipe, deliberately not shared with TextArea (issue #5): each control owns its whole recipe
|
|
7
|
+
// so one-recipe-per-component holds without a base module. Colours are semantic tokens re-pointed by
|
|
8
|
+
// `.dark`, so no variant carries a `dark:` class. The border is the only boundary of a transparent
|
|
9
|
+
// control, drawn in `controlBorder` (>=3:1 against surface) and turned `error` on both `:user-invalid`
|
|
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).
|
|
12
|
+
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',
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
// text/email/url are visually identical; the axis only selects the control's `type`
|
|
17
|
+
// attribute (issue #5), so each option carries no class of its own.
|
|
18
|
+
variant: { text: '', email: '', url: '' },
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: { variant: 'text' },
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
interface IInputProps extends VariantProps<typeof input> {
|
|
25
|
+
/** Always rendered and associated with the control; never replaced by the placeholder. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** How the surrounding form reads the value on submit. */
|
|
28
|
+
name: string;
|
|
29
|
+
required?: boolean;
|
|
30
|
+
invalid?: boolean;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
defaultValue?: string;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
autocomplete?: 'name' | 'email' | 'url' | 'organization' | 'tel' | 'off';
|
|
35
|
+
hint?: string;
|
|
36
|
+
errorMessage?: string;
|
|
37
|
+
onInput$?: Subject<string>;
|
|
38
|
+
/** Emit to empty the control in place, keeping the same node so focus and IME composition survive. */
|
|
39
|
+
reset$?: Subject<void>;
|
|
40
|
+
testId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A labelled single-line text control. Its value is uncontrolled - the form reads it by `name` on
|
|
45
|
+
* submit - so it works with JavaScript disabled. Ids are minted internally, so the prop surface
|
|
46
|
+
* stays closed and the label/hint/error associations survive with no hydration.
|
|
47
|
+
*/
|
|
48
|
+
export const Input: FunctionComponent<IInputProps> = ({
|
|
49
|
+
label,
|
|
50
|
+
name,
|
|
51
|
+
variant,
|
|
52
|
+
required,
|
|
53
|
+
invalid,
|
|
54
|
+
disabled,
|
|
55
|
+
defaultValue,
|
|
56
|
+
placeholder,
|
|
57
|
+
autocomplete,
|
|
58
|
+
hint,
|
|
59
|
+
errorMessage,
|
|
60
|
+
onInput$,
|
|
61
|
+
reset$,
|
|
62
|
+
testId,
|
|
63
|
+
}) => {
|
|
64
|
+
const id = useId();
|
|
65
|
+
const controlId = `${id}-control`;
|
|
66
|
+
const hintId = `${id}-hint`;
|
|
67
|
+
const errorId = `${id}-error`;
|
|
68
|
+
const describedBy =
|
|
69
|
+
[hint ? hintId : undefined, invalid ? errorId : undefined]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(' ') || undefined;
|
|
72
|
+
|
|
73
|
+
// reset$ is an inbound command, so the component subscribes here (coding.md#asynchrony), unlike
|
|
74
|
+
// onInput$ which it emits on. Emptying the live node keeps focus and any in-flight IME composition,
|
|
75
|
+
// which a `key` remount would discard (issue #64).
|
|
76
|
+
const controlRef = useRef<HTMLInputElement>(null);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const subscription = reset$?.subscribe(() => {
|
|
79
|
+
if (controlRef.current) {
|
|
80
|
+
controlRef.current.value = '';
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return () => subscription?.unsubscribe();
|
|
84
|
+
}, [reset$]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className={'flex flex-col gap-[var(--space-stack)]'}>
|
|
88
|
+
<label htmlFor={controlId} className={'font-medium text-foreground'}>
|
|
89
|
+
{label}
|
|
90
|
+
</label>
|
|
91
|
+
<input
|
|
92
|
+
ref={controlRef}
|
|
93
|
+
id={controlId}
|
|
94
|
+
name={name}
|
|
95
|
+
type={variant ?? 'text'}
|
|
96
|
+
className={input({ variant })}
|
|
97
|
+
required={required}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
defaultValue={defaultValue}
|
|
100
|
+
placeholder={placeholder}
|
|
101
|
+
autoComplete={autocomplete}
|
|
102
|
+
aria-invalid={invalid || undefined}
|
|
103
|
+
aria-describedby={describedBy}
|
|
104
|
+
data-testid={testId}
|
|
105
|
+
onInput={(event) => onInput$?.next(event.currentTarget.value)}
|
|
106
|
+
/>
|
|
107
|
+
{!required && <span className={'text-muted text-sm'}>optional</span>}
|
|
108
|
+
{hint && (
|
|
109
|
+
<p id={hintId} className={'text-muted text-sm'}>
|
|
110
|
+
{hint}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
{invalid && (
|
|
114
|
+
<p id={errorId} className={'text-error text-sm'}>
|
|
115
|
+
{errorMessage}
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
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, four treatments that behave differently, not one link with a colour prop (see
|
|
6
|
+
// docs/adr/0006). The focus ring is in the base - a link is focusable - drawn with outline, colour at
|
|
7
|
+
// rest so it never fades in (docs/adr/0002); no radius, a link has no box (docs/adr/0003). Colours are
|
|
8
|
+
// semantic tokens re-pointed by `.dark`, so no treatment carries a `dark:` class. Underlines read the
|
|
9
|
+
// library's --underline-* tokens and arrive instantly, off the transition allowlist (docs/adr/0001):
|
|
10
|
+
// prose thickens its on hover, quiet and label-link raise one at rest thickness, graphic has none.
|
|
11
|
+
const link = cva(
|
|
12
|
+
'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
treatment: {
|
|
16
|
+
prose:
|
|
17
|
+
'text-link underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] hover:decoration-[length:var(--underline-thickness-hover)]',
|
|
18
|
+
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',
|
|
20
|
+
'label-link':
|
|
21
|
+
'text-inherit no-underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] hover:underline',
|
|
22
|
+
graphic: 'text-inherit no-underline',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: { treatment: 'prose' },
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
interface ILinkProps extends VariantProps<typeof link> {
|
|
30
|
+
/** Where the link points. Carried on the anchor, so the link navigates with JavaScript disabled. */
|
|
31
|
+
href: string;
|
|
32
|
+
/** The link text. */
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
/** Opens in a new tab and severs the opener together - `target="_blank"` implies the `rel`, so
|
|
35
|
+
* neither half is settable alone. */
|
|
36
|
+
external?: boolean;
|
|
37
|
+
/** Marks this link as the current page for assistive technology (`aria-current="page"`). Semantics
|
|
38
|
+
* only: any visual current-page treatment belongs to the Header, not here. */
|
|
39
|
+
current?: boolean;
|
|
40
|
+
testId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 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 underline on hover),
|
|
46
|
+
* `label-link` for a link acting as a label (inherits its colour, underlines on hover, sets no type
|
|
47
|
+
* of its own), and `graphic` for an anchor whose child is not text (paints nothing, so a mark keeps
|
|
48
|
+
* its own colour). It renders a plain `<a>`, so it works with no hydration.
|
|
49
|
+
*/
|
|
50
|
+
export const Link: FunctionComponent<ILinkProps> = ({
|
|
51
|
+
href,
|
|
52
|
+
children,
|
|
53
|
+
treatment,
|
|
54
|
+
external,
|
|
55
|
+
current,
|
|
56
|
+
testId,
|
|
57
|
+
}) => {
|
|
58
|
+
return (
|
|
59
|
+
<a
|
|
60
|
+
href={href}
|
|
61
|
+
className={link({ treatment })}
|
|
62
|
+
target={external ? '_blank' : undefined}
|
|
63
|
+
rel={external ? 'noopener noreferrer' : undefined}
|
|
64
|
+
aria-current={current ? 'page' : undefined}
|
|
65
|
+
data-testid={testId}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</a>
|
|
69
|
+
);
|
|
70
|
+
};
|