@ahrowe/ui 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/esm/common/errorBoundary/errorBoundary.mjs +4 -0
  2. package/dist/esm/common/errorBoundary/errorBoundary.mjs.map +1 -0
  3. package/dist/esm/common/errorBoundary/errorBoundary.module.mjs +2 -0
  4. package/dist/esm/common/errorBoundary/errorBoundary.module.mjs.map +1 -0
  5. package/dist/esm/common/interactableDiv/interactableDiv.mjs +1 -1
  6. package/dist/esm/common/interactableDiv/interactableDiv.mjs.map +1 -1
  7. package/dist/esm/common/kanbanBoard/KanbanBoard.mjs +6 -1
  8. package/dist/esm/common/kanbanBoard/KanbanBoard.mjs.map +1 -1
  9. package/dist/esm/common/kanbanBoard/KanbanColumn.mjs +1 -1
  10. package/dist/esm/common/kanbanBoard/KanbanColumn.mjs.map +1 -1
  11. package/dist/esm/common/kanbanBoard/KanbanItem.mjs +1 -1
  12. package/dist/esm/common/kanbanBoard/KanbanItem.mjs.map +1 -1
  13. package/dist/esm/common/kanbanBoard/kanbanBoard.module.mjs +1 -1
  14. package/dist/esm/common/kanbanBoard/kanbanBoard.module.mjs.map +1 -1
  15. package/dist/esm/common/kanbanBoard/kanbanBoard.utils.mjs +2 -0
  16. package/dist/esm/common/kanbanBoard/kanbanBoard.utils.mjs.map +1 -0
  17. package/dist/esm/common/timeline/timeline.mjs +2 -0
  18. package/dist/esm/common/timeline/timeline.mjs.map +1 -0
  19. package/dist/esm/common/timeline/timeline.module.mjs +2 -0
  20. package/dist/esm/common/timeline/timeline.module.mjs.map +1 -0
  21. package/dist/esm/common/timeline/timeline.types.mjs +2 -0
  22. package/dist/esm/common/timeline/timeline.types.mjs.map +1 -0
  23. package/dist/esm/index.mjs +1 -1
  24. package/dist/index.cjs +11 -4
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/style.css +1 -1
  27. package/dist/types/package/common/errorBoundary/errorBoundary.d.ts +14 -0
  28. package/dist/types/package/common/errorBoundary/errorBoundary.types.d.ts +57 -0
  29. package/dist/types/package/common/errorBoundary/index.d.ts +2 -0
  30. package/dist/types/package/common/kanbanBoard/KanbanBoard.d.ts +1 -1
  31. package/dist/types/package/common/kanbanBoard/KanbanColumn.d.ts +37 -1
  32. package/dist/types/package/common/kanbanBoard/KanbanItem.d.ts +2 -6
  33. package/dist/types/package/common/kanbanBoard/kanbanBoard.types.d.ts +99 -5
  34. package/dist/types/package/common/kanbanBoard/kanbanBoard.utils.d.ts +60 -0
  35. package/dist/types/package/common/themeProvider/theme.types.d.ts +2 -0
  36. package/dist/types/package/common/timeline/index.d.ts +2 -0
  37. package/dist/types/package/common/timeline/timeline.d.ts +2 -0
  38. package/dist/types/package/common/timeline/timeline.types.d.ts +51 -0
  39. package/dist/types/package/index.d.ts +4 -0
  40. package/docs/CLAUDE.md +2 -0
  41. package/docs/ErrorBoundary.md +93 -0
  42. package/docs/KanbanBoard.md +165 -4
  43. package/docs/Timeline.md +127 -0
  44. package/package.json +2 -4
@@ -1,7 +1,3 @@
1
- import { ReactNode } from 'react';
2
- interface KanbanItemProps {
3
- id: string;
4
- children: ReactNode;
5
- }
6
- declare function KanbanItem({ id, children }: KanbanItemProps): import("react/jsx-runtime").JSX.Element;
1
+ import { KanbanItemProps } from './kanbanBoard.types';
2
+ declare function KanbanItem({ id, dragHandle, disabled, preventAsDropTarget, children }: KanbanItemProps): import("react/jsx-runtime").JSX.Element;
7
3
  export default KanbanItem;
