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,242 @@
|
|
|
1
|
+
# ChatThread
|
|
2
|
+
|
|
3
|
+
> A scrolling log of ChatMessages. It follows new text only while the reader is at the end, and it announces each finished reply to screen readers once.
|
|
4
|
+
|
|
5
|
+
- **Best for:** A conversation with an assistant, above a PromptInput, where replies stream in.
|
|
6
|
+
- **Not for:** A record of events on one thing: use Timeline.
|
|
7
|
+
- Organism. Source: `src/components/organisms/chat-thread/ChatThread.tsx`
|
|
8
|
+
- Rendered docs page: `/components/chat-thread`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { ChatThread } from 'src/components/organisms/chat-thread/ChatThread'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Requires
|
|
17
|
+
|
|
18
|
+
- A height, because the thread scrolls and the page doesn't. Use h-[28rem], or flex-1 min-h-0 inside a column.
|
|
19
|
+
- ChatMessage children, so the thread can announce each finished reply.
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
| Prop | Type | Default | Description |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| `label` | `string` | | Required. Names the log. |
|
|
26
|
+
| `busy` | `boolean` | `false` | The assistant is working. Shows a working line and makes one announcement when it starts. |
|
|
27
|
+
| `busyLabel` | `string` | `'Assistant is working'` | The working line's words, and what gets announced. |
|
|
28
|
+
| `jumpLabel` | `string` | `'Jump to latest'` | The jump button's words. |
|
|
29
|
+
| `className` | `string` | | Goes on the root. Give the thread a height here. |
|
|
30
|
+
|
|
31
|
+
## Examples
|
|
32
|
+
|
|
33
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
34
|
+
|
|
35
|
+
### A live conversation: send anything, watch the reply stream in, press Stop to end it, or scroll up while it's still arriving
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
type Turn =
|
|
39
|
+
| { id: number; role: 'user'; text: string }
|
|
40
|
+
| { id: number; role: 'assistant'; text: string; tool?: ToolCallStatus; approval?: boolean }
|
|
41
|
+
| { id: number; role: 'system'; text: string }
|
|
42
|
+
|
|
43
|
+
const REPLIES = [
|
|
44
|
+
'Depot 4 holds 1,240 replicas across 38 lines. Two lines are under their reorder point: the K-41 field radio has 6 left against a reorder point of 10, and the trail pack has 3 left against 8. Everything else has at least two weeks of cover at the current rate. The reorder above covers both lines; once it is approved, the supplier confirms within a working day.',
|
|
45
|
+
'Inbound this week: one delivery from Arad on Thursday morning, 22 pallets, booked into bay 2. Outbound: nine shipments, the largest to Sibiu on Friday. Nothing is waiting on a person except the reorder above.',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const HISTORY: Turn[] = [
|
|
49
|
+
{ id: 1, role: 'user', text: 'Summarise stock at Depot 4.' },
|
|
50
|
+
{
|
|
51
|
+
id: 2,
|
|
52
|
+
role: 'assistant',
|
|
53
|
+
text: 'Depot 4 is in good shape: 1,240 replicas on hand and nothing overdue. Two lines are low, so I have drafted a reorder for you to check.',
|
|
54
|
+
tool: 'done',
|
|
55
|
+
approval: true,
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
function LiveConversation() {
|
|
60
|
+
const [turns, setTurns] = useState<Turn[]>(HISTORY)
|
|
61
|
+
const [draft, setDraft] = useState('')
|
|
62
|
+
const [phase, setPhase] = useState<'idle' | 'tool' | 'streaming'>('idle')
|
|
63
|
+
const [approval, setApproval] = useState<ApprovalState>('pending')
|
|
64
|
+
const [replyIndex, setReplyIndex] = useState(0)
|
|
65
|
+
const nextId = useRef(HISTORY.length + 1)
|
|
66
|
+
const activeId = useRef<number | null>(null)
|
|
67
|
+
const reply = useStreamedText(REPLIES[replyIndex % REPLIES.length], { active: phase === 'streaming' })
|
|
68
|
+
|
|
69
|
+
const updateActive = (change: (turn: Turn & { role: 'assistant' }) => Turn) =>
|
|
70
|
+
setTurns(list => list.map(turn => (turn.id === activeId.current && turn.role === 'assistant' ? change(turn) : turn)))
|
|
71
|
+
|
|
72
|
+
const addStopped = () => {
|
|
73
|
+
const id = nextId.current++
|
|
74
|
+
setTurns(list => [...list, { id, role: 'system', text: 'Assistant stopped' }])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The tool "runs" for a moment, then the reply starts streaming.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (phase !== 'tool') return
|
|
80
|
+
const timer = window.setTimeout(() => {
|
|
81
|
+
updateActive(turn => ({ ...turn, tool: 'done' }))
|
|
82
|
+
setPhase('streaming')
|
|
83
|
+
}, 1200)
|
|
84
|
+
return () => window.clearTimeout(timer)
|
|
85
|
+
}, [phase])
|
|
86
|
+
|
|
87
|
+
// Finished or stopped: keep what arrived, and hand the thread a message that is no longer streaming.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (phase !== 'streaming' || !reply.done) return
|
|
90
|
+
updateActive(turn => ({ ...turn, text: reply.text }))
|
|
91
|
+
if (reply.stopped) addStopped()
|
|
92
|
+
setPhase('idle')
|
|
93
|
+
setReplyIndex(index => index + 1)
|
|
94
|
+
}, [phase, reply.done, reply.text, reply.stopped])
|
|
95
|
+
|
|
96
|
+
const send = (text: string) => {
|
|
97
|
+
const question = nextId.current++
|
|
98
|
+
const answer = nextId.current++
|
|
99
|
+
activeId.current = answer
|
|
100
|
+
setTurns(list => [...list, { id: question, role: 'user', text }, { id: answer, role: 'assistant', text: '', tool: 'running' }])
|
|
101
|
+
setDraft('')
|
|
102
|
+
setPhase('tool')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const stop = () => {
|
|
106
|
+
if (phase === 'streaming') {
|
|
107
|
+
reply.stop()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
// Stopped before any text: the empty reply goes, the system line says what happened.
|
|
111
|
+
setTurns(list => list.filter(turn => turn.id !== activeId.current))
|
|
112
|
+
addStopped()
|
|
113
|
+
setPhase('idle')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const busy = phase !== 'idle'
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="flex w-full max-w-2xl flex-col border border-[var(--color-border-hairline-strong)]">
|
|
120
|
+
<ChatThread label="Conversation with the assistant" busy={phase === 'tool'} className="h-[28rem]">
|
|
121
|
+
{turns.map(turn => {
|
|
122
|
+
if (turn.role === 'user') {
|
|
123
|
+
return (
|
|
124
|
+
<ChatMessage key={turn.id} role="user" author="You">
|
|
125
|
+
{turn.text}
|
|
126
|
+
</ChatMessage>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
if (turn.role === 'system') {
|
|
130
|
+
return (
|
|
131
|
+
<ChatMessage key={turn.id} role="system" author="Status">
|
|
132
|
+
{turn.text}
|
|
133
|
+
</ChatMessage>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
const streaming = phase === 'streaming' && turn.id === activeId.current
|
|
137
|
+
const text = streaming ? reply.text : turn.text
|
|
138
|
+
return (
|
|
139
|
+
<ChatMessage key={turn.id} role="assistant" author="Assistant" streaming={streaming}>
|
|
140
|
+
{turn.tool === undefined ? null : (
|
|
141
|
+
<ToolCall
|
|
142
|
+
name="query_stock_levels"
|
|
143
|
+
title="Read stock levels at Depot 4"
|
|
144
|
+
status={turn.tool}
|
|
145
|
+
duration={turn.tool === 'done' ? '1.2 s' : undefined}
|
|
146
|
+
input={'{ "depot": 4 }'}
|
|
147
|
+
output={turn.tool === 'done' ? '{ "on_hand": 1240, "lines": 38, "below_reorder_point": 2 }' : undefined}
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
{text === '' ? null : <p>{text}</p>}
|
|
151
|
+
{turn.approval ? (
|
|
152
|
+
<ApprovalRequest
|
|
153
|
+
title="Send this reorder to the supplier?"
|
|
154
|
+
state={approval}
|
|
155
|
+
onApprove={() => setApproval('approved')}
|
|
156
|
+
onReject={() => setApproval('rejected')}
|
|
157
|
+
approveLabel="Send reorder"
|
|
158
|
+
rejectLabel="Don't send"
|
|
159
|
+
>
|
|
160
|
+
<p>To: Arad replica supply</p>
|
|
161
|
+
<p>10 × K-41 field radio, 8 × trail pack, to Depot 4 by Thursday 22 October.</p>
|
|
162
|
+
</ApprovalRequest>
|
|
163
|
+
) : null}
|
|
164
|
+
</ChatMessage>
|
|
165
|
+
)
|
|
166
|
+
})}
|
|
167
|
+
</ChatThread>
|
|
168
|
+
<PromptInput
|
|
169
|
+
label="Message the assistant"
|
|
170
|
+
placeholder="Ask about stock, orders or depots"
|
|
171
|
+
value={draft}
|
|
172
|
+
onValueChange={setDraft}
|
|
173
|
+
onSubmit={send}
|
|
174
|
+
busy={busy}
|
|
175
|
+
onStop={stop}
|
|
176
|
+
className="p-4 border-t border-[var(--color-border-hairline-strong)]"
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
<LiveConversation />
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Keyboard
|
|
186
|
+
|
|
187
|
+
| Keys | Result |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `Tab` | Moves to the log, then into the controls inside messages, then to Jump to latest when it's shown. |
|
|
190
|
+
| `ArrowUp` `ArrowDown` | With the log focused, scrolls it. |
|
|
191
|
+
| `PageUp` `PageDown` | With the log focused, scrolls it a page at a time. |
|
|
192
|
+
|
|
193
|
+
## Do
|
|
194
|
+
|
|
195
|
+
- Give the thread a fixed or flexible height so the thread scrolls, not the page.
|
|
196
|
+
- Set busy as soon as the request starts. Clear it when the last fragment has arrived or the reply is stopped.
|
|
197
|
+
- After a Stop, add a system line ("Assistant stopped") so the record and the announcement both say what happened.
|
|
198
|
+
- Keep the PromptInput outside the thread, directly under it.
|
|
199
|
+
|
|
200
|
+
## Don't
|
|
201
|
+
|
|
202
|
+
- Don't scroll the thread yourself on new messages. It follows the end only while the reader is there.
|
|
203
|
+
- Don't move focus into the thread when a reply arrives. Focus stays where the person put it.
|
|
204
|
+
- Don't add a second live region (an area screen readers announce changes from) for replies or tool calls. The thread's status is the only voice.
|
|
205
|
+
|
|
206
|
+
## Common mistakes
|
|
207
|
+
|
|
208
|
+
Wrong:
|
|
209
|
+
|
|
210
|
+
```tsx
|
|
211
|
+
<div role="log" aria-live="polite">{messages}</div>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Right:
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
<ChatThread label="Conversation with the assistant">{messages}</ChatThread>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A polite live log makes a screen reader read a streaming reply aloud one fragment at a time. The thread keeps the log quiet and announces each finished reply once.
|
|
221
|
+
|
|
222
|
+
Wrong:
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
useEffect(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), [messages])
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Right:
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
<ChatThread ...> // follows the end only while the reader is there
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Scrolling on every change yanks away a reader who scrolled up to read. And smooth scrolling on every fragment means the screen never stops moving.
|
|
235
|
+
|
|
236
|
+
## Anatomy
|
|
237
|
+
|
|
238
|
+
- **Log**: Has role="log" and is named by label. aria-live is off, so screen readers don't read every change. It's a tab stop (a place the Tab key lands) so a keyboard can scroll it, and it scrolls inside its own height.
|
|
239
|
+
- **Messages**: ChatMessage children, oldest first, 24px (pixels) apart.
|
|
240
|
+
- **Working line**: Shown while busy: the conveyor Spinner and "Assistant is working" at the end of the log.
|
|
241
|
+
- **Jump to latest**: Appears only when the reader has scrolled away from the end. It jumps instantly and puts focus on the log.
|
|
242
|
+
- **Status**: A visually hidden role="status" that screen readers hear. It says the busy label once when work starts, and each finished reply once.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Checkbox
|
|
2
|
+
|
|
3
|
+
> A checkbox with three states: off, on and mixed. Radix (the open-source library of accessible building blocks underneath it) handles the semantics and the keyboard behaviour. The look comes from the system's shape language: a square with no rounding and a tick in ink.
|
|
4
|
+
|
|
5
|
+
- **Best for:** An on/off choice that applies when the form is saved, or a parent whose children are partly selected.
|
|
6
|
+
- **Not for:** A setting that takes effect the moment you flip it. Use Switch for that.
|
|
7
|
+
- Atom. Source: `src/components/atoms/checkbox/Checkbox.tsx`
|
|
8
|
+
- Rendered docs page: `/components/checkbox`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { Checkbox } from 'src/components/atoms/checkbox/Checkbox'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Default | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `checked` | `boolean \| 'indeterminate'` | | The checked state, when your code controls it. |
|
|
21
|
+
| `defaultChecked` | `boolean` | | The starting state when the checkbox manages itself (uncontrolled). It's narrower than Radix's own boolean \| 'indeterminate'. The tick or dash can only follow a checked value the caller passed in. Radix would track an uncontrolled mixed state internally but never hand it back, so that state is excluded instead of being drawn with the wrong mark. |
|
|
22
|
+
| `onCheckedChange` | `(checked: boolean \| 'indeterminate') => void` | | Runs every time it toggles. |
|
|
23
|
+
| `disabled` | `boolean` | | Takes it out of the tab order and stops it toggling. |
|
|
24
|
+
| `required` | `boolean` | | Uses the browser's built-in form validation. |
|
|
25
|
+
| `name` | `string` | | The field name sent with a native form. |
|
|
26
|
+
| `value` | `string \| number \| readonly string[]` | | The field value sent with a native form. |
|
|
27
|
+
| `invalid` | `boolean` | `false` | Sets aria-invalid (an ARIA attribute, from Accessible Rich Internet Applications, that tells screen readers the field has an error) and shows the invalid border. |
|
|
28
|
+
| `id` | `string` | | Pair it with a real label through htmlFor. |
|
|
29
|
+
| `className` | `string` | | Merged with the control's classes. If two clash, the later Tailwind utility wins (Tailwind is the CSS framework this system styles with). |
|
|
30
|
+
| `ref` | `Ref<HTMLButtonElement>` | | Passed through to the Radix root button. |
|
|
31
|
+
| `...rest` | `Omit<CheckboxProps, 'aria-invalid' \| 'defaultChecked'>` | | Every Radix Checkbox.Root prop except aria-invalid, spread onto the root. aria-invalid is removed from the type, so you can't pass it, instead of being quietly overwritten while the page runs. invalid is the only thing that sets both the attribute and the border. defaultChecked is declared again above, narrowed to boolean. |
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
36
|
+
|
|
37
|
+
### States
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
<div className="flex items-center gap-2">
|
|
41
|
+
<Checkbox id="state-unchecked" />
|
|
42
|
+
<label htmlFor="state-unchecked">Unchecked</label>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="flex items-center gap-2">
|
|
45
|
+
<Checkbox id="state-checked" checked />
|
|
46
|
+
<label htmlFor="state-checked">Checked</label>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="flex items-center gap-2">
|
|
49
|
+
<Checkbox id="state-indeterminate" checked="indeterminate" />
|
|
50
|
+
<label htmlFor="state-indeterminate">Mixed</label>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<Checkbox id="state-disabled" disabled />
|
|
54
|
+
<label htmlFor="state-disabled">Disabled</label>
|
|
55
|
+
</div>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### A group
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
const KIT_ITEMS = ['Speed loader', 'Rail light', 'Spare battery', 'First aid kit']
|
|
62
|
+
|
|
63
|
+
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-')
|
|
64
|
+
|
|
65
|
+
function GroupExample() {
|
|
66
|
+
const [checkedItems, setCheckedItems] = useState<Record<string, boolean>>({
|
|
67
|
+
'Speed loader': true,
|
|
68
|
+
'Rail light': true,
|
|
69
|
+
'Spare battery': false,
|
|
70
|
+
'First aid kit': false,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const checkedCount = KIT_ITEMS.filter(item => checkedItems[item]).length
|
|
74
|
+
const parentChecked: boolean | 'indeterminate' =
|
|
75
|
+
checkedCount === 0 ? false : checkedCount === KIT_ITEMS.length ? true : 'indeterminate'
|
|
76
|
+
|
|
77
|
+
function setAll(value: boolean) {
|
|
78
|
+
setCheckedItems(Object.fromEntries(KIT_ITEMS.map(item => [item, value])))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex flex-col gap-2">
|
|
83
|
+
<div className="flex items-center gap-2">
|
|
84
|
+
<Checkbox
|
|
85
|
+
id="kit-parent"
|
|
86
|
+
checked={parentChecked}
|
|
87
|
+
onCheckedChange={checked => setAll(checked === true)}
|
|
88
|
+
/>
|
|
89
|
+
<label htmlFor="kit-parent" className="font-medium">
|
|
90
|
+
Pack full field kit
|
|
91
|
+
</label>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="flex flex-col gap-2 pl-6">
|
|
94
|
+
{KIT_ITEMS.map(item => (
|
|
95
|
+
<div key={item} className="flex items-center gap-2">
|
|
96
|
+
<Checkbox
|
|
97
|
+
id={`kit-item-${slug(item)}`}
|
|
98
|
+
checked={checkedItems[item]}
|
|
99
|
+
onCheckedChange={checked =>
|
|
100
|
+
setCheckedItems(prev => ({ ...prev, [item]: checked === true }))
|
|
101
|
+
}
|
|
102
|
+
/>
|
|
103
|
+
<label htmlFor={`kit-item-${slug(item)}`}>{item}</label>
|
|
104
|
+
</div>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
<GroupExample />
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Invalid
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<Checkbox id="state-invalid" invalid />
|
|
119
|
+
<label htmlFor="state-invalid">Confirm waiver on file</label>
|
|
120
|
+
</div>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Keyboard
|
|
124
|
+
|
|
125
|
+
| Keys | Result |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `Tab` | Moves focus to it. |
|
|
128
|
+
| `Space` | Toggles it. |
|
|
129
|
+
| `Enter` | Nothing. That's how a native checkbox behaves, so it isn't a bug to fix. |
|
|
130
|
+
|
|
131
|
+
## Do
|
|
132
|
+
|
|
133
|
+
- Give it a real <label htmlFor>, or let FormField do it.
|
|
134
|
+
- Use the mixed state for a parent whose children are partly selected.
|
|
135
|
+
- Put the label on the right and make it part of the clickable target.
|
|
136
|
+
|
|
137
|
+
## Don't
|
|
138
|
+
|
|
139
|
+
- Don't use a checkbox for something that takes effect right away. That's a Switch.
|
|
140
|
+
- Don't rely on the fill colour alone. The tick and the dash are what show the state.
|
|
141
|
+
- Don't nest checkbox groups more than one level deep.
|
|
142
|
+
|
|
143
|
+
## Anatomy
|
|
144
|
+
|
|
145
|
+
- **Root**: A button with role=checkbox, which tells screen readers it's a checkbox. A native input can't be styled this way. The box is 20px (pixels) and the hit area, the part you can click or tap, is 24px.
|
|
146
|
+
- **Indicator**: A tick when checked, a dash when mixed. The shape changes, so the state never depends on colour alone.
|
|
147
|
+
- **Hit area**: A ::before inset of -2px, which clears the 24×24 minimum in WCAG 2.2 (the Web Content Accessibility Guidelines, the public rulebook for making websites usable by people with disabilities).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# CheckoutForm
|
|
2
|
+
|
|
3
|
+
> Shipping, delivery and payment in three steps, with the order summary beside them. It's a demo: no payment is ever taken, and the card fields say so.
|
|
4
|
+
|
|
5
|
+
- **Best for:** A demo checkout: shipping, delivery and payment in three steps beside the order summary.
|
|
6
|
+
- **Not for:** Taking a real payment. It's a demo and never charges.
|
|
7
|
+
- Organism. Source: `src/components/organisms/checkout-form/CheckoutForm.tsx`
|
|
8
|
+
- Rendered docs page: `/components/checkout-form`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { CheckoutForm, DELIVERY_OPTIONS } from 'src/components/organisms/checkout-form/CheckoutForm'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Default | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `lines` | `CartLine[]` | | Required. What's being bought. The order summary lists them. |
|
|
21
|
+
| `countries` | `SelectOption[]` | | Required. The options for the country Select. |
|
|
22
|
+
| `deliveryOptions` | `DeliveryOption[]` | `DELIVERY_OPTIONS` | Prices in the currency's smallest unit. Zero shows as Free. |
|
|
23
|
+
| `onPlaceOrder` | `(order: CheckoutOrder) => void \| { error: string } \| Promise<void \| { error: string }>` | | Required. Gets the contact details, addresses, delivery id and total, and never a card field. If it rejects, an alert appears that lets the person try again, so the failure isn't lost. Resolving { error } shows that specific message in the same alert. |
|
|
24
|
+
| `headingLevel` | `2 \| 3` | `2` | The level of the 'Checkout' heading. Steps and the order summary sit one level below it, and never below ErrorSummary's own fixed h4 (level-4 heading). |
|
|
25
|
+
| `currency` | `string` | `'EUR'` | Passed to formatMoney. |
|
|
26
|
+
| `locale` | `string` | `'en-IE'` | Passed to formatMoney. |
|
|
27
|
+
| `className` | `string` | | Merged with the root classes. Later Tailwind utilities win. |
|
|
28
|
+
| `ref` | `Ref<HTMLDivElement>` | | The root. |
|
|
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
|
+
export type DeliveryOption = {
|
|
36
|
+
id: string
|
|
37
|
+
label: string
|
|
38
|
+
detail: string
|
|
39
|
+
/** In the currency's minor unit (cents for EUR). Zero shows as "Free". */
|
|
40
|
+
price: number
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
export const DELIVERY_OPTIONS: DeliveryOption[] = [
|
|
46
|
+
{ id: 'standard', label: 'Standard', detail: '3 to 5 working days', price: 0 },
|
|
47
|
+
{ id: 'express', label: 'Express', detail: 'Next working day', price: 1200 },
|
|
48
|
+
]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
export type CheckoutAddress = { line1: string; line2: string; city: string; postalCode: string; country: string }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
/** What the form hands over. No card field ever leaves it, demo or not. */
|
|
57
|
+
export type CheckoutOrder = {
|
|
58
|
+
email: string
|
|
59
|
+
name: string
|
|
60
|
+
phone: string
|
|
61
|
+
shipping: CheckoutAddress
|
|
62
|
+
deliveryId: string
|
|
63
|
+
billing: CheckoutAddress | 'same-as-shipping'
|
|
64
|
+
/** In the currency's minor unit: the lines plus delivery. */
|
|
65
|
+
total: number
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Examples
|
|
70
|
+
|
|
71
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
72
|
+
|
|
73
|
+
### Three steps, a demo order
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
const LINES: CartLine[] = [
|
|
77
|
+
{ id: 'kestrel-c', name: 'Kestrel C carbine replica', unitPrice: 13900, quantity: 1 },
|
|
78
|
+
{ id: 'prism-holo-sight', name: 'Prism holo sight', unitPrice: 4900, quantity: 2 },
|
|
79
|
+
{ id: 'beam-600', name: 'Beam 600 rail light', unitPrice: 2400, quantity: 1 },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const COUNTRIES = [
|
|
83
|
+
{ value: 'ie', label: 'Ireland' },
|
|
84
|
+
{ value: 'ro', label: 'Romania' },
|
|
85
|
+
{ value: 'de', label: 'Germany' },
|
|
86
|
+
{ value: 'fr', label: 'France' },
|
|
87
|
+
{ value: 'es', label: 'Spain' },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
const wait = (ms: number) => new Promise(resolve => window.setTimeout(resolve, ms))
|
|
91
|
+
|
|
92
|
+
<CheckoutForm lines={LINES} countries={COUNTRIES} onPlaceOrder={() => wait(800).then(() => undefined)} headingLevel={3} />
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Keyboard
|
|
96
|
+
|
|
97
|
+
| Keys | Result |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `Tab` | Moves through the fields, then Back and Continue. |
|
|
100
|
+
| `Enter` | In a field, goes on to the next step, or places the order on the last one. |
|
|
101
|
+
| `↑` `↓` | In the delivery options, moves and picks. |
|
|
102
|
+
| `Space` | On the billing checkbox, toggles it. |
|
|
103
|
+
|
|
104
|
+
## Do
|
|
105
|
+
|
|
106
|
+
- Pass the cart lines, so the order summary and the total come from the same numbers.
|
|
107
|
+
- Keep the demo notice whenever the card fields are fake.
|
|
108
|
+
- Store prices in the currency's smallest unit (cents for EUR).
|
|
109
|
+
|
|
110
|
+
## Don't
|
|
111
|
+
|
|
112
|
+
- Don't make the Stepper clickable. Back and Continue move between steps.
|
|
113
|
+
- Don't ask for the billing address unless it's different from shipping.
|
|
114
|
+
- Don't validate a card number in a demo. Nothing's charged.
|
|
115
|
+
|
|
116
|
+
## Anatomy
|
|
117
|
+
|
|
118
|
+
- **Stepper**: Shows where the person is. It isn't clickable, so nobody can skip to a step whose fields don't exist yet.
|
|
119
|
+
- **Step heading**: Read as 'Step 2 of 3: Delivery' by a screen reader. Focus (where keyboard input goes) lands here on Continue and Back.
|
|
120
|
+
- **Error summary**: Lists the step's missing or wrong fields and takes focus. Each entry jumps to its field.
|
|
121
|
+
- **Delivery options**: A radio group with prices, and nothing picked in advance.
|
|
122
|
+
- **Billing**: Uses the shipping address by default. Unticking the box asks for a billing address.
|
|
123
|
+
- **Demo notice**: An info Alert above the card fields saying no payment is taken.
|
|
124
|
+
- **Order summary**: Lines, subtotal, delivery and total, formatted by formatMoney.
|
|
125
|
+
- **Confirmation**: A status line that's always on the page and confirms the order.
|
|
126
|
+
- **Placement alert**: Also always on the page. If onPlaceOrder rejects, or resolves { error } with a specific message, this alert says so, instead of the button just quietly stopping being busy. Everything already typed stays put for another try.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# CommandPalette
|
|
2
|
+
|
|
3
|
+
> Every action in one place. Press Ctrl+K anywhere, type a few letters, move with the arrow keys and press Enter. Focus (where keyboard input goes) returns to where it was, and then the command runs.
|
|
4
|
+
|
|
5
|
+
- **Best for:** Every action in one place behind Ctrl+K, with a visible button that opens it too.
|
|
6
|
+
- **Not for:** Searching page content: use Search. A few actions on one control: use DropdownMenu.
|
|
7
|
+
- Organism. Source: `src/components/organisms/command-palette/CommandPalette.tsx`
|
|
8
|
+
- Rendered docs page: `/components/command-palette`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { CommandPalette, filterCommands, groupCommands } from 'src/components/organisms/command-palette/CommandPalette'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Default | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `commands` | `Command[]` | | Required. Each has id, label, group, keywords, shortcut, icon and onSelect. |
|
|
21
|
+
| `open` | `boolean` | | Open state when controlled, meaning your code decides whether it is open. |
|
|
22
|
+
| `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled, meaning the palette keeps track of it itself. |
|
|
23
|
+
| `onOpenChange` | `(open: boolean) => void` | | Called on every open and close, including Escape and a click on the scrim (the dimmed backdrop). If you refuse the close, the chosen command stays ready to run until the user does anything else in the palette. Closing it yourself later counts as accepting it. |
|
|
24
|
+
| `shortcut` | `boolean` | `true` | Ctrl+K or Cmd+K opens and closes the palette from anywhere. |
|
|
25
|
+
| `label` | `string` | `'Command palette'` | The dialog's accessible name (what a screen reader announces), hidden from view. |
|
|
26
|
+
| `searchLabel` | `string` | `'Search commands'` | The search field's accessible name. |
|
|
27
|
+
| `placeholder` | `string` | `'Type a command or search'` | Shown in the empty search field. |
|
|
28
|
+
| `emptyMessage` | `string` | `'No matching commands'` | Shown when nothing matches the search. |
|
|
29
|
+
| `ref` | `Ref<HTMLInputElement>` | | The search field, while the palette is open. |
|
|
30
|
+
|
|
31
|
+
## Types and helpers
|
|
32
|
+
|
|
33
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
export type Command = {
|
|
37
|
+
id: string
|
|
38
|
+
label: string
|
|
39
|
+
/** Commands with the same group are drawn together under its heading. */
|
|
40
|
+
group?: string
|
|
41
|
+
/** Extra words a search matches on. */
|
|
42
|
+
keywords?: string[]
|
|
43
|
+
/** A hint shown on the option, e.g. "N". Display only: the palette does not bind it. */
|
|
44
|
+
shortcut?: string
|
|
45
|
+
/** Drawn aria-hidden; the label carries the meaning. */
|
|
46
|
+
icon?: ReactNode
|
|
47
|
+
onSelect: () => void
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
export type CommandGroup = { group?: string; commands: Command[] }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
/** Every whitespace-separated term must appear in the label, the group or a keyword, ignoring case.
|
|
57
|
+
* A label that starts with the first term ranks first; otherwise the given order holds. */
|
|
58
|
+
export function filterCommands(commands: readonly Command[], query: string): Command[]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
/** Groups in the order each group first appears; order within a group is kept. */
|
|
63
|
+
export function groupCommands(commands: readonly Command[]): CommandGroup[]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Examples
|
|
67
|
+
|
|
68
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
69
|
+
|
|
70
|
+
### Store commands
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
function StoreCommands() {
|
|
74
|
+
const [open, setOpen] = useState(false)
|
|
75
|
+
const [last, setLast] = useState('none')
|
|
76
|
+
const make = (id: string, label: string, group: string, icon: ReactNode, extra: Partial<Command> = {}): Command => ({
|
|
77
|
+
id,
|
|
78
|
+
label,
|
|
79
|
+
group,
|
|
80
|
+
icon,
|
|
81
|
+
onSelect: () => setLast(label),
|
|
82
|
+
...extra,
|
|
83
|
+
})
|
|
84
|
+
const commands = [
|
|
85
|
+
make('orders', 'Go to orders', 'Navigate', <Package />, { keywords: ['sales'] }),
|
|
86
|
+
make('shipments', 'Go to shipments', 'Navigate', <Truck />),
|
|
87
|
+
make('returns', 'Go to returns', 'Navigate', <Inbox />),
|
|
88
|
+
make('new-shipment', 'New shipment', 'Create', <Plus />, { shortcut: 'N' }),
|
|
89
|
+
make('new-product', 'New product', 'Create', <Box />),
|
|
90
|
+
make('invite', 'Invite a teammate', 'Account', <Users />, { keywords: ['team', 'people'] }),
|
|
91
|
+
make('settings', 'Open settings', 'Account', <Settings />),
|
|
92
|
+
]
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
95
|
+
<Button variant="secondary" aria-keyshortcuts="Control+K Meta+K" onClick={() => setOpen(true)}>
|
|
96
|
+
Open command palette
|
|
97
|
+
</Button>
|
|
98
|
+
<span aria-hidden="true" className="inline-flex gap-1">
|
|
99
|
+
<Kbd>Ctrl</Kbd>
|
|
100
|
+
<Kbd>K</Kbd>
|
|
101
|
+
</span>
|
|
102
|
+
<p role="status" className="font-mono text-xs text-[var(--color-text-secondary)]">
|
|
103
|
+
Last command: {last}
|
|
104
|
+
</p>
|
|
105
|
+
<CommandPalette commands={commands} open={open} onOpenChange={setOpen} />
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
<StoreCommands />
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Keyboard
|
|
114
|
+
|
|
115
|
+
| Keys | Result |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `Ctrl` `K` | Opens or closes it from anywhere on the page (Cmd+K on macOS). |
|
|
118
|
+
| `↓` | Next command, wrapping round to the first. |
|
|
119
|
+
| `↑` | Previous command, wrapping round to the last. |
|
|
120
|
+
| `Home` | First command. |
|
|
121
|
+
| `End` | Last command. |
|
|
122
|
+
| `Enter` | Runs the active command. |
|
|
123
|
+
| `Esc` | Closes without running anything. |
|
|
124
|
+
|
|
125
|
+
## Do
|
|
126
|
+
|
|
127
|
+
- Name commands as verbs: 'Go to orders', 'New shipment'.
|
|
128
|
+
- Add keywords for the words people actually type.
|
|
129
|
+
- Give it a visible button that opens it too. People won't find a shortcut on its own.
|
|
130
|
+
|
|
131
|
+
## Don't
|
|
132
|
+
|
|
133
|
+
- Don't put destructive commands in it without a dialog that asks for confirmation.
|
|
134
|
+
- Don't show a shortcut hint the app doesn't actually set up.
|
|
135
|
+
- Don't put more than one palette on a page. Each one listens for the same Ctrl+K/Cmd+K across the whole page, so a second one fights the first for the shortcut.
|
|
136
|
+
|
|
137
|
+
## Anatomy
|
|
138
|
+
|
|
139
|
+
- **Dialog**: Named 'Command palette', with the name hidden from view. Drawn on surface.base with the corner crosshair.
|
|
140
|
+
- **Search field**: A combobox, a text field tied to a list of choices. Focus stays here the whole time.
|
|
141
|
+
- **Listbox**: Named 'Commands'. It's not there at all when nothing matches.
|
|
142
|
+
- **Group**: Named by its heading.
|
|
143
|
+
- **Option**: The active one gets the fill, and the search field points to it as its active descendant, so screen readers announce it while focus stays in the field.
|
|
144
|
+
- **Count**: A status line hidden from view but read by screen readers: '2 commands'.
|
|
145
|
+
- **Shortcut hint**: A Kbd that's hidden from assistive technology (screen readers and similar tools). It's for display only.
|