@ahrowe/ui 0.13.0 → 0.14.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/dist/esm/common/animatedIcon/animatedIcon.mjs +2 -0
- package/dist/esm/common/animatedIcon/animatedIcon.mjs.map +1 -0
- package/dist/esm/common/animatedIcon/animatedIcon.module.mjs +2 -0
- package/dist/esm/common/animatedIcon/animatedIcon.module.mjs.map +1 -0
- package/dist/esm/common/breadcrumb/breadcrumb.mjs +1 -1
- package/dist/esm/common/breadcrumb/breadcrumb.mjs.map +1 -1
- package/dist/esm/common/buttonGroup/buttonGroup.mjs +2 -0
- package/dist/esm/common/buttonGroup/buttonGroup.mjs.map +1 -0
- package/dist/esm/common/buttonGroup/buttonGroup.module.mjs +2 -0
- package/dist/esm/common/buttonGroup/buttonGroup.module.mjs.map +1 -0
- package/dist/esm/common/buttonGroup/buttonGroup.types.mjs +2 -0
- package/dist/esm/common/buttonGroup/buttonGroup.types.mjs.map +1 -0
- package/dist/esm/common/divider/divider.mjs +1 -1
- package/dist/esm/common/divider/divider.mjs.map +1 -1
- package/dist/esm/common/divider/divider.module.mjs +1 -1
- package/dist/esm/common/divider/divider.module.mjs.map +1 -1
- package/dist/esm/common/divider/divider.types.mjs +1 -1
- package/dist/esm/common/divider/divider.types.mjs.map +1 -1
- package/dist/esm/common/dropdown/dropdown.module.mjs +1 -1
- package/dist/esm/common/dropdown/dropdown.module.mjs.map +1 -1
- package/dist/esm/common/fab/fab.mjs +1 -1
- package/dist/esm/common/fab/fab.mjs.map +1 -1
- package/dist/esm/common/floatingMenu/floatingMenu.mjs +1 -1
- package/dist/esm/common/floatingMenu/floatingMenu.mjs.map +1 -1
- package/dist/esm/common/floatingMenu/floatingMenu.module.mjs.map +1 -1
- package/dist/esm/common/hooks/useFocusBoundary.mjs +2 -0
- package/dist/esm/common/hooks/useFocusBoundary.mjs.map +1 -0
- package/dist/esm/common/input/input.mjs +1 -1
- package/dist/esm/common/input/input.mjs.map +1 -1
- package/dist/esm/common/input/input.module.mjs +1 -1
- package/dist/esm/common/input/input.module.mjs.map +1 -1
- package/dist/esm/common/input/input.types.mjs +1 -1
- package/dist/esm/common/input/input.types.mjs.map +1 -1
- package/dist/esm/common/interactableDiv/interactableDiv.mjs +1 -1
- package/dist/esm/common/interactableDiv/interactableDiv.mjs.map +1 -1
- package/dist/esm/common/kanbanBoard/KanbanBoard.mjs +6 -1
- package/dist/esm/common/kanbanBoard/KanbanBoard.mjs.map +1 -1
- package/dist/esm/common/kanbanBoard/KanbanColumn.mjs +1 -1
- package/dist/esm/common/kanbanBoard/KanbanColumn.mjs.map +1 -1
- package/dist/esm/common/kanbanBoard/KanbanItem.mjs +1 -1
- package/dist/esm/common/kanbanBoard/KanbanItem.mjs.map +1 -1
- package/dist/esm/common/kanbanBoard/kanbanBoard.module.mjs +1 -1
- package/dist/esm/common/kanbanBoard/kanbanBoard.module.mjs.map +1 -1
- package/dist/esm/common/kanbanBoard/kanbanBoard.utils.mjs +2 -0
- package/dist/esm/common/kanbanBoard/kanbanBoard.utils.mjs.map +1 -0
- package/dist/esm/common/klipyPicker/klipyPicker.mjs +1 -1
- package/dist/esm/common/klipyPicker/klipyPicker.mjs.map +1 -1
- package/dist/esm/common/numberInput/numberInput.mjs +1 -1
- package/dist/esm/common/numberInput/numberInput.mjs.map +1 -1
- package/dist/esm/common/popover/popover.mjs +1 -1
- package/dist/esm/common/popover/popover.mjs.map +1 -1
- package/dist/esm/common/splitButton/splitButton.mjs +2 -0
- package/dist/esm/common/splitButton/splitButton.mjs.map +1 -0
- package/dist/esm/common/splitButton/splitButton.module.mjs +2 -0
- package/dist/esm/common/splitButton/splitButton.module.mjs.map +1 -0
- package/dist/esm/common/virtualList/useRowDrag.mjs +1 -1
- package/dist/esm/common/virtualList/useRowDrag.mjs.map +1 -1
- package/dist/esm/common/virtualList/useVirtualWindow.mjs +1 -1
- package/dist/esm/common/virtualList/useVirtualWindow.mjs.map +1 -1
- package/dist/esm/common/virtualList/virtualList.mjs +1 -1
- package/dist/esm/common/virtualList/virtualList.mjs.map +1 -1
- package/dist/esm/common/virtualList/virtualRow.mjs +1 -1
- package/dist/esm/common/virtualList/virtualRow.mjs.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/index.cjs +9 -4
- package/dist/index.cjs.map +1 -1
- package/dist/style.css +1 -1
- package/dist/types/package/common/animatedIcon/animatedIcon.d.ts +3 -0
- package/dist/types/package/common/animatedIcon/animatedIcon.types.d.ts +25 -0
- package/dist/types/package/common/animatedIcon/index.d.ts +2 -0
- package/dist/types/package/common/buttonGroup/buttonGroup.d.ts +4 -0
- package/dist/types/package/common/buttonGroup/buttonGroup.types.d.ts +12 -0
- package/dist/types/package/common/buttonGroup/index.d.ts +2 -0
- package/dist/types/package/common/configProvider/configProvider.types.d.ts +2 -0
- package/dist/types/package/common/divider/divider.types.d.ts +5 -0
- package/dist/types/package/common/hooks/useFocusBoundary.d.ts +20 -0
- package/dist/types/package/common/input/input.types.d.ts +5 -0
- package/dist/types/package/common/kanbanBoard/KanbanBoard.d.ts +1 -1
- package/dist/types/package/common/kanbanBoard/KanbanColumn.d.ts +29 -1
- package/dist/types/package/common/kanbanBoard/KanbanItem.d.ts +2 -6
- package/dist/types/package/common/kanbanBoard/kanbanBoard.types.d.ts +71 -5
- package/dist/types/package/common/kanbanBoard/kanbanBoard.utils.d.ts +60 -0
- package/dist/types/package/common/splitButton/index.d.ts +2 -0
- package/dist/types/package/common/splitButton/splitButton.d.ts +3 -0
- package/dist/types/package/common/splitButton/splitButton.types.d.ts +28 -0
- package/dist/types/package/common/themeProvider/theme.types.d.ts +3 -0
- package/dist/types/package/common/virtualList/useRowDrag.d.ts +4 -0
- package/dist/types/package/common/virtualList/useVirtualWindow.d.ts +3 -1
- package/dist/types/package/common/virtualList/virtualList.types.d.ts +5 -1
- package/dist/types/package/common/virtualList/virtualRow.d.ts +8 -1
- package/dist/types/package/index.d.ts +4 -0
- package/docs/ButtonGroup.md +90 -0
- package/docs/CLAUDE.md +2 -0
- package/docs/Divider.md +15 -3
- package/docs/FloatingMenu.md +7 -3
- package/docs/Input.md +30 -6
- package/docs/KanbanBoard.md +129 -4
- package/docs/NumberInput.md +2 -0
- package/docs/SearchInput.md +6 -0
- package/docs/SplitButton.md +79 -0
- package/docs/VirtualList.md +86 -3
- package/package.json +2 -4
package/docs/KanbanBoard.md
CHANGED
|
@@ -34,22 +34,147 @@ const items: Record<string, Task> = {
|
|
|
34
34
|
)}
|
|
35
35
|
onChange={(updatedColumns) => setColumns(updatedColumns)}
|
|
36
36
|
/>
|
|
37
|
+
|
|
38
|
+
// Restrict drag initiation to a handle — needed when a card contains its own
|
|
39
|
+
// interactive controls (buttons, inputs), which would otherwise have their
|
|
40
|
+
// clicks swallowed by the drag listener on the whole card
|
|
41
|
+
<KanbanBoard
|
|
42
|
+
columns={columns}
|
|
43
|
+
items={items}
|
|
44
|
+
dragHandle
|
|
45
|
+
renderItem={(task, id) => (
|
|
46
|
+
<div className="task-card">
|
|
47
|
+
<span data-drag-handle>⠿</span>
|
|
48
|
+
<strong>{task.title}</strong>
|
|
49
|
+
<button onClick={() => deleteTask(id)}>Delete</button>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
onChange={(updatedColumns) => setColumns(updatedColumns)}
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
// Work-in-progress limit — shows a "count/max" badge on the column header and a
|
|
56
|
+
// warning border once it's exceeded. Display-only: KanbanBoard doesn't block the
|
|
57
|
+
// drop itself, so enforce a hard cap (if you want one) inside onChange, by simply
|
|
58
|
+
// not calling setColumns when the destination column would go over its maxItems.
|
|
59
|
+
const columnsWithLimit: KanbanColumnDef[] = [
|
|
60
|
+
{ id: 'inprogress', title: 'In Progress', itemIds: ['task-3'], maxItems: 3 },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// Accessible labels for screen-reader drag announcements — defaults to the card's
|
|
64
|
+
// id when omitted, so this is purely an upgrade for clearer narration
|
|
65
|
+
<KanbanBoard
|
|
66
|
+
columns={columns}
|
|
67
|
+
items={items}
|
|
68
|
+
getItemLabel={(task) => task.title}
|
|
69
|
+
renderItem={(task, id) => (
|
|
70
|
+
<div className="task-card">
|
|
71
|
+
<strong>{task.title}</strong>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
onChange={(updatedColumns) => setColumns(updatedColumns)}
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
// Freeze a column — cards inside can't be picked up, and nothing can be dropped
|
|
78
|
+
// into it. Use for a workflow rule like "cards in Done can't be moved back out."
|
|
79
|
+
const columnsWithLock: KanbanColumnDef[] = [
|
|
80
|
+
{ id: 'done', title: 'Done', itemIds: ['task-3'], disabled: true },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// Pin an individual card so it can't be dragged, independent of its column —
|
|
84
|
+
// e.g. a task that's locked pending approval
|
|
85
|
+
<KanbanBoard
|
|
86
|
+
columns={columns}
|
|
87
|
+
items={items}
|
|
88
|
+
getItemDisabled={(task) => task.locked === true}
|
|
89
|
+
renderItem={(task, id) => (
|
|
90
|
+
<div className="task-card">
|
|
91
|
+
<strong>{task.title}</strong>
|
|
92
|
+
{task.locked && <span>🔒</span>}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
onChange={(updatedColumns) => setColumns(updatedColumns)}
|
|
96
|
+
/>
|
|
97
|
+
|
|
98
|
+
// Custom content for an empty column, instead of leaving it blank
|
|
99
|
+
<KanbanBoard
|
|
100
|
+
columns={columns}
|
|
101
|
+
items={items}
|
|
102
|
+
renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
|
|
103
|
+
renderEmptyColumn={(column) => <span>No tasks in {column.title}</span>}
|
|
104
|
+
onChange={(updatedColumns) => setColumns(updatedColumns)}
|
|
105
|
+
/>
|
|
106
|
+
|
|
107
|
+
// Custom actions in a column's header — an "add card" button, a column menu — laid
|
|
108
|
+
// out next to the title (and the maxItems count badge, when both are present)
|
|
109
|
+
<KanbanBoard
|
|
110
|
+
columns={columns}
|
|
111
|
+
items={items}
|
|
112
|
+
renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
|
|
113
|
+
renderColumnActions={(column) => (
|
|
114
|
+
<button onClick={() => addTaskTo(column.id)} aria-label={`Add task to ${column.title}`}>+</button>
|
|
115
|
+
)}
|
|
116
|
+
onChange={(updatedColumns) => setColumns(updatedColumns)}
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
// Let whole columns be reordered by dragging their header, alongside card dragging.
|
|
120
|
+
// The same onChange receives the reordered columns array — no separate callback needed.
|
|
121
|
+
<KanbanBoard
|
|
122
|
+
columns={columns}
|
|
123
|
+
items={items}
|
|
124
|
+
renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
|
|
125
|
+
reorderableColumns
|
|
126
|
+
onChange={(updatedColumns) => setColumns(updatedColumns)}
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
// Keep every column the same width regardless of card count — opt out of the
|
|
130
|
+
// default shrink-when-empty behaviour
|
|
131
|
+
<KanbanBoard
|
|
132
|
+
columns={columns}
|
|
133
|
+
items={items}
|
|
134
|
+
renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
|
|
135
|
+
collapseEmptyColumns={false}
|
|
136
|
+
onChange={(updatedColumns) => setColumns(updatedColumns)}
|
|
137
|
+
/>
|
|
37
138
|
```
|
|
38
139
|
|
|
140
|
+
**Keyboard support:** cards are focusable and can be picked up and moved with the keyboard — Space or Enter to pick up, Space or Enter again to drop, Escape to cancel (reverts to the original position). Arrow keys move the card: Up/Down reorders within its current column; Left/Right moves it into the adjacent column, landing on the nearest card there (or the column itself, if it's empty). This uses a board-aware coordinate getter rather than `dnd-kit`'s generic `sortableKeyboardCoordinates`, which — across multiple per-column `SortableContext`s — can't reliably tell "next card in this column" from "some other column's droppable" and ends up mixing up Up/Down and Left/Right. With `reorderableColumns`, a column's header is focusable the same way — Space/Enter to pick it up, Left/Right to move it, Up/Down does nothing (columns are a single row).
|
|
141
|
+
|
|
142
|
+
**Column reordering (`reorderableColumns`):** a column's header becomes its drag handle for moving the whole column — dragging the cards area still only moves cards. This shares the same `DndContext` as card dragging (mouse, touch, and keyboard all work the same way), and commits through the same `onChange` — a column move is just a reordering of the `columns` array, same as any other change.
|
|
143
|
+
|
|
144
|
+
**Screen-reader announcements:** every pick-up, hover, drop, and cancel is announced (e.g. "Picked up card Design mockup.", "Card Design mockup is over the In Progress column."). By default cards are announced by their id — pass `getItemLabel` to announce something meaningful instead (a title, a short description).
|
|
145
|
+
|
|
39
146
|
**KanbanBoardProps:**
|
|
40
147
|
|
|
41
148
|
| Prop | Type | Description |
|
|
42
149
|
|------|------|-------------|
|
|
43
|
-
| `columns` | `KanbanColumnDef[]` | Column definitions with `id`, `title`, `itemIds` |
|
|
150
|
+
| `columns` | `KanbanColumnDef[]` | Column definitions with `id`, `title`, `itemIds`, optional `maxItems` |
|
|
44
151
|
| `items` | `Record<string, T>` | Flat map of all items keyed by id |
|
|
45
152
|
| `renderItem` | `(item: T, id: string) => ReactNode` | Render function for each item |
|
|
46
|
-
| `onChange` | `(columns: KanbanColumnDef[]) => void` | Called after
|
|
153
|
+
| `onChange` | `(columns: KanbanColumnDef[]) => void` | Called after a drop that actually changes column or order — replace state with this value |
|
|
154
|
+
| `dragHandle` | `boolean` | Restrict drag initiation to elements marked `data-drag-handle` within a card, instead of the whole card. Use when `renderItem` includes interactive content |
|
|
155
|
+
| `getItemLabel` | `(item: T, id: string) => string` | Accessible label for a card, used in screen-reader drag announcements. Defaults to the card's id |
|
|
156
|
+
| `getItemDisabled` | `(item: T, id: string) => boolean` | Returns whether a specific card can't be picked up. Defaults to every card being draggable; independent of a column's own `disabled`. A pinned card stays a valid drop target — other cards can still be reordered before/after it, unlike a card inside a fully frozen (`disabled`) column |
|
|
157
|
+
| `renderEmptyColumn` | `(column: KanbanColumnDef) => ReactNode` | Custom content shown inside a column when it has no cards, instead of leaving it blank |
|
|
158
|
+
| `renderColumnActions` | `(column: KanbanColumnDef) => ReactNode` | Custom actions rendered in a column's header, next to its title (e.g. an "add card" button, a column menu) |
|
|
159
|
+
| `reorderableColumns` | `boolean` | Allow whole columns to be reordered by dragging their header, alongside card dragging (default `false`) |
|
|
160
|
+
| `collapseEmptyColumns` | `boolean` | Shrink a column to a narrow strip once it has no cards (default `true`). Set `false` to keep every column the same width regardless of card count. The collapsed width is set via `--kanban-empty-column-width` (falls back to `140px`) — override it theme-wide via `ThemeProvider`, or per board via `style` |
|
|
161
|
+
| `id` / `role` / `aria-*` / `data-*` | | Passed through to the root element |
|
|
162
|
+
|
|
163
|
+
`KanbanColumn`'s own props accept the same `id` / `role` / `aria-*` / `data-*` pass-through on its root element.
|
|
47
164
|
|
|
48
165
|
**KanbanColumnDef:**
|
|
49
166
|
|
|
50
167
|
```ts
|
|
51
|
-
{
|
|
168
|
+
{
|
|
169
|
+
id: string;
|
|
170
|
+
title: string;
|
|
171
|
+
itemIds: string[];
|
|
172
|
+
maxItems?: number; // work-in-progress limit — display-only, see above
|
|
173
|
+
disabled?: boolean; // freezes the column — cards inside can't be dragged, nothing can drop in
|
|
174
|
+
}
|
|
52
175
|
```
|
|
53
176
|
|
|
177
|
+
**Note on item ids:** don't give a card an id starting with `column-` — the board uses that prefix internally to identify a column's own droppable (the empty space above/below its cards) versus a specific card. A colliding card id is silently misread as a column reference, and drops involving it silently no-op instead of throwing, so the bug can be easy to miss.
|
|
178
|
+
|
|
54
179
|
**Slots (KanbanBoard):** `dragOverlay`
|
|
55
|
-
**Slots (KanbanColumn):** `header` `items`
|
|
180
|
+
**Slots (KanbanColumn):** `header` `items` `count` (the `count/maxItems` badge, only rendered when `maxItems` is set) `placeholder` (the `renderEmptyColumn` wrapper, only rendered when the column has no cards and `renderEmptyColumn` is set) `actions` (the `renderColumnActions` wrapper, only rendered when `renderColumnActions` is set)
|
package/docs/NumberInput.md
CHANGED
|
@@ -55,3 +55,5 @@ const priceValidator = new FormValidator<number | undefined>(undefined, [Validat
|
|
|
55
55
|
| `readOnly` | `boolean` | Make the field read-only — typing is blocked, and the arrow steppers (`showArrows`) and ↑/↓ keys are disabled. Default: `false` |
|
|
56
56
|
| `placeholder` | `string` | |
|
|
57
57
|
| `error` | `string` | Manual error message |
|
|
58
|
+
|
|
59
|
+
**Mobile keyboard layout:** shows the numeric on-screen keyboard automatically (`inputMode="numeric"` when `decimalScale={0}`, `"decimal"` otherwise, so a decimal separator key is available whenever fractional values are allowed). Pass `inputMode` directly to override.
|
package/docs/SearchInput.md
CHANGED
|
@@ -33,6 +33,12 @@ import { SearchInput } from '@ahrowe/ui';
|
|
|
33
33
|
/>
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
**Theming:** override theme-wide via `ThemeProvider`'s `variables`; no built-in fallback, so it's unset in the default theme until set:
|
|
37
|
+
|
|
38
|
+
| Variable | Falls back to |
|
|
39
|
+
|----------|---------------|
|
|
40
|
+
| `--search-input-color` | *(none — unset by default)* |
|
|
41
|
+
|
|
36
42
|
**Key props:**
|
|
37
43
|
|
|
38
44
|
| Prop | Type | Description |
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SplitButton
|
|
2
|
+
|
|
3
|
+
**When to use:** A primary action button with a small attached dropdown for closely related secondary actions, e.g. "Save ▾" opening "Save as draft" / "Save and close". Built from `Button`, `ButtonGroup`, and `FloatingMenu` internally, rather than a mechanism of its own.
|
|
4
|
+
|
|
5
|
+
**Import:** `import { SplitButton } from '@ahrowe/ui'`
|
|
6
|
+
**Types:** `import type { SplitButtonAction, SplitButtonProps } from '@ahrowe/ui'`
|
|
7
|
+
|
|
8
|
+
**Requires:** `<div id="bodyEnd"></div>` in your HTML (the actions menu renders via `FloatingMenu`, which portals).
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { SplitButton } from '@ahrowe/ui';
|
|
12
|
+
import type { SplitButtonAction } from '@ahrowe/ui';
|
|
13
|
+
|
|
14
|
+
const actions: SplitButtonAction[] = [
|
|
15
|
+
{ id: 'draft', label: 'Save as draft', onClick: () => saveDraft() },
|
|
16
|
+
{ id: 'close', label: 'Save and close', onClick: () => saveAndClose() },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
<SplitButton actions={actions} onClick={() => save()}>
|
|
20
|
+
Save
|
|
21
|
+
</SplitButton>
|
|
22
|
+
|
|
23
|
+
// Destructive style, matching Button's own style variants
|
|
24
|
+
import { ButtonStyleType } from '@ahrowe/ui';
|
|
25
|
+
|
|
26
|
+
<SplitButton
|
|
27
|
+
actions={[{ id: 'archive', label: 'Archive instead', onClick: () => archive() }]}
|
|
28
|
+
styleType={ButtonStyleType.Delete}
|
|
29
|
+
onClick={() => deleteItem()}
|
|
30
|
+
>
|
|
31
|
+
Delete
|
|
32
|
+
</SplitButton>
|
|
33
|
+
|
|
34
|
+
// A disabled action within the menu
|
|
35
|
+
<SplitButton
|
|
36
|
+
actions={[
|
|
37
|
+
{ id: 'draft', label: 'Save as draft', onClick: () => saveDraft() },
|
|
38
|
+
{ id: 'archive', label: 'Archive instead', onClick: () => archive(), disabled: true },
|
|
39
|
+
]}
|
|
40
|
+
onClick={() => save()}
|
|
41
|
+
>
|
|
42
|
+
Save
|
|
43
|
+
</SplitButton>
|
|
44
|
+
|
|
45
|
+
// Loading / disabled — both the primary and trigger button reflect the state
|
|
46
|
+
<SplitButton actions={actions} onClick={() => save()} isLoading>
|
|
47
|
+
Save
|
|
48
|
+
</SplitButton>
|
|
49
|
+
<SplitButton actions={actions} onClick={() => save()} disabled>
|
|
50
|
+
Save
|
|
51
|
+
</SplitButton>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**SplitButtonAction:**
|
|
55
|
+
|
|
56
|
+
| Field | Type | Description |
|
|
57
|
+
|-------|------|-------------|
|
|
58
|
+
| `id` | `string` | Stable key for the action |
|
|
59
|
+
| `label` | `ReactNode` | Text shown in the dropdown |
|
|
60
|
+
| `onClick` | `() => void` | Called when the action is picked; the dropdown closes automatically afterward |
|
|
61
|
+
| `disabled` | `boolean` | Dims the action and prevents it from firing |
|
|
62
|
+
|
|
63
|
+
**Key props:**
|
|
64
|
+
|
|
65
|
+
| Prop | Type | Description |
|
|
66
|
+
|------|------|-------------|
|
|
67
|
+
| `children` | `ReactNode` | The primary button's label |
|
|
68
|
+
| `onClick` | `(event) => void \| Promise<void>` | The primary button's click handler |
|
|
69
|
+
| `actions` | `SplitButtonAction[]` | Secondary actions shown in the dropdown |
|
|
70
|
+
| `styleType` | `ButtonStyleType` | Visual variant, shared by both the primary and trigger button (default `Primary`) |
|
|
71
|
+
| `size` | `ButtonSize` | Size variant, shared by both buttons |
|
|
72
|
+
| `disabled` | `boolean` | Disables both the primary and trigger button |
|
|
73
|
+
| `isLoading` | `boolean` | Shows a spinner on the primary button (same as `Button`'s own `isLoading`) |
|
|
74
|
+
|
|
75
|
+
**`isLoading` vs. `disabled`:** `isLoading` only affects the primary button — the trigger and its menu stay usable while the primary action is in flight, e.g. to cancel via a menu action. To lock the whole control during the async action instead, pass both: `isLoading={isSaving} disabled={isSaving}`. Setting `disabled` also closes the menu if it happened to be open already, and blocks any in-flight click on a menu action.
|
|
76
|
+
|
|
77
|
+
**Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ SplitButton: { styleType: ButtonStyleType.Default } }}`. See [ConfigProvider.md](ConfigProvider.md).
|
|
78
|
+
|
|
79
|
+
**Slots:** `root` `primaryButton` `triggerButton` `menu` `menuItem`
|
package/docs/VirtualList.md
CHANGED
|
@@ -58,7 +58,7 @@ const columns: VirtualListColumn<User>[] = [
|
|
|
58
58
|
<VirtualList
|
|
59
59
|
items={items}
|
|
60
60
|
columns={columns}
|
|
61
|
-
onLoadMore={async () => { await fetchNextPage(); }}
|
|
61
|
+
onLoadMore={hasMore ? async () => { await fetchNextPage(); } : undefined}
|
|
62
62
|
isLoading={isInitialLoading}
|
|
63
63
|
height="100%"
|
|
64
64
|
/>
|
|
@@ -123,6 +123,7 @@ const columns: VirtualListColumn<User>[] = [
|
|
|
123
123
|
| `rowGap` | `number \| string` | Gap between rows — number = px; string = any CSS length (`'1em'`, `'var(--spacing-s)'`). Default `0` |
|
|
124
124
|
| `rowPadding` | `number \| string` | Padding on every row — number = px; string = any CSS padding value (`'8px 12px'`, `'var(--spacing-s)'`) |
|
|
125
125
|
| `showDivider` | `boolean` | Row divider lines (default `true`) |
|
|
126
|
+
| `rowHover` | `boolean` | Highlight rows on hover (default `true`). Set `false` when the row content brings its own hover affordance (e.g. a `Card` per row) so the two don't fight |
|
|
126
127
|
| `getItemKey` | `(item, index) => string \| number` | Stable key per item |
|
|
127
128
|
| `selectedKey` | `string \| number \| null` | Controlled single-select key |
|
|
128
129
|
| `onRowClick` | `(item, index) => void` | Row click handler |
|
|
@@ -132,10 +133,13 @@ const columns: VirtualListColumn<User>[] = [
|
|
|
132
133
|
| `showColumnToggle` | `boolean` | Show gear button to show/hide columns (default `true` when columns present) |
|
|
133
134
|
| `visibleColumnKeys` | `string[]` | Controlled visible column keys |
|
|
134
135
|
| `onVisibleColumnsChange` | `(keys) => void` | Column visibility change callback |
|
|
135
|
-
| `
|
|
136
|
+
| `persistColumnsKey` | `string` | Save column visibility to `localStorage` under this key and restore it on the next mount of a list using the same key. Uncontrolled mode only (ignored alongside `visibleColumnKeys`); columns added since the settings were saved fall back to their `defaultHidden` |
|
|
137
|
+
| `onLoadMore` | `() => Promise<void>` | Triggered near the bottom, and repeatedly while the rows don't fill the viewport — append items in the handler |
|
|
136
138
|
| `loadMoreThreshold` | `number` | Distance from bottom that triggers `onLoadMore` (default `100`) |
|
|
137
139
|
| `isLoading` | `boolean` | Replaces list body with a full-height spinner |
|
|
138
140
|
|
|
141
|
+
Drag-and-drop reordering adds `reorderable`, `treeReorder`, `dragHandle`, and their callbacks — see **Row reordering** below.
|
|
142
|
+
|
|
139
143
|
**Imperative handle (`ref`):**
|
|
140
144
|
|
|
141
145
|
Pass a `ref` typed as `VirtualListHandle` to control the list imperatively:
|
|
@@ -160,6 +164,85 @@ listRef.current?.scrollToKey(user.id, { align: 'center' });
|
|
|
160
164
|
| `getScrollElement` | `() => HTMLDivElement \| null` | The underlying scroll container, for advanced use |
|
|
161
165
|
| `recalculateColumns` | `() => void` | Re-measure `fit` columns on the next layout. Only needed after an out-of-band content change that doesn't flow through a new `items` array or `columns` set — those re-measure automatically, as does content first appearing in a cell |
|
|
162
166
|
|
|
163
|
-
**
|
|
167
|
+
**Row reordering:** two modes, both drag-and-drop, both mouse and touch. Set exactly one:
|
|
168
|
+
|
|
169
|
+
- **`reorderable`** — flat reorder. Rows move **live** as you drag, so `onReorder` fires repeatedly and you apply each move immediately.
|
|
170
|
+
- **`treeReorder`** — drop `before`, `after`, or `inside` another row. Rows do **not** move during the drag; a drop indicator is shown and `onTreeDrop` fires **once** on release.
|
|
171
|
+
|
|
172
|
+
If both are set, `treeReorder` wins. In either mode, pass `getItemKey` so rows keep their measured heights across the reorder.
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
// Flat: onReorder fires live, on every position change
|
|
176
|
+
const [rows, setRows] = useState(initialRows);
|
|
177
|
+
|
|
178
|
+
<VirtualList
|
|
179
|
+
items={rows}
|
|
180
|
+
renderRow={(row) => <div>{row.label}</div>}
|
|
181
|
+
getItemKey={(row) => row.id}
|
|
182
|
+
reorderable
|
|
183
|
+
onReorder={(from, to) => setRows((prev) => move(prev, from, to))}
|
|
184
|
+
height={400}
|
|
185
|
+
/>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The list is always a flat array, including in tree mode. Keep the real tree in your own state, flatten the expanded nodes into `items` tagging each with its depth, and re-flatten after applying a drop:
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
// Tree: onTreeDrop fires once, on release
|
|
192
|
+
<VirtualList
|
|
193
|
+
items={flattenedRows} // each row carries its own depth
|
|
194
|
+
renderRow={(row) => (
|
|
195
|
+
<div style={{ paddingLeft: row.depth * 24 }}>
|
|
196
|
+
<span data-drag-handle>⠿</span>
|
|
197
|
+
{row.label}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
getItemKey={(row) => row.id}
|
|
201
|
+
getItemDepth={(row) => row.depth}
|
|
202
|
+
isGroup={(row) => row.hasChildren} // only groups offer an `inside` drop
|
|
203
|
+
treeReorder
|
|
204
|
+
dragHandle
|
|
205
|
+
treeIndentPx={24} // match the row content's per-level padding
|
|
206
|
+
onTreeDrop={(dragKey, target) => applyDrop(dragKey, target)}
|
|
207
|
+
height={400}
|
|
208
|
+
/>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
| Prop | Type | Description |
|
|
212
|
+
|------|------|-------------|
|
|
213
|
+
| `reorderable` | `boolean` | Flat drag-to-reorder. The whole row is draggable unless `dragHandle` is set |
|
|
214
|
+
| `onReorder` | `(fromIndex, toIndex) => void` | Fires **live** during a flat drag, once per position change. Apply it straight away |
|
|
215
|
+
| `treeReorder` | `boolean` | Tree drag-and-drop: `before` / `after` / `inside` drops instead of a flat move. Wins over `reorderable` |
|
|
216
|
+
| `onTreeDrop` | `(dragKey, target: TreeDropTarget) => void` | Fires **once** on release. Never called when the target is the dragged node itself or one of its descendants |
|
|
217
|
+
| `getItemDepth` | `(item, index) => number` | Each row's depth in the tree, `0` = root (default `0` for every row). Aligns the drop indicator and blocks dropping a node into its own subtree |
|
|
218
|
+
| `isGroup` | `(item, index) => boolean` | Whether a row can take children, i.e. whether hovering its middle third offers an `inside` drop (default `false`, leaf) |
|
|
219
|
+
| `treeIndentPx` | `number` | Px per depth level used to indent the `before`/`after` drop line so it lines up with where the dropped content starts (default `24`) |
|
|
220
|
+
| `dragHandle` | `boolean` | Only elements marked `data-drag-handle` start a drag, instead of the whole row. Applies to both modes, mouse and touch |
|
|
221
|
+
| `longPressDelay` | `number` | Touch only: press-and-hold duration in ms before drag mode arms, so a normal swipe still scrolls (default `400`) |
|
|
222
|
+
|
|
223
|
+
**`TreeDropTarget`** (`import type { TreeDropTarget, TreeDropPosition } from '@ahrowe/ui'`):
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
interface TreeDropTarget {
|
|
227
|
+
key: string | number; // getItemKey of the row dropped on
|
|
228
|
+
position: 'before' | 'inside' | 'after'; // TreeDropPosition
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`before`/`after` place the dragged node as a sibling at the target's depth; `inside` makes it a child (only offered when `isGroup` is true for that row).
|
|
233
|
+
|
|
234
|
+
**Use `dragHandle` for rows containing inputs or other interactive controls.** Without it the whole row is draggable, which swallows clicking, typing, and text selection inside those controls. Render a handle in the row yourself and mark it: `<span data-drag-handle>⠿</span>`. With `dragHandle` the grab cursor is yours to style on the handle; without it, the list puts it on the whole row.
|
|
235
|
+
|
|
236
|
+
**Touch:** a press-and-hold of `longPressDelay` lifts a floating copy of the row that follows your finger, with auto-scroll near the list's top and bottom edges. A swipe before the hold completes scrolls the list as normal instead of starting a drag. With `dragHandle`, only a press that starts on the handle arms it, so the rest of the row stays tappable.
|
|
237
|
+
|
|
238
|
+
**Lazy loading:** `onLoadMore` fires when the user scrolls within `loadMoreThreshold` px of the bottom, **and** repeatedly whenever the rows don't fill the list's viewport. That second trigger matters because a list shorter than its own height can't be scrolled at all, so a first batch smaller than the viewport would otherwise be the last batch ever loaded. The list keeps asking for pages until the content overflows; each row count is asked at most once, so the loop ends as soon as a call appends nothing. Gate the prop on your own "has more" flag to skip even that last probe:
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
<VirtualList items={items} onLoadMore={hasMore ? loadNextPage : undefined} height={600} />
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
This needs the list to have a **bounded height** (the default `height="100%"` inside a sized parent, or an explicit `height`). With a height that grows with its content, there is no empty space to detect and only the scroll trigger applies. Nothing fires while `isLoading` is set, and only one load runs at a time.
|
|
245
|
+
|
|
246
|
+
**Slots:** `root` `header` `headerCell` `headerToggle` `togglePopover` `toggleItem` `body` `row` `cell` `selectCell` `loadingIndicator` `dropIndicator` `columnChip`
|
|
164
247
|
|
|
165
248
|
**In-row popovers:** Rows support inline popover content that overflows the row's bottom edge — e.g. a `Dropdown`, `InputDropdown`, `Tooltip`, or `FloatingMenu` whose list opens below its trigger. All four render their popover through a `BodyEnd` portal and track their trigger's position directly, so they always escape row/cell clipping and paint above the rows below, regardless of focus — no special CSS is needed on the row or cell for this. Cells still clip their own content by default (ellipsis / neighbour-spill guard); that's unaffected. This works in both list mode (`renderRow`) and table mode (`columns`). Stop the row's click handler from firing when interacting with the control (`onClick={(e) => e.stopPropagation()}` on the cell) if the row is also selectable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahrowe/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"build:mcp": "vite build --config vite.mcp.config.ts",
|
|
56
56
|
"preview": "vite preview",
|
|
57
57
|
"gen-barrel": "tsx scripts/gen-barrel.ts",
|
|
58
|
-
"gc": "bash src/scripts/generateComponent.sh
|
|
58
|
+
"gc": "bash src/scripts/generateComponent.sh",
|
|
59
59
|
"changeset": "changeset",
|
|
60
60
|
"lint": "eslint .",
|
|
61
61
|
"lint:fix": "eslint . --fix",
|
|
@@ -84,7 +84,6 @@
|
|
|
84
84
|
"@mona-health/react-input-mask": "^3.0.3",
|
|
85
85
|
"classnames": "^2.5.1",
|
|
86
86
|
"downshift": "^9.3.3",
|
|
87
|
-
"prop-types": "^15.8.1",
|
|
88
87
|
"react-number-format": "^5.4.5",
|
|
89
88
|
"zod": "^4.4.3"
|
|
90
89
|
},
|
|
@@ -101,7 +100,6 @@
|
|
|
101
100
|
"@types/node": "^25.9.1",
|
|
102
101
|
"@types/react": "^19.2.15",
|
|
103
102
|
"@types/react-dom": "^19.2.3",
|
|
104
|
-
"@types/react-helmet": "^6.1.11",
|
|
105
103
|
"@types/react-syntax-highlighter": "^15.5.13",
|
|
106
104
|
"@vitejs/plugin-react": "^6.0.2",
|
|
107
105
|
"@vitest/ui": "^4.1.7",
|