division-ds 0.1.1 → 0.2.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.
Files changed (97) hide show
  1. package/AGENTS.md +16 -2
  2. package/README.md +11 -0
  3. package/division-ds-0.2.0.tgz +0 -0
  4. package/package.json +2 -2
  5. package/public/docs/components/accordion.md +205 -0
  6. package/public/docs/components/ai-label.md +88 -0
  7. package/public/docs/components/alert-dialog.md +290 -0
  8. package/public/docs/components/alert.md +173 -0
  9. package/public/docs/components/approval-request.md +146 -0
  10. package/public/docs/components/auth-form.md +130 -0
  11. package/public/docs/components/avatar-group.md +150 -0
  12. package/public/docs/components/avatar.md +177 -0
  13. package/public/docs/components/badge.md +176 -0
  14. package/public/docs/components/breadcrumbs.md +110 -0
  15. package/public/docs/components/button.md +202 -0
  16. package/public/docs/components/card.md +255 -0
  17. package/public/docs/components/carousel.md +128 -0
  18. package/public/docs/components/cart.md +170 -0
  19. package/public/docs/components/chart-panel.md +356 -0
  20. package/public/docs/components/chat-message.md +154 -0
  21. package/public/docs/components/chat-thread.md +242 -0
  22. package/public/docs/components/checkbox.md +147 -0
  23. package/public/docs/components/checkout-form.md +126 -0
  24. package/public/docs/components/command-palette.md +145 -0
  25. package/public/docs/components/data-table.md +399 -0
  26. package/public/docs/components/description-list.md +101 -0
  27. package/public/docs/components/dialog.md +195 -0
  28. package/public/docs/components/divider.md +95 -0
  29. package/public/docs/components/dropdown-menu.md +220 -0
  30. package/public/docs/components/empty-state.md +143 -0
  31. package/public/docs/components/footer.md +395 -0
  32. package/public/docs/components/form-field.md +213 -0
  33. package/public/docs/components/icon-button.md +125 -0
  34. package/public/docs/components/input.md +198 -0
  35. package/public/docs/components/kanban-board.md +298 -0
  36. package/public/docs/components/kbd.md +70 -0
  37. package/public/docs/components/link.md +96 -0
  38. package/public/docs/components/list-item.md +238 -0
  39. package/public/docs/components/loading-animation.md +70 -0
  40. package/public/docs/components/multi-select.md +200 -0
  41. package/public/docs/components/navbar.md +142 -0
  42. package/public/docs/components/newsletter-form.md +110 -0
  43. package/public/docs/components/page-header.md +149 -0
  44. package/public/docs/components/pagination.md +210 -0
  45. package/public/docs/components/product-card.md +169 -0
  46. package/public/docs/components/progress.md +99 -0
  47. package/public/docs/components/prompt-input.md +149 -0
  48. package/public/docs/components/quantity-stepper.md +138 -0
  49. package/public/docs/components/radio.md +140 -0
  50. package/public/docs/components/rating.md +104 -0
  51. package/public/docs/components/score-ring.md +93 -0
  52. package/public/docs/components/search.md +114 -0
  53. package/public/docs/components/section-heading.md +111 -0
  54. package/public/docs/components/segmented-control.md +124 -0
  55. package/public/docs/components/select.md +222 -0
  56. package/public/docs/components/sheet.md +143 -0
  57. package/public/docs/components/sidebar.md +191 -0
  58. package/public/docs/components/skeleton.md +133 -0
  59. package/public/docs/components/slider.md +112 -0
  60. package/public/docs/components/sparkline.md +126 -0
  61. package/public/docs/components/spinner.md +96 -0
  62. package/public/docs/components/stat.md +104 -0
  63. package/public/docs/components/status-tracker.md +143 -0
  64. package/public/docs/components/stepper.md +145 -0
  65. package/public/docs/components/switch.md +151 -0
  66. package/public/docs/components/tabs.md +178 -0
  67. package/public/docs/components/tag.md +103 -0
  68. package/public/docs/components/textarea.md +146 -0
  69. package/public/docs/components/timeline.md +150 -0
  70. package/public/docs/components/toast.md +192 -0
  71. package/public/docs/components/tool-call.md +105 -0
  72. package/public/docs/components/tooltip.md +106 -0
  73. package/public/manifest/components.json +189 -7
  74. package/public/manifest/tokens.json +39 -0
  75. package/scripts/division-ds.mjs +76 -37
  76. package/scripts/lib/agent-docs.mjs +59 -2
  77. package/scripts/lib/ai-copy.mjs +106 -0
  78. package/scripts/lib/button-spacing.mjs +5 -5
  79. package/scripts/lib/check-text.mjs +44 -0
  80. package/scripts/lib/contrast.mjs +111 -0
  81. package/scripts/lib/host-inline.mjs +59 -0
  82. package/scripts/lib/mcp-server.mjs +115 -0
  83. package/scripts/lib/mcp-tools.mjs +241 -0
  84. package/scripts/lib/props-validator.mjs +1 -1
  85. package/scripts/lib/rules-installer.mjs +42 -4
  86. package/skills/division-ds/SKILL.md +90 -0
  87. package/skills/division-ds/commands/add.md +25 -0
  88. package/skills/division-ds/commands/build.md +46 -0
  89. package/skills/division-ds/commands/fix.md +28 -0
  90. package/skills/division-ds/commands/review.md +25 -0
  91. package/skills/division-ds/topics/ai.md +7 -0
  92. package/skills/division-ds/topics/colour.md +11 -0
  93. package/skills/division-ds/topics/feedback.md +9 -0
  94. package/skills/division-ds/topics/forms.md +19 -0
  95. package/skills/division-ds/topics/layout.md +34 -0
  96. package/skills/division-ds/topics/motion.md +10 -0
  97. package/skills/division-ds/topics/overlays.md +9 -0
