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,130 @@
|
|
|
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
|
+
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 region's accessible name (the name a screen reader announces), for example "Offers". Leaving it out is a compile error. |
|
|
23
|
+
| `children` | `ReactNode` | | Each child becomes one list item and keeps its own key. |
|
|
24
|
+
| `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. |
|
|
25
|
+
| `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. |
|
|
26
|
+
| `className` | `string` | | Merged onto the region. |
|
|
27
|
+
| `ref` | `Ref<HTMLElement>` | | Forwarded to the region. |
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
32
|
+
|
|
33
|
+
### Product cards
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
const photo = (id: string) => ({ src: `/media/products/${id}.webp` })
|
|
37
|
+
|
|
38
|
+
const OFFERS: Product[] = [
|
|
39
|
+
{ id: 'kestrel-k', name: 'Kestrel K compact replica', href: '#kestrel-k', price: 12900, compareAtPrice: 14900, badge: 'Sale' },
|
|
40
|
+
{ id: 'field-goggles', name: 'Field goggles', href: '#field-goggles', price: 3400, compareAtPrice: 3900, badge: 'Sale' },
|
|
41
|
+
{ id: 'beam-600', name: 'Beam 600 rail light', href: '#beam-600', price: 2400, compareAtPrice: 2900 },
|
|
42
|
+
{ id: 'speed-loader', name: 'Speed loader, 150 BBs', href: '#speed-loader', price: 790, compareAtPrice: 990 },
|
|
43
|
+
{ id: 'magazine-amber', name: 'Amber magazine, 48 BBs', href: '#magazine-amber', price: 890, compareAtPrice: 1090 },
|
|
44
|
+
{ id: 'knee-pads', name: 'Knee pads, pair', href: '#knee-pads', price: 2400, compareAtPrice: 2900 },
|
|
45
|
+
].map(product => ({ ...product, image: photo(product.id) }))
|
|
46
|
+
|
|
47
|
+
function ProductCardsExample() {
|
|
48
|
+
const { toast } = useToast()
|
|
49
|
+
return (
|
|
50
|
+
<Carousel label="Offers" itemWidth="min(16rem, 80%)">
|
|
51
|
+
{OFFERS.map(product => (
|
|
52
|
+
<ProductCard
|
|
53
|
+
key={product.id}
|
|
54
|
+
product={product}
|
|
55
|
+
headingLevel={4}
|
|
56
|
+
onAddToCart={added => toast({ title: 'Added to cart', description: added.name, tone: 'ok' })}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</Carousel>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
<ProductCardsExample />
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Nothing inside takes keyboard focus, so the Tab key stops on the track itself
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
const STEPS = ['Unload', 'Disconnect', 'Wipe down', 'Clean the barrel', 'Check the hop-up', 'Store']
|
|
70
|
+
|
|
71
|
+
<Carousel label="Cleaning steps" itemWidth="12rem">
|
|
72
|
+
{STEPS.map((step, index) => (
|
|
73
|
+
<div
|
|
74
|
+
key={step}
|
|
75
|
+
className="flex h-28 flex-col justify-between border border-[var(--color-border-hairline-strong)] p-4"
|
|
76
|
+
>
|
|
77
|
+
<span className="font-mono text-xs tabular-nums text-[var(--color-text-secondary)]">
|
|
78
|
+
{String(index + 1).padStart(2, '0')}
|
|
79
|
+
</span>
|
|
80
|
+
<span className="font-display font-semibold text-[var(--color-text-primary)]">{step}</span>
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
</Carousel>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Three short items: both buttons do nothing while everything fits
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
<Carousel label="Kestrel lengths" itemWidth="8rem">
|
|
90
|
+
{['Kestrel K', 'Kestrel C', 'Kestrel L'].map(name => (
|
|
91
|
+
<div
|
|
92
|
+
key={name}
|
|
93
|
+
className="flex h-20 items-center justify-center border border-[var(--color-border-hairline-strong)] font-display font-semibold text-[var(--color-text-primary)]"
|
|
94
|
+
>
|
|
95
|
+
{name}
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</Carousel>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Keyboard
|
|
102
|
+
|
|
103
|
+
| Keys | Result |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `Tab` | Moves to Previous, then Next, then through anything focusable in each item. An item scrolls into view as it takes focus. |
|
|
106
|
+
| `Enter` `Space` | On Previous or Next, scrolls one page of items. Does nothing (it's inert) at the matching end. |
|
|
107
|
+
| `←` `→` | Scroll the track when the track itself has focus. The track is only a tab stop when no item holds anything focusable. |
|
|
108
|
+
|
|
109
|
+
## Do
|
|
110
|
+
|
|
111
|
+
- Name it for what it holds: "Offers", "Top-rated accessories".
|
|
112
|
+
- Give it enough items to scroll. Three that fit are a grid.
|
|
113
|
+
- Link to the full list nearby. A carousel is a preview, so it shouldn't be the only way to reach the rest.
|
|
114
|
+
- Set the gutter to the page gutter when the track should run to the edge of the page.
|
|
115
|
+
|
|
116
|
+
## Don't
|
|
117
|
+
|
|
118
|
+
- 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.
|
|
119
|
+
- Don't put the only copy of anything in it. What's off to the side is easy to miss.
|
|
120
|
+
- Don't hide the buttons on touch screens. A pointer, a switch device or a screen magnifier may be all someone has.
|
|
121
|
+
- 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.
|
|
122
|
+
|
|
123
|
+
## Anatomy
|
|
124
|
+
|
|
125
|
+
- **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.)
|
|
126
|
+
- **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.
|
|
127
|
+
- **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).
|
|
128
|
+
- **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.
|
|
129
|
+
- **Item**: A list item, one per child, at the item width. The edge of the next one always shows.
|
|
130
|
+
- **Status**: Hidden from view, read by screen readers. Says where the track stopped after a press on Previous or Next.
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
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
|
+
| `lines` | `CartLine[]` | | Required. The cart doesn't keep any lines of its own. |
|
|
23
|
+
| `onQuantityChange` | `(id: string, quantity: number) => void` | | Required. Called when a line's QuantityStepper changes. |
|
|
24
|
+
| `onRemove` | `(id: string) => void` | | Required. Called when Remove is pressed. |
|
|
25
|
+
| `onRestore` | `(line: CartLine, index: number) => void` | | Puts a removed line back at its old position. When you pass it, the removal toast offers Undo. |
|
|
26
|
+
| `title` | `string` | `'Cart'` | The heading's text, and the cart's accessible name (the name a screen reader announces). |
|
|
27
|
+
| `headingLevel` | `2 \| 3 \| 4` | `2` | Match the page's heading outline. |
|
|
28
|
+
| `emptyMessage` | `string` | `'Your cart is empty.'` | Shown in place of the list. |
|
|
29
|
+
| `footer` | `ReactNode` | | Under the subtotal, such as a checkout button. Hidden while the cart's empty. |
|
|
30
|
+
| `currency` | `string` | `'EUR'` | Passed to formatMoney. |
|
|
31
|
+
| `locale` | `string` | `'en-IE'` | Passed to formatMoney. |
|
|
32
|
+
| `renderLink` | `(props: LinkRenderProps) => ReactNode` | `a plain anchor` | Draws a line name that has an href. |
|
|
33
|
+
| `className` | `string` | | Merged with the root classes. Later Tailwind utilities win. |
|
|
34
|
+
| `ref` | `Ref<HTMLElement>` | | The section. |
|
|
35
|
+
|
|
36
|
+
## Types and helpers
|
|
37
|
+
|
|
38
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
export type CartLine = {
|
|
42
|
+
/** Unique within lines: rows are keyed by it, and callbacks identify lines by it. */
|
|
43
|
+
id: string
|
|
44
|
+
name: string
|
|
45
|
+
href?: string
|
|
46
|
+
/** Size, colour and the like: "Size M · Ember". */
|
|
47
|
+
detail?: string
|
|
48
|
+
/** In the currency's minor unit (cents for EUR). */
|
|
49
|
+
unitPrice: number
|
|
50
|
+
quantity: number
|
|
51
|
+
/** @default 99 */
|
|
52
|
+
maxQuantity?: number
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Examples
|
|
57
|
+
|
|
58
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
59
|
+
|
|
60
|
+
### In the page
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
const LINES: CartLine[] = [
|
|
64
|
+
{ id: 'kestrel-c', name: 'Kestrel C carbine replica', href: '#kestrel-c', detail: 'Black', unitPrice: 13900, quantity: 1 },
|
|
65
|
+
{
|
|
66
|
+
id: 'prism-holo-sight',
|
|
67
|
+
name: 'Prism holo sight',
|
|
68
|
+
href: '#prism-holo-sight',
|
|
69
|
+
unitPrice: 4900,
|
|
70
|
+
quantity: 2,
|
|
71
|
+
},
|
|
72
|
+
{ id: 'beam-600', name: 'Beam 600 rail light', href: '#beam-600', unitPrice: 2400, quantity: 1 },
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
function useCart(initial: CartLine[]) {
|
|
76
|
+
const [lines, setLines] = useState(initial)
|
|
77
|
+
return {
|
|
78
|
+
lines,
|
|
79
|
+
onQuantityChange: (id: string, quantity: number) =>
|
|
80
|
+
setLines(all => all.map(line => (line.id === id ? { ...line, quantity } : line))),
|
|
81
|
+
onRemove: (id: string) => setLines(all => all.filter(line => line.id !== id)),
|
|
82
|
+
onRestore: (line: CartLine, index: number) => setLines(all => [...all.slice(0, index), line, ...all.slice(index)]),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function InThePageExample() {
|
|
87
|
+
const cart = useCart(LINES)
|
|
88
|
+
return <Cart {...cart} title="Cart" headingLevel={4} footer={<Button>Check out</Button>} />
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
<InThePageExample />
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### In a sheet
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
const LINES: CartLine[] = [
|
|
98
|
+
{ id: 'kestrel-c', name: 'Kestrel C carbine replica', href: '#kestrel-c', detail: 'Black', unitPrice: 13900, quantity: 1 },
|
|
99
|
+
{
|
|
100
|
+
id: 'prism-holo-sight',
|
|
101
|
+
name: 'Prism holo sight',
|
|
102
|
+
href: '#prism-holo-sight',
|
|
103
|
+
unitPrice: 4900,
|
|
104
|
+
quantity: 2,
|
|
105
|
+
},
|
|
106
|
+
{ id: 'beam-600', name: 'Beam 600 rail light', href: '#beam-600', unitPrice: 2400, quantity: 1 },
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
function useCart(initial: CartLine[]) {
|
|
110
|
+
const [lines, setLines] = useState(initial)
|
|
111
|
+
return {
|
|
112
|
+
lines,
|
|
113
|
+
onQuantityChange: (id: string, quantity: number) =>
|
|
114
|
+
setLines(all => all.map(line => (line.id === id ? { ...line, quantity } : line))),
|
|
115
|
+
onRemove: (id: string) => setLines(all => all.filter(line => line.id !== id)),
|
|
116
|
+
onRestore: (line: CartLine, index: number) => setLines(all => [...all.slice(0, index), line, ...all.slice(index)]),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function InASheetExample() {
|
|
121
|
+
const cart = useCart(LINES)
|
|
122
|
+
return (
|
|
123
|
+
<Sheet>
|
|
124
|
+
<SheetTrigger asChild>
|
|
125
|
+
<Button variant="secondary">Open cart</Button>
|
|
126
|
+
</SheetTrigger>
|
|
127
|
+
<SheetContent title="Your cart">
|
|
128
|
+
<Cart {...cart} title="Items" headingLevel={3} footer={<Button>Check out</Button>} />
|
|
129
|
+
</SheetContent>
|
|
130
|
+
</Sheet>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
<InASheetExample />
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Empty
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
<Cart lines={[]} onQuantityChange={() => {}} onRemove={() => {}} headingLevel={4} title="Saved for later" />
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Keyboard
|
|
144
|
+
|
|
145
|
+
| Keys | Result |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `Tab` | Moves through each line's link, quantity and Remove. |
|
|
148
|
+
| `↑` `↓` | On a quantity, steps it by one. |
|
|
149
|
+
| `Enter` | On Remove, removes the line. Focus moves to the next line. |
|
|
150
|
+
| `F8` | Jumps to the removal toast, so you can reach Undo. |
|
|
151
|
+
|
|
152
|
+
## Do
|
|
153
|
+
|
|
154
|
+
- Pass onRestore, so a removal can be undone.
|
|
155
|
+
- Store prices in the currency's smallest unit (cents for EUR).
|
|
156
|
+
- Put the cart in a Sheet on narrow screens. The Undo toast shows inside it.
|
|
157
|
+
- Remove a line on the page, then open a sheet: the Undo toast moves into the sheet and still works there.
|
|
158
|
+
- 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.
|
|
159
|
+
|
|
160
|
+
## Don't
|
|
161
|
+
|
|
162
|
+
- Don't let a quantity reach zero. Remove is how a line leaves.
|
|
163
|
+
- Don't remove a line without telling the cart. It's controlled: it shows only the lines you pass in.
|
|
164
|
+
|
|
165
|
+
## Anatomy
|
|
166
|
+
|
|
167
|
+
- **Heading**: Names the cart. Focus lands here once it's empty.
|
|
168
|
+
- **Line**: Name (a link when it has an href), detail, unit price, quantity, line total and Remove.
|
|
169
|
+
- **Quantity**: A QuantityStepper named '{name} quantity', starting at 1.
|
|
170
|
+
- **Remove**: A ghost icon button named 'Remove {name}'.
|
|
171
|
+
- **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.
|
|
172
|
+
- **Footer**: Optional actions, such as Check out.
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# ChartPanel
|
|
2
|
+
|
|
3
|
+
> One chart in a card. A sentence says what it shows, and the same numbers sit in a table. The data below is sample data.
|
|
4
|
+
|
|
5
|
+
- **Best for:** One chart (a line over time, bars by category, a donut of one whole, or a radar of several measures) with a summary sentence and the numbers as a table.
|
|
6
|
+
- **Not for:** One number and its change: use Stat. One score from 0 to 100: use ScoreRing. Two measures on two scales: use two panels.
|
|
7
|
+
- Organism. Source: `src/components/organisms/chart-panel/ChartPanel.tsx`
|
|
8
|
+
- Rendered docs page: `/components/chart-panel`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { ChartPanel } from 'src/components/organisms/chart-panel/ChartPanel'
|
|
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
|
+
| `title` | `string` | | Required. The panel's heading and its name. |
|
|
23
|
+
| `summary` | `string` | | Required. One sentence, the chart's caption. |
|
|
24
|
+
| `kind` | `'line' \| 'bar' \| 'donut' \| 'radar'` | | Required. Line for change over time. Bar to compare categories. Donut for parts of one whole (one series, a slice per row). Radar for several measures of one or two things (an axis per row). |
|
|
25
|
+
| `data` | `Row[]` | | Required. One object per category. |
|
|
26
|
+
| `xKey` | `keyof Row` | | Required. The category. Its values must be unique. |
|
|
27
|
+
| `xLabel` | `string` | | Required. The header of the table's first column. |
|
|
28
|
+
| `series` | `[ChartSeries] \| [ChartSeries, ChartSeries]` | | Required. One or two series, coloured chart.series-1 then chart.series-2. Passing a third is a type error, and so is a ChartSeries.key that isn't one of Row's own numeric columns. |
|
|
29
|
+
| `area` | `boolean` | `false` | Line only. Fills under each line with a gradient of its colour that fades to nothing. |
|
|
30
|
+
| `curve` | `'linear' \| 'smooth'` | `'linear'` | Line only. smooth draws a monotone curve, so it never overshoots a data point. |
|
|
31
|
+
| `grid` | `boolean` | `true` | The dashed horizontal grid (on a radar, its web). false also drops the axis lines but keeps the tick labels. |
|
|
32
|
+
| `size` | `'md' \| 'sm'` | `'md'` | sm is the stat-card size: a short sparkline with no axes or grid, and the caption under it. Pair it with headline. |
|
|
33
|
+
| `headline` | `string` | | A formatted figure printed large under the title. |
|
|
34
|
+
| `centerLabel` | `string` | | Donut only. The caption under the total in the centre of the ring. |
|
|
35
|
+
| `formatValue` | `(value: number) => string` | `Intl.NumberFormat('en-IE')` | Formats numbers in the tooltip and the table. It formats the y-axis too, unless you give formatAxisValue. |
|
|
36
|
+
| `formatAxisValue` | `(value: number) => string` | `formatValue` | Formats only the y-axis tick labels. Use a shorter, compact formatter when formatValue's output is too wide for the axis. SpendChart's compact euro formatter is one example. |
|
|
37
|
+
| `axisWidth` | `number` | `48` | The width the y-axis reserves, in px (pixels). 48 is Recharts' own YAxis default. Make it bigger if formatAxisValue's labels still get cut off. |
|
|
38
|
+
| `bands` | `{ from: string \| number; to: string \| number; label?: string }[]` | `[]` | Line and bar only. Shades stretches of the x-axis, such as night hours on a 24-hour chart. from and to are x-axis values (values of xKey), both included. When from comes after to in the data, the band wraps past the end: 22:00 to 06:00 shades 22:00 to the last point and the first point to 06:00. A band naming a value that isn't in the data is skipped. Every band is listed under the chart with its label, so screen readers hear it too. |
|
|
39
|
+
| `limits` | `{ value: number; label?: string }[]` | `[]` | Line and bar only, not at size sm. Draws a dashed horizontal line at a y value, such as an alert limit of 35 µg/m³. The y-axis grows to include it, so a limit above every data point stays in view. Every limit is listed under the chart with its label and its value formatted by formatValue, so screen readers hear it too. |
|
|
40
|
+
| `headingLevel` | `2 \| 3 \| 4` | `3` | Pick the level that fits the page's heading outline. |
|
|
41
|
+
| `className` | `string` | | Merged with the Card classes. Later Tailwind utilities win. |
|
|
42
|
+
| `ref` | `Ref<HTMLElement>` | | Points at the section element. |
|
|
43
|
+
|
|
44
|
+
## Types and helpers
|
|
45
|
+
|
|
46
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
export type ChartSeries<Row> = { key: NumericKey<Row>; label: string }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Examples
|
|
53
|
+
|
|
54
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
55
|
+
|
|
56
|
+
### Line, two series
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
type Week = { week: string; online: number; store: number }
|
|
60
|
+
|
|
61
|
+
const WEEKS: Week[] = [
|
|
62
|
+
{ week: 'W1', online: 120, store: 160 },
|
|
63
|
+
{ week: 'W2', online: 131, store: 158 },
|
|
64
|
+
{ week: 'W3', online: 142, store: 163 },
|
|
65
|
+
{ week: 'W4', online: 150, store: 155 },
|
|
66
|
+
{ week: 'W5', online: 171, store: 162 },
|
|
67
|
+
{ week: 'W6', online: 188, store: 159 },
|
|
68
|
+
{ week: 'W7', online: 197, store: 166 },
|
|
69
|
+
{ week: 'W8', online: 214, store: 161 },
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
<ChartPanel<Week>
|
|
73
|
+
title="Weekly orders"
|
|
74
|
+
summary="Online orders rose from 120 to 214 over eight weeks and passed in-store orders in week 5."
|
|
75
|
+
kind="line"
|
|
76
|
+
data={WEEKS}
|
|
77
|
+
xKey="week"
|
|
78
|
+
xLabel="Week"
|
|
79
|
+
series={[
|
|
80
|
+
{ key: 'online', label: 'Online' },
|
|
81
|
+
{ key: 'store', label: 'In store' },
|
|
82
|
+
]}
|
|
83
|
+
headingLevel={4}
|
|
84
|
+
/>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Bar, one series
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
type Category = { category: string; returns: number }
|
|
91
|
+
|
|
92
|
+
const RETURNS: Category[] = [
|
|
93
|
+
{ category: 'Replicas', returns: 18 },
|
|
94
|
+
{ category: 'Optics', returns: 11 },
|
|
95
|
+
{ category: 'Parts', returns: 26 },
|
|
96
|
+
{ category: 'Ammo', returns: 7 },
|
|
97
|
+
{ category: 'Tactical', returns: 4 },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
<ChartPanel<Category>
|
|
101
|
+
title="Returns by category"
|
|
102
|
+
summary="Parts have the most returns (26), then replicas (18); tactical equipment has the fewest (4)."
|
|
103
|
+
kind="bar"
|
|
104
|
+
data={RETURNS}
|
|
105
|
+
xKey="category"
|
|
106
|
+
xLabel="Category"
|
|
107
|
+
series={[{ key: 'returns', label: 'Returns' }]}
|
|
108
|
+
headingLevel={4}
|
|
109
|
+
/>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Area under a smooth line
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
type Day = { day: string; visits: number; orders: number }
|
|
116
|
+
|
|
117
|
+
const DAYS: Day[] = [
|
|
118
|
+
{ day: 'Mon', visits: 1840, orders: 61 },
|
|
119
|
+
{ day: 'Tue', visits: 2210, orders: 74 },
|
|
120
|
+
{ day: 'Wed', visits: 1960, orders: 58 },
|
|
121
|
+
{ day: 'Thu', visits: 2650, orders: 92 },
|
|
122
|
+
{ day: 'Fri', visits: 3120, orders: 118 },
|
|
123
|
+
{ day: 'Sat', visits: 3890, orders: 141 },
|
|
124
|
+
{ day: 'Sun', visits: 3370, orders: 126 },
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
<ChartPanel<Day>
|
|
128
|
+
title="Visits this week"
|
|
129
|
+
summary="Visits climbed from 1,840 on Monday to a Saturday peak of 3,890."
|
|
130
|
+
kind="line"
|
|
131
|
+
area
|
|
132
|
+
curve="smooth"
|
|
133
|
+
data={DAYS}
|
|
134
|
+
xKey="day"
|
|
135
|
+
xLabel="Day"
|
|
136
|
+
series={[{ key: 'visits', label: 'Visits' }]}
|
|
137
|
+
headline="19,040"
|
|
138
|
+
headingLevel={4}
|
|
139
|
+
/>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### No grid lines: only the labels along the axes
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
type Week = { week: string; online: number; store: number }
|
|
146
|
+
|
|
147
|
+
const WEEKS: Week[] = [
|
|
148
|
+
{ week: 'W1', online: 120, store: 160 },
|
|
149
|
+
{ week: 'W2', online: 131, store: 158 },
|
|
150
|
+
{ week: 'W3', online: 142, store: 163 },
|
|
151
|
+
{ week: 'W4', online: 150, store: 155 },
|
|
152
|
+
{ week: 'W5', online: 171, store: 162 },
|
|
153
|
+
{ week: 'W6', online: 188, store: 159 },
|
|
154
|
+
{ week: 'W7', online: 197, store: 166 },
|
|
155
|
+
{ week: 'W8', online: 214, store: 161 },
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
<ChartPanel<Week>
|
|
159
|
+
title="Weekly orders, gridless"
|
|
160
|
+
summary="Online orders rose from 120 to 214 over eight weeks and passed in-store orders in week 5."
|
|
161
|
+
kind="line"
|
|
162
|
+
area
|
|
163
|
+
curve="smooth"
|
|
164
|
+
grid={false}
|
|
165
|
+
data={WEEKS}
|
|
166
|
+
xKey="week"
|
|
167
|
+
xLabel="Week"
|
|
168
|
+
series={[
|
|
169
|
+
{ key: 'online', label: 'Online' },
|
|
170
|
+
{ key: 'store', label: 'In store' },
|
|
171
|
+
]}
|
|
172
|
+
headingLevel={4}
|
|
173
|
+
/>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### A shaded band: night hours on a 24-hour series (the band wraps past midnight)
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
type Hour = { hour: string; pm25: number }
|
|
180
|
+
|
|
181
|
+
const PM25 = [9, 8, 7, 7, 8, 11, 17, 26, 31, 24, 18, 15, 14, 13, 14, 16, 21, 29, 34, 27, 19, 14, 11, 10]
|
|
182
|
+
|
|
183
|
+
const HOURS: Hour[] = PM25.map((pm25, hour) => ({ hour: `${String(hour).padStart(2, '0')}:00`, pm25 }))
|
|
184
|
+
|
|
185
|
+
<ChartPanel<Hour>
|
|
186
|
+
title="Fine particles today"
|
|
187
|
+
summary="PM2.5 peaked at the morning and evening rush hours, 31 and 34 µg/m³, and stayed lowest overnight."
|
|
188
|
+
kind="line"
|
|
189
|
+
area
|
|
190
|
+
curve="smooth"
|
|
191
|
+
data={HOURS}
|
|
192
|
+
xKey="hour"
|
|
193
|
+
xLabel="Hour"
|
|
194
|
+
series={[{ key: 'pm25', label: 'PM2.5 (µg/m³)' }]}
|
|
195
|
+
bands={[{ from: '22:00', to: '06:00', label: 'Night' }]}
|
|
196
|
+
limits={[{ value: 35, label: 'Alert limit (µg/m³)' }]}
|
|
197
|
+
headingLevel={4}
|
|
198
|
+
/>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Small: stat cards, each with a sparkline (a tiny line chart with no axes)
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
type Week = { week: string; online: number; store: number }
|
|
205
|
+
|
|
206
|
+
type Day = { day: string; visits: number; orders: number }
|
|
207
|
+
|
|
208
|
+
const WEEKS: Week[] = [
|
|
209
|
+
{ week: 'W1', online: 120, store: 160 },
|
|
210
|
+
{ week: 'W2', online: 131, store: 158 },
|
|
211
|
+
{ week: 'W3', online: 142, store: 163 },
|
|
212
|
+
{ week: 'W4', online: 150, store: 155 },
|
|
213
|
+
{ week: 'W5', online: 171, store: 162 },
|
|
214
|
+
{ week: 'W6', online: 188, store: 159 },
|
|
215
|
+
{ week: 'W7', online: 197, store: 166 },
|
|
216
|
+
{ week: 'W8', online: 214, store: 161 },
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
const DAYS: Day[] = [
|
|
220
|
+
{ day: 'Mon', visits: 1840, orders: 61 },
|
|
221
|
+
{ day: 'Tue', visits: 2210, orders: 74 },
|
|
222
|
+
{ day: 'Wed', visits: 1960, orders: 58 },
|
|
223
|
+
{ day: 'Thu', visits: 2650, orders: 92 },
|
|
224
|
+
{ day: 'Fri', visits: 3120, orders: 118 },
|
|
225
|
+
{ day: 'Sat', visits: 3890, orders: 141 },
|
|
226
|
+
{ day: 'Sun', visits: 3370, orders: 126 },
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
<ChartPanel<Day>
|
|
230
|
+
title="Visits"
|
|
231
|
+
headline="19,040"
|
|
232
|
+
summary="Up every day but Wednesday and Sunday."
|
|
233
|
+
kind="line"
|
|
234
|
+
area
|
|
235
|
+
curve="smooth"
|
|
236
|
+
size="sm"
|
|
237
|
+
data={DAYS}
|
|
238
|
+
xKey="day"
|
|
239
|
+
xLabel="Day"
|
|
240
|
+
series={[{ key: 'visits', label: 'Visits' }]}
|
|
241
|
+
headingLevel={4}
|
|
242
|
+
/>
|
|
243
|
+
<ChartPanel<Day>
|
|
244
|
+
title="Orders"
|
|
245
|
+
headline="670"
|
|
246
|
+
summary="Saturday took 141, the most of the week."
|
|
247
|
+
kind="bar"
|
|
248
|
+
size="sm"
|
|
249
|
+
data={DAYS}
|
|
250
|
+
xKey="day"
|
|
251
|
+
xLabel="Day"
|
|
252
|
+
series={[{ key: 'orders', label: 'Orders' }]}
|
|
253
|
+
headingLevel={4}
|
|
254
|
+
/>
|
|
255
|
+
<ChartPanel<Week>
|
|
256
|
+
title="Online orders"
|
|
257
|
+
headline="214"
|
|
258
|
+
summary="Eight weeks of growth, from 120 a week."
|
|
259
|
+
kind="line"
|
|
260
|
+
curve="smooth"
|
|
261
|
+
size="sm"
|
|
262
|
+
data={WEEKS}
|
|
263
|
+
xKey="week"
|
|
264
|
+
xLabel="Week"
|
|
265
|
+
series={[{ key: 'online', label: 'Online' }]}
|
|
266
|
+
headingLevel={4}
|
|
267
|
+
/>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Donut with a centre total
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
type Slice = { channel: string; orders: number }
|
|
274
|
+
|
|
275
|
+
const CHANNELS: Slice[] = [
|
|
276
|
+
{ channel: 'Web shop', orders: 412 },
|
|
277
|
+
{ channel: 'Field store', orders: 236 },
|
|
278
|
+
{ channel: 'Club orders', orders: 148 },
|
|
279
|
+
{ channel: 'Marketplace', orders: 87 },
|
|
280
|
+
{ channel: 'Phone', orders: 41 },
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
<ChartPanel<Slice>
|
|
284
|
+
title="Orders by channel"
|
|
285
|
+
summary="The web shop took 412 of 924 orders this month, nearly half; phone orders are 4%."
|
|
286
|
+
kind="donut"
|
|
287
|
+
data={CHANNELS}
|
|
288
|
+
xKey="channel"
|
|
289
|
+
xLabel="Channel"
|
|
290
|
+
series={[{ key: 'orders', label: 'Orders' }]}
|
|
291
|
+
centerLabel="Orders"
|
|
292
|
+
headingLevel={4}
|
|
293
|
+
/>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Radar, two series
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
type Profile = { measure: string; m4: number; ak: number }
|
|
300
|
+
|
|
301
|
+
const PROFILES: Profile[] = [
|
|
302
|
+
{ measure: 'Range', m4: 78, ak: 64 },
|
|
303
|
+
{ measure: 'Accuracy', m4: 82, ak: 70 },
|
|
304
|
+
{ measure: 'Rate of fire', m4: 74, ak: 86 },
|
|
305
|
+
{ measure: 'Weight', m4: 58, ak: 44 },
|
|
306
|
+
{ measure: 'Reliability', m4: 70, ak: 90 },
|
|
307
|
+
{ measure: 'Upgrades', m4: 92, ak: 62 },
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
<ChartPanel<Profile>
|
|
311
|
+
title="Replica profiles"
|
|
312
|
+
summary="The M4 replica leads on range, accuracy and upgrades; the AK replica on rate of fire and reliability."
|
|
313
|
+
kind="radar"
|
|
314
|
+
data={PROFILES}
|
|
315
|
+
xKey="measure"
|
|
316
|
+
xLabel="Measure"
|
|
317
|
+
series={[
|
|
318
|
+
{ key: 'm4', label: 'M4 replica' },
|
|
319
|
+
{ key: 'ak', label: 'AK replica' },
|
|
320
|
+
]}
|
|
321
|
+
headingLevel={4}
|
|
322
|
+
/>
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Keyboard
|
|
326
|
+
|
|
327
|
+
| Keys | Result |
|
|
328
|
+
|---|---|
|
|
329
|
+
| `Tab` | Skips the chart, which isn't a tab stop (a place the Tab key lands), and goes to Show as table. |
|
|
330
|
+
| `Enter` `Space` | On Show as table, shows or hides the table. |
|
|
331
|
+
|
|
332
|
+
## Do
|
|
333
|
+
|
|
334
|
+
- Write the summary as the finding. Don't just describe the axes.
|
|
335
|
+
- Use a line for change over time and bars to compare categories.
|
|
336
|
+
- Keep to two series. Give a third its own panel.
|
|
337
|
+
- In print or greyscale, you can rely on the second line series being dashed. It doesn't depend on colour alone.
|
|
338
|
+
- Use size sm with a headline for a stat card. The caption and the table stay.
|
|
339
|
+
- Use bands for context such as night hours or a sale, and give each one a label.
|
|
340
|
+
- Past six slices, fold a donut's smallest slices into one Other row.
|
|
341
|
+
|
|
342
|
+
## Don't
|
|
343
|
+
|
|
344
|
+
- Don't give a chart two y-axes. Two measures means two panels.
|
|
345
|
+
- Don't colour a series with a status colour. Those colours mean state.
|
|
346
|
+
- Don't put a number on every point. The table has them all.
|
|
347
|
+
- Don't use a donut to compare categories over time. That's a job for a line or bars.
|
|
348
|
+
|
|
349
|
+
## Anatomy
|
|
350
|
+
|
|
351
|
+
- **Title**: The panel's heading. It's also the accessible name, the name a screen reader announces for the panel.
|
|
352
|
+
- **Summary**: One sentence that works as the chart's caption: what a reader should take away.
|
|
353
|
+
- **Legend**: Names both series when there are two. With one series, the title names it. A donut lists every slice with its value and share.
|
|
354
|
+
- **Headline**: Optional. The figure the chart is about, printed large under the title.
|
|
355
|
+
- **Chart**: A line, bar, donut or radar chart, with a tooltip that follows the pointer. It's hidden from assistive tech (screen readers and similar tools), which get the table instead.
|
|
356
|
+
- **Centre total**: Donut only. The sum of the slices, with centerLabel under it.
|
|
357
|
+
- **Shaded ranges**: Optional, line and bar only. A quiet wash behind the chart for each band, listed under it with its label and range.
|
|
358
|
+
- **Show as table**: A button that opens and closes a DataTable with the same rows. It uses aria-expanded, an ARIA (Accessible Rich Internet Applications) attribute that tells screen readers whether it's open.
|