@pixpilot/shadcn-kanban 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,14 +32,31 @@ assign `event.items` back to your state and the board settles.
32
32
 
33
33
  ## Documentation
34
34
 
35
- | Document | Covers |
36
- | ---------------------------------------------------- | -------------------------------------------------------------- |
37
- | [docs/README.md](./docs/README.md) | Overview, layout contract and generics |
38
- | [docs/api.md](./docs/api.md) | Every prop, type and callback |
39
- | [docs/drag-and-drop.md](./docs/drag-and-drop.md) | How the drag state machine works, and why it is built this way |
40
- | [docs/filters.md](./docs/filters.md) | Per-column filters, board-side and parent-side |
41
- | [docs/infinite-scroll.md](./docs/infinite-scroll.md) | Loading a column's items a page at a time |
42
- | [docs/virtualization.md](./docs/virtualization.md) | Mounting only the cards near a column's scroll window |
35
+ | Document | Covers |
36
+ | ------------------------------------------------------ | -------------------------------------------------------------- |
37
+ | [docs/README.md](./docs/README.md) | Overview, layout contract and generics |
38
+ | [docs/api.md](./docs/api.md) | Every prop, type and callback |
39
+ | [docs/drag-and-drop.md](./docs/drag-and-drop.md) | How the drag state machine works, and why it is built this way |
40
+ | [docs/filters.md](./docs/filters.md) | Per-column filters, board-side and parent-side |
41
+ | [docs/infinite-scroll.md](./docs/infinite-scroll.md) | Loading a column's items a page at a time |
42
+ | [docs/virtualization.md](./docs/virtualization.md) | Mounting only the cards near a column's scroll window |
43
+ | [docs/touch-and-mobile.md](./docs/touch-and-mobile.md) | Hold-to-drag on touch, and columns as a swipeable slider |
44
+
45
+ ## On a phone
46
+
47
+ Out of the box, and without any prop:
48
+
49
+ - **Swiping wins over dragging.** A card only becomes draggable after the finger
50
+ has rested on it for 250 ms, and rings while it is held. Anything shorter
51
+ stays a scroll, so reaching for the next column no longer drags a card along
52
+ with it.
53
+ - **Columns behave like a slider.** Below the `sm` breakpoint each column is a
54
+ CSS scroll-snap child roughly a screen wide, so a swipe moves exactly one
55
+ column and settles with the platform's own momentum.
56
+
57
+ Both are tunable — `touch` and `columnSnap` — and both are off the table for
58
+ mouse and keyboard, which are unchanged. See
59
+ [docs/touch-and-mobile.md](./docs/touch-and-mobile.md).
43
60
 
44
61
  ## License
45
62
 
@@ -8,6 +8,7 @@ const require_use_kanban_drag = require('./hooks/use-kanban-drag.cjs');
8
8
  const require_use_kanban_filters = require('./hooks/use-kanban-filters.cjs');
9
9
  const require_KanbanColumn = require('./KanbanColumn.cjs');
10
10
  const require_KanbanDragOverlay = require('./KanbanDragOverlay.cjs');
11
+ const require_resolve_column_snap = require('./utils/resolve-column-snap.cjs');
11
12
  let __dnd_kit_core = require("@dnd-kit/core");
12
13
  __dnd_kit_core = require_rolldown_runtime.__toESM(__dnd_kit_core);
13
14
  let __dnd_kit_sortable = require("@dnd-kit/sortable");