@@ -1,12 +1,31 @@
1
1
  import { CSSProperties, ReactNode } from 'react';
2
- import { SlotClassNames, SlotStyles } from '../types/slots.types';
2
+ import { SlotClassNames, SlotStyles, HtmlProps } from '../types/slots.types';
3
3
  export interface KanbanColumnDef {
4
4
  id: string;
5
5
  title: string;
6
6
  itemIds: string[];
7
+ /**
8
+ * Optional work-in-progress limit. When set, the column header shows a `count/maxItems`
9
+ * badge, and the column gets a warning style once `itemIds.length` exceeds it. This is
10
+ * display-only — `KanbanBoard` doesn't block drops on its own. To enforce a hard limit,
11
+ * check it in `onChange` and skip updating your state when a drop would push a column over.
12
+ */
13
+ maxItems?: number;
14
+ /**
15
+ * Freezes the column: cards inside it can't be picked up, and nothing can be dropped into it.
16
+ * Use for a workflow rule like "cards in Done can't be moved back out."
17
+ */
18
+ disabled?: boolean;
19
+ /**
20
+ * Whether more items exist for this column beyond what's currently in `itemIds` — enables the
21
+ * scroll-triggered `onLoadMore` request. Only meaningful alongside `KanbanBoardProps.onLoadMore`;
22
+ * a column without it never asks for more regardless of this flag. Set `false` (or omit) once
23
+ * the column's source is exhausted to stop asking.
24
+ */
25
+ hasMore?: boolean;
7
26
  }
8
27
  export type KanbanBoardSlots = 'dragOverlay';
