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,147 @@
|
|
|
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
|
+
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
|
+
| `commands` | `Command[]` | | Required. Each has id, label, group, keywords, shortcut, icon and onSelect. |
|
|
23
|
+
| `open` | `boolean` | | Open state when controlled, meaning your code decides whether it is open. |
|
|
24
|
+
| `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled, meaning the palette keeps track of it itself. |
|
|
25
|
+
| `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. |
|
|
26
|
+
| `shortcut` | `boolean` | `true` | Ctrl+K or Cmd+K opens and closes the palette from anywhere. |
|
|
27
|
+
| `label` | `string` | `'Command palette'` | The dialog's accessible name (what a screen reader announces), hidden from view. |
|
|
28
|
+
| `searchLabel` | `string` | `'Search commands'` | The search field's accessible name. |
|
|
29
|
+
| `placeholder` | `string` | `'Type a command or search'` | Shown in the empty search field. |
|
|
30
|
+
| `emptyMessage` | `string` | `'No matching commands'` | Shown when nothing matches the search. |
|
|
31
|
+
| `ref` | `Ref<HTMLInputElement>` | | The search field, while the palette is open. |
|
|
32
|
+
|
|
33
|
+
## Types and helpers
|
|
34
|
+
|
|
35
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
export type Command = {
|
|
39
|
+
id: string
|
|
40
|
+
label: string
|
|
41
|
+
/** Commands with the same group are drawn together under its heading. */
|
|
42
|
+
group?: string
|
|
43
|
+
/** Extra words a search matches on. */
|
|
44
|
+
keywords?: string[]
|
|
45
|
+
/** A hint shown on the option, e.g. "N". Display only: the palette does not bind it. */
|
|
46
|
+
shortcut?: string
|
|
47
|
+
/** Drawn aria-hidden; the label carries the meaning. */
|
|
48
|
+
icon?: ReactNode
|
|
49
|
+
onSelect: () => void
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
export type CommandGroup = { group?: string; commands: Command[] }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
/** Every whitespace-separated term must appear in the label, the group or a keyword, ignoring case.
|
|
59
|
+
* A label that starts with the first term ranks first; otherwise the given order holds. */
|
|
60
|
+
export function filterCommands(commands: readonly Command[], query: string): Command[]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
/** Groups in the order each group first appears; order within a group is kept. */
|
|
65
|
+
export function groupCommands(commands: readonly Command[]): CommandGroup[]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Examples
|
|
69
|
+
|
|
70
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
71
|
+
|
|
72
|
+
### Store commands
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
function StoreCommands() {
|
|
76
|
+
const [open, setOpen] = useState(false)
|
|
77
|
+
const [last, setLast] = useState('none')
|
|
78
|
+
const make = (id: string, label: string, group: string, icon: ReactNode, extra: Partial<Command> = {}): Command => ({
|
|
79
|
+
id,
|
|
80
|
+
label,
|
|
81
|
+
group,
|
|
82
|
+
icon,
|
|
83
|
+
onSelect: () => setLast(label),
|
|
84
|
+
...extra,
|
|
85
|
+
})
|
|
86
|
+
const commands = [
|
|
87
|
+
make('orders', 'Go to orders', 'Navigate', <Package />, { keywords: ['sales'] }),
|
|
88
|
+
make('shipments', 'Go to shipments', 'Navigate', <Truck />),
|
|
89
|
+
make('returns', 'Go to returns', 'Navigate', <Inbox />),
|
|
90
|
+
make('new-shipment', 'New shipment', 'Create', <Plus />, { shortcut: 'N' }),
|
|
91
|
+
make('new-product', 'New product', 'Create', <Box />),
|
|
92
|
+
make('invite', 'Invite a teammate', 'Account', <Users />, { keywords: ['team', 'people'] }),
|
|
93
|
+
make('settings', 'Open settings', 'Account', <Settings />),
|
|
94
|
+
]
|
|
95
|
+
return (
|
|
96
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
97
|
+
<Button variant="secondary" aria-keyshortcuts="Control+K Meta+K" onClick={() => setOpen(true)}>
|
|
98
|
+
Open command palette
|
|
99
|
+
</Button>
|
|
100
|
+
<span aria-hidden="true" className="inline-flex gap-1">
|
|
101
|
+
<Kbd>Ctrl</Kbd>
|
|
102
|
+
<Kbd>K</Kbd>
|
|
103
|
+
</span>
|
|
104
|
+
<p role="status" className="font-mono text-xs text-[var(--color-text-secondary)]">
|
|
105
|
+
Last command: {last}
|
|
106
|
+
</p>
|
|
107
|
+
<CommandPalette commands={commands} open={open} onOpenChange={setOpen} />
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
<StoreCommands />
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Keyboard
|
|
116
|
+
|
|
117
|
+
| Keys | Result |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `Ctrl` `K` | Opens or closes it from anywhere on the page (Cmd+K on macOS). |
|
|
120
|
+
| `↓` | Next command, wrapping round to the first. |
|
|
121
|
+
| `↑` | Previous command, wrapping round to the last. |
|
|
122
|
+
| `Home` | First command. |
|
|
123
|
+
| `End` | Last command. |
|
|
124
|
+
| `Enter` | Runs the active command. |
|
|
125
|
+
| `Esc` | Closes without running anything. |
|
|
126
|
+
|
|
127
|
+
## Do
|
|
128
|
+
|
|
129
|
+
- Name commands as verbs: 'Go to orders', 'New shipment'.
|
|
130
|
+
- Add keywords for the words people actually type.
|
|
131
|
+
- Give it a visible button that opens it too. People won't find a shortcut on its own.
|
|
132
|
+
|
|
133
|
+
## Don't
|
|
134
|
+
|
|
135
|
+
- Don't put destructive commands in it without a dialog that asks for confirmation.
|
|
136
|
+
- Don't show a shortcut hint the app doesn't actually set up.
|
|
137
|
+
- 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.
|
|
138
|
+
|
|
139
|
+
## Anatomy
|
|
140
|
+
|
|
141
|
+
- **Dialog**: Named 'Command palette', with the name hidden from view. Drawn on surface.base with the corner crosshair.
|
|
142
|
+
- **Search field**: A combobox, a text field tied to a list of choices. Focus stays here the whole time.
|
|
143
|
+
- **Listbox**: Named 'Commands'. It's not there at all when nothing matches.
|
|
144
|
+
- **Group**: Named by its heading.
|
|
145
|
+
- **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.
|
|
146
|
+
- **Count**: A status line hidden from view but read by screen readers: '2 commands'.
|
|
147
|
+
- **Shortcut hint**: A Kbd that's hidden from assistive technology (screen readers and similar tools). It's for display only.
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# DataTable
|
|
2
|
+
|
|
3
|
+
> Rows of records that people read, compare and sort. It's a real HTML table with a caption, column and row headers, and sortable columns. When there's a job to do, each row gets a checkbox.
|
|
4
|
+
|
|
5
|
+
- **Best for:** Records people read, compare and sort across columns, with optional bulk selection.
|
|
6
|
+
- **Not for:** A list people scan row by row: use ListItem. A form in every cell.
|
|
7
|
+
- Organism. Source: `src/components/organisms/data-table/DataTable.tsx`
|
|
8
|
+
- Rendered docs page: `/components/data-table`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { DataTable, sortRows } from 'src/components/organisms/data-table/DataTable'
|
|
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
|
+
| `caption` | `string` | | Required. Names the table and its scroll region. |
|
|
23
|
+
| `hideCaption` | `boolean` | `false` | Hides the caption visually. It still names the table and its scroll region for screen readers. |
|
|
24
|
+
| `columns` | `DataTableColumn<Row>[]` | | Required. Each column has key, header, cell, sortValue and numeric. |
|
|
25
|
+
| `rows` | `readonly Row[]` | | Required. |
|
|
26
|
+
| `getRowId` | `(row: Row) => string` | | Required. |
|
|
27
|
+
| `rowHeader` | `string` | | Required. The key of the column that heads each row. |
|
|
28
|
+
| `defaultSort` | `DataTableSort` | | Starts the table sorted. Uncontrolled: the table keeps track of the sort itself after that. |
|
|
29
|
+
| `sort` | `DataTableSort` | | Controlled sort, meaning your code holds the sort and passes it in. Set it together with onSortChange. A header click then only reports the next sort. |
|
|
30
|
+
| `onSortChange` | `(sort: DataTableSort) => void` | | Called with the next sort on every header click, whether sort is controlled or not. |
|
|
31
|
+
| `emptyMessage` | `ReactNode` | `'Nothing to show.'` | Shown across every column when rows is empty. |
|
|
32
|
+
| `selectable` | `boolean` | `false` | Turns on the checkbox column. |
|
|
33
|
+
| `rowLabel` | `(row: Row) => string` | | Required when selectable. Gives each row's checkbox its name. |
|
|
34
|
+
| `selected` | `ReadonlySet<string>` | | Controlled selection (your code holds it), keyed by getRowId. |
|
|
35
|
+
| `defaultSelected` | `Iterable<string>` | | The starting selection when it's uncontrolled (the table holds it). |
|
|
36
|
+
| `onSelectedChange` | `(selected: Set<string>) => void` | | Called with the next selection on every change. |
|
|
37
|
+
| `className` | `string` | | Goes on the table element. |
|
|
38
|
+
| `ref` | `Ref<HTMLTableElement>` | | Points at the table element. |
|
|
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 SortDirection = 'ascending' | 'descending'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
export type DataTableSort = { key: string; direction: SortDirection }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
export type DataTableColumn<Row> = {
|
|
54
|
+
key: string
|
|
55
|
+
header: string
|
|
56
|
+
cell: (row: Row) => ReactNode
|
|
57
|
+
/** Present means sortable: the value rows are ordered by. */
|
|
58
|
+
sortValue?: (row: Row) => string | number
|
|
59
|
+
/** Right-aligned, tabular figures. */
|
|
60
|
+
numeric?: boolean
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
/** Pure and stable: equal rows keep their order in both directions. Strings compare with numeric
|
|
66
|
+
* collation, so "2" sorts before "10". */
|
|
67
|
+
export function sortRows<Row>(
|
|
68
|
+
rows: readonly Row[],
|
|
69
|
+
sortValue: ((row: Row) => string | number) | undefined,
|
|
70
|
+
direction: SortDirection
|
|
71
|
+
): Row[]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Examples
|
|
75
|
+
|
|
76
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
77
|
+
|
|
78
|
+
### Sortable
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
type Status = 'In transit' | 'Delivered' | 'Held'
|
|
82
|
+
|
|
83
|
+
type Shipment = { ref: string; destination: string; weight: number; items: number; status: Status }
|
|
84
|
+
|
|
85
|
+
const SHIPMENTS: Shipment[] = [
|
|
86
|
+
{ ref: 'SHP-201', destination: 'Cluj depot', weight: 8.2, items: 5, status: 'In transit' },
|
|
87
|
+
{ ref: 'SHP-202', destination: 'Arad depot', weight: 14.0, items: 12, status: 'Delivered' },
|
|
88
|
+
{ ref: 'SHP-203', destination: 'Brasov depot', weight: 3.5, items: 2, status: 'Held' },
|
|
89
|
+
{ ref: 'SHP-204', destination: 'Sibiu depot', weight: 22.1, items: 9, status: 'In transit' },
|
|
90
|
+
{ ref: 'SHP-205', destination: 'Oradea depot', weight: 6.0, items: 1, status: 'Delivered' },
|
|
91
|
+
{ ref: 'SHP-206', destination: 'Timisoara depot', weight: 18.4, items: 15, status: 'Held' },
|
|
92
|
+
{ ref: 'SHP-207', destination: 'Iasi depot', weight: 9.9, items: 4, status: 'In transit' },
|
|
93
|
+
{ ref: 'SHP-208', destination: 'Constanta depot', weight: 27.3, items: 20, status: 'Delivered' },
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const STATUS_TONE: Record<Status, 'info' | 'ok' | 'warning'> = {
|
|
97
|
+
'In transit': 'info',
|
|
98
|
+
Delivered: 'ok',
|
|
99
|
+
Held: 'warning',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const COLUMNS: DataTableColumn<Shipment>[] = [
|
|
103
|
+
{ key: 'ref', header: 'Reference', cell: s => s.ref, sortValue: s => s.ref },
|
|
104
|
+
{ key: 'destination', header: 'Destination', cell: s => s.destination },
|
|
105
|
+
{ key: 'weight', header: 'Weight (kg)', cell: s => s.weight.toFixed(1), sortValue: s => s.weight, numeric: true },
|
|
106
|
+
{ key: 'items', header: 'Items', cell: s => String(s.items), sortValue: s => s.items, numeric: true },
|
|
107
|
+
{
|
|
108
|
+
key: 'status',
|
|
109
|
+
header: 'Status',
|
|
110
|
+
cell: s => (
|
|
111
|
+
<Badge emphasis="solid" tone={STATUS_TONE[s.status]}>
|
|
112
|
+
{s.status}
|
|
113
|
+
</Badge>
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
<DataTable
|
|
119
|
+
caption="Shipments, sortable"
|
|
120
|
+
columns={COLUMNS}
|
|
121
|
+
rows={SHIPMENTS}
|
|
122
|
+
getRowId={s => s.ref}
|
|
123
|
+
rowHeader="ref"
|
|
124
|
+
defaultSort={{ key: 'ref', direction: 'ascending' }}
|
|
125
|
+
/>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Controlled sort: the page, not the table, keeps track of the sort order
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
type Status = 'In transit' | 'Delivered' | 'Held'
|
|
132
|
+
|
|
133
|
+
type Shipment = { ref: string; destination: string; weight: number; items: number; status: Status }
|
|
134
|
+
|
|
135
|
+
const SHIPMENTS: Shipment[] = [
|
|
136
|
+
{ ref: 'SHP-201', destination: 'Cluj depot', weight: 8.2, items: 5, status: 'In transit' },
|
|
137
|
+
{ ref: 'SHP-202', destination: 'Arad depot', weight: 14.0, items: 12, status: 'Delivered' },
|
|
138
|
+
{ ref: 'SHP-203', destination: 'Brasov depot', weight: 3.5, items: 2, status: 'Held' },
|
|
139
|
+
{ ref: 'SHP-204', destination: 'Sibiu depot', weight: 22.1, items: 9, status: 'In transit' },
|
|
140
|
+
{ ref: 'SHP-205', destination: 'Oradea depot', weight: 6.0, items: 1, status: 'Delivered' },
|
|
141
|
+
{ ref: 'SHP-206', destination: 'Timisoara depot', weight: 18.4, items: 15, status: 'Held' },
|
|
142
|
+
{ ref: 'SHP-207', destination: 'Iasi depot', weight: 9.9, items: 4, status: 'In transit' },
|
|
143
|
+
{ ref: 'SHP-208', destination: 'Constanta depot', weight: 27.3, items: 20, status: 'Delivered' },
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
const STATUS_TONE: Record<Status, 'info' | 'ok' | 'warning'> = {
|
|
147
|
+
'In transit': 'info',
|
|
148
|
+
Delivered: 'ok',
|
|
149
|
+
Held: 'warning',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const COLUMNS: DataTableColumn<Shipment>[] = [
|
|
153
|
+
{ key: 'ref', header: 'Reference', cell: s => s.ref, sortValue: s => s.ref },
|
|
154
|
+
{ key: 'destination', header: 'Destination', cell: s => s.destination },
|
|
155
|
+
{ key: 'weight', header: 'Weight (kg)', cell: s => s.weight.toFixed(1), sortValue: s => s.weight, numeric: true },
|
|
156
|
+
{ key: 'items', header: 'Items', cell: s => String(s.items), sortValue: s => s.items, numeric: true },
|
|
157
|
+
{
|
|
158
|
+
key: 'status',
|
|
159
|
+
header: 'Status',
|
|
160
|
+
cell: s => (
|
|
161
|
+
<Badge emphasis="solid" tone={STATUS_TONE[s.status]}>
|
|
162
|
+
{s.status}
|
|
163
|
+
</Badge>
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
function ControlledSortExample() {
|
|
169
|
+
const [sort, setSort] = useState<DataTableSort>({ key: 'weight', direction: 'descending' })
|
|
170
|
+
return (
|
|
171
|
+
<DataTable
|
|
172
|
+
caption="Shipments, controlled sort"
|
|
173
|
+
columns={COLUMNS}
|
|
174
|
+
rows={SHIPMENTS}
|
|
175
|
+
getRowId={s => s.ref}
|
|
176
|
+
rowHeader="ref"
|
|
177
|
+
sort={sort}
|
|
178
|
+
onSortChange={setSort}
|
|
179
|
+
/>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
<ControlledSortExample />
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### With row selection and an action that applies to every selected row
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
type Status = 'In transit' | 'Delivered' | 'Held'
|
|
190
|
+
|
|
191
|
+
type Shipment = { ref: string; destination: string; weight: number; items: number; status: Status }
|
|
192
|
+
|
|
193
|
+
const SHIPMENTS: Shipment[] = [
|
|
194
|
+
{ ref: 'SHP-201', destination: 'Cluj depot', weight: 8.2, items: 5, status: 'In transit' },
|
|
195
|
+
{ ref: 'SHP-202', destination: 'Arad depot', weight: 14.0, items: 12, status: 'Delivered' },
|
|
196
|
+
{ ref: 'SHP-203', destination: 'Brasov depot', weight: 3.5, items: 2, status: 'Held' },
|
|
197
|
+
{ ref: 'SHP-204', destination: 'Sibiu depot', weight: 22.1, items: 9, status: 'In transit' },
|
|
198
|
+
{ ref: 'SHP-205', destination: 'Oradea depot', weight: 6.0, items: 1, status: 'Delivered' },
|
|
199
|
+
{ ref: 'SHP-206', destination: 'Timisoara depot', weight: 18.4, items: 15, status: 'Held' },
|
|
200
|
+
{ ref: 'SHP-207', destination: 'Iasi depot', weight: 9.9, items: 4, status: 'In transit' },
|
|
201
|
+
{ ref: 'SHP-208', destination: 'Constanta depot', weight: 27.3, items: 20, status: 'Delivered' },
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
const STATUS_TONE: Record<Status, 'info' | 'ok' | 'warning'> = {
|
|
205
|
+
'In transit': 'info',
|
|
206
|
+
Delivered: 'ok',
|
|
207
|
+
Held: 'warning',
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const COLUMNS: DataTableColumn<Shipment>[] = [
|
|
211
|
+
{ key: 'ref', header: 'Reference', cell: s => s.ref, sortValue: s => s.ref },
|
|
212
|
+
{ key: 'destination', header: 'Destination', cell: s => s.destination },
|
|
213
|
+
{ key: 'weight', header: 'Weight (kg)', cell: s => s.weight.toFixed(1), sortValue: s => s.weight, numeric: true },
|
|
214
|
+
{ key: 'items', header: 'Items', cell: s => String(s.items), sortValue: s => s.items, numeric: true },
|
|
215
|
+
{
|
|
216
|
+
key: 'status',
|
|
217
|
+
header: 'Status',
|
|
218
|
+
cell: s => (
|
|
219
|
+
<Badge emphasis="solid" tone={STATUS_TONE[s.status]}>
|
|
220
|
+
{s.status}
|
|
221
|
+
</Badge>
|
|
222
|
+
),
|
|
223
|
+
},
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
function BulkExample() {
|
|
227
|
+
const [rows, setRows] = useState(SHIPMENTS)
|
|
228
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
229
|
+
const count = selected.size
|
|
230
|
+
|
|
231
|
+
const archive = () => {
|
|
232
|
+
if (count === 0) return
|
|
233
|
+
setRows(current => current.filter(row => !selected.has(row.ref)))
|
|
234
|
+
setSelected(new Set())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div className="flex flex-col gap-4 w-full">
|
|
239
|
+
<Button variant="secondary" aria-disabled={count === 0 || undefined} onClick={archive} className="self-start">
|
|
240
|
+
{count > 0 ? `Archive ${count} shipment${count === 1 ? '' : 's'}` : 'Archive shipments'}
|
|
241
|
+
</Button>
|
|
242
|
+
<DataTable
|
|
243
|
+
caption="Shipments, selectable"
|
|
244
|
+
columns={COLUMNS}
|
|
245
|
+
rows={rows}
|
|
246
|
+
getRowId={s => s.ref}
|
|
247
|
+
rowHeader="ref"
|
|
248
|
+
selectable
|
|
249
|
+
rowLabel={s => s.ref}
|
|
250
|
+
selected={selected}
|
|
251
|
+
onSelectedChange={setSelected}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
<BulkExample />
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### With pagination and selection
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
type Status = 'In transit' | 'Delivered' | 'Held'
|
|
264
|
+
|
|
265
|
+
type Shipment = { ref: string; destination: string; weight: number; items: number; status: Status }
|
|
266
|
+
|
|
267
|
+
const SHIPMENTS: Shipment[] = [
|
|
268
|
+
{ ref: 'SHP-201', destination: 'Cluj depot', weight: 8.2, items: 5, status: 'In transit' },
|
|
269
|
+
{ ref: 'SHP-202', destination: 'Arad depot', weight: 14.0, items: 12, status: 'Delivered' },
|
|
270
|
+
{ ref: 'SHP-203', destination: 'Brasov depot', weight: 3.5, items: 2, status: 'Held' },
|
|
271
|
+
{ ref: 'SHP-204', destination: 'Sibiu depot', weight: 22.1, items: 9, status: 'In transit' },
|
|
272
|
+
{ ref: 'SHP-205', destination: 'Oradea depot', weight: 6.0, items: 1, status: 'Delivered' },
|
|
273
|
+
{ ref: 'SHP-206', destination: 'Timisoara depot', weight: 18.4, items: 15, status: 'Held' },
|
|
274
|
+
{ ref: 'SHP-207', destination: 'Iasi depot', weight: 9.9, items: 4, status: 'In transit' },
|
|
275
|
+
{ ref: 'SHP-208', destination: 'Constanta depot', weight: 27.3, items: 20, status: 'Delivered' },
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
const STATUS_TONE: Record<Status, 'info' | 'ok' | 'warning'> = {
|
|
279
|
+
'In transit': 'info',
|
|
280
|
+
Delivered: 'ok',
|
|
281
|
+
Held: 'warning',
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const COLUMNS: DataTableColumn<Shipment>[] = [
|
|
285
|
+
{ key: 'ref', header: 'Reference', cell: s => s.ref, sortValue: s => s.ref },
|
|
286
|
+
{ key: 'destination', header: 'Destination', cell: s => s.destination },
|
|
287
|
+
{ key: 'weight', header: 'Weight (kg)', cell: s => s.weight.toFixed(1), sortValue: s => s.weight, numeric: true },
|
|
288
|
+
{ key: 'items', header: 'Items', cell: s => String(s.items), sortValue: s => s.items, numeric: true },
|
|
289
|
+
{
|
|
290
|
+
key: 'status',
|
|
291
|
+
header: 'Status',
|
|
292
|
+
cell: s => (
|
|
293
|
+
<Badge emphasis="solid" tone={STATUS_TONE[s.status]}>
|
|
294
|
+
{s.status}
|
|
295
|
+
</Badge>
|
|
296
|
+
),
|
|
297
|
+
},
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
function PagedExample() {
|
|
301
|
+
const [perPage, setPerPage] = useState(4)
|
|
302
|
+
const [page, setPage] = useState(1)
|
|
303
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
304
|
+
const start = (page - 1) * perPage
|
|
305
|
+
const visible = SHIPMENTS.slice(start, start + perPage)
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div className="flex flex-col gap-3 w-full">
|
|
309
|
+
<DataTable
|
|
310
|
+
caption="Shipments, paged"
|
|
311
|
+
columns={COLUMNS}
|
|
312
|
+
rows={visible}
|
|
313
|
+
getRowId={s => s.ref}
|
|
314
|
+
rowHeader="ref"
|
|
315
|
+
selectable
|
|
316
|
+
rowLabel={s => s.ref}
|
|
317
|
+
selected={selected}
|
|
318
|
+
onSelectedChange={setSelected}
|
|
319
|
+
/>
|
|
320
|
+
<Pagination
|
|
321
|
+
page={page}
|
|
322
|
+
pageCount={Math.ceil(SHIPMENTS.length / perPage)}
|
|
323
|
+
onPageChange={setPage}
|
|
324
|
+
totalItems={SHIPMENTS.length}
|
|
325
|
+
pageSize={perPage}
|
|
326
|
+
onPageSizeChange={size => {
|
|
327
|
+
setPerPage(size)
|
|
328
|
+
setPage(1)
|
|
329
|
+
}}
|
|
330
|
+
pageSizeOptions={[4, 8, 12]}
|
|
331
|
+
label="Pagination, shipments"
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
<PagedExample />
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Empty
|
|
341
|
+
|
|
342
|
+
```tsx
|
|
343
|
+
type Status = 'In transit' | 'Delivered' | 'Held'
|
|
344
|
+
|
|
345
|
+
type Shipment = { ref: string; destination: string; weight: number; items: number; status: Status }
|
|
346
|
+
|
|
347
|
+
const STATUS_TONE: Record<Status, 'info' | 'ok' | 'warning'> = {
|
|
348
|
+
'In transit': 'info',
|
|
349
|
+
Delivered: 'ok',
|
|
350
|
+
Held: 'warning',
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const COLUMNS: DataTableColumn<Shipment>[] = [
|
|
354
|
+
{ key: 'ref', header: 'Reference', cell: s => s.ref, sortValue: s => s.ref },
|
|
355
|
+
{ key: 'destination', header: 'Destination', cell: s => s.destination },
|
|
356
|
+
{ key: 'weight', header: 'Weight (kg)', cell: s => s.weight.toFixed(1), sortValue: s => s.weight, numeric: true },
|
|
357
|
+
{ key: 'items', header: 'Items', cell: s => String(s.items), sortValue: s => s.items, numeric: true },
|
|
358
|
+
{
|
|
359
|
+
key: 'status',
|
|
360
|
+
header: 'Status',
|
|
361
|
+
cell: s => (
|
|
362
|
+
<Badge emphasis="solid" tone={STATUS_TONE[s.status]}>
|
|
363
|
+
{s.status}
|
|
364
|
+
</Badge>
|
|
365
|
+
),
|
|
366
|
+
},
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
<DataTable caption="Returns" columns={COLUMNS} rows={[]} getRowId={s => s.ref} rowHeader="ref" emptyMessage="No returns yet." />
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Keyboard
|
|
373
|
+
|
|
374
|
+
| Keys | Result |
|
|
375
|
+
|---|---|
|
|
376
|
+
| `Tab` | Moves to the scroll region, then to each sort button and checkbox in order. |
|
|
377
|
+
| `Enter` | Sorts by that column, or flips its direction. |
|
|
378
|
+
| `Space` | Sorts, or ticks a checkbox. |
|
|
379
|
+
| `←` `→` | Scroll the region sideways while it has focus. |
|
|
380
|
+
|
|
381
|
+
## Do
|
|
382
|
+
|
|
383
|
+
- Right-align numbers and mark their column as numeric.
|
|
384
|
+
- Say how many rows there are near the table, and put Pagination below it.
|
|
385
|
+
- Keep a bulk action's button a tab stop (a place the Tab key lands) while nothing is selected, and mark it aria-disabled so screen readers say it's unavailable.
|
|
386
|
+
- You can page or filter the rows freely. Select-all acts on the rows shown and keeps what was selected elsewhere, and the count says how many selected rows are out of view.
|
|
387
|
+
|
|
388
|
+
## Don't
|
|
389
|
+
|
|
390
|
+
- Don't make every column sortable. Sort the ones people compare.
|
|
391
|
+
- Don't tint selected or hovered rows. The checkbox and the count already say it.
|
|
392
|
+
- Don't put a form in every cell. That's a different component.
|
|
393
|
+
|
|
394
|
+
## Anatomy
|
|
395
|
+
|
|
396
|
+
- **Caption**: Names the table and its scroll region. You can hide it visually and it still works for screen readers.
|
|
397
|
+
- **Scroll region**: It can take keyboard focus, so a wide table scrolls from the keyboard.
|
|
398
|
+
- **Column header**: A sort button when the column sorts. The sorted column carries aria-sort, an ARIA (Accessible Rich Internet Applications) attribute that tells screen readers which way it's sorted.
|
|
399
|
+
- **Row header**: The cell from the rowHeader column. It names its row.
|
|
400
|
+
- **Selection**: A checkbox per row, one to select all, and a count above the table. Selected rows don't get a tint.
|
|
401
|
+
- **Empty**: One cell that spans every column.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# DescriptionList
|
|
2
|
+
|
|
3
|
+
> Pairs of a term and its value, all about one thing, in one to three columns. The columns drop away as the space they get narrows. It's a real dl (the HTML description list element): a label in a monospaced font over each value, and a thin line above each pair.
|
|
4
|
+
|
|
5
|
+
- **Best for:** The fixed fields of one record: serial, firmware, owner, dates.
|
|
6
|
+
- **Not for:** Many records: use DataTable; one headline number: use Stat.
|
|
7
|
+
- Molecule. Source: `src/components/molecules/description-list/DescriptionList.tsx`
|
|
8
|
+
- Rendered docs page: `/components/description-list`
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { DescriptionList } from 'src/components/molecules/description-list/DescriptionList'
|
|
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
|
+
| `items` | `readonly DescriptionListItem[]` | | Required. The pairs, in the order people read them. |
|
|
23
|
+
| `columns` | `1 \| 2 \| 3` | `1` | How many columns at full width. It drops to one when the container is narrower than 28rem. |
|
|
24
|
+
| `className` | `string` | | On the container. |
|
|
25
|
+
| `ref` | `Ref<HTMLDivElement>` | | Forwarded to the container. |
|
|
26
|
+
| `term (DescriptionListItem)` | `string` | | Required. The field name: "Serial". |
|
|
27
|
+
| `description (DescriptionListItem)` | `ReactNode` | | Required. Its value. |
|
|
28
|
+
|
|
29
|
+
## Types and helpers
|
|
30
|
+
|
|
31
|
+
Exported beside the component, from the same file. Import a type with `import type { ... }`.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
export type DescriptionListItem = {
|
|
35
|
+
/** The name of the field: "Serial", "Last seen". Shown as a mono label. */
|
|
36
|
+
term: string
|
|
37
|
+
/** Its value: text, a Badge, a Link. */
|
|
38
|
+
description: ReactNode
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
Each is compiled and rendered on the docs page. Imports are omitted; see above.
|
|
45
|
+
|
|
46
|
+
### One column
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<div className="w-full max-w-sm">
|
|
50
|
+
<DescriptionList
|
|
51
|
+
items={[
|
|
52
|
+
{ term: 'Serial', description: 'HS-4471-B' },
|
|
53
|
+
{ term: 'Status', description: <Badge tone="ok">Online</Badge> },
|
|
54
|
+
{ term: 'Firmware', description: '2.8.1' },
|
|
55
|
+
{ term: 'Last seen', description: '15 Oct, 09:58' },
|
|
56
|
+
]}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Three columns, fewer as the container narrows
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
<div className="w-full">
|
|
65
|
+
<DescriptionList
|
|
66
|
+
columns={3}
|
|
67
|
+
items={[
|
|
68
|
+
{ term: 'Order', description: 'ORD-2291' },
|
|
69
|
+
{ term: 'Placed', description: '12 Oct 2026' },
|
|
70
|
+
{ term: 'Ships', description: '16 Oct 2026' },
|
|
71
|
+
{ term: 'Depot', description: 'Cluj, bay 4' },
|
|
72
|
+
{ term: 'Carrier', description: 'Regular courier' },
|
|
73
|
+
{ term: 'Weight', description: '22.1 kg' },
|
|
74
|
+
]}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Keyboard
|
|
80
|
+
|
|
81
|
+
| Keys | Result |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `Tab` | Moves to any link or button inside a description. |
|
|
84
|
+
|
|
85
|
+
## Do
|
|
86
|
+
|
|
87
|
+
- Order pairs the way someone reads the record: identity first, then state, then dates.
|
|
88
|
+
- Say "None" or "Not set" for an empty value rather than leaving the description blank.
|
|
89
|
+
- Use two or three columns only for short values; long text reads better in one.
|
|
90
|
+
|
|
91
|
+
## Don't
|
|
92
|
+
|
|
93
|
+
- Don't use it for a list of records. That's what DataTable is for.
|
|
94
|
+
- Don't put a form field in a description. A record people can edit is a form.
|
|
95
|
+
- Don't repeat a term inside one list.
|
|
96
|
+
|
|
97
|
+
## Anatomy
|
|
98
|
+
|
|
99
|
+
- **Container**: A div with @container (a container query), so the columns follow the width of this box, not of the screen.
|
|
100
|
+
- **List**: A dl laid out as a grid: one column, two from 28rem wide, three from 42rem (as far as columns allows). A rem is the base text size, usually 16 pixels.
|
|
101
|
+
- **Pair**: A div holding one dt (the term) and one dd (its value), with a thin line above.
|
|
102
|
+
- **Term**: The dt, styled as a label: monospaced, uppercase, 11px (pixels).
|
|
103
|
+
- **Description**: The dd, in body text. It holds text, a Badge or a Link.
|