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.
- package/AGENTS.md +16 -2
- package/README.md +11 -0
- package/division-ds-0.2.0.tgz +0 -0
- package/package.json +2 -2
- package/public/docs/components/accordion.md +205 -0
- package/public/docs/components/ai-label.md +88 -0
- package/public/docs/components/alert-dialog.md +290 -0
- package/public/docs/components/alert.md +173 -0
- package/public/docs/components/approval-request.md +146 -0
- package/public/docs/components/auth-form.md +130 -0
- package/public/docs/components/avatar-group.md +150 -0
- package/public/docs/components/avatar.md +177 -0
- package/public/docs/components/badge.md +176 -0
- package/public/docs/components/breadcrumbs.md +110 -0
- package/public/docs/components/button.md +202 -0
- package/public/docs/components/card.md +255 -0
- package/public/docs/components/carousel.md +128 -0
- package/public/docs/components/cart.md +170 -0
- package/public/docs/components/chart-panel.md +356 -0
- package/public/docs/components/chat-message.md +154 -0
- package/public/docs/components/chat-thread.md +242 -0
- package/public/docs/components/checkbox.md +147 -0
- package/public/docs/components/checkout-form.md +126 -0
- package/public/docs/components/command-palette.md +145 -0
- package/public/docs/components/data-table.md +399 -0
- package/public/docs/components/description-list.md +101 -0
- package/public/docs/components/dialog.md +195 -0
- package/public/docs/components/divider.md +95 -0
- package/public/docs/components/dropdown-menu.md +220 -0
- package/public/docs/components/empty-state.md +143 -0
- package/public/docs/components/footer.md +395 -0
- package/public/docs/components/form-field.md +213 -0
- package/public/docs/components/icon-button.md +125 -0
- package/public/docs/components/input.md +198 -0
- package/public/docs/components/kanban-board.md +298 -0
- package/public/docs/components/kbd.md +70 -0
- package/public/docs/components/link.md +96 -0
- package/public/docs/components/list-item.md +238 -0
- package/public/docs/components/loading-animation.md +70 -0
- package/public/docs/components/multi-select.md +200 -0
- package/public/docs/components/navbar.md +142 -0
- package/public/docs/components/newsletter-form.md +110 -0
- package/public/docs/components/page-header.md +149 -0
- package/public/docs/components/pagination.md +210 -0
- package/public/docs/components/product-card.md +169 -0
- package/public/docs/components/progress.md +99 -0
- package/public/docs/components/prompt-input.md +149 -0
- package/public/docs/components/quantity-stepper.md +138 -0
- package/public/docs/components/radio.md +140 -0
- package/public/docs/components/rating.md +104 -0
- package/public/docs/components/score-ring.md +93 -0
- package/public/docs/components/search.md +114 -0
- package/public/docs/components/section-heading.md +111 -0
- package/public/docs/components/segmented-control.md +124 -0
- package/public/docs/components/select.md +222 -0
- package/public/docs/components/sheet.md +143 -0
- package/public/docs/components/sidebar.md +191 -0
- package/public/docs/components/skeleton.md +133 -0
- package/public/docs/components/slider.md +112 -0
- package/public/docs/components/sparkline.md +126 -0
- package/public/docs/components/spinner.md +96 -0
- package/public/docs/components/stat.md +104 -0
- package/public/docs/components/status-tracker.md +143 -0
- package/public/docs/components/stepper.md +145 -0
- package/public/docs/components/switch.md +151 -0
- package/public/docs/components/tabs.md +178 -0
- package/public/docs/components/tag.md +103 -0
- package/public/docs/components/textarea.md +146 -0
- package/public/docs/components/timeline.md +150 -0
- package/public/docs/components/toast.md +192 -0
- package/public/docs/components/tool-call.md +105 -0
- package/public/docs/components/tooltip.md +106 -0
- package/public/manifest/components.json +189 -7
- package/public/manifest/tokens.json +39 -0
- package/scripts/division-ds.mjs +76 -37
- package/scripts/lib/agent-docs.mjs +59 -2
- package/scripts/lib/ai-copy.mjs +106 -0
- package/scripts/lib/button-spacing.mjs +5 -5
- package/scripts/lib/check-text.mjs +44 -0
- package/scripts/lib/contrast.mjs +111 -0
- package/scripts/lib/host-inline.mjs +59 -0
- package/scripts/lib/mcp-server.mjs +115 -0
- package/scripts/lib/mcp-tools.mjs +241 -0
- package/scripts/lib/props-validator.mjs +1 -1
- package/scripts/lib/rules-installer.mjs +42 -4
- 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 +7 -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 +19 -0
- package/skills/division-ds/topics/layout.md +34 -0
- package/skills/division-ds/topics/motion.md +10 -0
- package/skills/division-ds/topics/overlays.md +9 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# ProductCard
|
|
2
|
+
|
|
3
|
+
> One product as one link. Clicking anywhere on the card opens it, Add to cart stays a separate control, and a grid of cards adapts to the space it's given.
|
|
4
|
+
|
|
5
|
+
- **Best for:** One product in a shop grid, where the whole card opens it and Add to cart sits on it.
|
|
6
|
+
- **Not for:** A general content panel: use Card.
|
|
7
|
+
- Organism. Source: `src/components/organisms/product-card/ProductCard.tsx`
|
|
8
|
+
- Rendered docs page: `/components/product-card`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { ProductCard, ProductGrid } from 'src/components/organisms/product-card/ProductCard'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Default | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `product (ProductCard)` | `Product` | | Required. id, name, href and price (in the currency's minor unit, so cents for EUR). rating, image, badge and soldOut are optional. |
|
|
21
|
+
| `onAddToCart` | `(product: Product) => void` | | Draws Add to cart. Leave it out for a card that only links to the product. |
|
|
22
|
+
| `headingLevel` | `2 \| 3 \| 4` | `3` | Pick the level that fits the page's heading outline. |
|
|
23
|
+
| `currency` | `string` | `'EUR'` | Passed to formatMoney. |
|
|
24
|
+
| `locale` | `string` | `'en-IE'` | Passed to formatMoney. |
|
|
25
|
+
| `renderLink` | `(props: LinkRenderProps) => ReactNode` | `a plain anchor` | Draws the name's link. It must pass className through, because that class carries the stretched click area and the focus ring. |
|
|
26
|
+
| `renderMedia` | `(product: Product) => ReactNode` | `the product's image, 4:3, cover` | Draws the picture instead of the image or the placeholder, for example a staged cutout. Keep the 4:3 box and keep it decorative. The badge still sits over it. |
|
|
27
|
+
| `className (ProductCard)` | `string` | | Merged with the root classes. Later Tailwind utilities win. |
|
|
28
|
+
| `ref (ProductCard)` | `Ref<HTMLElement>` | | Points at the article element. |
|
|
29
|
+
| `products (Grid)` | `Product[]` | | Required. |
|
|
30
|
+
| `label (Grid)` | `string` | | Required. The list's accessible name, the name a screen reader announces for it. |
|
|
31
|
+
| `emptyMessage (Grid)` | `string` | `'No products match.'` | Announced by a status message that's always on the page when products is empty. The list itself keeps rendering (so its ref stays stable), but it's hidden while there's nothing in it. |
|
|
32
|
+
| `className (Grid)` | `string` | | Merged with the list's own classes. Later Tailwind utilities win. |
|
|
33
|
+
| `ref (Grid)` | `Ref<HTMLUListElement>` | | Points at the list 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 Product = {
|
|
41
|
+
id: string
|
|
42
|
+
name: string
|
|
43
|
+
href: string
|
|
44
|
+
/** In the currency's minor unit (cents for EUR). */
|
|
45
|
+
price: number
|
|
46
|
+
/** The price before a sale, in the currency's minor unit (cents for EUR): struck through, and
|
|
47
|
+
* read as "was". */
|
|
48
|
+
compareAtPrice?: number
|
|
49
|
+
rating?: { value: number; count: number }
|
|
50
|
+
/** Without one, the card draws a decorative placeholder. `alt` defaults to '': the title names
|
|
51
|
+
* the product. */
|
|
52
|
+
image?: { src: string; alt?: string }
|
|
53
|
+
/** A short solid tag, e.g. "New" or "Sale". */
|
|
54
|
+
badge?: string
|
|
55
|
+
soldOut?: boolean
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Examples
|
|
60
|
+
|
|
61
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
62
|
+
|
|
63
|
+
### One product
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
const REPLICA: Product = {
|
|
67
|
+
id: 'kestrel-k',
|
|
68
|
+
name: 'Kestrel K compact replica',
|
|
69
|
+
href: '#kestrel-k',
|
|
70
|
+
price: 12900,
|
|
71
|
+
compareAtPrice: 14900,
|
|
72
|
+
rating: { value: 4.5, count: 128 },
|
|
73
|
+
badge: 'Sale',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function OneProductExample() {
|
|
77
|
+
const { toast } = useToast()
|
|
78
|
+
return (
|
|
79
|
+
<div className="max-w-xs">
|
|
80
|
+
<ProductCard
|
|
81
|
+
product={REPLICA}
|
|
82
|
+
headingLevel={4}
|
|
83
|
+
onAddToCart={product => toast({ title: 'Added to cart', description: product.name, tone: 'ok' })}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
<OneProductExample />
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### A grid
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
const RANGE: Product[] = [
|
|
96
|
+
{ id: 'kestrel-c', name: 'Kestrel C carbine replica', href: '#kestrel-c', price: 13900, rating: { value: 4.5, count: 212 } },
|
|
97
|
+
{ id: 'prism-holo-sight', name: 'Prism holo sight', href: '#prism-holo-sight', price: 4900, badge: 'New' },
|
|
98
|
+
{ id: 'speed-loader', name: 'Speed loader, 150 BBs', href: '#speed-loader', price: 790, rating: { value: 4, count: 87 } },
|
|
99
|
+
{ id: 'kestrel-l', name: 'Kestrel L rifle replica', href: '#kestrel-l', price: 15900, soldOut: true },
|
|
100
|
+
{ id: 'bbs-028', name: 'Precision BBs 0.28 g, 2,700', href: '#bbs-028', price: 590 },
|
|
101
|
+
{ id: 'field-goggles', name: 'Field goggles', href: '#field-goggles', price: 3400, compareAtPrice: 3900, badge: 'Sale' },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
function AGridExample() {
|
|
105
|
+
const { toast } = useToast()
|
|
106
|
+
return (
|
|
107
|
+
<ProductGrid
|
|
108
|
+
label="Hexline range"
|
|
109
|
+
products={RANGE}
|
|
110
|
+
headingLevel={4}
|
|
111
|
+
onAddToCart={product => toast({ title: 'Added to cart', description: product.name, tone: 'ok' })}
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
<AGridExample />
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### A custom picture through renderMedia
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
const STAGED: Product = { id: 'harrier-lr', name: 'Harrier LR bolt-action replica', href: '#harrier-lr', price: 17900, rating: { value: 4.5, count: 37 } }
|
|
123
|
+
|
|
124
|
+
<div className="max-w-xs">
|
|
125
|
+
<ProductCard
|
|
126
|
+
product={STAGED}
|
|
127
|
+
headingLevel={4}
|
|
128
|
+
renderMedia={product => (
|
|
129
|
+
<div aria-hidden="true" className="flex aspect-[4/3] items-center justify-center bg-[var(--color-surface-sunken)] p-4">
|
|
130
|
+
<img src={`/media/products/cutout/${product.id}-640.webp`} alt="" loading="lazy" className="max-h-full w-full object-contain" />
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Nothing matches
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
<ProductGrid label="Hexline range, filtered" products={[]} emptyMessage="No products match these filters." />
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Keyboard
|
|
144
|
+
|
|
145
|
+
| Keys | Result |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `Tab` | Moves to the product's link, then to Add to cart, then to the next card. |
|
|
148
|
+
| `Enter` | On the link, opens the product. On Add to cart, adds it. |
|
|
149
|
+
|
|
150
|
+
## Do
|
|
151
|
+
|
|
152
|
+
- Give the grid a label that says what it holds.
|
|
153
|
+
- Store prices in the currency's minor unit (cents for EUR). The card formats them.
|
|
154
|
+
- Pass headingLevel so the names fit the page's heading outline.
|
|
155
|
+
|
|
156
|
+
## Don't
|
|
157
|
+
|
|
158
|
+
- Don't put another link or button in the card body. Only Add to cart sits above the stretched link.
|
|
159
|
+
- Don't colour the price. It's text.primary.
|
|
160
|
+
- Don't write alt text (the image description for screen readers) that repeats the name.
|
|
161
|
+
|
|
162
|
+
## Anatomy
|
|
163
|
+
|
|
164
|
+
- **Image**: Optional. Without one, there's a decorative placeholder. renderMedia can draw something else in the same box.
|
|
165
|
+
- **Badge**: Optional. A short solid tag over the corner of the picture, like New or Sale. It sits outside the text flow, so names line up along a row of cards.
|
|
166
|
+
- **Name**: The heading, and the link. Its click area and its focus ring (the outline keyboard users see) cover the whole card.
|
|
167
|
+
- **Rating**: Optional. Screen readers hear the stars as 'rated 4.5 out of 5', and the review count is plain text.
|
|
168
|
+
- **Price**: In text.primary, with tabular (equal-width) figures. A sale adds the old price struck through, which screen readers hear as "was".
|
|
169
|
+
- **Add to cart**: Sits above the link as its own target, and it's named after the product.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Progress
|
|
2
|
+
|
|
3
|
+
> A progress bar that's either determinate (it knows how far along the task is) or indeterminate (it doesn't). The readout is real text in the DOM (the Document Object Model, the live page structure the browser builds) from the very first render. That's motion token M3's rule, and it means a screen reader, the software that reads the page aloud for blind and low-vision people, never hears a number roll.
|
|
4
|
+
|
|
5
|
+
- **Best for:** Showing how far a measurable task has got. Switch to indeterminate once you no longer know how much is left.
|
|
6
|
+
- **Not for:** A wait with no measurable work. Use Spinner.
|
|
7
|
+
- Atom. Source: `src/components/atoms/progress/Progress.tsx`
|
|
8
|
+
- Rendered docs page: `/components/progress`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { Progress } from 'src/components/atoms/progress/Progress'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Default | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `label` | `string` | | Required. The bar’s accessible name. |
|
|
21
|
+
| `value` | `number \| null` | | null means indeterminate. The value is clamped to [0, max], so an out-of-range value shows as complete instead of overflowing the track. |
|
|
22
|
+
| `max` | `number` | `100` | The value at which the bar is complete. |
|
|
23
|
+
| `showValue` | `boolean` | `false` | Shows the percentage (or "In progress") as a readout. |
|
|
24
|
+
| `tone` | `'signal' \| 'ok' \| 'warning' \| 'hostile'` | `'signal'` | The fill colour. signal paints accent.signal.fill to match Slider. On the track in light theme that’s a 2.15:1 contrast ratio, below the 3:1 a bar needs when it’s the only sign of progress. So set showValue there. |
|
|
25
|
+
| `className` | `string` | | Merged with the outer wrapper’s classes. Later Tailwind utilities (Tailwind is the CSS framework the system is styled with) win. |
|
|
26
|
+
| `ref` | `Ref<HTMLDivElement>` | | Passed through to the Radix Progress root, which is the bar itself. Radix is the library of accessible building blocks this component sits on. |
|
|
27
|
+
| `...rest` | `Omit<HTMLAttributes<HTMLDivElement>, 'children'>` | | Every native div prop except children, spread onto the outer wrapper, so a molecule can attach a data-testid there. children is typed away because this component draws its own label row and track, and a children prop would be thrown away without any warning. |
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
32
|
+
|
|
33
|
+
### Determinate
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
<div className="w-full max-w-sm">
|
|
37
|
+
<Progress label="Uploading manifest" value={40} />
|
|
38
|
+
</div>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### With a readout
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
<div className="w-full max-w-sm">
|
|
45
|
+
<Progress label="Uploading manifest" value={65} showValue />
|
|
46
|
+
</div>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Custom max
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
<div className="w-full max-w-sm">
|
|
53
|
+
<Progress label="Packing crates" value={3} max={12} showValue />
|
|
54
|
+
</div>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Indeterminate
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
<div className="w-full max-w-sm">
|
|
61
|
+
<Progress label="Syncing manifest" value={null} showValue />
|
|
62
|
+
</div>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Tones
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
<div className="flex flex-col gap-4 w-full max-w-sm">
|
|
69
|
+
<Progress label="Signal supply run" value={40} tone="signal" showValue />
|
|
70
|
+
<Progress label="BB reserves" value={82} tone="ok" showValue />
|
|
71
|
+
<Progress label="Gas reserves" value={22} tone="warning" showValue />
|
|
72
|
+
<Progress label="Battery reserves" value={6} tone="hostile" showValue />
|
|
73
|
+
</div>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Keyboard
|
|
77
|
+
|
|
78
|
+
Not focusable. No keyboard behaviour of its own.
|
|
79
|
+
|
|
80
|
+
## Do
|
|
81
|
+
|
|
82
|
+
- Name what’s progressing, not the act of progressing.
|
|
83
|
+
- Use showValue whenever the number itself is useful to the reader.
|
|
84
|
+
- Switch to null the moment you stop knowing how much is left. Don’t freeze at 90%.
|
|
85
|
+
|
|
86
|
+
## Don't
|
|
87
|
+
|
|
88
|
+
- Don't animate the number up from zero and call that animated value the readout. The real value belongs in the DOM straight away.
|
|
89
|
+
- Don't use Progress for a wait with no measurable work. That's a Spinner.
|
|
90
|
+
- Don't put the padded readout into the accessible name. That's exactly why the pad is a separate aria-hidden span, and label is what Radix announces.
|
|
91
|
+
- Don't rely on the tone alone to say something failed. Say it in text.
|
|
92
|
+
|
|
93
|
+
## Anatomy
|
|
94
|
+
|
|
95
|
+
- **Label**: Required. Set in mono (a monospaced font, where every character is the same width) and uppercase. It’s also the bar’s accessible name, the name assistive tech such as a screen reader announces for it.
|
|
96
|
+
- **Readout**: Optional, and the star of the row: 24px (pixels) Oxanium against an 11px label. It uses tabular figures, digits that all take the same width, so the number doesn’t jitter as it changes.
|
|
97
|
+
- **Leading zeros**: These are padding, not part of the value. They’re dimmed to numeral.pad and marked aria-hidden (an ARIA attribute, from Accessible Rich Internet Applications, that hides an element from assistive tech). So the readout keeps a fixed three-digit width, while a screen reader still hears 40%, not zero four zero percent.
|
|
98
|
+
- **Track**: 6px tall, a sunken surface, no border. It’s the same track as Slider.
|
|
99
|
+
- **Indicator**: It’s moved rather than resized. That way the browser animates a transform, which is cheap, instead of recalculating the layout.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# PromptInput
|
|
2
|
+
|
|
3
|
+
> The box where a person writes to an assistant. It grows to fit the text, has one Send/Stop button and can show suggestions. Enter sends, and Shift+Enter starts a new line.
|
|
4
|
+
|
|
5
|
+
- **Best for:** The message box under a ChatThread.
|
|
6
|
+
- **Not for:** A long field in a form: use Textarea inside a FormField.
|
|
7
|
+
- Molecule. Source: `src/components/molecules/prompt-input/PromptInput.tsx`
|
|
8
|
+
- Rendered docs page: `/components/prompt-input`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { PromptInput } from 'src/components/molecules/prompt-input/PromptInput'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Requires
|
|
17
|
+
|
|
18
|
+
- A parent that keeps the draft in state and clears it in onSubmit.
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
| Prop | Type | Default | Description |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| `label` | `string` | | Required. Names the box. It's hidden on screen. |
|
|
25
|
+
| `value` | `string` | | Required. The draft. Controlled, meaning your page holds it. |
|
|
26
|
+
| `onValueChange` | `(value: string) => void` | | Required. |
|
|
27
|
+
| `onSubmit` | `(value: string) => void` | | Required. Fires on Enter, Send or a suggestion, and never with a blank draft. Clear the draft here. |
|
|
28
|
+
| `busy` | `boolean` | `false` | Send turns into Stop, and Enter sends nothing. |
|
|
29
|
+
| `onStop` | `() => void` | | Runs on Stop, and on Escape in the box while busy. |
|
|
30
|
+
| `suggestions` | `readonly string[]` | | Ready-made prompts shown as chips. Clicking one submits it. |
|
|
31
|
+
| `editableSuggestions` | `boolean` | `false` | Clicking a suggestion puts its text in the box to edit instead of sending it. Use it when a person will likely change the prompt first. The chips then stay usable while busy. |
|
|
32
|
+
| `placeholder` | `string` | | Shown in the empty box. |
|
|
33
|
+
| `disabled` | `boolean` | `false` | Nothing can be typed or sent. Say why somewhere nearby. |
|
|
34
|
+
| `hint` | `string` | `'Enter to send, Shift+Enter for a new line'` | The line under the box, which is also its description. |
|
|
35
|
+
| `className` | `string` | | Added to the form. |
|
|
36
|
+
|
|
37
|
+
## Examples
|
|
38
|
+
|
|
39
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
40
|
+
|
|
41
|
+
### With suggestions
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
function DraftExample() {
|
|
45
|
+
const [value, setValue] = useState('')
|
|
46
|
+
const [sent, setSent] = useState<string | null>(null)
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex w-full max-w-xl flex-col gap-3">
|
|
49
|
+
<PromptInput
|
|
50
|
+
label="Message the assistant"
|
|
51
|
+
placeholder="Ask about stock, orders or depots"
|
|
52
|
+
value={value}
|
|
53
|
+
onValueChange={setValue}
|
|
54
|
+
onSubmit={text => {
|
|
55
|
+
setSent(text)
|
|
56
|
+
setValue('')
|
|
57
|
+
}}
|
|
58
|
+
suggestions={['Summarise stock at Depot 4', 'Which replicas are running low?']}
|
|
59
|
+
/>
|
|
60
|
+
<p className="font-body text-sm text-[var(--color-text-secondary)]" aria-live="polite">
|
|
61
|
+
{sent === null ? 'Nothing sent yet.' : `Sent: ${sent}`}
|
|
62
|
+
</p>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
<DraftExample />
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Busy: a Stop button takes the place of Send, and pressing Escape stops it too
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
function BusyExample() {
|
|
74
|
+
const [value, setValue] = useState('')
|
|
75
|
+
const [busy, setBusy] = useState(true)
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex w-full max-w-xl flex-col gap-3">
|
|
78
|
+
<PromptInput
|
|
79
|
+
label="Message the assistant, while it works"
|
|
80
|
+
value={value}
|
|
81
|
+
onValueChange={setValue}
|
|
82
|
+
onSubmit={() => setValue('')}
|
|
83
|
+
busy={busy}
|
|
84
|
+
onStop={() => setBusy(false)}
|
|
85
|
+
/>
|
|
86
|
+
{busy ? null : (
|
|
87
|
+
<Button size="sm" variant="ghost" wrapperClassName="self-start" onClick={() => setBusy(true)}>
|
|
88
|
+
Make it busy again
|
|
89
|
+
</Button>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
<BusyExample />
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Disabled
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
<div className="flex w-full max-w-xl flex-col gap-2">
|
|
102
|
+
<PromptInput label="Message the assistant (disabled)" value="" onValueChange={() => {}} onSubmit={() => {}} disabled />
|
|
103
|
+
<p className="font-body text-sm text-[var(--color-text-secondary)]">The assistant is offline until 14:00.</p>
|
|
104
|
+
</div>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Keyboard
|
|
108
|
+
|
|
109
|
+
| Keys | Result |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `Enter` | Sends the draft. Does nothing while busy, or while someone's composing characters with an IME (Input Method Editor, the tool used to type languages like Chinese or Japanese). |
|
|
112
|
+
| `Shift` `Enter` | Starts a new line. |
|
|
113
|
+
| `Escape` | While busy, stops the reply. |
|
|
114
|
+
|
|
115
|
+
## Do
|
|
116
|
+
|
|
117
|
+
- Clear the draft in onSubmit, and set busy as soon as the request starts.
|
|
118
|
+
- Wire up onStop whenever the reply can be stopped. A reply that streams in with no Stop is a wait with no way out.
|
|
119
|
+
- Offer two to four suggestions in an empty thread, and drop them once the conversation has started.
|
|
120
|
+
|
|
121
|
+
## Don't
|
|
122
|
+
|
|
123
|
+
- Don't disable the box while busy; a person may be writing the next message while the reply arrives.
|
|
124
|
+
- Don't wrap it in a FormField. Its label is hidden on purpose, and it brings its own hint.
|
|
125
|
+
- Don't move focus into it when the reply finishes. It never left, unless the person moved it.
|
|
126
|
+
|
|
127
|
+
## Common mistakes
|
|
128
|
+
|
|
129
|
+
Wrong:
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
{busy ? <Button onClick={stop}>Stop</Button> : <Button type="submit">Send</Button>}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Right:
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
<PromptInput busy={busy} onStop={stop} ... />
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Swapping two buttons drops keyboard focus to the page at the moment they switch. PromptInput keeps one button and changes its label and what it does.
|
|
142
|
+
|
|
143
|
+
## Anatomy
|
|
144
|
+
|
|
145
|
+
- **Form**: Paints surface.base itself, as every form does.
|
|
146
|
+
- **Suggestions**: A group of chips above the box. Clicking one submits its text (disabled while busy), or with editableSuggestions puts it in the box to edit.
|
|
147
|
+
- **Box**: A Textarea with a label that's hidden on screen but read by screen readers. It grows from one line to about eight, then scrolls inside.
|
|
148
|
+
- **Send / Stop**: One button: a primary Send with an arrow, or, while busy, a secondary Stop with a square.
|
|
149
|
+
- **Hint**: One line under the box, which is also the box's description: "Enter to send, Shift+Enter for a new line".
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# QuantityStepper
|
|
2
|
+
|
|
3
|
+
> A whole number between a lowest and a highest value, with a button on each side. It follows the spinbutton pattern (the standard recipe for a number you nudge up and down), so the keyboard can do everything from inside the field.
|
|
4
|
+
|
|
5
|
+
- **Best for:** A whole number between real limits, such as a quantity in a cart.
|
|
6
|
+
- **Not for:** A value that isn't a whole number: use Input. A range of hundreds: use Slider or Input.
|
|
7
|
+
- Molecule. Source: `src/components/molecules/quantity-stepper/QuantityStepper.tsx`
|
|
8
|
+
- Rendered docs page: `/components/quantity-stepper`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { QuantityStepper, clampQuantity, gridMax } from 'src/components/molecules/quantity-stepper/QuantityStepper'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Default | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `label` | `string` | | Required. Names the field and both buttons ("Decrease Speed loader quantity"). |
|
|
21
|
+
| `value` | `number` | | Controlled value (your page holds it). |
|
|
22
|
+
| `defaultValue` | `number` | `min` | Starting value when the component holds the value itself (uncontrolled). |
|
|
23
|
+
| `onValueChange` | `(value: number) => void` | | Fires with the final value, pulled inside the limits: after a button click, an arrow key, Home/End, or when typing is finished by leaving the field or pressing Enter. |
|
|
24
|
+
| `min` | `number` | `0` | Lowest allowed value. |
|
|
25
|
+
| `max` | `number` | `99` | Highest allowed value. |
|
|
26
|
+
| `step` | `number` | `1` | The size of each step the value snaps to, counted from min. |
|
|
27
|
+
| `disabled` | `boolean` | `false` | Disables the field and both buttons. |
|
|
28
|
+
| `inputSize` | `'sm' \| 'md'` | `'md'` | Size of the field and buttons. |
|
|
29
|
+
| `className` | `string` | | Combined with the root classes. Where Tailwind utility classes clash, the later one wins. |
|
|
30
|
+
| `ref` | `Ref<HTMLInputElement>` | | Passed through to the field. |
|
|
31
|
+
| `id` | `string` | | The field's id, so a FormField's label and error text can point at it. FormField sets it. |
|
|
32
|
+
| `aria-describedby` | `string` | | The ids of the help and error text, set on the field so screen readers read them out. FormField sets it. |
|
|
33
|
+
| `invalid` | `boolean` | | Marks the field as invalid: aria-invalid and the invalid border. FormField sets it when there is an error. |
|
|
34
|
+
| `required` | `boolean` | | Marks the field as required. FormField sets it. |
|
|
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
|
+
/** The step grid's own last rung at or below `max` -- the real upper bound the control can reach.
|
|
42
|
+
* `max` itself may not be a step away from `min` (min 0, max 99, step 5: the grid is 0, 5, ...,
|
|
43
|
+
* 95, and 99 is never a rung on it), so this is what `aria-valuemax`, the Increase button's
|
|
44
|
+
* disabled state, the focus-on-bound check and End all need instead of `max` directly -- a control
|
|
45
|
+
* that can never reach the value it announces as its maximum is worse than one that announces a
|
|
46
|
+
* lower, reachable one. */
|
|
47
|
+
export function gridMax(min: number, max: number, step = 1): number
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
/**
|
|
52
|
+
* Pure. Parses, snaps to the step grid measured from min, then clamps into `[min, gridMax(min,
|
|
53
|
+
* max, step)]`. Unparseable input returns `min`.
|
|
54
|
+
*
|
|
55
|
+
* `min > max` is the caller's error, not this function's to reject: the formula has no branch that
|
|
56
|
+
* assumes `min <= max`, so it still returns a number rather than throwing -- just not a meaningful
|
|
57
|
+
* one for an inverted range. Callers are expected to pass a sane range.
|
|
58
|
+
*/
|
|
59
|
+
export function clampQuantity(raw: string | number, min: number, max: number, step = 1): number
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Examples
|
|
63
|
+
|
|
64
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
65
|
+
|
|
66
|
+
### Default
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
<QuantityStepper label="Speed loader quantity" defaultValue={1} />
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Bounded: with a lowest and a highest allowed number
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
<div className="flex flex-col gap-2">
|
|
76
|
+
<QuantityStepper label="Bump helmet quantity" defaultValue={1} min={1} max={4} />
|
|
77
|
+
<span className="font-mono text-xs text-[var(--color-text-secondary)]">Only four left</span>
|
|
78
|
+
</div>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Stepping by five
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
<QuantityStepper label="BB bottle quantity" defaultValue={5} min={0} max={50} step={5} />
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### In a cart line
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
function CartLine() {
|
|
91
|
+
const price = 38
|
|
92
|
+
const [qty, setQty] = useState(2)
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex items-center gap-4 w-full">
|
|
95
|
+
<span className="flex-1 font-body text-sm text-[var(--color-text-primary)]">Speed loader</span>
|
|
96
|
+
<QuantityStepper
|
|
97
|
+
label="Speed loader quantity"
|
|
98
|
+
defaultValue={2}
|
|
99
|
+
min={1}
|
|
100
|
+
max={9}
|
|
101
|
+
onValueChange={setQty}
|
|
102
|
+
/>
|
|
103
|
+
<span className="w-16 text-right font-display font-semibold tabular-nums text-[var(--color-text-primary)]">
|
|
104
|
+
€{price * qty}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
<CartLine />
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Keyboard
|
|
114
|
+
|
|
115
|
+
| Keys | Result |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `↑` `↓` | One step. |
|
|
118
|
+
| `Page Up` `Page Down` | Ten steps. |
|
|
119
|
+
| `Home` `End` | Jumps to the minimum or maximum. |
|
|
120
|
+
| `Enter` | Confirms what you typed. |
|
|
121
|
+
| `Tab` | Leaves the field and confirms what you typed. Tab doesn't stop on the buttons. |
|
|
122
|
+
|
|
123
|
+
## Do
|
|
124
|
+
|
|
125
|
+
- Set a real maximum, like the stock level or an order limit, not an arbitrary 99.
|
|
126
|
+
- Name the field after the item: 'Speed loader quantity'.
|
|
127
|
+
- Show the line total beside it, updating as the value changes.
|
|
128
|
+
|
|
129
|
+
## Don't
|
|
130
|
+
|
|
131
|
+
- Don't use it for values that aren't whole numbers; that's an Input.
|
|
132
|
+
- Don't use it for a range of hundreds; that's a Slider or an Input.
|
|
133
|
+
- Don't pull the value back inside the limits while someone's still typing.
|
|
134
|
+
|
|
135
|
+
## Anatomy
|
|
136
|
+
|
|
137
|
+
- **Field**: Has role=spinbutton, plus its value and limits, so screen readers know what it is. It's a text field that brings up a number keypad on phones, not the browser's own number input.
|
|
138
|
+
- **Decrease / Increase**: Secondary IconButtons named after the field. As the pattern says, Tab skips them. They're disabled at the lowest and highest value.
|