@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,110 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import { type FunctionComponent, useEffect, useId, useRef } from 'react';
|
|
3
|
+
import type { Subject } from 'rxjs';
|
|
4
|
+
|
|
5
|
+
// One recipe, deliberately not shared with Input (issue #5): each control owns its whole recipe so
|
|
6
|
+
// one-recipe-per-component holds without a base module. Colours are semantic tokens re-pointed by
|
|
7
|
+
// `.dark`, so no `dark:` class is needed. The border is the only boundary of a transparent control,
|
|
8
|
+
// drawn in `controlBorder` (>=3:1 against surface) and turned `error` on both `:user-invalid` and
|
|
9
|
+
// `aria-invalid` so a server-rendered and a browser-validated invalid state paint identically. Focus
|
|
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.
|
|
12
|
+
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',
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
interface ITextAreaProps {
|
|
17
|
+
/** Always rendered and associated with the control; never replaced by the placeholder. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** How the surrounding form reads the value on submit. */
|
|
20
|
+
name: string;
|
|
21
|
+
required?: boolean;
|
|
22
|
+
invalid?: boolean;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
defaultValue?: string;
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
autocomplete?: 'name' | 'email' | 'url' | 'organization' | 'tel' | 'off';
|
|
27
|
+
hint?: string;
|
|
28
|
+
errorMessage?: string;
|
|
29
|
+
onInput$?: Subject<string>;
|
|
30
|
+
/** Emit to empty the control in place, keeping the same node so focus and IME composition survive. */
|
|
31
|
+
reset$?: Subject<void>;
|
|
32
|
+
testId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A labelled multi-line text control. Its value is uncontrolled - the form reads it by `name` on
|
|
37
|
+
* submit - so it works with JavaScript disabled. Ids are minted internally, so the prop surface
|
|
38
|
+
* stays closed and the label/hint/error associations survive with no hydration.
|
|
39
|
+
*/
|
|
40
|
+
export const TextArea: FunctionComponent<ITextAreaProps> = ({
|
|
41
|
+
label,
|
|
42
|
+
name,
|
|
43
|
+
required,
|
|
44
|
+
invalid,
|
|
45
|
+
disabled,
|
|
46
|
+
defaultValue,
|
|
47
|
+
placeholder,
|
|
48
|
+
autocomplete,
|
|
49
|
+
hint,
|
|
50
|
+
errorMessage,
|
|
51
|
+
onInput$,
|
|
52
|
+
reset$,
|
|
53
|
+
testId,
|
|
54
|
+
}) => {
|
|
55
|
+
const id = useId();
|
|
56
|
+
const controlId = `${id}-control`;
|
|
57
|
+
const hintId = `${id}-hint`;
|
|
58
|
+
const errorId = `${id}-error`;
|
|
59
|
+
const describedBy =
|
|
60
|
+
[hint ? hintId : undefined, invalid ? errorId : undefined]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(' ') || undefined;
|
|
63
|
+
|
|
64
|
+
// reset$ is an inbound command, so the component subscribes here (coding.md#asynchrony), unlike
|
|
65
|
+
// onInput$ which it emits on. Emptying the live node keeps focus and any in-flight IME composition,
|
|
66
|
+
// which a `key` remount would discard (issue #64).
|
|
67
|
+
const controlRef = useRef<HTMLTextAreaElement>(null);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const subscription = reset$?.subscribe(() => {
|
|
70
|
+
if (controlRef.current) {
|
|
71
|
+
controlRef.current.value = '';
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return () => subscription?.unsubscribe();
|
|
75
|
+
}, [reset$]);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={'flex flex-col gap-[var(--space-stack)]'}>
|
|
79
|
+
<label htmlFor={controlId} className={'font-medium text-foreground'}>
|
|
80
|
+
{label}
|
|
81
|
+
</label>
|
|
82
|
+
<textarea
|
|
83
|
+
ref={controlRef}
|
|
84
|
+
id={controlId}
|
|
85
|
+
name={name}
|
|
86
|
+
className={textArea()}
|
|
87
|
+
required={required}
|
|
88
|
+
disabled={disabled}
|
|
89
|
+
defaultValue={defaultValue}
|
|
90
|
+
placeholder={placeholder}
|
|
91
|
+
autoComplete={autocomplete}
|
|
92
|
+
aria-invalid={invalid || undefined}
|
|
93
|
+
aria-describedby={describedBy}
|
|
94
|
+
data-testid={testId}
|
|
95
|
+
onInput={(event) => onInput$?.next(event.currentTarget.value)}
|
|
96
|
+
/>
|
|
97
|
+
{!required && <span className={'text-muted text-sm'}>optional</span>}
|
|
98
|
+
{hint && (
|
|
99
|
+
<p id={hintId} className={'text-muted text-sm'}>
|
|
100
|
+
{hint}
|
|
101
|
+
</p>
|
|
102
|
+
)}
|
|
103
|
+
{invalid && (
|
|
104
|
+
<p id={errorId} className={'text-error text-sm'}>
|
|
105
|
+
{errorMessage}
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
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 the <footer>. The type is set here, not borrowed from Header's label role: this slot is
|
|
6
|
+
// opaque and legitimately holds a sentence, and --tracking-label across one is damage, not a device (ADR
|
|
7
|
+
// 0004). `edge` is a variant, not a conditional: a footer is always last, exactly once, so there is no
|
|
8
|
+
// "first footer" for a data-section-style selector to suppress, and its predecessor is unpredictable - a
|
|
9
|
+
// conditional would silently drop the hairline. It draws in `rule`, the page's single hairline weight
|
|
10
|
+
// Section joins and Header's bottom edge share. Colours are semantic tokens re-pointed by `.dark`.
|
|
11
|
+
const footer = cva(
|
|
12
|
+
'font-secondary text-small text-muted py-[var(--space-region)] px-[var(--gutter)]',
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
edge: {
|
|
16
|
+
none: '',
|
|
17
|
+
rule: 'border-t border-solid border-rule',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: { edge: 'rule' },
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export interface IFooterProps extends VariantProps<typeof footer> {
|
|
25
|
+
children?: ReactNode;
|
|
26
|
+
testId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The shell's bottom edge: a `<footer>` that owns the `contentinfo` landmark, the small type, the shell's
|
|
31
|
+
* vertical air and the gutter, and its own top rule - and nothing about what is inside it. It is a frame,
|
|
32
|
+
* not an arrangement: `children` are placed directly in the footer with no wrapper and no `<nav>`.
|
|
33
|
+
*
|
|
34
|
+
* @Guarantees — enforced on every render
|
|
35
|
+
* - The footer is set at the `small` role and carries no size of its own.
|
|
36
|
+
* - Children are placed unmodified, directly inside the `<footer>`, with no wrapping element.
|
|
37
|
+
* - The top edge is the caller's choice - `edge="rule"` (the default) or `edge="none"` - and when drawn is
|
|
38
|
+
* always the page's single hairline weight, in the `rule` colour Section's join uses.
|
|
39
|
+
* - It is never sticky or fixed and needs no JavaScript.
|
|
40
|
+
*
|
|
41
|
+
* @CallerMustEnsure
|
|
42
|
+
* - The footer is a direct child of the page, not nested inside `main`, `article`, `aside`, `nav` or a
|
|
43
|
+
* `Section`. Nesting silently demotes it out of the `contentinfo` landmark, and the component cannot
|
|
44
|
+
* detect this.
|
|
45
|
+
*
|
|
46
|
+
* @UXGuidelines
|
|
47
|
+
* - Supply your own `<nav aria-label="…">` inside the slot if the footer navigates - a footer's contents
|
|
48
|
+
* are frequently not navigation, so this component declares no nav landmark over them. Where the footer
|
|
49
|
+
* does navigate, name `Header`'s nav with `navName` too: two unnamed navigation landmarks are
|
|
50
|
+
* indistinguishable to a screen-reader user.
|
|
51
|
+
*/
|
|
52
|
+
export const Footer: FunctionComponent<IFooterProps> = ({
|
|
53
|
+
edge,
|
|
54
|
+
children,
|
|
55
|
+
testId,
|
|
56
|
+
}) => (
|
|
57
|
+
<footer className={footer({ edge })} data-testid={testId}>
|
|
58
|
+
{children}
|
|
59
|
+
</footer>
|
|
60
|
+
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/** Which of the four states the page has put the form in. Owned by the page, never by Form: the
|
|
5
|
+
* component renders the state it is given and never decides which one it is in. */
|
|
6
|
+
export type FormState = 'idle' | 'sending' | 'sent' | 'failed';
|
|
7
|
+
|
|
8
|
+
// The one recipe, and it paints the note only (issue #6): `state` selects the note's tone - muted
|
|
9
|
+
// while the form stands, success on the outcome, error on the failure. Region selection is the
|
|
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
|
+
const form = cva('text-sm', {
|
|
13
|
+
variants: {
|
|
14
|
+
state: {
|
|
15
|
+
idle: 'text-muted',
|
|
16
|
+
sending: 'text-muted',
|
|
17
|
+
sent: 'text-success',
|
|
18
|
+
failed: 'text-error',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: { state: 'idle' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// The note's ARIA role: a settled outcome is a status region, a failure an alert; while the form
|
|
25
|
+
// still stands the note is plain prose and carries none.
|
|
26
|
+
const NOTE_ROLE: Partial<Record<FormState, 'status' | 'alert'>> = {
|
|
27
|
+
sent: 'status',
|
|
28
|
+
failed: 'alert',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface IFormProps {
|
|
32
|
+
/** Where the submission goes. Passed straight to the form element; Form makes no decision about it. */
|
|
33
|
+
action: string;
|
|
34
|
+
method?: 'get' | 'post';
|
|
35
|
+
state?: FormState;
|
|
36
|
+
/** The fields. */
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
/** The actions row - the consumer supplies its own submit Button. */
|
|
39
|
+
actions?: ReactNode;
|
|
40
|
+
/** Standing note in `idle`/`sending`; the outcome message in `sent`/`failed`. */
|
|
41
|
+
note?: string;
|
|
42
|
+
testId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The container around a set of fields: fields, an actions row, a note. It renders a real `<form>`
|
|
47
|
+
* carrying `action` and `method`, so a native submission works with no JavaScript, and it renders
|
|
48
|
+
* the `state` it is given without ever owning a submission, a transport or validation timing - both
|
|
49
|
+
* a server-rendered driver and a hydrated island express every state through the same props.
|
|
50
|
+
*
|
|
51
|
+
* `sent` drops the fields and actions so a completed submission cannot be resubmitted, while the
|
|
52
|
+
* `<form>` itself is retained in every state so the DOM shape is stable across a runtime change.
|
|
53
|
+
*
|
|
54
|
+
* The note's text is always the consumer's; Form chooses only its element and ARIA role. `sent` is
|
|
55
|
+
* the outcome message, so a `sent` with no `note` is a programmer error and throws.
|
|
56
|
+
*/
|
|
57
|
+
export const Form: FunctionComponent<IFormProps> = ({
|
|
58
|
+
action,
|
|
59
|
+
method = 'post',
|
|
60
|
+
state = 'idle',
|
|
61
|
+
children,
|
|
62
|
+
actions,
|
|
63
|
+
note,
|
|
64
|
+
testId,
|
|
65
|
+
}) => {
|
|
66
|
+
if (state === 'sent' && !note) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'Form in the `sent` state must be given a `note` - it is the outcome message.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const showFields = state !== 'sent';
|
|
73
|
+
const noteRole = NOTE_ROLE[state];
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<form
|
|
77
|
+
action={action}
|
|
78
|
+
method={method}
|
|
79
|
+
aria-busy={state === 'sending' || undefined}
|
|
80
|
+
data-testid={testId}
|
|
81
|
+
className={
|
|
82
|
+
'flex max-w-[var(--measure)] flex-col gap-[var(--space-region)]'
|
|
83
|
+
}
|
|
84
|
+
>
|
|
85
|
+
{showFields && (
|
|
86
|
+
<div className={'flex flex-col gap-[var(--space-stack)]'}>
|
|
87
|
+
{children}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
{showFields && actions && (
|
|
91
|
+
<div className={'flex flex-row gap-[var(--space-stack)]'}>
|
|
92
|
+
{actions}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
{note && (
|
|
96
|
+
<p role={noteRole} className={form({ state })}>
|
|
97
|
+
{note}
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
</form>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
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 the <header>. It sets the label type role - the "small grotesk, letter-spaced, muted"
|
|
6
|
+
// the issue described, whose "never grows past 1rem" was a role wearing a number, so no size literal
|
|
7
|
+
// appears here (#14). The shell's air is one value in every direction: --space-region above, below and
|
|
8
|
+
// (on the nav) between, --gutter across, so the bar aligns with every inset Section. The current-page
|
|
9
|
+
// treatment keys on the attribute, not on a component: [&_[aria-current=page]] compiles to specificity
|
|
10
|
+
// 0,2,0 and beats Link's quiet text-muted (0,1,0) with no !important and no import, so the current item
|
|
11
|
+
// sits at foreground - the colour every other item reaches only on hover. `edge` draws the bottom
|
|
12
|
+
// hairline in `rule`, the one weight a page's boundaries share with a Section join, and defaults to
|
|
13
|
+
// `rule` - the conventional header - so a ruleless shell opts out. Colours are semantic tokens re-pointed
|
|
14
|
+
// by `.dark`, so no `dark:` class.
|
|
15
|
+
const header = cva(
|
|
16
|
+
[
|
|
17
|
+
'flex items-baseline justify-between',
|
|
18
|
+
'font-secondary text-label tracking-label text-muted',
|
|
19
|
+
'py-[var(--space-region)] px-[var(--gutter)]',
|
|
20
|
+
'[&_[aria-current=page]]:text-foreground',
|
|
21
|
+
].join(' '),
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
edge: {
|
|
25
|
+
none: '',
|
|
26
|
+
rule: 'border-b border-solid border-rule',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: { edge: 'rule' },
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export interface IHeaderProps extends VariantProps<typeof header> {
|
|
34
|
+
/** The standing link: a place name, a mark, or a home link. The consumer supplies the whole anchor -
|
|
35
|
+
* Header renders no link of its own. A place name uses `<Link treatment="quiet" href="/">…</Link>`; a
|
|
36
|
+
* mark uses `<Link treatment="graphic" href="/"><Brandmark …/></Link>`, whose `graphic` treatment
|
|
37
|
+
* paints nothing over a `currentColor` mark. */
|
|
38
|
+
standing?: ReactNode;
|
|
39
|
+
/** Names the nav for assistive technology. Omit unless the page has more than one nav. */
|
|
40
|
+
navName?: string;
|
|
41
|
+
/** The nav links. */
|
|
42
|
+
children?: ReactNode;
|
|
43
|
+
testId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The shell's top edge: a standing link and a nav, at the label type role. It renders the banner
|
|
48
|
+
* landmark and a single `<nav>`, arranging nothing beyond the two slots, so it works with no hydration.
|
|
49
|
+
*
|
|
50
|
+
* @Guarantees — enforced on every render
|
|
51
|
+
* - The header is set at the label role and carries no size of its own: `font-secondary text-label
|
|
52
|
+
* tracking-label text-muted`, so it never grows past that role whatever the page font size.
|
|
53
|
+
* - A nav item marked `aria-current="page"` renders at `foreground` whatever supplied it - the treatment
|
|
54
|
+
* keys on the attribute, not on `Link`, so it holds against a bare anchor or any component.
|
|
55
|
+
* - The shell's air is one value above, below and between: `--space-region` vertically and between nav
|
|
56
|
+
* items, `--gutter` across, so the bar aligns with every inset `Section`.
|
|
57
|
+
* - It is never sticky and needs no JavaScript: there is no `sticky` variant and nothing to hydrate.
|
|
58
|
+
* - Omitting `navName` emits no `aria-label` at all, not an empty one.
|
|
59
|
+
*
|
|
60
|
+
* @UXGuidelines
|
|
61
|
+
* - Name the nav with `navName` once the page has more than one navigation landmark - a footer nav will
|
|
62
|
+
* be the second, and two unnamed navs are indistinguishable to a screen-reader user.
|
|
63
|
+
* - Nav links use `Link`'s `quiet` treatment. The current-page treatment is applied here from
|
|
64
|
+
* `aria-current`, so set `current` on the `Link` and style nothing yourself.
|
|
65
|
+
* - The nav does not collapse into a menu: a disclosure needs JavaScript, so on a narrow viewport the
|
|
66
|
+
* items wrap. A mobile menu is out of scope, not a follow-up.
|
|
67
|
+
*/
|
|
68
|
+
export const Header: FunctionComponent<IHeaderProps> = ({
|
|
69
|
+
edge,
|
|
70
|
+
standing,
|
|
71
|
+
navName,
|
|
72
|
+
children,
|
|
73
|
+
testId,
|
|
74
|
+
}) => (
|
|
75
|
+
<header className={header({ edge })} data-testid={testId}>
|
|
76
|
+
<div>{standing}</div>
|
|
77
|
+
<nav
|
|
78
|
+
aria-label={navName}
|
|
79
|
+
className={'flex flex-wrap items-baseline gap-[var(--space-region)]'}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</nav>
|
|
83
|
+
</header>
|
|
84
|
+
);
|
|
@@ -0,0 +1,73 @@
|
|
|
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>. It holds the fold - a minimum height taller than its content, so leftover
|
|
6
|
+
// space is guaranteed and how the slot sits in it is a decision, not a default (#17). With no className
|
|
7
|
+
// escape hatch, whatever place picks is final, so it is a variant: center is the conventional hero and the
|
|
8
|
+
// default; between drops a poster's foot on the fold's bottom edge; start asks for the minimum height
|
|
9
|
+
// without the centring. `end` is deliberately absent - content pinned to the bottom over empty space is
|
|
10
|
+
// not a hero. It reads --fold-height for the floor and --space-stack for the gap Prose and PageHead use.
|
|
11
|
+
const hero = cva(
|
|
12
|
+
'flex flex-col min-h-[var(--fold-height)] gap-[var(--space-stack)]',
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
place: {
|
|
16
|
+
start: 'justify-start',
|
|
17
|
+
center: 'justify-center',
|
|
18
|
+
between: 'justify-between',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: { place: 'center' },
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export interface IHeroProps extends VariantProps<typeof hero> {
|
|
26
|
+
/** The fold's one opaque slot. Rendered unmodified: a poster composes a mark, an `H1` lead and a foot
|
|
27
|
+
* here; another brand composes something else. `Hero` imposes no anatomy. */
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
testId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The first screen's frame: a plain container that holds at least the fold height and places one opaque
|
|
34
|
+
* slot within it. It renders no heading and no landmark - a hero's lead is an `<H1>` the consumer places
|
|
35
|
+
* in the slot, and the `Section` around it owns the region, the bleed, the band and the gutter. A hero
|
|
36
|
+
* varies in its structure where a footer varies only in its contents, so there is no shared anatomy to
|
|
37
|
+
* model and the opaque slot is the honest answer.
|
|
38
|
+
*
|
|
39
|
+
* @Guarantees — enforced on every render
|
|
40
|
+
* - It is at least `--fold-height` tall and never taller by construction: a `min-height` floor, not a
|
|
41
|
+
* fixed height, so content longer than the fold grows the frame rather than overflowing it.
|
|
42
|
+
* - `children` render unmodified; the component adds nothing to and strips nothing from them, and sets no
|
|
43
|
+
* margin, max-width, colour or heading of its own.
|
|
44
|
+
* - It owns no bleed, band or gutter and emits no `<section>` and no landmark role - the enclosing
|
|
45
|
+
* `Section` carries those.
|
|
46
|
+
* - `place` positions the slot in the leftover space: `center` (the default), `start` or `between`; the
|
|
47
|
+
* parts are gapped with `--space-stack`.
|
|
48
|
+
* - It is never sticky or fixed and needs no JavaScript, so it renders identically server-side.
|
|
49
|
+
*
|
|
50
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
51
|
+
* - The hero sits inside a `Section` - `<Section bleed="full"><Hero>…</Hero></Section>` - which owns the
|
|
52
|
+
* bleed, the band, the gutter and the region. `Hero` imports no component and adds none of these.
|
|
53
|
+
*
|
|
54
|
+
* @UXGuidelines
|
|
55
|
+
* - Cap the lead at `--measure-display`, not at a caption's width, and apply it to your own lead - not
|
|
56
|
+
* here, since capping the slot would cap the mark too. Measured on the lockup that prompted this: at
|
|
57
|
+
* 24ch a one-sentence lead set five lines and became a second block competing with the mark; at 36ch it
|
|
58
|
+
* sets three and reads as the mark's caption. This is the one place where copy length is a layout
|
|
59
|
+
* parameter - a hero whose lead sentence can vary in length needs its measure chosen against the real
|
|
60
|
+
* sentence, not a rule of thumb.
|
|
61
|
+
* - A hero built on a wide lockup is a desktop bet by construction. A 15-character lockup can only be as
|
|
62
|
+
* wide as the phone, so at 390px the scale contrast between mark and lead is capped by the viewport
|
|
63
|
+
* rather than chosen - roughly 2:1, against roughly 6:1 at 1440.
|
|
64
|
+
*/
|
|
65
|
+
export const Hero: FunctionComponent<IHeroProps> = ({
|
|
66
|
+
place,
|
|
67
|
+
children,
|
|
68
|
+
testId,
|
|
69
|
+
}) => (
|
|
70
|
+
<div className={hero({ place })} data-testid={testId}>
|
|
71
|
+
{children}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
// One recipe on the <header>, one on each element it paints. The head is full-bleed: it carries the
|
|
5
|
+
// band and the gutter every page unit shares but takes no max-width and joins no grid, so it opts out
|
|
6
|
+
// of the index column a default Section sits in (#9) by spanning the full content width - the text, not
|
|
7
|
+
// the head, owns every measure. The title is the title role, one rung below the hero's display: the cap
|
|
8
|
+
// on out-scaling the homepage hero is the display > title token relationship renderTokens pins (ADR
|
|
9
|
+
// 0004/0005), never a local clamp, so tracking-optical is the head's only optical touch and no size
|
|
10
|
+
// literal appears. That tracking is the title role's, not this component's (#57): H2 binds to the same
|
|
11
|
+
// role and carries the same correction, so the role reads one way wherever it is rendered. The stack gap is --space-stack, the sibling gap Prose uses. Colours are semantic
|
|
12
|
+
// tokens re-pointed by `.dark`, so no `dark:` class.
|
|
13
|
+
const pageHead = cva(
|
|
14
|
+
'flex flex-col gap-[var(--space-stack)] py-[var(--space-band)] px-[var(--gutter)]',
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// The scale event: the title role, led and tracked as a large heading, foreground. No measure - the
|
|
18
|
+
// head is full-bleed and the consumer keeps titles short.
|
|
19
|
+
const pageHeadTitle = cva(
|
|
20
|
+
'font-primary text-title leading-title tracking-optical text-foreground',
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// The standfirst: the lede role, foreground, run one measure wider than the reading column (#18).
|
|
24
|
+
const pageHeadLede = cva(
|
|
25
|
+
'font-primary text-lede leading-lede text-foreground max-w-[var(--measure-wide)]',
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// The small print: the small role, muted, held to the reading measure like Prose's tail.
|
|
29
|
+
const pageHeadIntro = cva(
|
|
30
|
+
'font-primary text-small text-muted max-w-[var(--measure)]',
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export interface IPageHeadProps {
|
|
34
|
+
/** The page title - the head's one scale event, rendered as the page's `h1` at the title role. */
|
|
35
|
+
title: ReactNode;
|
|
36
|
+
/** The standfirst under the title: a lede-role paragraph at a measure slightly wider than the
|
|
37
|
+
* reading column. Omit for a bare title. */
|
|
38
|
+
lede?: ReactNode;
|
|
39
|
+
/** The muted small-print paragraph under the lede, held to the reading measure. Omit where the head
|
|
40
|
+
* is title and lede only. */
|
|
41
|
+
intro?: ReactNode;
|
|
42
|
+
testId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A subpage's opening: one scale event, then small print. It renders a `<header>` carrying the page's
|
|
47
|
+
* `h1`, an optional lede standfirst and an optional muted intro, full-bleed over the vertical band and
|
|
48
|
+
* the gutter. It arranges the three slots and owns their type roles; the consumer supplies the copy.
|
|
49
|
+
*
|
|
50
|
+
* @Guarantees — enforced on every render
|
|
51
|
+
* - The title is an `h1` at the `title` role - one rung below the hero's `display` - so it can never
|
|
52
|
+
* out-scale the homepage hero: the cap is the `display > title` token relationship (ADR 0004/0005),
|
|
53
|
+
* not two `clamp()`s ordered by luck, and no size literal is set here.
|
|
54
|
+
* - The head is full-bleed: it carries `--space-band` and `--gutter` but takes no `max-width` and joins
|
|
55
|
+
* no grid, so it opts out of the index column and spans the full content width.
|
|
56
|
+
* - The lede is the `lede` role at `--measure-wide`, one measure wider than the reading column; the
|
|
57
|
+
* intro is the `small` role, muted, at `--measure`. Each measure is on the text, never on the head.
|
|
58
|
+
* - Omitting `lede` or `intro` renders that paragraph not at all.
|
|
59
|
+
* - It is never sticky or fixed and needs no JavaScript.
|
|
60
|
+
*
|
|
61
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
62
|
+
* - The head sits inside the page's main content (a `main`, `article` or `section`), not as a top-level
|
|
63
|
+
* child of `body`, so its `<header>` is the page head and not a second `banner` beside the shell's
|
|
64
|
+
* `Header` (#14).
|
|
65
|
+
* - This head opens a subpage. The homepage hero is `PosterFold` (#17), which renders at the `display`
|
|
66
|
+
* role; reach for that where the page is the poster, not for this.
|
|
67
|
+
*/
|
|
68
|
+
export const PageHead: FunctionComponent<IPageHeadProps> = ({
|
|
69
|
+
title,
|
|
70
|
+
lede,
|
|
71
|
+
intro,
|
|
72
|
+
testId,
|
|
73
|
+
}) => (
|
|
74
|
+
<header className={pageHead()} data-testid={testId}>
|
|
75
|
+
<h1 className={pageHeadTitle()}>{title}</h1>
|
|
76
|
+
{lede !== undefined && <p className={pageHeadLede()}>{lede}</p>}
|
|
77
|
+
{intro !== undefined && <p className={pageHeadIntro()}>{intro}</p>}
|
|
78
|
+
</header>
|
|
79
|
+
);
|
|
@@ -0,0 +1,79 @@
|
|
|
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 the <section>. The join is the load-bearing part: a top rule is drawn only when the
|
|
6
|
+
// immediately preceding sibling also carries data-section, so it appears between two sections and never
|
|
7
|
+
// above the first - and never beneath a header, whose own rule is a direction-level choice (#14), not a
|
|
8
|
+
// default here. The [[data-section]+&] selector says exactly "every section after the first", which
|
|
9
|
+
// :not(:first-child) does not - that would rule beneath anything at all. It reads `rule`, not `border`:
|
|
10
|
+
// a section join carries the whole load a gap would, and at page scale a 1.23:1 line may not read as a
|
|
11
|
+
// separation. The rule spans the full element width in both bleed variants, since a border sits outside
|
|
12
|
+
// padding - the join is the page's structure, so it does not inset with the content. `bleed` is the only
|
|
13
|
+
// axis: inset holds content off the viewport edge by the gutter, full runs edge to edge for a hero or
|
|
14
|
+
// page head; both keep the vertical band. No margin and no max-width anywhere - sections abut, the band
|
|
15
|
+
// is air inside the unit, and Prose owns the reading measure. Colours are semantic tokens re-pointed by
|
|
16
|
+
// `.dark`, so no `dark:` class.
|
|
17
|
+
const section = cva(
|
|
18
|
+
[
|
|
19
|
+
'py-[var(--space-band)]',
|
|
20
|
+
'[[data-section]+&]:border-t [[data-section]+&]:border-solid [[data-section]+&]:border-rule',
|
|
21
|
+
].join(' '),
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
bleed: {
|
|
25
|
+
inset: 'px-[var(--gutter)]',
|
|
26
|
+
full: '',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: { bleed: 'inset' },
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export interface ISectionProps extends VariantProps<typeof section> {
|
|
34
|
+
/** Names the section as a region a screen-reader user can jump to. Omit for an ordinary section: an
|
|
35
|
+
* unnamed section is inert to assistive technology, which is the right default. Two or three named
|
|
36
|
+
* regions on a page is navigation; six is landmark noise, which is why this is opt-in. */
|
|
37
|
+
name?: string;
|
|
38
|
+
children?: ReactNode;
|
|
39
|
+
testId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The page's structural unit: a band carrying its own vertical air and, unless it bleeds, the gutter.
|
|
44
|
+
* It arranges nothing inside itself - what it owns is the join to the section before it, which is why
|
|
45
|
+
* it is a composable of the page rather than a container of its contents.
|
|
46
|
+
*
|
|
47
|
+
* @Guarantees — enforced on every render
|
|
48
|
+
* - Sections abut and never gap: the component sets no margin, and the join is the separation. A rule
|
|
49
|
+
* appears only between two sections - drawn when the preceding sibling also carries data-section, so
|
|
50
|
+
* never above the first section nor beneath a header before it.
|
|
51
|
+
* - A `bleed="full"` section drops the gutter and runs edge to edge; the vertical band is kept on both
|
|
52
|
+
* bleed variants, and the join spans the full width of either.
|
|
53
|
+
* - An unnamed section is not a landmark: it emits no `aria-label` and is inert to assistive technology.
|
|
54
|
+
*
|
|
55
|
+
* @CallerMustEnsure
|
|
56
|
+
* - Where `name` is given it matches the section's visible heading. The component labels the region with
|
|
57
|
+
* that string because it cannot reach the heading's id to reference it with `aria-labelledby` instead.
|
|
58
|
+
*
|
|
59
|
+
* @UXGuidelines
|
|
60
|
+
* - On a page with no borrowed proof - no logos, no testimonials, no credits - a gap between blocks reads
|
|
61
|
+
* as missing content, while air inside the type reads as care. So the levers on a page's rhythm are the
|
|
62
|
+
* measure, the leading and the band, never a space between sections. This is why Section offers no gap
|
|
63
|
+
* and no margin.
|
|
64
|
+
*/
|
|
65
|
+
export const Section: FunctionComponent<ISectionProps> = ({
|
|
66
|
+
bleed,
|
|
67
|
+
name,
|
|
68
|
+
children,
|
|
69
|
+
testId,
|
|
70
|
+
}) => (
|
|
71
|
+
<section
|
|
72
|
+
data-section={''}
|
|
73
|
+
className={section({ bleed })}
|
|
74
|
+
aria-label={name}
|
|
75
|
+
data-testid={testId}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</section>
|
|
79
|
+
);
|