9
- export interface KanbanBoardProps<T = unknown> {
28
+ export interface KanbanBoardProps<T = unknown> extends HtmlProps {
10
29
  /** Ordered list of column definitions. Each column owns its itemIds. */
11
30
  columns: KanbanColumnDef[];
12
31
  /** Flat map of all items keyed by their id. */
@@ -18,18 +37,93 @@ export interface KanbanBoardProps<T = unknown> {
18
37
  * Receives the full updated columns array — replace your state with this value.
19
38
  */
20
39
  onChange: (columns: KanbanColumnDef[]) => void;
40
+ /**
41
+ * Restrict drag initiation to elements marked `data-drag-handle` within a card,
42
+ * instead of the whole card. Use when `renderItem` includes interactive content
43
+ * (buttons, inputs) that would otherwise have its clicks swallowed by the drag listener.
44
+ */
45
+ dragHandle?: boolean;
46
+ /**
47
+ * Produce an accessible label for a card, announced to screen-reader users while dragging
48
+ * (e.g. "Picked up <label>."). Defaults to the card's id when omitted.
49
+ */
50
+ getItemLabel?: (item: T, id: string) => string;
51
+ /**
52
+ * Returns whether a specific card can't be picked up. Defaults to every card being draggable.
53
+ * Independent of a column's own `disabled` — a card can be pinned even in an otherwise
54
+ * unrestricted column.
55
+ */
56
+ getItemDisabled?: (item: T, id: string) => boolean;
57
+ /**
58
+ * Custom content shown inside a column when it has no cards, instead of leaving it blank.
59
+ * Receives the column definition.
60
+ */
61
+ renderEmptyColumn?: (column: KanbanColumnDef) => ReactNode;
62
+ /**
63
+ * Custom actions rendered in a column's header, next to its title (e.g. an "add card"
64
+ * button, a column menu). Receives the column definition.
65
+ */
66
+ renderColumnActions?: (column: KanbanColumnDef) => ReactNode;
67
+ /**
68
+ * Allow whole columns to be reordered by dragging their header, alongside the existing
69
+ * card dragging. Defaults to `false`.
70
+ */
71
+ reorderableColumns?: boolean;
72
+ /**
73
+ * Shrink a column to a narrow strip once it has no cards, instead of keeping it at the same
74
+ * width as every other column. Defaults to `true`. Set `false` to keep every column the same
75
+ * width regardless of card count. The collapsed width itself is set via the
76
+ * `--kanban-empty-column-width` CSS variable (falls back to `140px`) — override it theme-wide
77
+ * via `ThemeProvider`'s `variables`, or per board via `style`.
78
+ */
79
+ collapseEmptyColumns?: boolean;
80
+ /**
81
+ * Bounds each column's height (any CSS length, e.g. `'70vh'`, `'600px'`) and makes its card
82
+ * list scroll independently once content overflows, instead of growing the whole page. Leave
83
+ * unset for the default unbounded height. Required for `onLoadMore` to ever trigger, since an
84
+ * unbounded column never scrolls.
85
+ */
86
+ columnMaxHeight?: string;
87
+ /**
88
+ * Called with a column's `id` when that column has `hasMore` set and its card list is scrolled
89
+ * within `loadMoreThreshold` of the bottom — fetch and append the next page of items for it.
90
+ * `KanbanColumn` tracks its own in-flight state per column (shown as a spinner at the bottom of
91
+ * the list), so overlapping calls for the same column are never made concurrently. Also
92
+ * retried automatically if a loaded batch doesn't fill the column's visible height, since a
93
+ * column shorter than its own viewport never generates a scroll event to trigger a next page.
94
+ */
95
+ onLoadMore?: (columnId: string) => void | Promise<void>;
96
+ /**
97
+ * Distance in px from the bottom of a column's scroll area that triggers `onLoadMore` (default
98
+ * `100`, matching `VirtualList`'s `loadMoreThreshold`).
99
+ */
100
+ loadMoreThreshold?: number;
21
101
  className?: string;
22
102
  style?: CSSProperties;
23
103
  classNames?: SlotClassNames<KanbanBoardSlots>;
24
104
  styles?: SlotStyles<KanbanBoardSlots>;
25
105
  }
26
- export type KanbanColumnSlots = 'header' | 'items';
27
- export interface KanbanColumnProps {
106
+ export type KanbanColumnSlots = 'header' | 'items' | 'count' | 'placeholder' | 'actions' | 'loadingIndicator';
107
+ export interface KanbanColumnProps extends HtmlProps {
28
108
  id: string;
29
109
  title: string;
30
- isOver?: boolean;
31
110
  children?: ReactNode;
32
111
  className?: string;
112
+ style?: CSSProperties;
33
113
  classNames?: SlotClassNames<KanbanColumnSlots>;
34
114
  styles?: SlotStyles<KanbanColumnSlots>;
35
115
  }
116
+ export interface KanbanItemProps {
117
+ id: string;
118
+ dragHandle?: boolean;
119
+ /** Whether this card can't be picked up (column frozen, or the card itself is pinned). */
120
+ disabled?: boolean;
121
+ /**
122
+ * Whether this card also can't be a drop target for other cards — i.e. other cards can't be
123
+ * reordered relative to it either. Only true when the *column* is frozen (nothing should be
124
+ * dropped into it at all); a card pinned individually via `getItemDisabled` stays droppable, so
125
+ * other cards can still land before/after it normally.
126
+ */
127
+ preventAsDropTarget?: boolean;
128
+ children?: ReactNode;
129
+ }
@@ -0,0 +1,60 @@
1
+ import { Announcements, ClientRect, CollisionDetection, KeyboardCoordinateGetter } from '@dnd-kit/core';
2
+ import { KanbanColumnDef } from './kanbanBoard.types';
3
+ export declare function columnsEqual(a: KanbanColumnDef[], b: KanbanColumnDef[]): boolean;
4
+ export declare function findColumnForItem(columns: KanbanColumnDef[], itemId: string): number;
5
+ /** Whether a drag's `active`/`over` participant is a whole column (vs. a card) — see `reorderableColumns`. */
6
+ export declare function isColumnDragData(data: unknown): boolean;
7
+ /**
8
+ * Reorders whole columns: moves `activeColumnId` to `overColumnId`'s position. Returns the same
9
+ * `columns` reference, unchanged, when either id doesn't resolve to a column or they're the same
10
+ * — callers can use that to skip a state update.
11
+ */
12
+ export declare function moveColumn(columns: KanbanColumnDef[], activeColumnId: string, overColumnId: string): KanbanColumnDef[];
13
+ /**
14
+ * Collision detection for the board's shared `DndContext`, which has to pick between two very
15
+ * differently-scaled sets of droppables at once: individual cards / a column's own card list
16
+ * (small, numerous) and whole columns (large, few). Plain `closestCorners` weighs every
17
+ * registered droppable together regardless of what's actually being dragged, so while dragging a
18
+ * column it competes against every card and card-list droppable it happens to fly over too — the
19
+ * column-level target only wins when the pointer lands somewhere that geometrically favors it,
20
+ * which reads as needing to "wiggle it around" before the right drop target lights up. This
21
+ * restricts the candidates to only the same kind as whatever is being dragged: a column drag only
22
+ * considers other columns, a card drag only considers cards and card lists.
23
+ */
24
+ export declare const kanbanCollisionDetection: CollisionDetection;
25
+ /** Extracts the column id from a column droppable's `column-<id>` identifier, or `null` for a card id. */
26
+ export declare function resolveColumnDropId(overId: string): string | null;
27
+ export interface DragGeometry {
28
+ /** The dragged card's current rect (translated by the drag delta), if measured. */
29
+ activeRect: ClientRect | null;
30
+ /** The rect of whatever `overId` refers to — a card, or a column's own droppable. */
31
+ overRect: ClientRect;
32
+ }
33
+ /**
34
+ * Computes the columns that result from dragging `activeItemId` over `overId` (a card id, or a
35
+ * `column-<id>` droppable id for empty column space). Returns the same `columns` reference,
36
+ * unchanged, when the drag doesn't resolve to a valid source/destination — callers can use that
37
+ * to skip a state update.
38
+ *
39
+ * `geometry` resolves cross-column drops that land on a card or gap with more than one possible
40
+ * position (see the two branches below for what each one decides).
41
+ */
42
+ export declare function moveItem(columns: KanbanColumnDef[], activeItemId: string, overId: string, geometry: DragGeometry): KanbanColumnDef[];
43
+ /**
44
+ * Builds screen-reader drag announcements against the columns layout as currently understood
45
+ * (the live shadow layout while a drag is in progress, or the settled `columns` prop otherwise).
46
+ */
47
+ export declare function buildAnnouncements<T>(columns: KanbanColumnDef[], items: Record<string, T>, getItemLabel: (item: T, id: string) => string): Announcements;
48
+ /**
49
+ * Custom keyboard coordinate getter for the board's multi-column, multi-`SortableContext`
50
+ * layout. dnd-kit's default `sortableKeyboardCoordinates` picks the geometrically closest
51
+ * droppable across the *entire* board — cards and column containers alike — which is unreliable
52
+ * once cards live inside independent per-column `SortableContext`s: up/down and left/right end up
53
+ * interchangeable depending on incidental rect positions. This instead restricts up/down to
54
+ * reordering within the active card's own column, and left/right to moving into the adjacent
55
+ * column (landing on the closest card there by vertical position, or the column itself when
56
+ * empty). Requires each `KanbanColumn`'s `SortableContext` to be given an explicit `id` matching
57
+ * its column id, so a card's `sortable.containerId` can be correlated with its `column-<id>`
58
+ * droppable.
59
+ */
60
+ export declare const kanbanKeyboardCoordinateGetter: KeyboardCoordinateGetter;
@@ -107,6 +107,8 @@ export interface ThemeVariables {
107
107
  '--stepper-gap'?: string;
108
108
  /** Text/check colour on a filled marker. Falls back to --text-on-primary. */
109
109
  '--stepper-text-on-marker'?: string;
110
+ /** Width a column collapses to once it has no cards (see `collapseEmptyColumns`). Falls back to 140px. */
111
+ '--kanban-empty-column-width'?: string;
110
112
  [key: string]: string | undefined;
111
113
  }
112
114
  export interface Theme {
@@ -0,0 +1,2 @@
1
+ export { default } from './timeline';
2
+ export * from './timeline.types';
@@ -0,0 +1,2 @@
1
+ import { TimelineProps } from './timeline.types';
2
+ export default function Timeline({ items, alignment, className, style, classNames, styles: slotStyles, ...rest }: TimelineProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,51 @@
1
+ import { default as React } from 'react';
2
+ import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
3
+ import { SlotClassNames, SlotStyles, HtmlProps } from '../types/slots.types';
4
+ /** Layout of entries relative to the connecting line. */
5
+ export declare enum TimelineAlignment {
6
+ /** Line and markers on the left, content to the right (the default). */
7
+ Left = "left",
8
+ /** Line and markers on the right, content to the left. */
9
+ Right = "right",
10
+ /** Line down the center, entries alternating left/right. */
11
+ Alternate = "alternate"
12
+ }
13
+ /** Semantic colour for an entry's marker, matching the theme's status colours. */
14
+ export declare enum TimelineItemStatus {
15
+ Default = "default",
16
+ Primary = "primary",
17
+ Success = "success",
18
+ Warn = "warn",
19
+ Error = "error"
20
+ }
21
+ export type TimelineSlots = 'root' | 'item' | 'marker' | 'dot' | 'connector' | 'content' | 'title' | 'description' | 'timestamp';
22
+ export interface TimelineItem {
23
+ /** Stable identity for the entry, used as its React key. Falls back to its array index when omitted; pass one whenever `items` can be reordered or have entries prepended (e.g. a live "newest first" audit log), so React doesn't misattribute row state across the shift. */
24
+ id?: string | number;
25
+ /** Primary label for the entry. */
26
+ title: React.ReactNode;
27
+ /** Secondary line under the title. */
28
+ description?: React.ReactNode;
29
+ /** Rendered next to the title, e.g. a date or relative time ("2 hours ago"). */
30
+ timestamp?: React.ReactNode;
31
+ /** FontAwesome icon shown in the marker instead of a plain dot. */
32
+ icon?: IconDefinition;
33
+ /** Semantic marker colour (default `Default`). */
34
+ status?: TimelineItemStatus;
35
+ /** Extra content rendered under the description, e.g. a diff, an attachment, an actor avatar. */
36
+ content?: React.ReactNode;
37
+ /** Makes the whole entry clickable (e.g. to open the full record), rendering it as an accessible button. */
38
+ onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
39
+ /** Disables the entry: never clickable, dimmed. Only relevant alongside `onClick`. */
40
+ disabled?: boolean;
41
+ }
42
+ export interface TimelineProps extends HtmlProps {
43
+ /** The entries to render, in display order (typically newest first for an audit log). */
44
+ items?: TimelineItem[];
45
+ /** Layout of entries relative to the connecting line (default `Left`). */
46
+ alignment?: TimelineAlignment;
47
+ className?: string;
48
+ style?: React.CSSProperties;
49
+ classNames?: SlotClassNames<TimelineSlots>;
50
+ styles?: SlotStyles<TimelineSlots>;
51
+ }
@@ -47,6 +47,8 @@ export { default as Dropdown } from './common/dropdown';
47
47
  export * from './common/dropdown';
48
48
  export { default as EmptyState } from './common/emptyState';
49
49
  export * from './common/emptyState';
50
+ export { default as ErrorBoundary } from './common/errorBoundary';
51
+ export * from './common/errorBoundary';
50
52
  export { default as Fab } from './common/fab';
51
53
  export * from './common/fab';
52
54
  export { default as FloatingMenu } from './common/floatingMenu';
@@ -117,6 +119,8 @@ export * from './common/themeProvider';
117
119
  export { useTheme } from './common/themeProvider/useTheme';
118
120
  export { default as TimeInput } from './common/timeInput';
119
121
  export * from './common/timeInput';
122
+ export { default as Timeline } from './common/timeline';
123
+ export * from './common/timeline';
120
124
  export { default as ToastProvider } from './common/toast';
121
125
  export * from './common/toast';
122
126
  export { default as Tooltip } from './common/tooltip';
package/docs/CLAUDE.md CHANGED
@@ -117,6 +117,7 @@ Slot keys per component are documented in each component's doc file below.
117
117
  @Dropdown.md
118
118
  @DropZone.md
119
119
  @EmptyState.md
120
+ @ErrorBoundary.md
120
121
  @Fab.md
121
122
  @FloatingMenu.md
122
123
  @FormValidator.md
@@ -154,6 +155,7 @@ Slot keys per component are documented in each component's doc file below.
154
155
  @Textarea.md
155
156
  @ThemeProvider.md
156
157
  @TimeInput.md
158
+ @Timeline.md
157
159
  @Toast.md
158
160
  @Tooltip.md
159
161
  @VirtualList.md
@@ -0,0 +1,93 @@
1
+ # ErrorBoundary
2
+
3
+ **When to use:** Wrap any subtree whose render errors shouldn't take down the rest of the app — a widget, a route, a third-party embed, a list row. It catches JavaScript errors thrown while rendering descendants (via React's `getDerivedStateFromError` / `componentDidCatch`) and swaps in a fallback UI instead of unmounting the whole tree. Only errors thrown during rendering, in lifecycle methods, or in constructors of the tree below it are caught — event handlers, async code, and errors in the boundary itself are not, same as any React error boundary.
4
+
5
+ **Import:** `import { ErrorBoundary } from '@ahrowe/ui'`
6
+
7
+ ```tsx
8
+ import { ErrorBoundary } from '@ahrowe/ui';
9
+
10
+ // Basic — default fallback with a "Try again" button
11
+ <ErrorBoundary>
12
+ <Widget />
13
+ </ErrorBoundary>
14
+
15
+ // Report to your error tracker, and re-arm whatever caused the crash on reset
16
+ <ErrorBoundary
17
+ onError={(error, errorInfo) => reportToSentry(error, errorInfo)}
18
+ onReset={() => setWidgetKey((k) => k + 1)}
19
+ >
20
+ <Widget />
21
+ </ErrorBoundary>
22
+
23
+ // Custom copy, no stack-trace section (e.g. for a small inline widget)
24
+ <ErrorBoundary
25
+ title="Widget crashed"
26
+ description="This panel failed to load. The rest of the page is unaffected."
27
+ hideStack
28
+ >
29
+ <Widget />
30
+ </ErrorBoundary>
31
+
32
+ // Fully custom fallback UI — render function receives the error and a reset callback
33
+ <ErrorBoundary
34
+ fallback={({ error, resetErrorBoundary }) => (
35
+ <div>
36
+ Something broke: {error.message}
37
+ <button onClick={resetErrorBoundary}>Retry</button>
38
+ </div>
39
+ )}
40
+ >
41
+ <Widget />
42
+ </ErrorBoundary>
43
+
44
+ // Auto-reset when inputs the crash depends on change (e.g. a route or record id)
45
+ <ErrorBoundary resetKeys={[recordId]}>
46
+ <RecordDetail id={recordId} />
47
+ </ErrorBoundary>
48
+
49
+ // Translated strings for a non-English app — title/description are their own
50
+ // props (see above); labels covers everything else the built-in fallback renders
51
+ <ErrorBoundary
52
+ title="Etwas ist schiefgelaufen"
53
+ labels={{
54
+ stackTrace: 'Stapelverfolgung',
55
+ copy: 'Kopieren',
56
+ copied: 'Kopiert',
57
+ reset: 'Erneut versuchen',
58
+ copySuccessToast: 'Stapelverfolgung kopiert',
59
+ copyErrorToast: 'Kopieren fehlgeschlagen',
60
+ }}
61
+ >
62
+ <Widget />
63
+ </ErrorBoundary>
64
+ ```
65
+
66
+ The default fallback is a centered card with a tinted icon badge, an `Accordion` for the collapsible stack trace (with a labeled copy `Button` that fires a `Toast` confirmation if `ToastProvider` is mounted), and a `Button` for "Try again".
67
+
68
+ **Key props:**
69
+
70
+ | Prop | Type | Description |
71
+ |------|------|-------------|
72
+ | `children` | `ReactNode` | The subtree to protect |
73
+ | `title` | `ReactNode` | Heading in the default fallback (default `"Something went wrong"`) |
74
+ | `description` | `ReactNode` | Overrides the message shown under the title. Defaults to `error.message` |
75
+ | `labels` | `ErrorBoundaryLabels` | Overrides for every other built-in string (stack trace label, copy/reset button text, copy toasts) — pass a translated set for non-English apps. See below |
76
+ | `fallback` | `ReactNode \| (props: ErrorBoundaryFallbackProps) => ReactNode` | Replaces the built-in fallback entirely. The render-function form receives `{ error, errorInfo, resetErrorBoundary }` |
77
+ | `onError` | `(error: Error, errorInfo: ErrorInfo) => void` | Called once per catch — the place to report to Sentry/Datadog/etc. |
78
+ | `onReset` | `() => void` | Called after the boundary is reset, whether via the built-in "Try again" button, a custom fallback calling `resetErrorBoundary`, or a `resetKeys` change. Use it to re-arm whatever state caused the crash |
79
+ | `resetKeys` | `unknown[]` | When any value in this array changes while the boundary is showing an error, it resets automatically — mirrors the common `react-error-boundary` pattern |
80
+ | `hideStack` | `boolean` | Hide the collapsible stack-trace section. Default `false` |
81
+
82
+ **`ErrorBoundaryLabels`:**
83
+
84
+ | Field | Default | Description |
85
+ |-------|---------|--------------|
86
+ | `stackTrace` | `"Stack trace"` | Accordion header for the stack trace section |
87
+ | `copy` | `"Copy"` | Copy button label before copying |
88
+ | `copied` | `"Copied"` | Copy button label right after a successful copy |
89
+ | `reset` | `"Try again"` | Reset button label |
90
+ | `copySuccessToast` | `"Stack trace copied to clipboard"` | Toast shown after a successful copy (only visible if `ToastProvider` is mounted) |
91
+ | `copyErrorToast` | `"Could not copy stack trace"` | Toast shown if the copy fails |
92
+
93
+ **Slots:** `root` `header` `badge` `icon` `title` `description` `stack` `stackHeader` `stackLabel` `stackTrace` `copyButton` `actions` `resetButton`
@@ -34,22 +34,183 @@ 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
+ />
138
+
139
+ // Bound each column's height and let its card list scroll independently, instead
140
+ // of growing the whole page — useful once a column can hold a lot of cards
141
+ <KanbanBoard
142
+ columns={columns}
143
+ items={items}
144
+ renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
145
+ columnMaxHeight="70vh"
146
+ onChange={(updatedColumns) => setColumns(updatedColumns)}
147
+ />
148
+
149
+ // Paginate a column instead of loading everything up front — mark it `hasMore`
150
+ // and fetch/append the next page in `onLoadMore` once it's scrolled near the
151
+ // bottom. Requires `columnMaxHeight`, since an unbounded column never scrolls.
152
+ // KanbanBoard tracks its own in-flight state per column (shown as a spinner at
153
+ // the bottom of the list) and retries automatically if a page doesn't fill the
154
+ // column's visible height, so pagination doesn't dead-end on a short first batch.
155
+ const columnsPaged: KanbanColumnDef[] = [
156
+ { id: 'done', title: 'Done', itemIds: doneTaskIds, hasMore: doneHasMore },
157
+ ];
158
+
159
+ <KanbanBoard
160
+ columns={columnsPaged}
161
+ items={items}
162
+ renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
163
+ columnMaxHeight="70vh"
164
+ onLoadMore={async (columnId) => {
165
+ const nextPage = await fetchNextPage(columnId);
166
+ appendItemsToColumn(columnId, nextPage); // your own state update
167
+ }}
168
+ onChange={(updatedColumns) => setColumns(updatedColumns)}
169
+ />
37
170
  ```
38
171
 
172
+ **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).
173
+
174
+ **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.
175
+
176
+ **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).
177
+
39
178
  **KanbanBoardProps:**
40
179
 
41
180
  | Prop | Type | Description |
42
181
  |------|------|-------------|
43
- | `columns` | `KanbanColumnDef[]` | Column definitions with `id`, `title`, `itemIds` |
182
+ | `columns` | `KanbanColumnDef[]` | Column definitions with `id`, `title`, `itemIds`, optional `maxItems` |
44
183
  | `items` | `Record<string, T>` | Flat map of all items keyed by id |
45
184
  | `renderItem` | `(item: T, id: string) => ReactNode` | Render function for each item |
46
- | `onChange` | `(columns: KanbanColumnDef[]) => void` | Called after every drop — replace state with this value |
185
+ | `onChange` | `(columns: KanbanColumnDef[]) => void` | Called after a drop that actually changes column or order — replace state with this value |
186
+ | `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 |
187
+ | `getItemLabel` | `(item: T, id: string) => string` | Accessible label for a card, used in screen-reader drag announcements. Defaults to the card's id |
188
+ | `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 |
189
+ | `renderEmptyColumn` | `(column: KanbanColumnDef) => ReactNode` | Custom content shown inside a column when it has no cards, instead of leaving it blank |
190
+ | `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) |
191
+ | `reorderableColumns` | `boolean` | Allow whole columns to be reordered by dragging their header, alongside card dragging (default `false`) |
192
+ | `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` |
193
+ | `columnMaxHeight` | `string` | Bounds each column's height (any CSS length, e.g. `'70vh'`) and makes its card list scroll independently once content overflows, instead of growing the whole page. Unset by default (unbounded height). Required for `onLoadMore` to ever trigger |
194
+ | `onLoadMore` | `(columnId: string) => void \| Promise<void>` | Called when a column with `hasMore` is scrolled within `loadMoreThreshold` of the bottom — fetch and append the next page for that column. Tracked per column, so overlapping calls for the same column are never made concurrently; retried automatically if a loaded batch doesn't fill the column's own visible height |
195
+ | `loadMoreThreshold` | `number` | Distance in px from the bottom of a column's scroll area that triggers `onLoadMore` (default `100`, matching `VirtualList`) |
196
+ | `id` / `role` / `aria-*` / `data-*` | | Passed through to the root element |
197
+
198
+ `KanbanColumn`'s own props accept the same `id` / `role` / `aria-*` / `data-*` pass-through on its root element.
47
199
 
48
200
  **KanbanColumnDef:**
49
201
 
50
202
  ```ts
51
- { id: string; title: string; itemIds: string[] }
203
+ {
204
+ id: string;
205
+ title: string;
206
+ itemIds: string[];
207
+ maxItems?: number; // work-in-progress limit — display-only, see above
208
+ disabled?: boolean; // freezes the column — cards inside can't be dragged, nothing can drop in
209
+ hasMore?: boolean; // more items exist beyond itemIds — enables onLoadMore, see above
210
+ }
52
211
  ```
53
212
 
213
+ **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.
214
+
54
215
  **Slots (KanbanBoard):** `dragOverlay`
55
- **Slots (KanbanColumn):** `header` `items`
216
+ **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) `loadingIndicator` (the spinner shown while an `onLoadMore` request for that column is in flight)