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,127 @@
|
|
|
1
|
+
# IconButton
|
|
2
|
+
|
|
3
|
+
> A square button with one icon. It uses exactly the same variants as Button (same source code, so the two can't drift apart). It requires a label, because an icon on its own tells a screen reader nothing.
|
|
4
|
+
|
|
5
|
+
- **Best for:** An action shown as a single icon, with a label that names the action.
|
|
6
|
+
- **Not for:** An action with a visible word (use Button), or a status (use Badge).
|
|
7
|
+
- Atom. Source: `src/components/atoms/icon-button/IconButton.tsx`
|
|
8
|
+
- Rendered docs page: `/components/icon-button`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { IconButton } from 'src/components/atoms/icon-button/IconButton'
|
|
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. It becomes aria-label. It's required in the types, so an icon button with no name won't compile. |
|
|
23
|
+
| `variant` | `'primary' \| 'secondary' \| 'danger' \| 'ghost'` | `'primary'` | Which fill/border treatment to use. These are Button’s variants minus link, since an icon has no text to underline. |
|
|
24
|
+
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 32 / 40 / 48px (pixels), all above the 24px minimum target size, the smallest area people can reliably click or tap. Button’s xs isn't offered: 32px is the smallest this control goes. |
|
|
25
|
+
| `count` | `number` | | A number in a corner badge, like items in a cart or unread notifications. It's added to the accessible name, the name screen readers announce (‘Notifications, 3 unread’). It shows as 99+ past 99 and is hidden at 0 unless showZero is set. |
|
|
26
|
+
| `countLabel` | `(count: number) => string` | `String` | Formats the spoken part of the name that comes after the label and a comma: count => `${count} unread`. It always gets the real number, never 99+. |
|
|
27
|
+
| `showZero` | `boolean` | `false` | Shows the badge when the count is 0. |
|
|
28
|
+
| `asChild` | `boolean` | `false` | Renders the child element with the button's classes. Use it for links that look like icon buttons. Combining it with disabled is a type error, so the code won't compile, instead of quietly doing nothing. A link can't really be disabled, so the prop is removed from the types rather than ignored when the page runs. |
|
|
29
|
+
| `fullWidth` | `—` | | Left out on purpose. An icon button is always square, and stretching it would break that. |
|
|
30
|
+
| `wrapperClassName` | `string` | | Classes for the focus wrapper, not the button. |
|
|
31
|
+
| `className` | `string` | | Merged with the component's classes. When Tailwind utility classes clash, the later one wins. |
|
|
32
|
+
| `ref` | `Ref<HTMLButtonElement>` | | Passed through to the button element. |
|
|
33
|
+
| `...rest` | `Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label' \| 'children'>` | | Every native button prop except aria-label. aria-label is removed from the types rather than quietly overwritten when the page runs. label is the only source of the accessible name, and an aria-label in rest would beat it. children is its own declared prop above, not part of rest. |
|
|
34
|
+
|
|
35
|
+
## Examples
|
|
36
|
+
|
|
37
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
38
|
+
|
|
39
|
+
### Variants
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
<IconButton label="Dismiss" variant="primary"><X aria-hidden="true" /></IconButton>
|
|
43
|
+
<IconButton label="Save" variant="secondary"><Settings aria-hidden="true" /></IconButton>
|
|
44
|
+
<IconButton label="Delete" variant="danger"><Trash2 aria-hidden="true" /></IconButton>
|
|
45
|
+
<IconButton label="Search" variant="ghost"><Search aria-hidden="true" /></IconButton>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Sizes
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
<IconButton label="Dismiss" size="sm"><X aria-hidden="true" /></IconButton>
|
|
52
|
+
<IconButton label="Dismiss" size="md"><X aria-hidden="true" /></IconButton>
|
|
53
|
+
<IconButton label="Dismiss" size="lg"><X aria-hidden="true" /></IconButton>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### In context
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
<div className="flex items-center justify-between w-full max-w-sm">
|
|
60
|
+
<p className="font-display text-sm font-semibold uppercase tracking-wide">
|
|
61
|
+
Field kit checklist
|
|
62
|
+
</p>
|
|
63
|
+
<IconButton label="Dismiss panel" variant="ghost" size="sm">
|
|
64
|
+
<X aria-hidden="true" />
|
|
65
|
+
</IconButton>
|
|
66
|
+
</div>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### With a count
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
<div className="flex flex-wrap items-center gap-6">
|
|
73
|
+
<IconButton label="Cart" variant="secondary" count={3} countLabel={n => `${n} replicas`}>
|
|
74
|
+
<ShoppingCart aria-hidden="true" />
|
|
75
|
+
</IconButton>
|
|
76
|
+
<IconButton label="Notifications" variant="ghost" count={128} countLabel={n => `${n} unread`}>
|
|
77
|
+
<Bell aria-hidden="true" />
|
|
78
|
+
</IconButton>
|
|
79
|
+
<IconButton label="Notifications" variant="primary" count={7} countLabel={n => `${n} unread`}>
|
|
80
|
+
<Bell aria-hidden="true" />
|
|
81
|
+
</IconButton>
|
|
82
|
+
<IconButton label="Cart" variant="secondary" count={0} showZero>
|
|
83
|
+
<ShoppingCart aria-hidden="true" />
|
|
84
|
+
</IconButton>
|
|
85
|
+
</div>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Disabled
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
<IconButton label="Next step" disabled>
|
|
92
|
+
<ChevronRight aria-hidden="true" />
|
|
93
|
+
</IconButton>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Keyboard
|
|
97
|
+
|
|
98
|
+
| Keys | Result |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `Tab` | Moves focus to the button. The ring appears on the wrapper. |
|
|
101
|
+
| `Enter` | Activates it. |
|
|
102
|
+
| `Space` | Activates it. |
|
|
103
|
+
|
|
104
|
+
## Do
|
|
105
|
+
|
|
106
|
+
- Make the label describe the action, not the icon: ‘Dismiss alert’, not ‘X’.
|
|
107
|
+
- Mark the icon aria-hidden so it's never announced twice.
|
|
108
|
+
- Pair it with a Tooltip when the icon's meaning isn't obvious.
|
|
109
|
+
- Stick to one icon. Two icons in a square is a dropdown, not a button.
|
|
110
|
+
- Give a count a countLabel that says what's being counted, so the name reads ‘Notifications, 3 unread’ and not just a number.
|
|
111
|
+
|
|
112
|
+
## Don't
|
|
113
|
+
|
|
114
|
+
- Don't ship an icon button that only regular users understand.
|
|
115
|
+
- Don't go smaller than sm. 32px is already close to the 24px floor.
|
|
116
|
+
- Don't use danger for a dismiss. Dismissing can be undone.
|
|
117
|
+
- Don't put the count in the label yourself. Pass count, so the badge and the name always match.
|
|
118
|
+
- Don't use a count for a status (online, error). A count is a number. A status is a Badge with a word.
|
|
119
|
+
- Don't try to disable a link (asChild). Render the state you want directly instead (a plain span, or a real disabled icon button) rather than fighting the type error.
|
|
120
|
+
|
|
121
|
+
## Anatomy
|
|
122
|
+
|
|
123
|
+
- **Focus wrapper**: A span that isn't clipped, carrying .bevel-focus-wrap. It draws the focus ring (the outline that shows which control the keyboard is on) on :has(:focus-visible), so a mouse click doesn't show it, plus the reticle brackets (corner marks like a targeting sight) that close in on hover. It's always there and never optional.
|
|
124
|
+
- **Square button**: Its width is pinned to the height token, and the padding is removed.
|
|
125
|
+
- **Icon**: Always aria-hidden, an ARIA attribute (Accessible Rich Internet Applications, the labels that tell screen readers what things are) that hides it from screen readers. The label gives the button its name.
|
|
126
|
+
- **aria-label**: Comes from the required label prop, with the count added when there is one.
|
|
127
|
+
- **Count badge**: Optional. It sits on the unclipped wrapper at the top-right corner, so the bevel (the cut corners) can't slice it. Square, mono, accent.signal.fill with its ink, and ringed in the page colour so it stays visible on a primary button. It's aria-hidden because the name already says the number. Past 99 it reads 99+.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
> A single-line text field. It doesn't label itself. The label, help text, error text and the wiring between them are FormField's job. On its own, Input handles the box, its states and the focus ring (the outline that shows which element the keyboard is on). It also has three optional extras inside its right edge: a password reveal, a character counter and a clear button.
|
|
4
|
+
|
|
5
|
+
- **Best for:** Single-line text, inside a FormField.
|
|
6
|
+
- **Not for:** Several lines of text: use Textarea. Searching the page: use Search.
|
|
7
|
+
- Atom. Source: `src/components/atoms/input/Input.tsx`
|
|
8
|
+
- Rendered docs page: `/components/input`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { Input, inputVariants } from 'src/components/atoms/input/Input'
|
|
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
|
+
## Requires
|
|
19
|
+
|
|
20
|
+
- A <FormField> around it. On its own the control has no label. FormField adds the label, the help text and the error, and connects their ids.
|
|
21
|
+
|
|
22
|
+
## Props
|
|
23
|
+
|
|
24
|
+
| Prop | Type | Default | Description |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| `inputSize` | `'sm' \| 'md' \| 'lg'` | `'md'` | Called inputSize because size is already a real HTML attribute on input. |
|
|
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. It doesn't show any error text. |
|
|
28
|
+
| `size` | `number` | | The browser's own attribute, still available. |
|
|
29
|
+
| `revealable` | `boolean` | `false` | On type="password", adds a Show password toggle with aria-pressed. The name stays the same, and the pressed state says whether the password is showing. It goes back to hidden when the form submits. Other types ignore it. |
|
|
30
|
+
| `showCount` | `boolean` | `false` | Works with maxLength. It shows 12/80 in mono inside the field and adds it to aria-describedby, the attribute that links extra description to a field. It's merged with any describedby you pass in, which is how FormField keeps its help and error text. It only announces the characters left near the limit (the last tenth, and at least the last three), through a polite live region, an area screen readers read out without interrupting. |
|
|
31
|
+
| `clearable` | `boolean` | `false` | Shows a clear button while the field has a value (never when it's disabled or read-only). Clearing fires onChange with an empty value, so a controlled field clears through its own handler. Then it calls onClear, then puts focus back in the input. |
|
|
32
|
+
| `onClear` | `() => void` | | Called after the clear button empties the field. |
|
|
33
|
+
| `clearLabel` | `string` | `'Clear'` | The clear button’s name. If the page has more than one, say what it clears: ‘Clear call sign’. |
|
|
34
|
+
| `aiValue` | `string` | | The value an AI proposed. While the field holds exactly this value it shows the AI-filled state: an ai.border edge, a faint ai.wash tint and a sparkle mark that describes the field as Filled by AI. Once the person changes it, the field looks normal again and a Revert to suggestion button puts the value back through onChange (no button on a disabled or read-only field). An invalid field keeps its invalid border. |
|
|
35
|
+
| `wrapperClassName` | `string` | | Classes for the wrapper a field with extras renders. Put width classes here when an extra is on. It does nothing on a bare input. |
|
|
36
|
+
| `className` | `string` | | Merged with the input’s classes. If two clash, the later Tailwind utility wins (Tailwind is the CSS framework this system styles with). |
|
|
37
|
+
| `ref` | `Ref<HTMLInputElement>` | | Passed through to the input element. |
|
|
38
|
+
| `...rest` | `Omit<InputHTMLAttributes<HTMLInputElement>, 'aria-invalid' \| 'size'>` | | Every native input prop except aria-invalid, spread onto the element. 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. If a caller could set one directly, what you see and what screen readers hear could drift apart. size is declared again above as the native number attribute, because inputSize controls the variant. |
|
|
39
|
+
|
|
40
|
+
## Types and helpers
|
|
41
|
+
|
|
42
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
export const inputVariants = cva( …
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Examples
|
|
49
|
+
|
|
50
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
51
|
+
|
|
52
|
+
### Sizes
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
56
|
+
Small
|
|
57
|
+
<Input inputSize="sm" placeholder="Call sign" />
|
|
58
|
+
</label>
|
|
59
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
60
|
+
Medium
|
|
61
|
+
<Input inputSize="md" placeholder="Call sign" />
|
|
62
|
+
</label>
|
|
63
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
64
|
+
Large
|
|
65
|
+
<Input inputSize="lg" placeholder="Call sign" />
|
|
66
|
+
</label>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### States
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
73
|
+
Rest
|
|
74
|
+
<Input placeholder="Call sign" />
|
|
75
|
+
</label>
|
|
76
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
77
|
+
Hover (try it)
|
|
78
|
+
<Input placeholder="Call sign" />
|
|
79
|
+
</label>
|
|
80
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
81
|
+
Invalid
|
|
82
|
+
<Input invalid defaultValue="Anvil-3" />
|
|
83
|
+
</label>
|
|
84
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
85
|
+
Disabled
|
|
86
|
+
<Input disabled defaultValue="Anvil-3" />
|
|
87
|
+
</label>
|
|
88
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
89
|
+
Read only
|
|
90
|
+
<Input readOnly defaultValue="Anvil-3" />
|
|
91
|
+
</label>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Types
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
98
|
+
Text
|
|
99
|
+
<Input type="text" placeholder="Site name" />
|
|
100
|
+
</label>
|
|
101
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
102
|
+
Email
|
|
103
|
+
<Input type="email" placeholder="scout@fieldops.example" />
|
|
104
|
+
</label>
|
|
105
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
106
|
+
Number
|
|
107
|
+
<Input type="number" placeholder="0" className="[font-variant-numeric:tabular-nums]" />
|
|
108
|
+
</label>
|
|
109
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
110
|
+
Password
|
|
111
|
+
<Input type="password" placeholder="Team radio passphrase" />
|
|
112
|
+
</label>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Password reveal
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
<div className="w-full max-w-xs">
|
|
119
|
+
<FormField label="Team radio passphrase">
|
|
120
|
+
<Input type="password" revealable autoComplete="current-password" defaultValue="anvil-north-3" />
|
|
121
|
+
</FormField>
|
|
122
|
+
</div>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Character counter
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
<div className="w-full max-w-xs">
|
|
129
|
+
<FormField label="Loadout name" help="Shown on your public profile.">
|
|
130
|
+
<Input maxLength={40} showCount defaultValue="Night recon, woodland" />
|
|
131
|
+
</FormField>
|
|
132
|
+
</div>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Clear button
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
<div className="w-full max-w-xs">
|
|
139
|
+
<FormField label="Call sign">
|
|
140
|
+
<Input clearable clearLabel="Clear call sign" defaultValue="Anvil-3" />
|
|
141
|
+
</FormField>
|
|
142
|
+
</div>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Filled by AI, with a revert
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
<div className="w-full max-w-xs">
|
|
149
|
+
<FormField label="Call sign" help="Suggested from your last order. Edit it and the revert button appears.">
|
|
150
|
+
<Input aiValue="Anvil-3" defaultValue="Anvil-3" />
|
|
151
|
+
</FormField>
|
|
152
|
+
</div>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Labelled the manual way
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
<div className="flex flex-col gap-1 text-sm max-w-xs">
|
|
159
|
+
<label htmlFor="input-docs-callsign">Call sign</label>
|
|
160
|
+
<Input id="input-docs-callsign" aria-describedby="input-docs-callsign-help" />
|
|
161
|
+
<p id="input-docs-callsign-help" className="text-xs text-[var(--color-text-secondary)]">
|
|
162
|
+
Letters, numbers and a hyphen. FormField will set this pattern up for you.
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Keyboard
|
|
168
|
+
|
|
169
|
+
| Keys | Result |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `Tab` | Moves focus to the input, then to its revert, clear and reveal buttons if they are there. |
|
|
172
|
+
| `Esc` | Nothing by default, even with clearable. Clearing on Escape is a search behaviour, and it lives in the Search molecule. |
|
|
173
|
+
| `Space` | On the reveal toggle, shows or hides the password. On the clear button, empties the field and puts focus back in it. |
|
|
174
|
+
|
|
175
|
+
## Do
|
|
176
|
+
|
|
177
|
+
- Label it: with a real <label> today, and with FormField once that ships.
|
|
178
|
+
- Set the right type and autocomplete. They do more for usability than any styling here.
|
|
179
|
+
- Pair invalid with visible error text that the input points to through aria-describedby.
|
|
180
|
+
- Size the field to the expected input. A four-digit code doesn't need a full-width box.
|
|
181
|
+
- Use showCount with maxLength instead of building a counter next to the field. You get the describedby wiring and the near-limit announcement for free.
|
|
182
|
+
- Label a field with extras through FormField or a label htmlFor, never a label wrapped around it. A wrapping label pulls the clear and reveal buttons into the field’s name.
|
|
183
|
+
|
|
184
|
+
## Don't
|
|
185
|
+
|
|
186
|
+
- Don't use the placeholder as the label. It disappears the moment someone types.
|
|
187
|
+
- Don't bevel it. A cut corner on a 40px box looks like damage, and it would clip the focus ring.
|
|
188
|
+
- Don't set invalid without saying what's wrong.
|
|
189
|
+
- Don't disable a field to make it read-only. Use readOnly, which you can still focus and copy from.
|
|
190
|
+
- Don't add clearable to a search field. Search already has its own clear button and the Escape key.
|
|
191
|
+
- Don't build your own show-password button next to a revealable field. You'll end up with two.
|
|
192
|
+
- Don't set aiValue on a field the person filled in themselves. The AI look says the machine wrote it.
|
|
193
|
+
|
|
194
|
+
## Anatomy
|
|
195
|
+
|
|
196
|
+
- **Input**: 40px (pixels) tall by default. The height comes from the control token, not a hand-picked number.
|
|
197
|
+
- **Border**: border.control at rest, text.muted on hover and hostile.text when invalid. A control's edge tells you something, it isn't decoration, so it doesn't use a hairline token.
|
|
198
|
+
- **Focus ring**: Drawn on the input itself. Form controls aren't bevelled (given a cut corner), so nothing clips it.
|
|
199
|
+
- **Adornments**: Optional extras inside the right edge, in this order: the AI mark or its revert button, the count in mono (a fixed-width typeface), the clear button and the reveal toggle. Turning on any of them wraps the input in a relative box (wrapperClassName styles it) and sizes the right padding to fit them, so text never runs underneath. With none on, the input is bare, with no wrapper.
|
|
200
|
+
- **Placeholder**: text.placeholder, with contrast measured against the control fill it sits on, not the page. It's never a stand-in for a label.
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# KanbanBoard
|
|
2
|
+
|
|
3
|
+
> Cards in columns that people move between stages: by dragging with a pointer, by dragging with the keyboard, or from each card's Move menu. It's controlled, meaning the board reports a move and the parent updates its items.
|
|
4
|
+
|
|
5
|
+
- **Best for:** Work items that move through a few named stages, where the point is seeing every stage at once.
|
|
6
|
+
- **Not for:** Records compared across many fields: use DataTable. A single ordered sequence: use Stepper.
|
|
7
|
+
- Organism. Source: `src/components/organisms/kanban-board/KanbanBoard.tsx`
|
|
8
|
+
- Rendered docs page: `/components/kanban-board`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { KanbanBoard, applyKanbanMove, groupKanbanItems, kanbanDragOver, kanbanDrop, kanbanMessages, kanbanPosition } from 'src/components/organisms/kanban-board/KanbanBoard'
|
|
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
|
+
## Requires
|
|
19
|
+
|
|
20
|
+
- A parent that holds items in state and applies each onMove (applyKanbanMove does the update).
|
|
21
|
+
|
|
22
|
+
## Props
|
|
23
|
+
|
|
24
|
+
| Prop | Type | Default | Description |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| `label` | `string` | | Required. Names the board's region. |
|
|
27
|
+
| `columns` | `readonly KanbanColumn[]` | | Required. { id, title, meta? }, in display order. |
|
|
28
|
+
| `items` | `readonly T[]` | | Required. Every card. A column shows its items in this array's order. |
|
|
29
|
+
| `getItemId` | `(item: T) => string` | | Required. |
|
|
30
|
+
| `getColumnId` | `(item: T) => string` | | Required. The id of the item's column. |
|
|
31
|
+
| `getItemLabel` | `(item: T) => string` | | Required. The card in plain words. The Move control, its menu and every screen reader announcement use it. |
|
|
32
|
+
| `renderCard` | `(item: T) => ReactNode` | | Required. The card's body. It can hold one link or button. The board draws the frame. |
|
|
33
|
+
| `renderCardCorner` | `(item: T) => ReactNode` | | A small mark in the card's bottom-right corner, under the Move control, about 2rem square, such as the owner's avatar. Wrap it in aria-hidden (so screen readers skip it) when the card already prints the same name. |
|
|
34
|
+
| `onMove` | `(itemId: string, toColumnId: string, toIndex: number) => void` | | Required. Called once per move. toIndex is the card's position in the destination column after the move. A move from the menu puts it last. |
|
|
35
|
+
| `emptyColumnMessage` | `string` | `'Nothing here'` | Shown in a column with no cards. |
|
|
36
|
+
| `columnHeadingLevel` | `2 \| 3 \| 4` | `3` | The heading level of the column titles. Pick one that sits under the page's own heading outline. |
|
|
37
|
+
| `className` | `string` | | Goes on the region. |
|
|
38
|
+
| `applyKanbanMove()` | `(items, itemId, toColumnId, toIndex, { getItemId, getColumnId, setColumnId }) => T[]` | | The parent's half of onMove. It returns a new items array with the card in its new column and place. |
|
|
39
|
+
|
|
40
|
+
## Types and helpers
|
|
41
|
+
|
|
42
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
export type KanbanColumn = {
|
|
46
|
+
id: string
|
|
47
|
+
title: string
|
|
48
|
+
/** Beside the title, in mono: a count, a total. */
|
|
49
|
+
meta?: ReactNode
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
/** Column id to the ids of its cards, in order. What a drag rearranges before `onMove` runs. */
|
|
55
|
+
export type KanbanLayout = Record<string, string[]>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
/** Where a card is being dropped: on another card (it takes that card's place), or on a column
|
|
60
|
+
* with no `itemId` (the end of it; only an empty column is a drop target of its own). */
|
|
61
|
+
export type KanbanTarget = { columnId: string; itemId?: string }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
export function groupKanbanItems<T>(
|
|
66
|
+
columns: readonly KanbanColumn[],
|
|
67
|
+
items: readonly T[],
|
|
68
|
+
getItemId: (item: T) => string,
|
|
69
|
+
getColumnId: (item: T) => string
|
|
70
|
+
): KanbanLayout
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
/** The card's column and 0-based index in a layout, and how many cards that column holds. */
|
|
75
|
+
export function kanbanPosition(layout: KanbanLayout, itemId: string)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
/** The move into another column while the card is still held, so the cards there make room. A
|
|
80
|
+
* move inside one column is left to the sortable transforms until the drop. Returns `layout`
|
|
81
|
+
* itself when nothing changes, so a state update with it does not render. */
|
|
82
|
+
export function kanbanDragOver(layout: KanbanLayout, itemId: string, target: KanbanTarget): KanbanLayout
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
/** The layout a drop on `target` leaves: across columns as `kanbanDragOver`, inside one column
|
|
87
|
+
* the card takes the target card's index. Pure, and what the announcements read ahead of time. */
|
|
88
|
+
export function kanbanDrop(layout: KanbanLayout, itemId: string, target: KanbanTarget): KanbanLayout
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
/** The parent's half of `onMove`: a new `items` array with the card in its new column, placed
|
|
93
|
+
* before the card that held `toIndex` there (or after the column's last card). Other cards keep
|
|
94
|
+
* their order. `setColumnId` returns the item with its column changed, a copy. */
|
|
95
|
+
export function applyKanbanMove<T>(
|
|
96
|
+
items: readonly T[],
|
|
97
|
+
itemId: string,
|
|
98
|
+
toColumnId: string,
|
|
99
|
+
toIndex: number,
|
|
100
|
+
{
|
|
101
|
+
getItemId,
|
|
102
|
+
getColumnId,
|
|
103
|
+
setColumnId,
|
|
104
|
+
}: { getItemId: (item: T) => string; getColumnId: (item: T) => string; setColumnId: (item: T, columnId: string) => T }
|
|
105
|
+
): T[]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
/** Every sentence the board speaks, in plain words: a card's name, a column's title and a
|
|
110
|
+
* position counted from 1. Exported so the wording is tested as wording. */
|
|
111
|
+
export const kanbanMessages = { …
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Examples
|
|
115
|
+
|
|
116
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
117
|
+
|
|
118
|
+
### Hiring board
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
type Stage = 'applied' | 'screen' | 'interview' | 'offer'
|
|
122
|
+
|
|
123
|
+
type Candidate = { id: string; name: string; role: string; stage: Stage; recruiter: string; referral?: boolean }
|
|
124
|
+
|
|
125
|
+
const STAGES: { id: Stage; title: string }[] = [
|
|
126
|
+
{ id: 'applied', title: 'Applied' },
|
|
127
|
+
{ id: 'screen', title: 'Screening' },
|
|
128
|
+
{ id: 'interview', title: 'Interview' },
|
|
129
|
+
{ id: 'offer', title: 'Offer' },
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
const CANDIDATES: Candidate[] = [
|
|
133
|
+
{ id: 'c1', name: 'Ilse Varga', role: 'Depot lead, Cluj', stage: 'applied', recruiter: 'Dana Holm', referral: true },
|
|
134
|
+
{ id: 'c2', name: 'Tomas Reyes', role: 'Forklift driver, Arad', stage: 'applied', recruiter: 'Radu Mares' },
|
|
135
|
+
{ id: 'c3', name: 'Mara Lind', role: 'Stock planner, Sibiu', stage: 'applied', recruiter: 'Dana Holm' },
|
|
136
|
+
{ id: 'c4', name: 'Oskar Behn', role: 'Depot lead, Cluj', stage: 'screen', recruiter: 'Radu Mares' },
|
|
137
|
+
{ id: 'c5', name: 'Ana Petrescu', role: 'Dispatcher, Brasov', stage: 'interview', recruiter: 'Dana Holm', referral: true },
|
|
138
|
+
{ id: 'c6', name: 'Jon Arvid', role: 'Stock planner, Sibiu', stage: 'interview', recruiter: 'Radu Mares' },
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
const accessors = {
|
|
142
|
+
getItemId: (candidate: Candidate) => candidate.id,
|
|
143
|
+
getColumnId: (candidate: Candidate) => candidate.stage,
|
|
144
|
+
setColumnId: (candidate: Candidate, stage: string) => ({ ...candidate, stage: stage as Stage }),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function CandidateCard({ candidate }: { candidate: Candidate }) {
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex flex-col gap-1">
|
|
150
|
+
<p className="font-semibold">{candidate.name}</p>
|
|
151
|
+
<p className="text-[var(--color-text-secondary)]">{candidate.role}</p>
|
|
152
|
+
{candidate.referral ? (
|
|
153
|
+
<Badge tone="signal" emphasis="solid" className="self-start mt-1">
|
|
154
|
+
Referral
|
|
155
|
+
</Badge>
|
|
156
|
+
) : null}
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function HiringBoard() {
|
|
162
|
+
const [candidates, setCandidates] = useState(CANDIDATES)
|
|
163
|
+
const columns: KanbanColumn[] = STAGES.map(stage => ({
|
|
164
|
+
...stage,
|
|
165
|
+
meta: candidates.filter(candidate => candidate.stage === stage.id).length,
|
|
166
|
+
}))
|
|
167
|
+
return (
|
|
168
|
+
<div className="w-full min-w-0">
|
|
169
|
+
<KanbanBoard
|
|
170
|
+
label="Hiring board"
|
|
171
|
+
columns={columns}
|
|
172
|
+
items={candidates}
|
|
173
|
+
getItemId={accessors.getItemId}
|
|
174
|
+
getColumnId={accessors.getColumnId}
|
|
175
|
+
getItemLabel={candidate => candidate.name}
|
|
176
|
+
renderCard={candidate => <CandidateCard candidate={candidate} />}
|
|
177
|
+
renderCardCorner={candidate => <Avatar name={candidate.recruiter} size="sm" />}
|
|
178
|
+
onMove={(id, stage, index) => setCandidates(current => applyKanbanMove(current, id, stage, index, accessors))}
|
|
179
|
+
columnHeadingLevel={4}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
<HiringBoard />
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Work orders, with an empty column
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
type Order = { id: string; title: string; bay: string; status: 'open' | 'parts' | 'done' }
|
|
192
|
+
|
|
193
|
+
const ORDER_COLUMNS: KanbanColumn[] = [
|
|
194
|
+
{ id: 'open', title: 'Open' },
|
|
195
|
+
{ id: 'parts', title: 'Waiting for parts' },
|
|
196
|
+
{ id: 'done', title: 'Done' },
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
const ORDERS: Order[] = [
|
|
200
|
+
{ id: 'wo-31', title: 'Replace dock door seal', bay: 'Bay 2', status: 'open' },
|
|
201
|
+
{ id: 'wo-32', title: 'Recalibrate floor scale', bay: 'Bay 5', status: 'open' },
|
|
202
|
+
{ id: 'wo-29', title: 'Service pallet jack', bay: 'Bay 1', status: 'done' },
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
const orderAccessors = {
|
|
206
|
+
getItemId: (order: Order) => order.id,
|
|
207
|
+
getColumnId: (order: Order) => order.status,
|
|
208
|
+
setColumnId: (order: Order, status: string) => ({ ...order, status: status as Order['status'] }),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function WorkOrders() {
|
|
212
|
+
const [orders, setOrders] = useState(ORDERS)
|
|
213
|
+
return (
|
|
214
|
+
<div className="w-full min-w-0">
|
|
215
|
+
<KanbanBoard
|
|
216
|
+
label="Depot work orders"
|
|
217
|
+
columns={ORDER_COLUMNS}
|
|
218
|
+
items={orders}
|
|
219
|
+
getItemId={orderAccessors.getItemId}
|
|
220
|
+
getColumnId={orderAccessors.getColumnId}
|
|
221
|
+
getItemLabel={order => order.title}
|
|
222
|
+
renderCard={order => (
|
|
223
|
+
<div className="flex flex-col gap-1">
|
|
224
|
+
<p className="font-semibold">{order.title}</p>
|
|
225
|
+
<p className="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-text-secondary)]">{order.bay}</p>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
onMove={(id, status, index) => setOrders(current => applyKanbanMove(current, id, status, index, orderAccessors))}
|
|
229
|
+
emptyColumnMessage="No orders waiting"
|
|
230
|
+
columnHeadingLevel={4}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
<WorkOrders />
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Keyboard
|
|
240
|
+
|
|
241
|
+
| Keys | Result |
|
|
242
|
+
|---|---|
|
|
243
|
+
| `Tab` | Moves through each card's own link or button, then its Move control. |
|
|
244
|
+
| `Enter` | On a Move control, opens the menu of the other columns. On a menu item, moves the card to the end of that column. While a card is lifted, drops it. |
|
|
245
|
+
| `Space` | On a Move control, lifts the card. While it is lifted, Space drops it. |
|
|
246
|
+
| `↑` `↓` `←` `→` | While a card is lifted, move it between places in a column and between columns. |
|
|
247
|
+
| `Escape` | While a card is lifted, puts it back where it was (Tab does the same). In the menu, closes it. |
|
|
248
|
+
|
|
249
|
+
## Do
|
|
250
|
+
|
|
251
|
+
- Name each card with getItemLabel in words a screen reader listener can tell apart: the candidate, not "Card 3".
|
|
252
|
+
- Put a count or a total in each column's meta, so people can see a column's size without counting.
|
|
253
|
+
- Keep it to the stages of one process, three to six columns.
|
|
254
|
+
- Apply onMove with applyKanbanMove, or with anything that leaves the card at toIndex in its new column.
|
|
255
|
+
|
|
256
|
+
## Don't
|
|
257
|
+
|
|
258
|
+
- Don't make the card body a link from edge to edge. Put one link on the card's name.
|
|
259
|
+
- Don't write instructions that only mention dragging. The Move menu is the way to move a card without dragging.
|
|
260
|
+
- Don't reorder items inside renderCard or around the board. The order in items is the order shown.
|
|
261
|
+
|
|
262
|
+
## Common mistakes
|
|
263
|
+
|
|
264
|
+
Wrong:
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
onMove={() => {}} // items never change
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Right:
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
onMove={(id, column, index) => setItems(items => applyKanbanMove(items, id, column, index, accessors))}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The board is controlled. If the parent doesn't apply a move, the card snaps back where it was.
|
|
277
|
+
|
|
278
|
+
Wrong:
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
renderCard={job => <a href={job.url} className="block p-3">...</a>}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Right:
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
renderCard={job => <><a href={job.url}>{job.title}</a><p>{job.team}</p></>}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The board draws the frame and the Move control. A link wrapped around the whole body would swallow the drag and put controls inside each other.
|
|
291
|
+
|
|
292
|
+
## Anatomy
|
|
293
|
+
|
|
294
|
+
- **Region**: A section named by label that holds the board.
|
|
295
|
+
- **Scroll track**: Scrolls the columns sideways on a narrow screen. The page never scrolls sideways. Each column is at least 18rem wide (rem is a unit tied to the base font size), so a title, a count and a six-figure total stay on one line, and the cards in every column start at the same height.
|
|
296
|
+
- **Column**: A crosshair frame: the title as a monospaced heading with its meta on the right, then the cards as a list named by the title.
|
|
297
|
+
- **Card**: A panel with the body from renderCard. Down its right edge are the Move control at the top and the renderCardCorner mark, if there is one, in the bottom corner.
|
|
298
|
+
- **Move control**: An IconButton named "Move <label>". Enter or a click opens the menu of other columns. Space lifts the card for a keyboard drag.
|
|
299
|
+
- **Empty column**: A dashed box with emptyColumnMessage. It's where you drop a card into an empty column.
|
|
300
|
+
- **Lifted card**: The copy being dragged, with a 2px (pixel) signal border, over a dashed outline showing where the card will land.
|