@@ -0,0 +1,255 @@
1
+ # Card
2
+
3
+ > A surface that groups related content. It gets the crosshair corners, the visual mark this system uses for a frame around several things. It never gets the cut corner, because cards hold buttons.
4
+
5
+ - **Best for:** Grouping related content on one surface, or one item in a grid whose whole surface is one link.
6
+ - **Not for:** A card inside another card: cards don't nest.
7
+ - Molecule. Source: `src/components/molecules/card/Card.tsx`
8
+ - Rendered docs page: `/components/card`
9
+
10
+ ## Import
11
+
12
+ ```tsx
13
+ import { Card, CardActions, CardBody, CardDescription, CardFooter, CardHeader, CardMedia, CardTitle, cardLinkClass } from 'src/components/molecules/card/Card'
14
+ ```
15
+
16
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `as` | `'article' \| 'section' \| 'div'` | `'article'` | article for an item that stands on its own, section for a group inside a page, div for neither. |
21
+ | `interactive` | `boolean` | `false` | Says the whole card is one click target, through a link or button stretched over it. Adds the hover and focus border and a 4px lift. It adds no role and no Tab stop: the link inside is still the control. |
22
+ | `level (CardTitle)` | `2 \| 3 \| 4` | `3` | The heading level, set on CardTitle only. Card itself takes no level. The page decides its own outline. |
23
+ | `src (CardMedia)` | `string` | | The picture, drawn with object-cover (it fills the box and crops the overflow) and loaded lazily (only when it's about to scroll into view). Leave it out to show CardMedia's children instead, such as a placeholder. |
24
+ | `alt (CardMedia)` | `string` | `''` | The alt text screen readers read for the picture. Empty by default: the picture is decorative because the card title already names it. Write real text only when the picture says something the title doesn't. |
25
+ | `aspect (CardMedia)` | `'16/9' \| '4/3' \| '1/1'` | `'16/9'` | The shape of the box the picture is cropped to. |
26
+ | `className` | `string` | | Merged onto any part. |
27
+ | `...rest` | `HTMLAttributes<...>` | | Standard HTML attributes on every part, except aria-labelledby on Card: the Card points that at its CardTitle itself, and the types won't accept one passed in. Give CardTitle an id only if something else needs it. |
28
+ | `ref` | `Ref<HTMLElement>` | | Passed through to the Card root DOM element. |
29
+
30
+ ## Types and helpers
31
+
32
+ Exported beside the component, from the same file. Import a type with `import type { ... }`.
33
+
34
+ ```ts
35
+ /** The stretched link an interactive card needs, for the `a` (or button) in its title: the link
36
+ * is the name's own, its `::after` covers the card, and the focus ring is drawn there, around the
37
+ * whole card. The shape ProductCard uses. Exported so a consumer never copies the string. */
38
+ export const cardLinkClass =
39
+ "outline-none hover:underline underline-offset-2 after:absolute after:inset-0 after:content-[''] focus-visible:after:outline-2 focus-visible:after:outline-offset-2 focus-visible:after:outline-[var(--color-focus-ring)]"
40
+ ```
41
+
42
+ ## Examples
43
+
44
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
45
+
46
+ ### Depot
47
+
48
+ ```tsx
49
+ function Depot({ title = 'North field depot' }: { title?: string }) {
50
+ return (
51
+ <Card>
52
+ <CardHeader>
53
+ <CardTitle>{title}</CardTitle>
54
+ <CardDescription>Open until 18:00.</CardDescription>
55
+ </CardHeader>
56
+ <CardBody>Twelve kits ready for collection.</CardBody>
57
+ <CardFooter>
58
+ <Button size="sm" variant="ghost">
59
+ Details
60
+ </Button>
61
+ <Button size="sm">Collect</Button>
62
+ </CardFooter>
63
+ </Card>
64
+ )
65
+ }
66
+
67
+ <div className="max-w-sm w-full">
68
+ <Depot />
69
+ </div>
70
+ ```
71
+
72
+ ### With an image: edge to edge, no padding around it. The title already names the picture, so its alt text (the description a screen reader reads out) is empty
73
+
74
+ ```tsx
75
+ <div className="max-w-sm w-full">
76
+ <Card>
77
+ <CardMedia src="/media/hero-poster-960.webp" aspect="16/9" />
78
+ <CardHeader className="pt-5">
79
+ <CardTitle>Range hall depot</CardTitle>
80
+ <CardDescription>Open until 18:00.</CardDescription>
81
+ </CardHeader>
82
+ <CardBody>Twelve kits ready for collection.</CardBody>
83
+ </Card>
84
+ </div>
85
+ ```
86
+
87
+ ### A row of three
88
+
89
+ ```tsx
90
+ function Depot({ title = 'North field depot' }: { title?: string }) {
91
+ return (
92
+ <Card>
93
+ <CardHeader>
94
+ <CardTitle>{title}</CardTitle>
95
+ <CardDescription>Open until 18:00.</CardDescription>
96
+ </CardHeader>
97
+ <CardBody>Twelve kits ready for collection.</CardBody>
98
+ <CardFooter>
99
+ <Button size="sm" variant="ghost">
100
+ Details
101
+ </Button>
102
+ <Button size="sm">Collect</Button>
103
+ </CardFooter>
104
+ </Card>
105
+ )
106
+ }
107
+
108
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full">
109
+ <Depot title="West site depot" />
110
+ <Card>
111
+ <CardHeader>
112
+ <CardTitle>South gate depot</CardTitle>
113
+ <CardDescription>Open until 20:00.</CardDescription>
114
+ </CardHeader>
115
+ <CardBody>Four kits ready for collection, three on order.</CardBody>
116
+ <CardFooter>
117
+ <Button size="sm" variant="ghost">
118
+ Details
119
+ </Button>
120
+ <Button size="sm">Collect</Button>
121
+ </CardFooter>
122
+ </Card>
123
+ <Card>
124
+ <CardHeader>
125
+ <CardTitle>Harbour depot</CardTitle>
126
+ <CardDescription>Closed today.</CardDescription>
127
+ </CardHeader>
128
+ <CardBody>Nothing ready. Next opening tomorrow, 08:00.</CardBody>
129
+ <CardFooter>
130
+ <Button size="sm" variant="secondary">
131
+ Notify me
132
+ </Button>
133
+ </CardFooter>
134
+ </Card>
135
+ </div>
136
+ ```
137
+
138
+ ### Actions inside the content, no action bar: left-aligned, primary first, secondary after
139
+
140
+ ```tsx
141
+ <div className="max-w-sm w-full">
142
+ <Card>
143
+ <CardHeader>
144
+ <CardTitle>Marsh depot</CardTitle>
145
+ <CardDescription>Open until 17:00.</CardDescription>
146
+ </CardHeader>
147
+ <CardBody>
148
+ Twelve kits ready for collection. Bring your booking number.
149
+ <CardActions>
150
+ <Button size="sm">Collect</Button>
151
+ <Button size="sm" variant="secondary">
152
+ Details
153
+ </Button>
154
+ </CardActions>
155
+ </CardBody>
156
+ </Card>
157
+ </div>
158
+ ```
159
+
160
+ ### Interactive: the whole card is one link. Hover over it, or press Tab to reach it
161
+
162
+ ```tsx
163
+ function DepotLink({ title, note, href }: { title: string; note: string; href: string }) {
164
+ return (
165
+ <Card interactive className="h-full">
166
+ <CardBody className="flex flex-col gap-1">
167
+ <CardTitle>
168
+ <a href={href} className={cardLinkClass}>
169
+ {title}
170
+ </a>
171
+ </CardTitle>
172
+ <CardDescription>{note}</CardDescription>
173
+ </CardBody>
174
+ </Card>
175
+ )
176
+ }
177
+
178
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full">
179
+ <DepotLink title="Ridge depot" note="Open until 18:00. Twelve kits ready." href="#ridge-depot" />
180
+ <DepotLink title="Quarry depot" note="Open until 20:00. Four kits ready." href="#quarry-depot" />
181
+ <DepotLink title="Mill depot" note="Closed today. Opens tomorrow, 08:00." href="#mill-depot" />
182
+ </div>
183
+ ```
184
+
185
+ ### As a section
186
+
187
+ ```tsx
188
+ <div className="max-w-sm w-full">
189
+ <Card as="section">
190
+ <CardHeader>
191
+ <CardTitle level={2}>Totals</CardTitle>
192
+ <CardDescription>This week, all depots.</CardDescription>
193
+ </CardHeader>
194
+ <CardBody>128 kits collected, 6 outstanding.</CardBody>
195
+ </Card>
196
+ </div>
197
+ ```
198
+
199
+ ## Keyboard
200
+
201
+ | Keys | Result |
202
+ |---|---|
203
+ | `Tab` | Moves through the card's own buttons and links. The card itself isn't a stop, interactive or not. Focus on anything inside an interactive card lights its border and lifts it 4px. |
204
+
205
+ ## Do
206
+
207
+ - Put actions in CardFooter when the card ends in an action bar. It's a right-aligned row with the gap between buttons already set and the primary last. A fullWidth button fills it.
208
+ - Put actions in CardActions, inside CardBody, when the button belongs to the text above it and there should be no bar. Primary first, then the secondary.
209
+ - Wherever a card has a primary button, put a secondary or ghost button beside it (Details, Cancel), unless the card really has only one action.
210
+ - Give every card a title, even a short one. It names the card for screen readers.
211
+ - Put CardMedia first in the card, and give a row of cards the same aspect, so the titles line up.
212
+ - Give every card in a row the same parts, so they line up.
213
+ - Use section for a group inside a page and article for an item that stands on its own.
214
+ - Set interactive on a card whose whole surface is one link, and only on those: a product, a category tile.
215
+ - Make the click target a real link stretched over the card from its title, so the name, the focus ring and middle-click all belong to the link.
216
+
217
+ ## Don't
218
+
219
+ - Don't make the whole card a link by wrapping it in an a element. That puts clickable things inside a clickable thing. Stretch the title's link over the card and set interactive.
220
+ - Don't set interactive on a card that only holds a button or a link somewhere inside it. The lit border promises that a click anywhere does something.
221
+ - Don't add your own hover border, lift or glow to a card. There's one prop and one treatment, so every clickable card responds the same way.
222
+ - Don't clip an interactive card: it lifts 4px and its crosshair arms reach 5px outside it. Carousel leaves that room by itself.
223
+ - Don't put CardActions and CardFooter in the same card. Two places to act, with opposite alignment and order, read as two different decisions.
224
+ - Don't right-align CardActions or put the primary last in it. The two parts differ on purpose: the bar is on the right with the primary last, the inline row is on the left with the primary first.
225
+ - Don't pad around a picture to fake CardMedia, or wrap it in CardBody. The picture runs edge to edge, so it goes directly in the Card.
226
+ - Don't bevel a card.
227
+ - Don't nest cards.
228
+
229
+ ## Common mistakes
230
+
231
+ Wrong:
232
+
233
+ ```tsx
234
+ <Card className="border-2 border-[var(--color-accent-signal-fill)]">
235
+ ```
236
+
237
+ Right:
238
+
239
+ ```tsx
240
+ <Card style={{ border: '2px solid var(--color-accent-signal-fill)' }}>
241
+ ```
242
+
243
+ The panel sets its own border outside any CSS cascade layer, so a border utility class never wins. To highlight or select a card, set the border inline, using a token (one of the system's named design values).
244
+
245
+ ## Anatomy
246
+
247
+ - **Card**: The root. An article element by default, named by its title. Uses the .panel and .crosshair classes.
248
+ - **Interactive state**: Only with interactive. On hover, and while anything inside has keyboard focus, the thin border turns accent.signal.fill and the whole card slides up 4px (four pixels). It slides back down when the pointer leaves or focus moves on. It's a CSS transform, so nothing around it moves. When someone has turned on reduced motion (prefers-reduced-motion), the border still changes but the card doesn't move.
249
+ - **CardMedia**: A picture across the full width of the card, flush with the top and side edges inside the border, cropped to an aspect ratio. It's the first child, before CardHeader or CardBody. Without a src, its children fill the box instead (a placeholder).
250
+ - **CardHeader**: Groups the title and description.
251
+ - **CardTitle**: A heading at whatever level the page needs, 3 by default.
252
+ - **CardDescription**: A short secondary line under the title.
253
+ - **CardBody**: Grows to fill the space, so the footers in a row of cards line up.
254
+ - **CardFooter**: The action bar: right-aligned, with the primary button last (rightmost), above a thin line that runs edge to edge. It's where a task ends, the same position as DialogFooter.
255
+ - **CardActions**: Actions inside the content, placed in CardBody after the text. A row that wraps, left-aligned, primary first and secondary after it, with no line and no bar. It's on the left because people read a body down its left edge, so the button stays next to the text it acts on at any card width.
@@ -0,0 +1,128 @@
1
+ # Carousel
2
+
3
+ > A row of items that scrolls sideways. The browser does the scrolling, and Previous and Next move it a page at a time. Nothing ever moves on its own.
4
+
5
+ - **Best for:** A sideways preview of more items than fit, with a link to the full list nearby.
6
+ - **Not for:** A few items that fit: use a grid. And it's not for the only copy of anything.
7
+ - Organism. Source: `src/components/organisms/carousel/Carousel.tsx`
8
+ - Rendered docs page: `/components/carousel`
9
+
10
+ ## Import
11
+
12
+ ```tsx
13
+ import { Carousel } from 'src/components/organisms/carousel/Carousel'
14
+ ```
15
+
16
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `label` | `string` | | Required. The region's accessible name (the name a screen reader announces), for example "Offers". Leaving it out is a compile error. |
21
+ | `children` | `ReactNode` | | Each child becomes one list item and keeps its own key. |
22
+ | `itemWidth` | `string` | `'min(18rem, 80%)'` | Any CSS length. A percentage is a share of the track's width. Keep it under the full width, so the edge of the next item shows. |
23
+ | `gutter` | `string` | `'0.5rem'` | The space between the track's edge and the first and last item, in any CSS length (rem is a unit tied to the base text size). It's never less than 0.5rem, whatever you pass, because an edge item draws its focus ring there. Set it to the page gutter to run the track edge to edge. |
24
+ | `className` | `string` | | Merged onto the region. |
25
+ | `ref` | `Ref<HTMLElement>` | | Forwarded to the region. |
26
+
27
+ ## Examples
28
+
29
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
30
+
31
+ ### Product cards
32
+
33
+ ```tsx
34
+ const photo = (id: string) => ({ src: `/media/products/${id}.webp` })
35
+
36
+ const OFFERS: Product[] = [
37
+ { id: 'kestrel-k', name: 'Kestrel K compact replica', href: '#kestrel-k', price: 12900, compareAtPrice: 14900, badge: 'Sale' },
38
+ { id: 'field-goggles', name: 'Field goggles', href: '#field-goggles', price: 3400, compareAtPrice: 3900, badge: 'Sale' },
39
+ { id: 'beam-600', name: 'Beam 600 rail light', href: '#beam-600', price: 2400, compareAtPrice: 2900 },
40
+ { id: 'speed-loader', name: 'Speed loader, 150 BBs', href: '#speed-loader', price: 790, compareAtPrice: 990 },
41
+ { id: 'magazine-amber', name: 'Amber magazine, 48 BBs', href: '#magazine-amber', price: 890, compareAtPrice: 1090 },
42
+ { id: 'knee-pads', name: 'Knee pads, pair', href: '#knee-pads', price: 2400, compareAtPrice: 2900 },
43
+ ].map(product => ({ ...product, image: photo(product.id) }))
44
+
45
+ function ProductCardsExample() {
46
+ const { toast } = useToast()
47
+ return (
48
+ <Carousel label="Offers" itemWidth="min(16rem, 80%)">
49
+ {OFFERS.map(product => (
50
+ <ProductCard
51
+ key={product.id}
52
+ product={product}
53
+ headingLevel={4}
54
+ onAddToCart={added => toast({ title: 'Added to cart', description: added.name, tone: 'ok' })}
55
+ />
56
+ ))}
57
+ </Carousel>
58
+ )
59
+ }
60
+
61
+ <ProductCardsExample />
62
+ ```
63
+
64
+ ### Nothing inside takes keyboard focus, so the Tab key stops on the track itself
65
+
66
+ ```tsx
67
+ const STEPS = ['Unload', 'Disconnect', 'Wipe down', 'Clean the barrel', 'Check the hop-up', 'Store']
68
+
69
+ <Carousel label="Cleaning steps" itemWidth="12rem">
70
+ {STEPS.map((step, index) => (
71
+ <div
72
+ key={step}
73
+ className="flex h-28 flex-col justify-between border border-[var(--color-border-hairline-strong)] p-4"
74
+ >
75
+ <span className="font-mono text-xs tabular-nums text-[var(--color-text-secondary)]">
76
+ {String(index + 1).padStart(2, '0')}
77
+ </span>
78
+ <span className="font-display font-semibold text-[var(--color-text-primary)]">{step}</span>
79
+ </div>
80
+ ))}
81
+ </Carousel>
82
+ ```
83
+
84
+ ### Three short items: both buttons do nothing while everything fits
85
+
86
+ ```tsx
87
+ <Carousel label="Kestrel lengths" itemWidth="8rem">
88
+ {['Kestrel K', 'Kestrel C', 'Kestrel L'].map(name => (
89
+ <div
90
+ key={name}
91
+ className="flex h-20 items-center justify-center border border-[var(--color-border-hairline-strong)] font-display font-semibold text-[var(--color-text-primary)]"
92
+ >
93
+ {name}
94
+ </div>
95
+ ))}
96
+ </Carousel>
97
+ ```
98
+
99
+ ## Keyboard
100
+
101
+ | Keys | Result |
102
+ |---|---|
103
+ | `Tab` | Moves to Previous, then Next, then through anything focusable in each item. An item scrolls into view as it takes focus. |
104
+ | `Enter` `Space` | On Previous or Next, scrolls one page of items. Does nothing (it's inert) at the matching end. |
105
+ | `←` `→` | Scroll the track when the track itself has focus. The track is only a tab stop when no item holds anything focusable. |
106
+
107
+ ## Do
108
+
109
+ - Name it for what it holds: "Offers", "Top-rated accessories".
110
+ - Give it enough items to scroll. Three that fit are a grid.
111
+ - Link to the full list nearby. A carousel is a preview, so it shouldn't be the only way to reach the rest.
112
+ - Set the gutter to the page gutter when the track should run to the edge of the page.
113
+
114
+ ## Don't
115
+
116
+ - Don't add autoplay. Content that moves on its own needs a pause control, and it pulls attention away from whoever's reading the page.
117
+ - Don't put the only copy of anything in it. What's off to the side is easy to miss.
118
+ - Don't hide the buttons on touch screens. A pointer, a switch device or a screen magnifier may be all someone has.
119
+ - Don't wrap it in an element with overflow hidden to tidy the edges. That clips the focus ring the gutter is there to show.
120
+
121
+ ## Anatomy
122
+
123
+ - **Region**: A section with aria-roledescription="carousel", named by the label. (ARIA, Accessible Rich Internet Applications, is a set of attributes that tell screen readers what an element is.)
124
+ - **Position**: Monospaced text such as "1 to 3 of 8", saying which items are showing. It updates on every scroll, but it isn't a live region, so screen readers don't read it out each time.
125
+ - **Previous / Next**: Secondary IconButtons, placed before the items in the Tab order. At the ends they're marked aria-disabled instead of disabled, so each stays a tab stop (a place the Tab key lands).
126
+ - **Track**: A list that's the browser's own scroll container, with scroll snap and no visible scrollbar. It's padded on all four sides, so an item at the edge draws its focus ring inside the track.
127
+ - **Item**: A list item, one per child, at the item width. The edge of the next one always shows.
128
+ - **Status**: Hidden from view, read by screen readers. Says where the track stopped after a press on Previous or Next.
@@ -0,0 +1,170 @@
1
+ # Cart
2
+
3
+ > The lines someone means to buy, with quantities, line totals and a subtotal that screen readers announce as it changes. Removing a line can be undone, and focus (where keyboard input goes) is never lost.
4
+
5
+ - **Best for:** The lines someone means to buy: quantities, line totals, a subtotal and removal that can be undone.
6
+ - **Not for:** Collecting shipping and payment: use CheckoutForm.
7
+ - Organism. Source: `src/components/organisms/cart/Cart.tsx`
8
+ - Rendered docs page: `/components/cart`
9
+
10
+ ## Import
11
+
12
+ ```tsx
13
+ import { Cart } from 'src/components/organisms/cart/Cart'
14
+ ```
15
+
16
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `lines` | `CartLine[]` | | Required. The cart doesn't keep any lines of its own. |
21
+ | `onQuantityChange` | `(id: string, quantity: number) => void` | | Required. Called when a line's QuantityStepper changes. |
22
+ | `onRemove` | `(id: string) => void` | | Required. Called when Remove is pressed. |
23
+ | `onRestore` | `(line: CartLine, index: number) => void` | | Puts a removed line back at its old position. When you pass it, the removal toast offers Undo. |
24
+ | `title` | `string` | `'Cart'` | The heading's text, and the cart's accessible name (the name a screen reader announces). |
25
+ | `headingLevel` | `2 \| 3 \| 4` | `2` | Match the page's heading outline. |
26
+ | `emptyMessage` | `string` | `'Your cart is empty.'` | Shown in place of the list. |
27
+ | `footer` | `ReactNode` | | Under the subtotal, such as a checkout button. Hidden while the cart's empty. |
28
+ | `currency` | `string` | `'EUR'` | Passed to formatMoney. |
29
+ | `locale` | `string` | `'en-IE'` | Passed to formatMoney. |
30
+ | `renderLink` | `(props: LinkRenderProps) => ReactNode` | `a plain anchor` | Draws a line name that has an href. |
31
+ | `className` | `string` | | Merged with the root classes. Later Tailwind utilities win. |
32
+ | `ref` | `Ref<HTMLElement>` | | The section. |
33
+
34
+ ## Types and helpers
35
+
36
+ Exported beside the component, from the same file. Import a type with `import type { ... }`.
37
+
38
+ ```ts
39
+ export type CartLine = {
40
+ /** Unique within lines: rows are keyed by it, and callbacks identify lines by it. */
41
+ id: string
42
+ name: string
43
+ href?: string
44
+ /** Size, colour and the like: "Size M · Ember". */
45
+ detail?: string
46
+ /** In the currency's minor unit (cents for EUR). */
47
+ unitPrice: number
48
+ quantity: number
49
+ /** @default 99 */
50
+ maxQuantity?: number
51
+ }
52
+ ```
53
+
54
+ ## Examples
55
+
56
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
57
+
58
+ ### In the page
59
+
60
+ ```tsx
61
+ const LINES: CartLine[] = [
62
+ { id: 'kestrel-c', name: 'Kestrel C carbine replica', href: '#kestrel-c', detail: 'Black', unitPrice: 13900, quantity: 1 },
63
+ {
64
+ id: 'prism-holo-sight',
65
+ name: 'Prism holo sight',
66
+ href: '#prism-holo-sight',
67
+ unitPrice: 4900,
68
+ quantity: 2,
69
+ },
70
+ { id: 'beam-600', name: 'Beam 600 rail light', href: '#beam-600', unitPrice: 2400, quantity: 1 },
71
+ ]
72
+
73
+ function useCart(initial: CartLine[]) {
74
+ const [lines, setLines] = useState(initial)
75
+ return {
76
+ lines,
77
+ onQuantityChange: (id: string, quantity: number) =>
78
+ setLines(all => all.map(line => (line.id === id ? { ...line, quantity } : line))),
79
+ onRemove: (id: string) => setLines(all => all.filter(line => line.id !== id)),
80
+ onRestore: (line: CartLine, index: number) => setLines(all => [...all.slice(0, index), line, ...all.slice(index)]),
81
+ }
82
+ }
83
+
84
+ function InThePageExample() {
85
+ const cart = useCart(LINES)
86
+ return <Cart {...cart} title="Cart" headingLevel={4} footer={<Button>Check out</Button>} />
87
+ }
88
+
89
+ <InThePageExample />
90
+ ```
91
+
92
+ ### In a sheet
93
+
94
+ ```tsx
95
+ const LINES: CartLine[] = [
96
+ { id: 'kestrel-c', name: 'Kestrel C carbine replica', href: '#kestrel-c', detail: 'Black', unitPrice: 13900, quantity: 1 },
97
+ {
98
+ id: 'prism-holo-sight',
99
+ name: 'Prism holo sight',
100
+ href: '#prism-holo-sight',
101
+ unitPrice: 4900,
102
+ quantity: 2,
103
+ },
104
+ { id: 'beam-600', name: 'Beam 600 rail light', href: '#beam-600', unitPrice: 2400, quantity: 1 },
105
+ ]
106
+
107
+ function useCart(initial: CartLine[]) {
108
+ const [lines, setLines] = useState(initial)
109
+ return {
110
+ lines,
111
+ onQuantityChange: (id: string, quantity: number) =>
112
+ setLines(all => all.map(line => (line.id === id ? { ...line, quantity } : line))),
113
+ onRemove: (id: string) => setLines(all => all.filter(line => line.id !== id)),
114
+ onRestore: (line: CartLine, index: number) => setLines(all => [...all.slice(0, index), line, ...all.slice(index)]),
115
+ }
116
+ }
117
+
118
+ function InASheetExample() {
119
+ const cart = useCart(LINES)
120
+ return (
121
+ <Sheet>
122
+ <SheetTrigger asChild>
123
+ <Button variant="secondary">Open cart</Button>
124
+ </SheetTrigger>
125
+ <SheetContent title="Your cart">
126
+ <Cart {...cart} title="Items" headingLevel={3} footer={<Button>Check out</Button>} />
127
+ </SheetContent>
128
+ </Sheet>
129
+ )
130
+ }
131
+
132
+ <InASheetExample />
133
+ ```
134
+
135
+ ### Empty
136
+
137
+ ```tsx
138
+ <Cart lines={[]} onQuantityChange={() => {}} onRemove={() => {}} headingLevel={4} title="Saved for later" />
139
+ ```
140
+
141
+ ## Keyboard
142
+
143
+ | Keys | Result |
144
+ |---|---|
145
+ | `Tab` | Moves through each line's link, quantity and Remove. |
146
+ | `↑` `↓` | On a quantity, steps it by one. |
147
+ | `Enter` | On Remove, removes the line. Focus moves to the next line. |
148
+ | `F8` | Jumps to the removal toast, so you can reach Undo. |
149
+
150
+ ## Do
151
+
152
+ - Pass onRestore, so a removal can be undone.
153
+ - Store prices in the currency's smallest unit (cents for EUR).
154
+ - Put the cart in a Sheet on narrow screens. The Undo toast shows inside it.
155
+ - Remove a line on the page, then open a sheet: the Undo toast moves into the sheet and still works there.
156
+ - Expect Undo pressed there to leave focus on the sheet's own toast list, inside its focus trap (the sheet keeps keyboard focus inside itself), never on the page body. The page's cart is hidden and can't take it.
157
+
158
+ ## Don't
159
+
160
+ - Don't let a quantity reach zero. Remove is how a line leaves.
161
+ - Don't remove a line without telling the cart. It's controlled: it shows only the lines you pass in.
162
+
163
+ ## Anatomy
164
+
165
+ - **Heading**: Names the cart. Focus lands here once it's empty.
166
+ - **Line**: Name (a link when it has an href), detail, unit price, quantity, line total and Remove.
167
+ - **Quantity**: A QuantityStepper named '{name} quantity', starting at 1.
168
+ - **Remove**: A ghost icon button named 'Remove {name}'.
169
+ - **Subtotal**: A status line visible only to screen readers (sr-only), plus a visual twin. Screen readers announce the status as it changes. The twin, in tabular figures, is what sighted readers see, and it's aria-hidden (hidden from screen readers) so nothing is announced twice.
170
+ - **Footer**: Optional actions, such as Check out.