@@ -47,7 +48,7 @@ function useScrollOnlyFeatureWarning(feature, consequence, requested, columnOver
47
48
  * column definitions and calls `onChange` whenever an item is moved
48
49
  * between (or within) columns.
49
50
  */
50
- function KanbanBoard({ items: externalItems, columns, onChange, renderItem, renderColumnHeader, className, style, columnClassName, getColumnProps, hideColumnHeaders = false, itemClassName, columnOverflow = "scroll", allowAddColumn, onAddColumn, onColumnChange, filters, onFilterChange, infiniteScroll, virtualization, dragDisabled = false }) {
51
+ function KanbanBoard({ items: externalItems, columns, onChange, renderItem, renderColumnHeader, className, style, columnClassName, getColumnProps, hideColumnHeaders = false, itemClassName, columnOverflow = "scroll", allowAddColumn, onAddColumn, onColumnChange, filters, onFilterChange, infiniteScroll, virtualization, dragDisabled = false, touch, columnSnap = true }) {
51
52
  useScrollOnlyFeatureWarning("infiniteScroll", "The sentinel will not be rendered.", Boolean(infiniteScroll), columnOverflow);
52
53
  useScrollOnlyFeatureWarning("virtualization", "Every card will be rendered.", Boolean(virtualization), columnOverflow);
53
54
  const activeInfiniteScroll = columnOverflow === "scroll" ? infiniteScroll : void 0;
@@ -56,8 +57,10 @@ function KanbanBoard({ items: externalItems, columns, onChange, renderItem, rend
56
57
  externalItems,
57
58
  columns,
58
59
  onChange,
59
- onColumnChange
60
+ onColumnChange,
61
+ touch
60
62
  });
63
+ const snap = require_resolve_column_snap.resolveColumnSnap(columnSnap, activeId !== null);
61
64
  const { activeFilters, resolveFilters, handleToggleFilter, handleClearFilters } = require_use_kanban_filters.useKanbanFilters({
62
65
  filters,
63
66
  onFilterChange
@@ -98,14 +101,18 @@ function KanbanBoard({ items: externalItems, columns, onChange, renderItem, rend
98
101
  strategy: __dnd_kit_sortable.horizontalListSortingStrategy,
99
102
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
100
103
  "data-testid": "kanban-board",
101
- className: (0, __pixpilot_shadcn_ui.cn)("relative flex gap-4 overflow-x-auto overscroll-x-contain p-2", columnOverflow === "scroll" ? "h-full min-h-0" : "min-h-full", className),
102
- style,
104
+ "data-snapping": snap.enabled ? "" : void 0,
105
+ className: (0, __pixpilot_shadcn_ui.cn)("relative flex gap-4 overflow-x-auto overscroll-x-contain p-2", columnOverflow === "scroll" ? "h-full min-h-0" : "min-h-full", snap.boardClassName, className),
106
+ style: {
107
+ ...snap.style,
108
+ ...style
109
+ },
103
110
  children: [columnItemsMap.map(({ column, colItems, colFilters, activeIds }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_KanbanColumn.KanbanColumn, {
104
111
  column,
105
112
  items: colItems,
106
113
  renderItem,
107
114
  renderColumnHeader,
108
- columnClassName,
115
+ columnClassName: (0, __pixpilot_shadcn_ui.cn)(snap.columnClassName, columnClassName),
109
116
  containerProps: getColumnProps?.(column),
110
117
  hideHeader: hideColumnHeaders,
111
118
  itemClassName,
@@ -116,10 +123,14 @@ function KanbanBoard({ items: externalItems, columns, onChange, renderItem, rend
116
123
  infiniteScroll: activeInfiniteScroll,
117
124
  virtualization: activeVirtualization,
118
125
  dragDisabled,
126
+ touch,
119
127
  activeItemId: activeItem?.id ?? null,
120
128
  onToggleFilter: (filterId) => handleToggleFilter(column, filterId),
121
129
  onClearFilters: () => handleClearFilters(column)
122
- }, column.id)), allowAddColumn && onAddColumn ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_AddColumnButton.AddColumnButton, { onAdd: onAddColumn }) : null]
130
+ }, column.id)), allowAddColumn && onAddColumn ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_AddColumnButton.AddColumnButton, {
131
+ onAdd: onAddColumn,
132
+ className: snap.columnClassName
133
+ }) : null]
123
134
  })
124
135
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_KanbanDragOverlay.KanbanDragOverlay, {
125
136
  activeItem,
@@ -30,7 +30,9 @@ declare function KanbanBoard<T = Record<string, unknown>>({
30
30
  onFilterChange,
31
31
  infiniteScroll,
32
32
  virtualization,
33
- dragDisabled
33
+ dragDisabled,
34
+ touch,
35
+ columnSnap
34
36
  }: KanbanBoardProps<T>): react_jsx_runtime0.JSX.Element;
35
37
  //#endregion
36
38
  export { KanbanBoard };
@@ -30,7 +30,9 @@ declare function KanbanBoard<T = Record<string, unknown>>({
30
30
  onFilterChange,
31
31
  infiniteScroll,
32
32
  virtualization,
33
- dragDisabled
33
+ dragDisabled,
34
+ touch,
35
+ columnSnap
34
36
  }: KanbanBoardProps<T>): react_jsx_runtime0.JSX.Element;
35
37
  //#endregion
36
38
  export { KanbanBoard };
@@ -7,6 +7,7 @@ import { useKanbanDrag } from "./hooks/use-kanban-drag.js";
7
7
  import { useKanbanFilters } from "./hooks/use-kanban-filters.js";
8
8
  import { KanbanColumn } from "./KanbanColumn.js";
9
9
  import { KanbanDragOverlay } from "./KanbanDragOverlay.js";
10
+ import { resolveColumnSnap } from "./utils/resolve-column-snap.js";
10
11
  import { DndContext, MeasuringStrategy } from "@dnd-kit/core";
11
12
  import { SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
12
13
  import { cn } from "@pixpilot/shadcn-ui";
@@ -41,7 +42,7 @@ function useScrollOnlyFeatureWarning(feature, consequence, requested, columnOver
41
42
  * column definitions and calls `onChange` whenever an item is moved
42
43
  * between (or within) columns.
43
44
  */
44
- function KanbanBoard({ items: externalItems, columns, onChange, renderItem, renderColumnHeader, className, style, columnClassName, getColumnProps, hideColumnHeaders = false, itemClassName, columnOverflow = "scroll", allowAddColumn, onAddColumn, onColumnChange, filters, onFilterChange, infiniteScroll, virtualization, dragDisabled = false }) {
45
+ function KanbanBoard({ items: externalItems, columns, onChange, renderItem, renderColumnHeader, className, style, columnClassName, getColumnProps, hideColumnHeaders = false, itemClassName, columnOverflow = "scroll", allowAddColumn, onAddColumn, onColumnChange, filters, onFilterChange, infiniteScroll, virtualization, dragDisabled = false, touch, columnSnap = true }) {
45
46
  useScrollOnlyFeatureWarning("infiniteScroll", "The sentinel will not be rendered.", Boolean(infiniteScroll), columnOverflow);
46
47
  useScrollOnlyFeatureWarning("virtualization", "Every card will be rendered.", Boolean(virtualization), columnOverflow);
47
48
  const activeInfiniteScroll = columnOverflow === "scroll" ? infiniteScroll : void 0;
@@ -50,8 +51,10 @@ function KanbanBoard({ items: externalItems, columns, onChange, renderItem, rend
50
51
  externalItems,
51
52
  columns,
52
53
  onChange,
53
- onColumnChange
54
+ onColumnChange,
55
+ touch
54
56
  });
57
+ const snap = resolveColumnSnap(columnSnap, activeId !== null);
55
58
  const { activeFilters, resolveFilters, handleToggleFilter, handleClearFilters } = useKanbanFilters({
56
59
  filters,
57
60
  onFilterChange
@@ -92,14 +95,18 @@ function KanbanBoard({ items: externalItems, columns, onChange, renderItem, rend
92
95
  strategy: horizontalListSortingStrategy,
93
96
  children: /* @__PURE__ */ jsxs("div", {
94
97
  "data-testid": "kanban-board",
95
- className: cn("relative flex gap-4 overflow-x-auto overscroll-x-contain p-2", columnOverflow === "scroll" ? "h-full min-h-0" : "min-h-full", className),
96
- style,
98
+ "data-snapping": snap.enabled ? "" : void 0,
99
+ className: cn("relative flex gap-4 overflow-x-auto overscroll-x-contain p-2", columnOverflow === "scroll" ? "h-full min-h-0" : "min-h-full", snap.boardClassName, className),
100
+ style: {
101
+ ...snap.style,
102
+ ...style
103
+ },
97
104
  children: [columnItemsMap.map(({ column, colItems, colFilters, activeIds }) => /* @__PURE__ */ jsx(KanbanColumn, {
98
105
  column,
99
106
  items: colItems,
100
107
  renderItem,
101
108
  renderColumnHeader,
102
- columnClassName,
109
+ columnClassName: cn(snap.columnClassName, columnClassName),
103
110
  containerProps: getColumnProps?.(column),
104
111
  hideHeader: hideColumnHeaders,
105
112
  itemClassName,
@@ -110,10 +117,14 @@ function KanbanBoard({ items: externalItems, columns, onChange, renderItem, rend
110
117
  infiniteScroll: activeInfiniteScroll,
111
118
  virtualization: activeVirtualization,
112
119
  dragDisabled,
120
+ touch,
113
121
  activeItemId: activeItem?.id ?? null,
114
122
  onToggleFilter: (filterId) => handleToggleFilter(column, filterId),
115
123
  onClearFilters: () => handleClearFilters(column)
116
- }, column.id)), allowAddColumn && onAddColumn ? /* @__PURE__ */ jsx(AddColumnButton, { onAdd: onAddColumn }) : null]
124
+ }, column.id)), allowAddColumn && onAddColumn ? /* @__PURE__ */ jsx(AddColumnButton, {
125
+ onAdd: onAddColumn,
126
+ className: snap.columnClassName
127
+ }) : null]
117
128
  })
118
129
  }), /* @__PURE__ */ jsx(KanbanDragOverlay, {
119
130
  activeItem,
@@ -3,6 +3,7 @@
3
3
 
4
4
  const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
5
5
  const require_column_sortable_id = require('./utils/column-sortable-id.cjs');
6
+ const require_kanban_touch_defaults = require('./utils/kanban-touch-defaults.cjs');
6
7
  const require_ColumnFilterButton = require('./ColumnFilterButton.cjs');
7
8
  const require_InfiniteScrollSentinel = require('./infinite-scroll/InfiniteScrollSentinel.cjs');
8
9
  const require_KanbanColumnCards = require('./KanbanColumnCards.cjs');
@@ -23,7 +24,7 @@ let __dnd_kit_utilities = require("@dnd-kit/utilities");
23
24
  __dnd_kit_utilities = require_rolldown_runtime.__toESM(__dnd_kit_utilities);
24
25
 
25
26
  //#region src/KanbanColumn.tsx
26
- function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnClassName, itemClassName, containerProps, hideHeader = false, sortable = false, filters, activeFilterIds, columnOverflow, infiniteScroll, virtualization, dragDisabled = false, activeItemId = null, onToggleFilter, onClearFilters }) {
27
+ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnClassName, itemClassName, containerProps, hideHeader = false, sortable = false, filters, activeFilterIds, columnOverflow, infiniteScroll, virtualization, dragDisabled = false, touch, activeItemId = null, onToggleFilter, onClearFilters }) {
27
28
  const { className: containerClassName, style: containerStyle,...containerAttributes } = containerProps ?? {};
28
29
  const filterButton = filters && filters.length > 0 && onToggleFilter && onClearFilters ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ColumnFilterButton.ColumnFilterButton, {
29
30
  filters,
@@ -58,6 +59,7 @@ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnCla
58
59
  }, [setDroppableRef]);
59
60
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
60
61
  ref: setSortableRef,
62
+ "data-testid": `kanban-column-${column.id}`,
61
63
  style: {
62
64
  ...containerStyle,
63
65
  ...style
@@ -70,7 +72,9 @@ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnCla
70
72
  children: [
71
73
  sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
72
74
  type: "button",
73
- className: "text-muted-foreground hover:text-foreground cursor-grab touch-none active:cursor-grabbing",
75
+ "aria-label": `Reorder ${column.title} column`,
76
+ className: "text-muted-foreground hover:text-foreground -m-1 cursor-grab touch-none p-1 active:cursor-grabbing",
77
+ ...require_kanban_touch_defaults.dragHandleProps,
74
78
  ...listeners,
75
79
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.GripVertical, { className: "h-4 w-4" })
76
80
  }) : null,
@@ -86,7 +90,9 @@ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnCla
86
90
  className: "flex items-center gap-1",
87
91
  children: [sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
88
92
  type: "button",
89
- className: "text-muted-foreground hover:text-foreground cursor-grab touch-none active:cursor-grabbing",
93
+ "aria-label": `Reorder ${column.title} column`,
94
+ className: "text-muted-foreground hover:text-foreground -m-1 cursor-grab touch-none p-1 active:cursor-grabbing",
95
+ ...require_kanban_touch_defaults.dragHandleProps,
90
96
  ...listeners,
91
97
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.GripVertical, { className: "h-4 w-4" })
92
98
  }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
@@ -115,13 +121,15 @@ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnCla
115
121
  scroller,
116
122
  activeItemId,
117
123
  options: virtualization,
118
- dragDisabled
124
+ dragDisabled,
125
+ touch
119
126
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_KanbanColumnCards.KanbanColumnCards, {
120
127
  column,
121
128
  items,
122
129
  renderItem,
123
130
  itemClassName,
124
- dragDisabled
131
+ dragDisabled,
132
+ touch
125
133
  }), infiniteScroll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_InfiniteScrollSentinel.InfiniteScrollSentinel, {
126
134
  root: scroller,
127
135
  distance: infiniteScroll.distance,
@@ -2,6 +2,7 @@
2
2
 
3
3
 
4
4
  import { toColumnSortableId } from "./utils/column-sortable-id.js";
5
+ import { dragHandleProps } from "./utils/kanban-touch-defaults.js";
5
6
  import { ColumnFilterButton } from "./ColumnFilterButton.js";
6
7
  import { InfiniteScrollSentinel } from "./infinite-scroll/InfiniteScrollSentinel.js";
7
8
  import { KanbanColumnCards } from "./KanbanColumnCards.js";
@@ -15,7 +16,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
15
16
  import { CSS } from "@dnd-kit/utilities";
16
17
 
17
18
  //#region src/KanbanColumn.tsx
18
- function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnClassName, itemClassName, containerProps, hideHeader = false, sortable = false, filters, activeFilterIds, columnOverflow, infiniteScroll, virtualization, dragDisabled = false, activeItemId = null, onToggleFilter, onClearFilters }) {
19
+ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnClassName, itemClassName, containerProps, hideHeader = false, sortable = false, filters, activeFilterIds, columnOverflow, infiniteScroll, virtualization, dragDisabled = false, touch, activeItemId = null, onToggleFilter, onClearFilters }) {
19
20
  const { className: containerClassName, style: containerStyle,...containerAttributes } = containerProps ?? {};
20
21
  const filterButton = filters && filters.length > 0 && onToggleFilter && onClearFilters ? /* @__PURE__ */ jsx(ColumnFilterButton, {
21
22
  filters,
@@ -50,6 +51,7 @@ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnCla
50
51
  }, [setDroppableRef]);
51
52
  return /* @__PURE__ */ jsxs("div", {
52
53
  ref: setSortableRef,
54
+ "data-testid": `kanban-column-${column.id}`,
53
55
  style: {
54
56
  ...containerStyle,
55
57
  ...style
@@ -62,7 +64,9 @@ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnCla
62
64
  children: [
63
65
  sortable ? /* @__PURE__ */ jsx("button", {
64
66
  type: "button",
65
- className: "text-muted-foreground hover:text-foreground cursor-grab touch-none active:cursor-grabbing",
67
+ "aria-label": `Reorder ${column.title} column`,
68
+ className: "text-muted-foreground hover:text-foreground -m-1 cursor-grab touch-none p-1 active:cursor-grabbing",
69
+ ...dragHandleProps,
66
70
  ...listeners,
67
71
  children: /* @__PURE__ */ jsx(GripVertical, { className: "h-4 w-4" })
68
72
  }) : null,
@@ -78,7 +82,9 @@ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnCla
78
82
  className: "flex items-center gap-1",
79
83
  children: [sortable ? /* @__PURE__ */ jsx("button", {
80
84
  type: "button",
81
- className: "text-muted-foreground hover:text-foreground cursor-grab touch-none active:cursor-grabbing",
85
+ "aria-label": `Reorder ${column.title} column`,
86
+ className: "text-muted-foreground hover:text-foreground -m-1 cursor-grab touch-none p-1 active:cursor-grabbing",
87
+ ...dragHandleProps,
82
88
  ...listeners,
83
89
  children: /* @__PURE__ */ jsx(GripVertical, { className: "h-4 w-4" })
84
90
  }) : null, /* @__PURE__ */ jsx("h3", {
@@ -107,13 +113,15 @@ function KanbanColumn({ column, items, renderItem, renderColumnHeader, columnCla
107
113
  scroller,
108
114
  activeItemId,
109
115
  options: virtualization,
110
- dragDisabled
116
+ dragDisabled,
117
+ touch
111
118
  }) : /* @__PURE__ */ jsx(KanbanColumnCards, {
112
119
  column,
113
120
  items,
114
121
  renderItem,
115
122
  itemClassName,
116
- dragDisabled
123
+ dragDisabled,
124
+ touch
117
125
  }), infiniteScroll ? /* @__PURE__ */ jsx(InfiniteScrollSentinel, {
118
126
  root: scroller,
119
127
  distance: infiniteScroll.distance,
@@ -11,13 +11,14 @@ react_jsx_runtime = require_rolldown_runtime.__toESM(react_jsx_runtime);
11
11
  * The card list of a column, rendered in full. Each card is a flex child of
12
12
  * the column's scroller, so spacing comes from the scroller's `gap`.
13
13
  */
14
- function KanbanColumnCards({ column, items, renderItem, itemClassName, dragDisabled = false }) {
14
+ function KanbanColumnCards({ column, items, renderItem, itemClassName, dragDisabled = false, touch }) {
15
15
  return items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_KanbanItem.KanbanItem, {
16
16
  item,
17
17
  column,
18
18
  renderItem,
19
19
  className: itemClassName,
20
- dragDisabled
20
+ dragDisabled,
21
+ touch
21
22
  }, item.id));
22
23
  }
23
24
 
@@ -9,13 +9,14 @@ import { jsx } from "react/jsx-runtime";
9
9
  * The card list of a column, rendered in full. Each card is a flex child of
10
10
  * the column's scroller, so spacing comes from the scroller's `gap`.
11
11
  */
12
- function KanbanColumnCards({ column, items, renderItem, itemClassName, dragDisabled = false }) {
12
+ function KanbanColumnCards({ column, items, renderItem, itemClassName, dragDisabled = false, touch }) {
13
13
  return items.map((item) => /* @__PURE__ */ jsx(KanbanItem, {
14
14
  item,
15
15
  column,
16
16
  renderItem,
17
17
  className: itemClassName,
18
- dragDisabled
18
+ dragDisabled,
19
+ touch
19
20
  }, item.id));
20
21
  }
21
22
 
@@ -2,6 +2,7 @@
2
2
 
3
3
 
4
4
  const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
5
+ const require_use_kanban_press_feedback = require('./hooks/use-kanban-press-feedback.cjs');
5
6
  let __dnd_kit_sortable = require("@dnd-kit/sortable");
6
7
  __dnd_kit_sortable = require_rolldown_runtime.__toESM(__dnd_kit_sortable);
7
8
  let __pixpilot_shadcn_ui = require("@pixpilot/shadcn-ui");
@@ -21,11 +22,24 @@ function KanbanItemContent({ item, column, renderItem }) {
21
22
  });
22
23
  }
23
24
  const MemoizedKanbanItemContent = react.default.memo(KanbanItemContent);
25
+ /** Runs the sortable's own touch listener first, then the feedback one. */
26
+ function chainTouchHandlers(listeners, pressHandlers) {
27
+ const chained = {};
28
+ for (const key of Object.keys(pressHandlers)) {
29
+ const existing = listeners?.[key];
30
+ const added = pressHandlers[key];
31
+ chained[key] = (event) => {
32
+ existing?.(event);
33
+ added(event);
34
+ };
35
+ }
36
+ return chained;
37
+ }
24
38
  /**
25
39
  * A single draggable item inside a Kanban column.
26
40
  * Wraps `useSortable` from `@dnd-kit/sortable`.
27
41
  */
28
- function KanbanItem({ item, column, renderItem, className, dragDisabled = false }) {
42
+ function KanbanItem({ item, column, renderItem, className, dragDisabled = false, touch }) {
29
43
  const sortableData = react.default.useMemo(() => ({
30
44
  type: "item",
31
45
  item
@@ -35,6 +49,12 @@ function KanbanItem({ item, column, renderItem, className, dragDisabled = false
35
49
  data: sortableData,
36
50
  disabled: dragDisabled
37
51
  });
52
+ const { isPressing, handlers: pressHandlers } = require_use_kanban_press_feedback.useKanbanPressFeedback({
53
+ delay: touch?.dragActivationDelay,
54
+ tolerance: touch?.dragActivationTolerance,
55
+ disabled: dragDisabled || touch?.pressFeedback === false
56
+ });
57
+ const touchHandlers = react.default.useMemo(() => chainTouchHandlers(listeners, pressHandlers), [listeners, pressHandlers]);
38
58
  const style = {
39
59
  transform: __dnd_kit_utilities.CSS.Transform.toString(transform),
40
60
  transition: transition ?? void 0
@@ -42,10 +62,12 @@ function KanbanItem({ item, column, renderItem, className, dragDisabled = false
42
62
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
43
63
  ref: setNodeRef,
44
64
  "data-testid": `kanban-item-${item.id}`,
65
+ "data-pressing": isPressing ? "" : void 0,
45
66
  style,
46
- className: (0, __pixpilot_shadcn_ui.cn)("bg-background touch-none rounded-md border p-3 shadow-sm transition-shadow", "hover:shadow-md", dragDisabled ? "cursor-default" : "cursor-grab active:cursor-grabbing", isDragging && "z-50 opacity-50 shadow-lg", className),
67
+ className: (0, __pixpilot_shadcn_ui.cn)("bg-background touch-manipulation rounded-md border p-3 shadow-sm select-none", "[-webkit-touch-callout:none]", "transition-[box-shadow,scale] hover:shadow-md", dragDisabled ? "cursor-default" : "cursor-grab active:cursor-grabbing", isPressing && "ring-primary/40 scale-[1.02] shadow-md ring-2", isDragging && "z-50 opacity-50 shadow-lg", className),
47
68
  ...attributes,
48
69
  ...listeners,
70
+ ...touchHandlers,
49
71
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemoizedKanbanItemContent, {
50
72
  item,
51
73
  column,
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
 
4
+ import { useKanbanPressFeedback } from "./hooks/use-kanban-press-feedback.js";
4
5
  import { useSortable } from "@dnd-kit/sortable";
5
6
  import { cn } from "@pixpilot/shadcn-ui";
6
7
  import React from "react";
@@ -15,11 +16,24 @@ function KanbanItemContent({ item, column, renderItem }) {
15
16
  });
16
17
  }
17
18
  const MemoizedKanbanItemContent = React.memo(KanbanItemContent);
19
+ /** Runs the sortable's own touch listener first, then the feedback one. */
20
+ function chainTouchHandlers(listeners, pressHandlers) {
21
+ const chained = {};
22
+ for (const key of Object.keys(pressHandlers)) {
23
+ const existing = listeners?.[key];
24
+ const added = pressHandlers[key];
25
+ chained[key] = (event) => {
26
+ existing?.(event);
27
+ added(event);
28
+ };
29
+ }
30
+ return chained;
31
+ }
18
32
  /**
19
33
  * A single draggable item inside a Kanban column.
20
34
  * Wraps `useSortable` from `@dnd-kit/sortable`.
21
35
  */
22
- function KanbanItem({ item, column, renderItem, className, dragDisabled = false }) {
36
+ function KanbanItem({ item, column, renderItem, className, dragDisabled = false, touch }) {
23
37
  const sortableData = React.useMemo(() => ({
24
38
  type: "item",
25
39
  item
@@ -29,6 +43,12 @@ function KanbanItem({ item, column, renderItem, className, dragDisabled = false
29
43
  data: sortableData,
30
44
  disabled: dragDisabled
31
45
  });
46
+ const { isPressing, handlers: pressHandlers } = useKanbanPressFeedback({
47
+ delay: touch?.dragActivationDelay,
48
+ tolerance: touch?.dragActivationTolerance,
49
+ disabled: dragDisabled || touch?.pressFeedback === false
50
+ });
51
+ const touchHandlers = React.useMemo(() => chainTouchHandlers(listeners, pressHandlers), [listeners, pressHandlers]);
32
52
  const style = {
33
53
  transform: CSS.Transform.toString(transform),
34
54
  transition: transition ?? void 0
@@ -36,10 +56,12 @@ function KanbanItem({ item, column, renderItem, className, dragDisabled = false
36
56
  return /* @__PURE__ */ jsx("div", {
37
57
  ref: setNodeRef,
38
58
  "data-testid": `kanban-item-${item.id}`,
59
+ "data-pressing": isPressing ? "" : void 0,
39
60
  style,
40
- className: cn("bg-background touch-none rounded-md border p-3 shadow-sm transition-shadow", "hover:shadow-md", dragDisabled ? "cursor-default" : "cursor-grab active:cursor-grabbing", isDragging && "z-50 opacity-50 shadow-lg", className),
61
+ className: cn("bg-background touch-manipulation rounded-md border p-3 shadow-sm select-none", "[-webkit-touch-callout:none]", "transition-[box-shadow,scale] hover:shadow-md", dragDisabled ? "cursor-default" : "cursor-grab active:cursor-grabbing", isPressing && "ring-primary/40 scale-[1.02] shadow-md ring-2", isDragging && "z-50 opacity-50 shadow-lg", className),
41
62
  ...attributes,
42
63
  ...listeners,
64
+ ...touchHandlers,
43
65
  children: /* @__PURE__ */ jsx(MemoizedKanbanItemContent, {
44
66
  item,
45
67
  column,
@@ -24,7 +24,7 @@ const DEFAULT_GAP = 8;
24
24
  * wrapper carries the virtual offset, the card inside carries whatever
25
25
  * dnd-kit's sortable is animating.
26
26
  */
27
- function KanbanVirtualColumnCards({ column, items, renderItem, itemClassName, scroller, activeItemId, options, dragDisabled = false }) {
27
+ function KanbanVirtualColumnCards({ column, items, renderItem, itemClassName, scroller, activeItemId, options, dragDisabled = false, touch }) {
28
28
  const { estimateItemHeight = DEFAULT_ESTIMATED_ITEM_HEIGHT, overscan = DEFAULT_OVERSCAN, gap = DEFAULT_GAP } = options;
29
29
  const activeIndex = react.default.useMemo(() => activeItemId == null ? -1 : items.findIndex((item) => item.id === String(activeItemId)), [items, activeItemId]);
30
30
  const rangeExtractor = react.default.useCallback((range) => {
@@ -59,7 +59,8 @@ function KanbanVirtualColumnCards({ column, items, renderItem, itemClassName, sc
59
59
  column,
60
60
  renderItem,
61
61
  className: itemClassName,
62
- dragDisabled
62
+ dragDisabled,
63
+ touch
63
64
  })
64
65
  }, virtualItem.key);
65
66
  })
@@ -20,7 +20,7 @@ const DEFAULT_GAP = 8;
20
20
  * wrapper carries the virtual offset, the card inside carries whatever
21
21
  * dnd-kit's sortable is animating.
22
22
  */
23
- function KanbanVirtualColumnCards({ column, items, renderItem, itemClassName, scroller, activeItemId, options, dragDisabled = false }) {
23
+ function KanbanVirtualColumnCards({ column, items, renderItem, itemClassName, scroller, activeItemId, options, dragDisabled = false, touch }) {
24
24
  const { estimateItemHeight = DEFAULT_ESTIMATED_ITEM_HEIGHT, overscan = DEFAULT_OVERSCAN, gap = DEFAULT_GAP } = options;
25
25
  const activeIndex = React.useMemo(() => activeItemId == null ? -1 : items.findIndex((item) => item.id === String(activeItemId)), [items, activeItemId]);
26
26
  const rangeExtractor = React.useCallback((range) => {
@@ -55,7 +55,8 @@ function KanbanVirtualColumnCards({ column, items, renderItem, itemClassName, sc
55
55
  column,
56
56
  renderItem,
57
57
  className: itemClassName,
58
- dragDisabled
58
+ dragDisabled,
59
+ touch
59
60
  })
60
61
  }, virtualItem.key);
61
62
  })
@@ -4,6 +4,7 @@
4
4
  const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
5
5
  const require_column_sortable_id = require('../utils/column-sortable-id.cjs');
6
6
  const require_kanban_keyboard_coordinates = require('../utils/kanban-keyboard-coordinates.cjs');
7
+ const require_kanban_touch_defaults = require('../utils/kanban-touch-defaults.cjs');
7
8
  const require_use_kanban_board_state = require('./use-kanban-board-state.cjs');
8
9
  const require_use_kanban_card_drag = require('./use-kanban-card-drag.cjs');
9
10
  const require_use_kanban_collision_detection = require('./use-kanban-collision-detection.cjs');
@@ -14,13 +15,22 @@ let react = require("react");
14
15
  react = require_rolldown_runtime.__toESM(react);
15
16
 
16
17
  //#region src/hooks/use-kanban-drag.ts
17
- const POINTER_ACTIVATION_DISTANCE = 5;
18
+ const MOUSE_ACTIVATION_DISTANCE = 5;
19
+ /**
20
+ * An explicit drag handle is already an unambiguous gesture, so it skips the
21
+ * hold and picks up on contact. Only cards — which share their hit area with
22
+ * the board's own scrolling — have to earn the drag.
23
+ */
24
+ function isOnDragHandle(event) {
25
+ const { target } = event;
26
+ return target instanceof Element && target.closest(require_kanban_touch_defaults.DRAG_HANDLE_SELECTOR) !== null;
27
+ }
18
28
  /**
19
29
  * Drag-and-drop state machine for the Kanban board, following dnd-kit's
20
30
  * multiple-containers pattern. Owns the shared drag state and routes each
21
31
  * event to either the card handlers or the column reorder handler.
22
32
  */
23
- function useKanbanDrag({ externalItems, columns, onChange, onColumnChange }) {
33
+ function useKanbanDrag({ externalItems, columns, onChange, onColumnChange, touch }) {
24
34
  const [activeId, setActiveId] = react.default.useState(null);
25
35
  const isDragActiveRef = react.default.useRef(false);
26
36
  const dragStartItemsRef = react.default.useRef(externalItems);
@@ -38,7 +48,13 @@ function useKanbanDrag({ externalItems, columns, onChange, onColumnChange }) {
38
48
  return () => cancelAnimationFrame(frame);
39
49
  }, [items]);
40
50
  const columnIds = react.default.useMemo(() => new Set(internalColumns.map((column) => column.id)), [internalColumns]);
41
- const sensors = (0, __dnd_kit_core.useSensors)((0, __dnd_kit_core.useSensor)(__dnd_kit_core.PointerSensor, { activationConstraint: { distance: POINTER_ACTIVATION_DISTANCE } }), (0, __dnd_kit_core.useSensor)(__dnd_kit_core.KeyboardSensor, { coordinateGetter: require_kanban_keyboard_coordinates.kanbanKeyboardCoordinates }));
51
+ const sensors = (0, __dnd_kit_core.useSensors)((0, __dnd_kit_core.useSensor)(__dnd_kit_core.MouseSensor, { activationConstraint: { distance: MOUSE_ACTIVATION_DISTANCE } }), (0, __dnd_kit_core.useSensor)(__dnd_kit_core.TouchSensor, {
52
+ activationConstraint: {
53
+ delay: touch?.dragActivationDelay ?? require_kanban_touch_defaults.TOUCH_ACTIVATION_DELAY,
54
+ tolerance: touch?.dragActivationTolerance ?? require_kanban_touch_defaults.TOUCH_ACTIVATION_TOLERANCE
55
+ },
56
+ bypassActivationConstraint: ({ event }) => isOnDragHandle(event)
57
+ }), (0, __dnd_kit_core.useSensor)(__dnd_kit_core.KeyboardSensor, { coordinateGetter: require_kanban_keyboard_coordinates.kanbanKeyboardCoordinates }));
42
58
  const collisionDetection = require_use_kanban_collision_detection.useKanbanCollisionDetection({
43
59
  columnIds,
44
60
  itemsRef,
@@ -3,21 +3,31 @@
3
3
 
4
4
  import { isColumnSortableId } from "../utils/column-sortable-id.js";
5
5
  import { kanbanKeyboardCoordinates } from "../utils/kanban-keyboard-coordinates.js";
6
+ import { DRAG_HANDLE_SELECTOR, TOUCH_ACTIVATION_DELAY, TOUCH_ACTIVATION_TOLERANCE } from "../utils/kanban-touch-defaults.js";
6
7
  import { useKanbanBoardState } from "./use-kanban-board-state.js";
7
8
  import { useKanbanCardDrag } from "./use-kanban-card-drag.js";
8
9
  import { useKanbanCollisionDetection } from "./use-kanban-collision-detection.js";
9
10
  import { useKanbanColumnReorder } from "./use-kanban-column-reorder.js";
10
- import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
11
+ import { KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
11
12
  import React from "react";
12
13
 
13
14
  //#region src/hooks/use-kanban-drag.ts
14
- const POINTER_ACTIVATION_DISTANCE = 5;
15
+ const MOUSE_ACTIVATION_DISTANCE = 5;
16
+ /**
17
+ * An explicit drag handle is already an unambiguous gesture, so it skips the
18
+ * hold and picks up on contact. Only cards — which share their hit area with
19
+ * the board's own scrolling — have to earn the drag.
20
+ */
21
+ function isOnDragHandle(event) {
22
+ const { target } = event;
23
+ return target instanceof Element && target.closest(DRAG_HANDLE_SELECTOR) !== null;
24
+ }
15
25
  /**
16
26
  * Drag-and-drop state machine for the Kanban board, following dnd-kit's
17
27
  * multiple-containers pattern. Owns the shared drag state and routes each
18
28
  * event to either the card handlers or the column reorder handler.
19
29
  */
20
- function useKanbanDrag({ externalItems, columns, onChange, onColumnChange }) {
30
+ function useKanbanDrag({ externalItems, columns, onChange, onColumnChange, touch }) {
21
31
  const [activeId, setActiveId] = React.useState(null);
22
32
  const isDragActiveRef = React.useRef(false);
23
33
  const dragStartItemsRef = React.useRef(externalItems);
@@ -35,7 +45,13 @@ function useKanbanDrag({ externalItems, columns, onChange, onColumnChange }) {
35
45
  return () => cancelAnimationFrame(frame);
36
46
  }, [items]);
37
47
  const columnIds = React.useMemo(() => new Set(internalColumns.map((column) => column.id)), [internalColumns]);
38
- const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: POINTER_ACTIVATION_DISTANCE } }), useSensor(KeyboardSensor, { coordinateGetter: kanbanKeyboardCoordinates }));
48
+ const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: MOUSE_ACTIVATION_DISTANCE } }), useSensor(TouchSensor, {
49
+ activationConstraint: {
50
+ delay: touch?.dragActivationDelay ?? TOUCH_ACTIVATION_DELAY,
51
+ tolerance: touch?.dragActivationTolerance ?? TOUCH_ACTIVATION_TOLERANCE
52
+ },
53
+ bypassActivationConstraint: ({ event }) => isOnDragHandle(event)
54
+ }), useSensor(KeyboardSensor, { coordinateGetter: kanbanKeyboardCoordinates }));
39
55
  const collisionDetection = useKanbanCollisionDetection({
40
56
  columnIds,
41
57
  itemsRef,
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+
4
+ const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
5
+ const require_kanban_touch_defaults = require('../utils/kanban-touch-defaults.cjs');
6
+ let react = require("react");
7
+ react = require_rolldown_runtime.__toESM(react);
8
+
9
+ //#region src/hooks/use-kanban-press-feedback.ts
10
+ /**
11
+ * Mirrors the touch sensor's activation window so a held card can show that it
12
+ * is being held.
13
+ *
14
+ * dnd-kit only reports a drag once it has already started, which on touch is
15
+ * one delay too late: without feedback the hold feels like nothing is
16
+ * happening, and the user lets go before the drag ever arms. This watches the
17
+ * same touch stream under the same constraints and reports the in-between
18
+ * state, so the card can acknowledge the press while it is still a press.
19
+ *
20
+ * It never drives the drag itself — dnd-kit remains the only thing that decides
21
+ * whether a gesture becomes one. The worst a desync can do is show or hide a
22
+ * ring.
23
+ */
24
+ function useKanbanPressFeedback({ delay = require_kanban_touch_defaults.TOUCH_ACTIVATION_DELAY, tolerance = require_kanban_touch_defaults.TOUCH_ACTIVATION_TOLERANCE, disabled = false } = {}) {
25
+ const [isPressing, setIsPressing] = react.default.useState(false);
26
+ const originRef = react.default.useRef(null);
27
+ const timeoutRef = react.default.useRef(null);
28
+ const clear = react.default.useCallback(() => {
29
+ if (timeoutRef.current !== null) {
30
+ clearTimeout(timeoutRef.current);
31
+ timeoutRef.current = null;
32
+ }
33
+ originRef.current = null;
34
+ setIsPressing(false);
35
+ }, []);
36
+ react.default.useEffect(() => clear, [clear]);
37
+ return {
38
+ isPressing,
39
+ handlers: react.default.useMemo(() => ({
40
+ onTouchStart: (event) => {
41
+ const touch = event.touches[0];
42
+ if (disabled || !touch || event.touches.length > 1) return;
43
+ originRef.current = {
44
+ x: touch.clientX,
45
+ y: touch.clientY
46
+ };
47
+ setIsPressing(true);
48
+ timeoutRef.current = setTimeout(() => setIsPressing(false), delay);
49
+ },
50
+ onTouchMove: (event) => {
51
+ const origin = originRef.current;
52
+ const touch = event.touches[0];
53
+ if (!origin || !touch) return;
54
+ if (Math.abs(touch.clientX - origin.x) > tolerance || Math.abs(touch.clientY - origin.y) > tolerance) clear();
55
+ },
56
+ onTouchEnd: clear,
57
+ onTouchCancel: clear
58
+ }), [
59
+ clear,
60
+ delay,
61
+ tolerance,
62
+ disabled
63
+ ])
64
+ };
65
+ }
66
+
67
+ //#endregion
68
+ exports.useKanbanPressFeedback = useKanbanPressFeedback;
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+
4
+ import { TOUCH_ACTIVATION_DELAY, TOUCH_ACTIVATION_TOLERANCE } from "../utils/kanban-touch-defaults.js";
5
+ import React from "react";
6
+
7
+ //#region src/hooks/use-kanban-press-feedback.ts
8
+ /**
9
+ * Mirrors the touch sensor's activation window so a held card can show that it
10
+ * is being held.
11
+ *
12
+ * dnd-kit only reports a drag once it has already started, which on touch is
13
+ * one delay too late: without feedback the hold feels like nothing is
14
+ * happening, and the user lets go before the drag ever arms. This watches the
15
+ * same touch stream under the same constraints and reports the in-between
16
+ * state, so the card can acknowledge the press while it is still a press.
17
+ *
18
+ * It never drives the drag itself — dnd-kit remains the only thing that decides
19
+ * whether a gesture becomes one. The worst a desync can do is show or hide a
20
+ * ring.
21
+ */
22
+ function useKanbanPressFeedback({ delay = TOUCH_ACTIVATION_DELAY, tolerance = TOUCH_ACTIVATION_TOLERANCE, disabled = false } = {}) {
23
+ const [isPressing, setIsPressing] = React.useState(false);
24
+ const originRef = React.useRef(null);
25
+ const timeoutRef = React.useRef(null);
26
+ const clear = React.useCallback(() => {
27
+ if (timeoutRef.current !== null) {
28
+ clearTimeout(timeoutRef.current);
29
+ timeoutRef.current = null;
30
+ }
31
+ originRef.current = null;
32
+ setIsPressing(false);
33
+ }, []);
34
+ React.useEffect(() => clear, [clear]);
35
+ return {
36
+ isPressing,
37
+ handlers: React.useMemo(() => ({
38
+ onTouchStart: (event) => {
39
+ const touch = event.touches[0];
40
+ if (disabled || !touch || event.touches.length > 1) return;
41
+ originRef.current = {
42
+ x: touch.clientX,
43
+ y: touch.clientY
44
+ };
45
+ setIsPressing(true);
46
+ timeoutRef.current = setTimeout(() => setIsPressing(false), delay);
47
+ },
48
+ onTouchMove: (event) => {
49
+ const origin = originRef.current;
50
+ const touch = event.touches[0];
51
+ if (!origin || !touch) return;
52
+ if (Math.abs(touch.clientX - origin.x) > tolerance || Math.abs(touch.clientY - origin.y) > tolerance) clear();
53
+ },
54
+ onTouchEnd: clear,
55
+ onTouchCancel: clear
56
+ }), [
57
+ clear,
58
+ delay,
59
+ tolerance,
60
+ disabled
61
+ ])
62
+ };
63
+ }
64
+
65
+ //#endregion
66
+ export { useKanbanPressFeedback };
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { IntersectionOptions } from "./infinite-scroll/types.cjs";
2
2
  import "./infinite-scroll/index.cjs";
3
- import { ColumnOverflow, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanVirtualization } from "./types.cjs";
3
+ import { ColumnOverflow, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanColumnSnapAlign, KanbanColumnSnapOptions, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanTouchOptions, KanbanVirtualization } from "./types.cjs";
4
4
  import { KanbanBoard } from "./KanbanBoard.cjs";
5
- export { ColumnOverflow, type IntersectionOptions, KanbanBoard, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanVirtualization };
5
+ export { ColumnOverflow, type IntersectionOptions, KanbanBoard, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanColumnSnapAlign, KanbanColumnSnapOptions, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanTouchOptions, KanbanVirtualization };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { IntersectionOptions } from "./infinite-scroll/types.js";
2
2
  import "./infinite-scroll/index.js";
3
- import { ColumnOverflow, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanVirtualization } from "./types.js";
3
+ import { ColumnOverflow, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanColumnSnapAlign, KanbanColumnSnapOptions, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanTouchOptions, KanbanVirtualization } from "./types.js";
4
4
  import { KanbanBoard } from "./KanbanBoard.js";
5
- export { ColumnOverflow, type IntersectionOptions, KanbanBoard, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanVirtualization };
5
+ export { ColumnOverflow, type IntersectionOptions, KanbanBoard, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanColumnSnapAlign, KanbanColumnSnapOptions, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanTouchOptions, KanbanVirtualization };
package/dist/types.d.cts CHANGED
@@ -53,6 +53,68 @@ interface KanbanFilter<T = Record<string, unknown>> {
53
53
  type KanbanFilters<T = Record<string, unknown>> = KanbanFilter<T>[] | ((column: KanbanColumn) => KanbanFilter<T>[] | undefined);
54
54
  /** Controls how columns behave when their cards exceed the board height. */
55
55
  type ColumnOverflow = 'scroll' | 'expand';
56
+ /**
57
+ * Touch-input behaviour.
58
+ *
59
+ * On a touch screen a finger that lands on a card is ambiguous: it could be the
60
+ * start of a drag, or of a swipe to reach the next column. The board resolves
61
+ * that the way native mobile UIs do — a card only becomes draggable after the
62
+ * finger has been held still on it for {@link dragActivationDelay}. Anything
63
+ * shorter, or any movement past {@link dragActivationTolerance} before the
64
+ * delay elapses, stays a scroll.
65
+ *
66
+ * Mouse and keyboard input is unaffected: a mouse drag still starts as soon as
67
+ * the pointer has travelled a few pixels.
68
+ */
69
+ interface KanbanTouchOptions {
70
+ /**
71
+ * Milliseconds a finger must rest on a card before the drag arms.
72
+ *
73
+ * @default 250
74
+ */
75
+ dragActivationDelay?: number;
76
+ /**
77
+ * Pixels of movement tolerated during the hold. Moving further cancels the
78
+ * press and hands the gesture back to the browser as a scroll.
79
+ *
80
+ * @default 8
81
+ */
82
+ dragActivationTolerance?: number;
83
+ /**
84
+ * Renders a ring on a card while it is being held but has not armed yet, so
85
+ * the press is visible before the drag begins.
86
+ *
87
+ * @default true
88
+ */
89
+ pressFeedback?: boolean;
90
+ }
91
+ /** Where a snapped column comes to rest inside the board's viewport. */
92
+ type KanbanColumnSnapAlign = 'start' | 'center';
93
+ /**
94
+ * Turns the board into a one-column-at-a-time slider on small screens, using
95
+ * CSS scroll snapping — so the swipe keeps the platform's own momentum and
96
+ * settling animation rather than a re-implementation of it.
97
+ *
98
+ * Only applies below the `sm` breakpoint (40rem); above it the board keeps its
99
+ * regular multi-column layout. Snapping is suspended for the duration of a drag
100
+ * so dnd-kit's auto-scroll is not fought by the snap points.
101
+ */
102
+ interface KanbanColumnSnapOptions {
103
+ /**
104
+ * Where each column lands when the swipe settles.
105
+ *
106
+ * @default 'start'
107
+ */
108
+ align?: KanbanColumnSnapAlign;
109
+ /**
110
+ * Any CSS width for a column while snapping is active. Leaving a margin
111
+ * (the default 85%) keeps the next column peeking in, which is what tells the
112
+ * user there is more to swipe to.
113
+ *
114
+ * @default '85%'
115
+ */
116
+ columnWidth?: string;
117
+ }
56
118
  /**
57
119
  * Payload delivered by {@link KanbanBoardProps.onFilterChange} whenever the
58
120
  * set of active filters for a column changes.
@@ -246,6 +308,20 @@ interface KanbanBoardProps<T = Record<string, unknown>> {
246
308
  * board. Cards stay mounted and fully interactive; only the drag is off.
247
309
  */
248
310
  dragDisabled?: boolean;
311
+ /**
312
+ * Tunes how a touch drag arms. See {@link KanbanTouchOptions}; the defaults
313
+ * are what most boards want, so this is mainly an escape hatch for a board
314
+ * whose cards are unusually small or tall.
315
+ */
316
+ touch?: KanbanTouchOptions;
317
+ /**
318
+ * Makes the board swipe one column at a time on small screens. Pass `false`
319
+ * to keep the plain horizontal scroller at every width, or an options object
320
+ * to change the resting alignment or the column width.
321
+ *
322
+ * @default true
323
+ */
324
+ columnSnap?: boolean | KanbanColumnSnapOptions;
249
325
  }
250
326
  //#endregion
251
- export { ColumnOverflow, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanVirtualization };
327
+ export { ColumnOverflow, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanColumnSnapAlign, KanbanColumnSnapOptions, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanTouchOptions, KanbanVirtualization };
package/dist/types.d.ts CHANGED
@@ -53,6 +53,68 @@ interface KanbanFilter<T = Record<string, unknown>> {
53
53
  type KanbanFilters<T = Record<string, unknown>> = KanbanFilter<T>[] | ((column: KanbanColumn) => KanbanFilter<T>[] | undefined);
54
54
  /** Controls how columns behave when their cards exceed the board height. */
55
55
  type ColumnOverflow = 'scroll' | 'expand';
56
+ /**
57
+ * Touch-input behaviour.
58
+ *
59
+ * On a touch screen a finger that lands on a card is ambiguous: it could be the
60
+ * start of a drag, or of a swipe to reach the next column. The board resolves
61
+ * that the way native mobile UIs do — a card only becomes draggable after the
62
+ * finger has been held still on it for {@link dragActivationDelay}. Anything
63
+ * shorter, or any movement past {@link dragActivationTolerance} before the
64
+ * delay elapses, stays a scroll.
65
+ *
66
+ * Mouse and keyboard input is unaffected: a mouse drag still starts as soon as
67
+ * the pointer has travelled a few pixels.
68
+ */
69
+ interface KanbanTouchOptions {
70
+ /**
71
+ * Milliseconds a finger must rest on a card before the drag arms.
72
+ *
73
+ * @default 250
74
+ */
75
+ dragActivationDelay?: number;
76
+ /**
77
+ * Pixels of movement tolerated during the hold. Moving further cancels the
78
+ * press and hands the gesture back to the browser as a scroll.
79
+ *
80
+ * @default 8
81
+ */
82
+ dragActivationTolerance?: number;
83
+ /**
84
+ * Renders a ring on a card while it is being held but has not armed yet, so
85
+ * the press is visible before the drag begins.
86
+ *
87
+ * @default true
88
+ */
89
+ pressFeedback?: boolean;
90
+ }
91
+ /** Where a snapped column comes to rest inside the board's viewport. */
92
+ type KanbanColumnSnapAlign = 'start' | 'center';
93
+ /**
94
+ * Turns the board into a one-column-at-a-time slider on small screens, using
95
+ * CSS scroll snapping — so the swipe keeps the platform's own momentum and
96
+ * settling animation rather than a re-implementation of it.
97
+ *
98
+ * Only applies below the `sm` breakpoint (40rem); above it the board keeps its
99
+ * regular multi-column layout. Snapping is suspended for the duration of a drag
100
+ * so dnd-kit's auto-scroll is not fought by the snap points.
101
+ */
102
+ interface KanbanColumnSnapOptions {
103
+ /**
104
+ * Where each column lands when the swipe settles.
105
+ *
106
+ * @default 'start'
107
+ */
108
+ align?: KanbanColumnSnapAlign;
109
+ /**
110
+ * Any CSS width for a column while snapping is active. Leaving a margin
111
+ * (the default 85%) keeps the next column peeking in, which is what tells the
112
+ * user there is more to swipe to.
113
+ *
114
+ * @default '85%'
115
+ */
116
+ columnWidth?: string;
117
+ }
56
118
  /**
57
119
  * Payload delivered by {@link KanbanBoardProps.onFilterChange} whenever the
58
120
  * set of active filters for a column changes.
@@ -246,6 +308,20 @@ interface KanbanBoardProps<T = Record<string, unknown>> {
246
308
  * board. Cards stay mounted and fully interactive; only the drag is off.
247
309
  */
248
310
  dragDisabled?: boolean;
311
+ /**
312
+ * Tunes how a touch drag arms. See {@link KanbanTouchOptions}; the defaults
313
+ * are what most boards want, so this is mainly an escape hatch for a board
314
+ * whose cards are unusually small or tall.
315
+ */
316
+ touch?: KanbanTouchOptions;
317
+ /**
318
+ * Makes the board swipe one column at a time on small screens. Pass `false`
319
+ * to keep the plain horizontal scroller at every width, or an options object
320
+ * to change the resting alignment or the column width.
321
+ *
322
+ * @default true
323
+ */
324
+ columnSnap?: boolean | KanbanColumnSnapOptions;
249
325
  }
250
326
  //#endregion
251
- export { ColumnOverflow, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanVirtualization };
327
+ export { ColumnOverflow, KanbanBoardProps, KanbanChangeEvent, KanbanColumn, KanbanColumnSnapAlign, KanbanColumnSnapOptions, KanbanFilter, KanbanFilterChangeEvent, KanbanFilters, KanbanInfiniteScroll, KanbanItem, KanbanTouchOptions, KanbanVirtualization };
@@ -0,0 +1,29 @@
1
+
2
+ //#region src/utils/kanban-touch-defaults.ts
3
+ /**
4
+ * Shared touch constants.
5
+ *
6
+ * The sensor and the press-feedback hook have to agree on the hold duration and
7
+ * the movement tolerance, or the ring would appear at a moment the drag does
8
+ * not actually arm. Keeping both numbers here is what makes that guarantee
9
+ * mechanical rather than a comment.
10
+ */
11
+ /** Milliseconds a finger must rest on a card before the drag arms. */
12
+ const TOUCH_ACTIVATION_DELAY = 250;
13
+ /** Pixels of movement tolerated during the hold before the press is cancelled. */
14
+ const TOUCH_ACTIVATION_TOLERANCE = 8;
15
+ /**
16
+ * Marks an element as an explicit drag handle, which picks up on contact
17
+ * instead of waiting out the hold.
18
+ */
19
+ const DRAG_HANDLE_ATTRIBUTE = "data-kanban-drag-handle";
20
+ /** Matches {@link DRAG_HANDLE_ATTRIBUTE} and anything inside it (e.g. an icon). */
21
+ const DRAG_HANDLE_SELECTOR = `[${DRAG_HANDLE_ATTRIBUTE}]`;
22
+ /** Spread onto an element to turn it into a drag handle. */
23
+ const dragHandleProps = { [DRAG_HANDLE_ATTRIBUTE]: "" };
24
+
25
+ //#endregion
26
+ exports.DRAG_HANDLE_SELECTOR = DRAG_HANDLE_SELECTOR;
27
+ exports.TOUCH_ACTIVATION_DELAY = TOUCH_ACTIVATION_DELAY;
28
+ exports.TOUCH_ACTIVATION_TOLERANCE = TOUCH_ACTIVATION_TOLERANCE;
29
+ exports.dragHandleProps = dragHandleProps;
@@ -0,0 +1,25 @@
1
+ //#region src/utils/kanban-touch-defaults.ts
2
+ /**
3
+ * Shared touch constants.
4
+ *
5
+ * The sensor and the press-feedback hook have to agree on the hold duration and
6
+ * the movement tolerance, or the ring would appear at a moment the drag does
7
+ * not actually arm. Keeping both numbers here is what makes that guarantee
8
+ * mechanical rather than a comment.
9
+ */
10
+ /** Milliseconds a finger must rest on a card before the drag arms. */
11
+ const TOUCH_ACTIVATION_DELAY = 250;
12
+ /** Pixels of movement tolerated during the hold before the press is cancelled. */
13
+ const TOUCH_ACTIVATION_TOLERANCE = 8;
14
+ /**
15
+ * Marks an element as an explicit drag handle, which picks up on contact
16
+ * instead of waiting out the hold.
17
+ */
18
+ const DRAG_HANDLE_ATTRIBUTE = "data-kanban-drag-handle";
19
+ /** Matches {@link DRAG_HANDLE_ATTRIBUTE} and anything inside it (e.g. an icon). */
20
+ const DRAG_HANDLE_SELECTOR = `[${DRAG_HANDLE_ATTRIBUTE}]`;
21
+ /** Spread onto an element to turn it into a drag handle. */
22
+ const dragHandleProps = { [DRAG_HANDLE_ATTRIBUTE]: "" };
23
+
24
+ //#endregion
25
+ export { DRAG_HANDLE_SELECTOR, TOUCH_ACTIVATION_DELAY, TOUCH_ACTIVATION_TOLERANCE, dragHandleProps };
@@ -0,0 +1,44 @@
1
+
2
+ //#region src/utils/resolve-column-snap.ts
3
+ /** Custom property the column reads its snapped width from. */
4
+ const COLUMN_SNAP_WIDTH_VAR = "--kanban-column-snap-width";
5
+ const DEFAULT_COLUMN_WIDTH = "85%";
6
+ const DEFAULT_ALIGN = "start";
7
+ const BOARD_SNAP_CLASS = "max-sm:snap-x max-sm:snap-mandatory max-sm:scroll-px-2 scroll-smooth";
8
+ const COLUMN_SNAP_CLASS = "max-sm:w-[var(--kanban-column-snap-width)] max-sm:min-w-0 max-sm:flex-none max-sm:snap-always";
9
+ const ALIGN_CLASS = {
10
+ start: "max-sm:snap-start",
11
+ center: "max-sm:snap-center"
12
+ };
13
+ const DISABLED = {
14
+ enabled: false,
15
+ boardClassName: "",
16
+ columnClassName: ""
17
+ };
18
+ /**
19
+ * Turns the `columnSnap` prop into the classes that make the board swipe one
20
+ * column at a time below the `sm` breakpoint.
21
+ *
22
+ * Everything here is CSS scroll snapping, which buys the platform's own
23
+ * momentum, rubber-banding and settle animation for free — and, unlike a JS
24
+ * carousel, keeps working while the main thread is busy re-measuring droppables
25
+ * mid-drag.
26
+ *
27
+ * @param columnSnap - The board's `columnSnap` prop.
28
+ * @param isDragging - Suspends snapping for the length of a drag: dnd-kit
29
+ * auto-scrolls the board by writing `scrollLeft`, and mandatory snap points
30
+ * would pull every one of those writes back to the nearest column.
31
+ */
32
+ function resolveColumnSnap(columnSnap, isDragging) {
33
+ if (columnSnap === false) return DISABLED;
34
+ const { align = DEFAULT_ALIGN, columnWidth = DEFAULT_COLUMN_WIDTH } = columnSnap === true ? {} : columnSnap ?? {};
35
+ return {
36
+ enabled: true,
37
+ boardClassName: isDragging ? "snap-none" : BOARD_SNAP_CLASS,
38
+ columnClassName: `${COLUMN_SNAP_CLASS} ${ALIGN_CLASS[align]}`,
39
+ style: { [COLUMN_SNAP_WIDTH_VAR]: columnWidth }
40
+ };
41
+ }
42
+
43
+ //#endregion
44
+ exports.resolveColumnSnap = resolveColumnSnap;
@@ -0,0 +1,43 @@
1
+ //#region src/utils/resolve-column-snap.ts
2
+ /** Custom property the column reads its snapped width from. */
3
+ const COLUMN_SNAP_WIDTH_VAR = "--kanban-column-snap-width";
4
+ const DEFAULT_COLUMN_WIDTH = "85%";
5
+ const DEFAULT_ALIGN = "start";
6
+ const BOARD_SNAP_CLASS = "max-sm:snap-x max-sm:snap-mandatory max-sm:scroll-px-2 scroll-smooth";
7
+ const COLUMN_SNAP_CLASS = "max-sm:w-[var(--kanban-column-snap-width)] max-sm:min-w-0 max-sm:flex-none max-sm:snap-always";
8
+ const ALIGN_CLASS = {
9
+ start: "max-sm:snap-start",
10
+ center: "max-sm:snap-center"
11
+ };
12
+ const DISABLED = {
13
+ enabled: false,
14
+ boardClassName: "",
15
+ columnClassName: ""
16
+ };
17
+ /**
18
+ * Turns the `columnSnap` prop into the classes that make the board swipe one
19
+ * column at a time below the `sm` breakpoint.
20
+ *
21
+ * Everything here is CSS scroll snapping, which buys the platform's own
22
+ * momentum, rubber-banding and settle animation for free — and, unlike a JS
23
+ * carousel, keeps working while the main thread is busy re-measuring droppables
24
+ * mid-drag.
25
+ *
26
+ * @param columnSnap - The board's `columnSnap` prop.
27
+ * @param isDragging - Suspends snapping for the length of a drag: dnd-kit
28
+ * auto-scrolls the board by writing `scrollLeft`, and mandatory snap points
29
+ * would pull every one of those writes back to the nearest column.
30
+ */
31
+ function resolveColumnSnap(columnSnap, isDragging) {
32
+ if (columnSnap === false) return DISABLED;
33
+ const { align = DEFAULT_ALIGN, columnWidth = DEFAULT_COLUMN_WIDTH } = columnSnap === true ? {} : columnSnap ?? {};
34
+ return {
35
+ enabled: true,
36
+ boardClassName: isDragging ? "snap-none" : BOARD_SNAP_CLASS,
37
+ columnClassName: `${COLUMN_SNAP_CLASS} ${ALIGN_CLASS[align]}`,
38
+ style: { [COLUMN_SNAP_WIDTH_VAR]: columnWidth }
39
+ };
40
+ }
41
+
42
+ //#endregion
43
+ export { resolveColumnSnap };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pixpilot/shadcn-kanban",
3
3
  "type": "module",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "description": "Accessible drag-and-drop Kanban board built with shadcn/ui and dnd-kit.",
6
6
  "author": "m.doaie <m.doaie@hotmail.com>",
7
7
  "license": "MIT",