division-ds 0.1.2 → 0.2.1
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/AGENTS.md +23 -3
- package/README.md +11 -0
- package/division-ds-0.2.1.tgz +0 -0
- package/package.json +2 -2
- package/public/docs/components/accordion.md +207 -0
- package/public/docs/components/ai-label.md +90 -0
- package/public/docs/components/alert-dialog.md +292 -0
- package/public/docs/components/alert.md +175 -0
- package/public/docs/components/approval-request.md +148 -0
- package/public/docs/components/auth-form.md +132 -0
- package/public/docs/components/avatar-group.md +152 -0
- package/public/docs/components/avatar.md +179 -0
- package/public/docs/components/badge.md +178 -0
- package/public/docs/components/breadcrumbs.md +112 -0
- package/public/docs/components/button.md +204 -0
- package/public/docs/components/card.md +257 -0
- package/public/docs/components/carousel.md +130 -0
- package/public/docs/components/cart.md +172 -0
- package/public/docs/components/chart-panel.md +358 -0
- package/public/docs/components/chat-message.md +157 -0
- package/public/docs/components/chat-thread.md +244 -0
- package/public/docs/components/checkbox.md +149 -0
- package/public/docs/components/checkout-form.md +128 -0
- package/public/docs/components/command-palette.md +147 -0
- package/public/docs/components/data-table.md +401 -0
- package/public/docs/components/description-list.md +103 -0
- package/public/docs/components/dialog.md +197 -0
- package/public/docs/components/divider.md +97 -0
- package/public/docs/components/dropdown-menu.md +222 -0
- package/public/docs/components/empty-state.md +145 -0
- package/public/docs/components/footer.md +397 -0
- package/public/docs/components/form-field.md +216 -0
- package/public/docs/components/icon-button.md +127 -0
- package/public/docs/components/input.md +200 -0
- package/public/docs/components/kanban-board.md +300 -0
- package/public/docs/components/kbd.md +72 -0
- package/public/docs/components/link.md +98 -0
- package/public/docs/components/list-item.md +241 -0
- package/public/docs/components/loading-animation.md +72 -0
- package/public/docs/components/multi-select.md +202 -0
- package/public/docs/components/navbar.md +144 -0
- package/public/docs/components/newsletter-form.md +112 -0
- package/public/docs/components/page-header.md +151 -0
- package/public/docs/components/pagination.md +212 -0
- package/public/docs/components/product-card.md +172 -0
- package/public/docs/components/progress.md +101 -0
- package/public/docs/components/prompt-input.md +151 -0
- package/public/docs/components/quantity-stepper.md +140 -0
- package/public/docs/components/radio.md +177 -0
- package/public/docs/components/rating.md +106 -0
- package/public/docs/components/score-ring.md +95 -0
- package/public/docs/components/search.md +116 -0
- package/public/docs/components/section-heading.md +113 -0
- package/public/docs/components/segmented-control.md +126 -0
- package/public/docs/components/select.md +224 -0
- package/public/docs/components/sheet.md +145 -0
- package/public/docs/components/sidebar.md +193 -0
- package/public/docs/components/skeleton.md +135 -0
- package/public/docs/components/slider.md +114 -0
- package/public/docs/components/sparkline.md +128 -0
- package/public/docs/components/spinner.md +98 -0
- package/public/docs/components/stat.md +106 -0
- package/public/docs/components/status-tracker.md +145 -0
- package/public/docs/components/stepper.md +147 -0
- package/public/docs/components/switch.md +153 -0
- package/public/docs/components/tabs.md +180 -0
- package/public/docs/components/tag.md +105 -0
- package/public/docs/components/textarea.md +148 -0
- package/public/docs/components/timeline.md +152 -0
- package/public/docs/components/toast.md +194 -0
- package/public/docs/components/tool-call.md +107 -0
- package/public/docs/components/tooltip.md +108 -0
- package/public/manifest/components.json +198 -15
- package/public/manifest/tokens.json +75 -5
- package/scripts/division-ds.mjs +78 -42
- package/scripts/lib/agent-docs.mjs +80 -6
- package/scripts/lib/ai-copy.mjs +120 -0
- package/scripts/lib/button-spacing.mjs +5 -5
- package/scripts/lib/check-text.mjs +57 -0
- package/scripts/lib/contrast.mjs +111 -0
- package/scripts/lib/host-inline.mjs +59 -0
- package/scripts/lib/mcp-server.mjs +116 -0
- package/scripts/lib/mcp-tools.mjs +266 -0
- package/scripts/lib/props-validator.mjs +3 -2
- package/scripts/lib/rules-installer.mjs +42 -4
- package/scripts/lib/token-discipline.mjs +10 -3
- package/skills/division-ds/SKILL.md +90 -0
- package/skills/division-ds/commands/add.md +25 -0
- package/skills/division-ds/commands/build.md +46 -0
- package/skills/division-ds/commands/fix.md +28 -0
- package/skills/division-ds/commands/review.md +25 -0
- package/skills/division-ds/topics/ai.md +8 -0
- package/skills/division-ds/topics/colour.md +11 -0
- package/skills/division-ds/topics/feedback.md +9 -0
- package/skills/division-ds/topics/forms.md +22 -0
- package/skills/division-ds/topics/layout.md +36 -0
- package/skills/division-ds/topics/motion.md +10 -0
- package/skills/division-ds/topics/overlays.md +9 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# Footer
|
|
2
|
+
|
|
3
|
+
> The bottom of a site: where else to go, one more thing to say, the small print, and the name. Link groups sit on the left and a section of your own on the right. Under them is the wordmark, as wide as the footer and cut off by its bottom edge. One nav holds every group, so a screen reader meets one navigation landmark (a labelled region it can jump to), not six.
|
|
4
|
+
|
|
5
|
+
- **Best for:** The bottom of a site: link groups, a section of your own, the small print and the wordmark.
|
|
6
|
+
- **Not for:** A copy of the main navigation, or the only route to a page: use Navbar.
|
|
7
|
+
- Organism. Source: `src/components/organisms/footer/Footer.tsx`
|
|
8
|
+
- Rendered docs page: `/components/footer`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { Footer } from 'src/components/organisms/footer/Footer'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Paths are from the Division DS repository root. Installed with shadcn they start `src/division-ds/` instead of `src/` (`src/division-ds/components/...`); import them relative to your file.
|
|
17
|
+
|
|
18
|
+
## Props
|
|
19
|
+
|
|
20
|
+
| Prop | Type | Default | Description |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| `label` | `string` | | Required. The name of the nav landmark. |
|
|
23
|
+
| `columns` | `FooterColumn[]` | | Required. Each has a title, links and an optional defaultOpen. A link can be external. The grid is laid out for up to six groups; more wrap onto extra rows. |
|
|
24
|
+
| `aside` | `{ title: string; content: ReactNode }` | | The second section, such as a sign-up form, an address or opening hours. The footer draws the title as a heading at headingLevel, with the slash. |
|
|
25
|
+
| `wordmark` | `string` | | The name, drawn across the whole width at the bottom edge. It's a string, not a node, because the footer sizes the line from its letters and uses it to name the image. |
|
|
26
|
+
| `backdrop` | `ReactNode` | | Decoration behind the wordmark. Position it with absolute inset-0. It's hidden from assistive technology (screen readers and similar tools) and takes no pointer input, so it mustn't hold content or anything focusable. |
|
|
27
|
+
| `brand` | `ReactNode` | | A small brand mark above the groups, for a footer with no wordmark. |
|
|
28
|
+
| `tagline` | `string` | | A line under the brand, or on its own above the groups. |
|
|
29
|
+
| `legal` | `ReactNode` | | The small print, such as a copyright line or a legal link. |
|
|
30
|
+
| `headingLevel` | `2 \| 3 \| 4` | `2` | The level of the group headings and the aside's heading. Match the page's heading outline. |
|
|
31
|
+
| `renderLink` | `(props: LinkRenderProps) => ReactNode` | `a plain anchor` | Internal links only. An external link always renders through the Link atom. Pass your router's link component here, mapping href to its own prop and passing className through. |
|
|
32
|
+
| `className` | `string` | | Merged with the root classes. Later Tailwind utilities win. The side spacing is the page gutter, --page-gutter (1rem unless a shell sets it). The wordmark and the backdrop ignore it. |
|
|
33
|
+
| `ref` | `Ref<HTMLElement>` | | The footer element. |
|
|
34
|
+
|
|
35
|
+
## Types and helpers
|
|
36
|
+
|
|
37
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
export type FooterLink = { label: string; href: string; external?: boolean }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
export type FooterColumn = {
|
|
45
|
+
title: string
|
|
46
|
+
links: FooterLink[]
|
|
47
|
+
/** Whether this group starts open where the groups are an accordion. Ignored where they are a
|
|
48
|
+
* grid, because nothing collapses there. @default false */
|
|
49
|
+
defaultOpen?: boolean
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Examples
|
|
54
|
+
|
|
55
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
56
|
+
|
|
57
|
+
### Store footer: four groups, a small brand and no wordmark
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
const STORE_COLUMNS: FooterColumn[] = [
|
|
61
|
+
{
|
|
62
|
+
title: 'Shop',
|
|
63
|
+
links: [
|
|
64
|
+
{ label: 'Replicas', href: '#replicas' },
|
|
65
|
+
{ label: 'Optics', href: '#optics' },
|
|
66
|
+
{ label: 'Parts', href: '#parts' },
|
|
67
|
+
{ label: 'Care', href: '#care' },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
title: 'Support',
|
|
72
|
+
links: [
|
|
73
|
+
{ label: 'Shipping', href: '#shipping' },
|
|
74
|
+
{ label: 'Returns', href: '#returns' },
|
|
75
|
+
{ label: 'Warranty', href: '#warranty' },
|
|
76
|
+
{ label: 'Contact', href: '#contact' },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
title: 'Company',
|
|
81
|
+
links: [
|
|
82
|
+
{ label: 'About', href: '#about' },
|
|
83
|
+
{ label: 'Field notes', href: '#field-notes' },
|
|
84
|
+
{ label: 'Careers', href: '#careers' },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
title: 'Elsewhere',
|
|
89
|
+
links: [
|
|
90
|
+
{ label: 'Site finder', href: 'https://example.com/sites', external: true },
|
|
91
|
+
{ label: 'Field guides', href: 'https://example.com/field-guides', external: true },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const LEGAL = '© 2026 Hexline. A demo brand for this design system.'
|
|
97
|
+
|
|
98
|
+
const BRAND = (
|
|
99
|
+
<span className="font-display font-bold uppercase tracking-[0.14em] text-[var(--color-text-primary)]">
|
|
100
|
+
Hexline
|
|
101
|
+
</span>
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
<Footer label="Store footer" headingLevel={4} brand={BRAND} tagline="Airsoft replicas, optics and parts." columns={STORE_COLUMNS} legal={LEGAL} />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Six groups: four across, then two
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
const STORE_COLUMNS: FooterColumn[] = [
|
|
111
|
+
{
|
|
112
|
+
title: 'Shop',
|
|
113
|
+
links: [
|
|
114
|
+
{ label: 'Replicas', href: '#replicas' },
|
|
115
|
+
{ label: 'Optics', href: '#optics' },
|
|
116
|
+
{ label: 'Parts', href: '#parts' },
|
|
117
|
+
{ label: 'Care', href: '#care' },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
title: 'Support',
|
|
122
|
+
links: [
|
|
123
|
+
{ label: 'Shipping', href: '#shipping' },
|
|
124
|
+
{ label: 'Returns', href: '#returns' },
|
|
125
|
+
{ label: 'Warranty', href: '#warranty' },
|
|
126
|
+
{ label: 'Contact', href: '#contact' },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
title: 'Company',
|
|
131
|
+
links: [
|
|
132
|
+
{ label: 'About', href: '#about' },
|
|
133
|
+
{ label: 'Field notes', href: '#field-notes' },
|
|
134
|
+
{ label: 'Careers', href: '#careers' },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
title: 'Elsewhere',
|
|
139
|
+
links: [
|
|
140
|
+
{ label: 'Site finder', href: 'https://example.com/sites', external: true },
|
|
141
|
+
{ label: 'Field guides', href: 'https://example.com/field-guides', external: true },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
const SIX_COLUMNS: FooterColumn[] = [
|
|
147
|
+
...STORE_COLUMNS,
|
|
148
|
+
{
|
|
149
|
+
title: 'Account',
|
|
150
|
+
links: [
|
|
151
|
+
{ label: 'Sign in', href: '#sign-in' },
|
|
152
|
+
{ label: 'Orders', href: '#orders' },
|
|
153
|
+
{ label: 'Settings', href: '#settings' },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
title: 'Legal',
|
|
158
|
+
links: [
|
|
159
|
+
{ label: 'Terms', href: '#six-terms' },
|
|
160
|
+
{ label: 'Privacy', href: '#six-privacy' },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
const LEGAL = '© 2026 Hexline. A demo brand for this design system.'
|
|
166
|
+
|
|
167
|
+
const BRAND = (
|
|
168
|
+
<span className="font-display font-bold uppercase tracking-[0.14em] text-[var(--color-text-primary)]">
|
|
169
|
+
Hexline
|
|
170
|
+
</span>
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
<Footer label="Six-group footer" headingLevel={4} brand={BRAND} columns={SIX_COLUMNS} legal={LEGAL} />
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### In a narrow container: an accordion (groups that open and close), with the second group open to start
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
const COMPACT_COLUMNS: FooterColumn[] = [
|
|
180
|
+
{
|
|
181
|
+
title: 'Help',
|
|
182
|
+
links: [
|
|
183
|
+
{ label: 'Shipping', href: '#compact-shipping' },
|
|
184
|
+
{ label: 'Returns', href: '#compact-returns' },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
title: 'Legal',
|
|
189
|
+
links: [
|
|
190
|
+
{ label: 'Terms', href: '#terms' },
|
|
191
|
+
{ label: 'Privacy', href: '#privacy' },
|
|
192
|
+
],
|
|
193
|
+
defaultOpen: true,
|
|
194
|
+
},
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
const LEGAL = '© 2026 Hexline. A demo brand for this design system.'
|
|
198
|
+
|
|
199
|
+
<div className="max-w-sm">
|
|
200
|
+
<Footer label="Compact footer" headingLevel={4} columns={COMPACT_COLUMNS} legal={LEGAL} />
|
|
201
|
+
</div>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### With an aside: below the groups at this width, and to their right once there's 64rem (rem is a unit based on the page's text size) of room
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
const STORE_COLUMNS: FooterColumn[] = [
|
|
208
|
+
{
|
|
209
|
+
title: 'Shop',
|
|
210
|
+
links: [
|
|
211
|
+
{ label: 'Replicas', href: '#replicas' },
|
|
212
|
+
{ label: 'Optics', href: '#optics' },
|
|
213
|
+
{ label: 'Parts', href: '#parts' },
|
|
214
|
+
{ label: 'Care', href: '#care' },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
title: 'Support',
|
|
219
|
+
links: [
|
|
220
|
+
{ label: 'Shipping', href: '#shipping' },
|
|
221
|
+
{ label: 'Returns', href: '#returns' },
|
|
222
|
+
{ label: 'Warranty', href: '#warranty' },
|
|
223
|
+
{ label: 'Contact', href: '#contact' },
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
title: 'Company',
|
|
228
|
+
links: [
|
|
229
|
+
{ label: 'About', href: '#about' },
|
|
230
|
+
{ label: 'Field notes', href: '#field-notes' },
|
|
231
|
+
{ label: 'Careers', href: '#careers' },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
title: 'Elsewhere',
|
|
236
|
+
links: [
|
|
237
|
+
{ label: 'Site finder', href: 'https://example.com/sites', external: true },
|
|
238
|
+
{ label: 'Field guides', href: 'https://example.com/field-guides', external: true },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
const LEGAL = '© 2026 Hexline. A demo brand for this design system.'
|
|
244
|
+
|
|
245
|
+
const ASIDE = {
|
|
246
|
+
title: 'Visit',
|
|
247
|
+
content: (
|
|
248
|
+
<p className="font-body text-sm text-[var(--color-text-secondary)]">
|
|
249
|
+
Unit 4, Fieldside Yard. Weekdays, nine to five. Bring your replica in a bag.
|
|
250
|
+
</p>
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
<Footer label="Aside footer" headingLevel={4} columns={STORE_COLUMNS.slice(0, 3)} aside={ASIDE} legal={LEGAL} />
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### With a wordmark: the name is the last thing in the footer, edge to edge
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
const STORE_COLUMNS: FooterColumn[] = [
|
|
261
|
+
{
|
|
262
|
+
title: 'Shop',
|
|
263
|
+
links: [
|
|
264
|
+
{ label: 'Replicas', href: '#replicas' },
|
|
265
|
+
{ label: 'Optics', href: '#optics' },
|
|
266
|
+
{ label: 'Parts', href: '#parts' },
|
|
267
|
+
{ label: 'Care', href: '#care' },
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
title: 'Support',
|
|
272
|
+
links: [
|
|
273
|
+
{ label: 'Shipping', href: '#shipping' },
|
|
274
|
+
{ label: 'Returns', href: '#returns' },
|
|
275
|
+
{ label: 'Warranty', href: '#warranty' },
|
|
276
|
+
{ label: 'Contact', href: '#contact' },
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
title: 'Company',
|
|
281
|
+
links: [
|
|
282
|
+
{ label: 'About', href: '#about' },
|
|
283
|
+
{ label: 'Field notes', href: '#field-notes' },
|
|
284
|
+
{ label: 'Careers', href: '#careers' },
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
title: 'Elsewhere',
|
|
289
|
+
links: [
|
|
290
|
+
{ label: 'Site finder', href: 'https://example.com/sites', external: true },
|
|
291
|
+
{ label: 'Field guides', href: 'https://example.com/field-guides', external: true },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
const LEGAL = '© 2026 Hexline. A demo brand for this design system.'
|
|
297
|
+
|
|
298
|
+
<Footer label="Wordmark footer" headingLevel={4} columns={STORE_COLUMNS.slice(0, 2)} legal={LEGAL} wordmark="Hexline" />
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### With a wordmark and a backdrop: a layer behind the letters, with headroom over them
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
const STORE_COLUMNS: FooterColumn[] = [
|
|
305
|
+
{
|
|
306
|
+
title: 'Shop',
|
|
307
|
+
links: [
|
|
308
|
+
{ label: 'Replicas', href: '#replicas' },
|
|
309
|
+
{ label: 'Optics', href: '#optics' },
|
|
310
|
+
{ label: 'Parts', href: '#parts' },
|
|
311
|
+
{ label: 'Care', href: '#care' },
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
title: 'Support',
|
|
316
|
+
links: [
|
|
317
|
+
{ label: 'Shipping', href: '#shipping' },
|
|
318
|
+
{ label: 'Returns', href: '#returns' },
|
|
319
|
+
{ label: 'Warranty', href: '#warranty' },
|
|
320
|
+
{ label: 'Contact', href: '#contact' },
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
title: 'Company',
|
|
325
|
+
links: [
|
|
326
|
+
{ label: 'About', href: '#about' },
|
|
327
|
+
{ label: 'Field notes', href: '#field-notes' },
|
|
328
|
+
{ label: 'Careers', href: '#careers' },
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
title: 'Elsewhere',
|
|
333
|
+
links: [
|
|
334
|
+
{ label: 'Site finder', href: 'https://example.com/sites', external: true },
|
|
335
|
+
{ label: 'Field guides', href: 'https://example.com/field-guides', external: true },
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
const LEGAL = '© 2026 Hexline. A demo brand for this design system.'
|
|
341
|
+
|
|
342
|
+
const BACKDROP = (
|
|
343
|
+
<div
|
|
344
|
+
className="absolute inset-0"
|
|
345
|
+
style={{
|
|
346
|
+
backgroundImage: 'radial-gradient(var(--color-text-muted) 1px, transparent 1.5px)',
|
|
347
|
+
backgroundSize: '14px 14px',
|
|
348
|
+
maskImage: 'linear-gradient(to bottom, transparent, black 70%)',
|
|
349
|
+
}}
|
|
350
|
+
/>
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
<Footer label="Backdrop footer" headingLevel={4} columns={STORE_COLUMNS.slice(0, 2)} legal={LEGAL} wordmark="Hexline" backdrop={BACKDROP} />
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Keyboard
|
|
357
|
+
|
|
358
|
+
| Keys | Result |
|
|
359
|
+
|---|---|
|
|
360
|
+
| `Tab` | Moves through each link, group by group, then into the aside. In the accordion, it moves through each group button and the links of the open groups. The wordmark and the backdrop aren't tab stops (places the Tab key lands). |
|
|
361
|
+
| `Enter` | Follows the link. On a group button, opens or closes the group, and focus stays on the button. |
|
|
362
|
+
| `Space` | On a group button, opens or closes the group. |
|
|
363
|
+
|
|
364
|
+
## Do
|
|
365
|
+
|
|
366
|
+
- Keep groups short: four to six links each, and six groups at most.
|
|
367
|
+
- Set headingLevel so the headings carry on the page's outline.
|
|
368
|
+
- Mark links that leave the site as external.
|
|
369
|
+
- Leave the accordion closed unless one group is where most readers are heading. Then give that one defaultOpen.
|
|
370
|
+
- Say the name once: a wordmark or a brand, not both.
|
|
371
|
+
- Give the backdrop a still version for people who've asked for reduced motion, and pass none where it can't draw.
|
|
372
|
+
|
|
373
|
+
## Don't
|
|
374
|
+
|
|
375
|
+
- Don't repeat the whole navbar here.
|
|
376
|
+
- Don't put the only route to a page in the footer.
|
|
377
|
+
- Don't shrink the small print below 12px.
|
|
378
|
+
- Don't type the slash into a title. The footer draws it, and a typed one would be read out.
|
|
379
|
+
- Don't put anything under the wordmark. The footer ends on it.
|
|
380
|
+
- Don't put a control, a link or anything a reader needs in the backdrop. It's aria-hidden, so a focusable element there fails axe, the automated accessibility checker (rule aria-hidden-focus).
|
|
381
|
+
- Don't switch to the accordion with a JavaScript width check. The component does it in CSS, so the accessibility tree is right on the first paint and after every resize.
|
|
382
|
+
|
|
383
|
+
## Anatomy
|
|
384
|
+
|
|
385
|
+
- **footer**: On a real page, it's the contentinfo landmark at the top level (the region screen readers know as the page footer). The examples here sit inside <main>, so they aren't. It's a CSS container, so the two sections sit side by side based on the footer's own width, not the browser window's (the viewport).
|
|
386
|
+
- **Link section**: On the left. It's a container of its own: the grid below and the switch to an accordion both measure this section, so next to an aside the groups respond to the space they really have.
|
|
387
|
+
- **Brand and tagline**: Optional, above the groups. Leave brand out when there is a wordmark, since the wordmark is the name.
|
|
388
|
+
- **nav**: Named by label, holding every group. It's one column while the link section is under 28rem wide (rem is a unit tied to the base text size), then two, three from 42rem and four from 48rem.
|
|
389
|
+
- **Group title**: A heading in the monospaced face, in capitals, at the 11px (pixel) label size, followed by a slash. The slash is decoration and isn't part of any accessible name (the name a screen reader announces).
|
|
390
|
+
- **Group title, below 28rem**: The same heading holds a button instead of text, with aria-expanded, aria-controls, a 44px height and a chevron. (ARIA, Accessible Rich Internet Applications, is a set of attributes that tell screen readers what a control does.) CSS shows one form and removes the other from the accessibility tree, the page structure screen readers read, so from 28rem up there's no button to announce.
|
|
391
|
+
- **List**: Named by its title. Below 28rem a closed group's list is display: none, so it's out of the accessibility tree and out of the Tab order. Every group starts closed unless its defaultOpen says otherwise.
|
|
392
|
+
- **Corner mark**: Before each link: one corner of the crosshair, 7px in size with a 1px line, in text.muted, sitting on the text baseline. When the link's hovered it takes the signal colour. It's aria-hidden, so screen readers skip it.
|
|
393
|
+
- **Link**: The Link atom's quiet tone at 14px, with a 24px target. An external link says it opens a new tab.
|
|
394
|
+
- **Aside**: Only with aside. A heading drawn like a group title, then whatever you pass. It sits under the link section, and moves to its right at 21rem wide once the footer is 64rem wide. It isn't a landmark, because a form inside it already is one.
|
|
395
|
+
- **Small print**: In text.muted at 12px, under a hairline and above the wordmark.
|
|
396
|
+
- **Wordmark**: Only with wordmark. The name in the display face, bold and in capitals, exactly as wide as the footer at every width, with its foot cut off by the bottom edge. Nothing sits under it. It's an image named by the string, so it's announced once. Its box is sized from the letters alone, so it's already at its final size before the font loads.
|
|
397
|
+
- **Backdrop layer**: Only with backdrop. A layer the full width of the footer behind the wordmark, with room above the letters so it can be seen. It's aria-hidden and ignores the pointer. Its height comes from the footer's width, so the space is there before the backdrop draws.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# FormField
|
|
2
|
+
|
|
3
|
+
> The label, help text and error text for one form control. They're connected so a screen reader reads all three, in that order, when the control gets focus. The atoms (the smallest components) never label themselves. This is where it happens.
|
|
4
|
+
|
|
5
|
+
- **Best for:** The label, help and error for an Input, Textarea, Select, MultiSelect or QuantityStepper.
|
|
6
|
+
- **Not for:** Search or RatingInput, which name themselves. Switch, Checkbox and Radio take an id and a label with htmlFor instead.
|
|
7
|
+
- **Also on this page:** `ErrorSummary`
|
|
8
|
+
- Molecule. Source: `src/components/molecules/form-field/FormField.tsx`
|
|
9
|
+
- Rendered docs page: `/components/form-field`
|
|
10
|
+
|
|
11
|
+
## Import
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { FormField } from 'src/components/molecules/form-field/FormField'
|
|
15
|
+
import { ErrorSummary } from 'src/components/molecules/form-field/ErrorSummary'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Paths are from the Division DS repository root. Installed with shadcn they start `src/division-ds/` instead of `src/` (`src/division-ds/components/...`); import them relative to your file.
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
| Prop | Type | Default | Description |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| `label` | `string` | | The field label. Required. |
|
|
25
|
+
| `help` | `string` | | Instruction text, read out before any error. |
|
|
26
|
+
| `error` | `string` | | Sets invalid on the control and adds the error line. |
|
|
27
|
+
| `required` | `boolean` | `false` | Adds the asterisk (hidden from screen readers) and the built-in required attribute on the control. That attribute is what assistive technology actually announces. |
|
|
28
|
+
| `children` | `ReactElement` | | Exactly one control: Input, Textarea, Select or QuantityStepper. A label set on a Select or QuantityStepper child is replaced by the FormField label. An id you give the control is kept (point an ErrorSummary entry at it); without one, FormField makes one up. |
|
|
29
|
+
| `className` | `string` | | Extra CSS classes, merged with the outer element's own. When two Tailwind classes clash, the later one wins. |
|
|
30
|
+
| `ref` | `Ref<HTMLDivElement>` | | Passed through to the outer element. |
|
|
31
|
+
| `errors (ErrorSummary)` | `{ fieldId: string; message: string }[]` | | What stopped the form from submitting, in the order of the fields. Each entry links to its field and moves focus there. An empty list shows nothing. |
|
|
32
|
+
| `title (ErrorSummary)` | `string` | `'There is a problem'` | The heading. It's what a screen reader reads first when the form moves focus to the summary. |
|
|
33
|
+
| `headingLevel (ErrorSummary)` | `2 \| 3 \| 4` | `2` | The heading level, so the summary fits the outline of headings on its page. |
|
|
34
|
+
| `className (ErrorSummary)` | `string` | | Extra CSS classes, merged with the outer element's own. When two Tailwind classes clash, the later one wins. |
|
|
35
|
+
| `ref (ErrorSummary)` | `Ref<HTMLDivElement>` | | The summary itself. A form moves focus to it after a submit that failed. |
|
|
36
|
+
|
|
37
|
+
## Types and helpers
|
|
38
|
+
|
|
39
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
export type FormError = {
|
|
43
|
+
/** The id of the control to focus, the one its FormField labels. */
|
|
44
|
+
fieldId: string
|
|
45
|
+
message: string
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Examples
|
|
50
|
+
|
|
51
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
52
|
+
|
|
53
|
+
### Label only
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
<div className="max-w-xs w-full">
|
|
57
|
+
<FormField label="Callsign">
|
|
58
|
+
<Input placeholder="Anvil" />
|
|
59
|
+
</FormField>
|
|
60
|
+
</div>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### With help
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
<div className="max-w-xs w-full">
|
|
67
|
+
<FormField label="Callsign" help="Four to eight letters.">
|
|
68
|
+
<Input placeholder="Anvil" />
|
|
69
|
+
</FormField>
|
|
70
|
+
</div>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Required, with an error
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
function RequiredWithErrorExample() {
|
|
77
|
+
const [value, setValue] = useState('')
|
|
78
|
+
const [error, setError] = useState<string>()
|
|
79
|
+
|
|
80
|
+
const validate = (next: string) => {
|
|
81
|
+
if (next.length === 0) return 'Callsign is required.'
|
|
82
|
+
if (next.length < 4) return 'Use four to eight letters.'
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
// Field errors sit on a form's own opaque surface.base, as every form in this system does.
|
|
88
|
+
// Contrast: "accent.hostile.text on surface.base".
|
|
89
|
+
<div className="max-w-xs p-4 bg-[var(--color-surface-base)]">
|
|
90
|
+
<FormField label="Callsign" help="Four to eight letters." error={error} required>
|
|
91
|
+
<Input
|
|
92
|
+
value={value}
|
|
93
|
+
onChange={event => {
|
|
94
|
+
const next = event.target.value
|
|
95
|
+
setValue(next)
|
|
96
|
+
if (!error) return
|
|
97
|
+
if (validate(next) === undefined) setError(undefined)
|
|
98
|
+
else if (error === 'Callsign is required.' && next.length > 0) setError(undefined)
|
|
99
|
+
}}
|
|
100
|
+
onBlur={() => setError(validate(value))}
|
|
101
|
+
/>
|
|
102
|
+
</FormField>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
<RequiredWithErrorExample />
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Wrapping a Textarea
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
<div className="max-w-sm w-full">
|
|
114
|
+
<FormField label="Dispatch notes" help="Visible to the whole depot.">
|
|
115
|
+
<Textarea placeholder="Depot code, and anything the next shift needs to know." />
|
|
116
|
+
</FormField>
|
|
117
|
+
</div>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### A form with an error summary
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
const RESERVE_COUNTRIES = [
|
|
124
|
+
{ value: 'ro', label: 'Romania' },
|
|
125
|
+
{ value: 'ie', label: 'Ireland' },
|
|
126
|
+
{ value: 'de', label: 'Germany' },
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
function ReserveExample() {
|
|
130
|
+
const [country, setCountry] = useState('')
|
|
131
|
+
const [magazines, setMagazines] = useState(0)
|
|
132
|
+
const [errors, setErrors] = useState<FormError[]>([])
|
|
133
|
+
const [attempt, setAttempt] = useState(0)
|
|
134
|
+
const summary = useRef<HTMLDivElement>(null)
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (attempt > 0) summary.current?.focus()
|
|
138
|
+
}, [attempt])
|
|
139
|
+
|
|
140
|
+
const countryError = errors.find(current => current.fieldId === 'reserve-country')?.message
|
|
141
|
+
const magazinesError = errors.find(current => current.fieldId === 'reserve-magazines')?.message
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<form
|
|
145
|
+
noValidate
|
|
146
|
+
onSubmit={event => {
|
|
147
|
+
event.preventDefault()
|
|
148
|
+
const next: FormError[] = []
|
|
149
|
+
if (!country) next.push({ fieldId: 'reserve-country', message: 'Choose a country.' })
|
|
150
|
+
if (magazines < 4) next.push({ fieldId: 'reserve-magazines', message: 'Order at least 4 spare magazines.' })
|
|
151
|
+
setErrors(next)
|
|
152
|
+
if (next.length > 0) setAttempt(value => value + 1)
|
|
153
|
+
}}
|
|
154
|
+
className="flex flex-col gap-4 w-full max-w-sm p-5 bg-[var(--color-surface-base)]"
|
|
155
|
+
>
|
|
156
|
+
<ErrorSummary ref={summary} errors={errors} headingLevel={4} />
|
|
157
|
+
<FormField label="Country" required error={countryError}>
|
|
158
|
+
<Select
|
|
159
|
+
id="reserve-country"
|
|
160
|
+
label="Country"
|
|
161
|
+
options={RESERVE_COUNTRIES}
|
|
162
|
+
value={country}
|
|
163
|
+
onValueChange={value => {
|
|
164
|
+
setCountry(value)
|
|
165
|
+
if (value) setErrors(current => current.filter(item => item.fieldId !== 'reserve-country'))
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
</FormField>
|
|
169
|
+
<FormField label="Spare magazines" help="Four per replica." error={magazinesError}>
|
|
170
|
+
<QuantityStepper
|
|
171
|
+
id="reserve-magazines"
|
|
172
|
+
label="Spare magazines"
|
|
173
|
+
max={40}
|
|
174
|
+
value={magazines}
|
|
175
|
+
onValueChange={value => {
|
|
176
|
+
setMagazines(value)
|
|
177
|
+
if (value >= 4) setErrors(current => current.filter(item => item.fieldId !== 'reserve-magazines'))
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
</FormField>
|
|
181
|
+
<Button type="submit">Reserve</Button>
|
|
182
|
+
</form>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
<ReserveExample />
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Keyboard
|
|
190
|
+
|
|
191
|
+
| Keys | Result |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `Tab` | Moves to the control. FormField adds no keyboard stops of its own. |
|
|
194
|
+
|
|
195
|
+
## Do
|
|
196
|
+
|
|
197
|
+
- Pass an id to the control when something else has to point at it (an ErrorSummary entry, a link). FormField keeps an id you give it and only makes one up when you don't.
|
|
198
|
+
- Keep help text to the one thing people get wrong.
|
|
199
|
+
- Write the error as what to do next: 'Use four to eight letters', not 'Invalid'.
|
|
200
|
+
- Check the value when someone leaves the field (on blur) or submits, not on every keystroke.
|
|
201
|
+
- Wrap a Select or a QuantityStepper: FormField hands it the id, the description, the invalid state and its own label.
|
|
202
|
+
|
|
203
|
+
## Don't
|
|
204
|
+
|
|
205
|
+
- Don't wrap Search or RatingInput. Each is named by its own label and takes no help or error text.
|
|
206
|
+
- Don't hide the help text when an error appears. That's when people need the instruction most.
|
|
207
|
+
- Don't use placeholder text as the label. It disappears the moment someone types.
|
|
208
|
+
- Don't show a field error on a raised panel. Field errors sit on surface.base.
|
|
209
|
+
|
|
210
|
+
## Anatomy
|
|
211
|
+
|
|
212
|
+
- **Label**: A real label element that points at the control's id. Monospaced, 11px (pixels), uppercase.
|
|
213
|
+
- **Required mark**: A signal-orange asterisk that screen readers skip (aria-hidden). Assistive technology, such as screen readers, announces the built-in HTML required attribute instead.
|
|
214
|
+
- **Control**: The single child, copied with id, aria-describedby, invalid and required added. aria-describedby is an ARIA (Accessible Rich Internet Applications) attribute that links the control to its help and error text.
|
|
215
|
+
- **Help**: Optional. Read out first.
|
|
216
|
+
- **Error**: Optional. Read out after the help, with an icon so the error isn't shown by colour alone. It isn't a live region, so it isn't announced the moment it appears.
|