@getdom/studio 0.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.
Files changed (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,1703 @@
1
+ <script>
2
+ /**
3
+ * @typedef {object} KanbanColumn
4
+ * @property {string|number} key Stable value stored on each row by columnKey.
5
+ * @property {string} [label] Visible column label.
6
+ * @property {string} [description] Optional supporting text below the label.
7
+ * @property {number} [limit] Maximum cards accepted from another column.
8
+ * @property {object|Function|string} [component] Vue component used to render cards in this column.
9
+ * @property {boolean} [disabled] Disable card creation, card drops, and column reordering.
10
+ * @property {boolean} [addable] Set false to hide the built-in Add card control for this column.
11
+ * @property {Function} [canDrop] Function that receives a drop context and decides whether the column accepts it.
12
+ * @property {string|Array|object} [class] Classes applied to the column shell.
13
+ * @property {string|Array|object} [cardClass] Classes applied to card wrappers in the column.
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} KanbanOrder
18
+ * @property {string|number} columnKey Column represented by the order.
19
+ * @property {Array<string|number>} rowKeys Complete ordered row keys for the column.
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} KanbanCardMove
24
+ * @property {Record<string, unknown>} row Moved resource row.
25
+ * @property {string|number} rowKey Stable key of the moved row.
26
+ * @property {{ column: KanbanColumn, columnKey: string|number, index: number }} source Original placement.
27
+ * @property {{ column: KanbanColumn, columnKey: string|number, index: number }} target Requested placement.
28
+ * @property {Record<string, unknown>} changes Minimal field changes for the moved row.
29
+ * @property {Array<KanbanOrder>} orders Complete orders for affected columns.
30
+ * @property {Array<Record<string, unknown>>} nextRows Immutable local projection of the move.
31
+ * @property {'pointer'|'keyboard'} input Interaction that requested the move.
32
+ */
33
+
34
+ export const kanbanProps = {
35
+ rows: {
36
+ type: Array,
37
+ default: () => [],
38
+ _edit: { description: 'Resource rows shared with a grid, calendar, API, or application store.' },
39
+ },
40
+ /**
41
+ * @type {Array<KanbanColumn>}
42
+ */
43
+ columns: {
44
+ type: Array,
45
+ default: () => [],
46
+ _edit: { description: 'Ordered Kanban column definitions. Keys should match values stored by columnKey.' },
47
+ },
48
+ rowKey: {
49
+ type: [String, Function],
50
+ default: 'id',
51
+ _edit: { description: 'Field name or function used to identify each resource row.' },
52
+ },
53
+ columnKey: {
54
+ type: String,
55
+ default: 'status',
56
+ _edit: { description: 'Row field that assigns a resource to a Kanban column.' },
57
+ },
58
+ orderKey: {
59
+ type: String,
60
+ default: 'position',
61
+ _edit: { description: 'Row field that stores the zero-based order within a column.' },
62
+ },
63
+ titleKey: {
64
+ type: [String, Function],
65
+ default: 'title',
66
+ _edit: { description: 'Field name or function used by the built-in card renderer for its title.' },
67
+ },
68
+ descriptionKey: {
69
+ type: [String, Function],
70
+ default: 'description',
71
+ _edit: { description: 'Field name or function used by the built-in card renderer for supporting text.' },
72
+ },
73
+ cardComponent: {
74
+ type: [Object, Function, String],
75
+ default: null,
76
+ _edit: { description: 'Vue component used to render every card. A column component or card slot can override it.' },
77
+ },
78
+ createCard: {
79
+ type: Function,
80
+ default: null,
81
+ _edit: { group: 'Creation', description: 'Optional async hook that persists a suggested row and returns a row or stable id.' },
82
+ },
83
+ createColumn: {
84
+ type: Function,
85
+ default: null,
86
+ _edit: { group: 'Creation', description: 'Optional async hook that persists a suggested column and returns a column or stable key.' },
87
+ },
88
+ allowAddCards: {
89
+ type: Boolean,
90
+ default: true,
91
+ _edit: { group: 'Creation', description: 'Show a compact title composer at the bottom of each enabled column.' },
92
+ },
93
+ allowAddColumns: {
94
+ type: Boolean,
95
+ default: true,
96
+ _edit: { group: 'Creation', description: 'Show a column composer after the final board column.' },
97
+ },
98
+ dragCards: {
99
+ type: Boolean,
100
+ default: true,
101
+ _edit: { group: 'Interaction', description: 'Allow native pointer drag/drop and Alt+Arrow keyboard card movement.' },
102
+ },
103
+ reorderColumns: {
104
+ type: Boolean,
105
+ default: true,
106
+ _edit: { group: 'Interaction', description: 'Allow columns to be reordered with the grip or Alt+Left/Right.' },
107
+ },
108
+ cardDraggable: {
109
+ type: [Boolean, Function],
110
+ default: true,
111
+ _edit: { group: 'Interaction', description: 'Boolean or function that decides whether an individual card can move.' },
112
+ },
113
+ canDrop: {
114
+ type: Function,
115
+ default: null,
116
+ _edit: { group: 'Interaction', description: 'Optional application rule that receives source and target context before a drop.' },
117
+ },
118
+ disabled: {
119
+ type: Boolean,
120
+ default: false,
121
+ _edit: { group: 'State', description: 'Disable creation and reordering while keeping the board readable.' },
122
+ },
123
+ loading: {
124
+ type: Boolean,
125
+ default: false,
126
+ _edit: { group: 'State', description: 'Show a non-blocking loading indicator over the board.' },
127
+ },
128
+ boardLabel: {
129
+ type: String,
130
+ default: 'Kanban board',
131
+ _edit: { group: 'Labels', description: 'Accessible name for the board region.' },
132
+ },
133
+ resourceLabel: {
134
+ type: String,
135
+ default: 'cards',
136
+ _edit: { group: 'Labels', description: 'Human label used in card counts and announcements.' },
137
+ },
138
+ addCardLabel: {
139
+ type: String,
140
+ default: 'Add card',
141
+ _edit: { group: 'Labels', description: 'Label for each column creation control.' },
142
+ },
143
+ addColumnLabel: {
144
+ type: String,
145
+ default: 'Add column',
146
+ _edit: { group: 'Labels', description: 'Label for the board column creation control.' },
147
+ },
148
+ emptyText: {
149
+ type: String,
150
+ default: 'Drop a card here or add a new one.',
151
+ _edit: { group: 'Labels', description: 'Default text shown in an empty column.' },
152
+ },
153
+ columnWidth: {
154
+ type: [String, Number],
155
+ default: '18rem',
156
+ _edit: { group: 'Layout', description: 'Fixed width of each horizontally scrolling column.' },
157
+ },
158
+ height: {
159
+ type: [String, Number],
160
+ default: '36rem',
161
+ _edit: { group: 'Layout', description: 'Height of the board viewport.' },
162
+ },
163
+ };
164
+ </script>
165
+
166
+ <script setup>
167
+ import { computed, markRaw, nextTick, onBeforeUnmount, reactive, ref, useId } from 'vue';
168
+ import { resolveResourceKey, resolveResourceValue } from '../_shared/resourceRecord.js';
169
+ import { applyKanbanCardMove } from './kanbanApi.js';
170
+
171
+ defineOptions({
172
+ __doc: {
173
+ name: 'Kanban',
174
+ tag: '<DomKanban>',
175
+ description: 'A controlled, API-shaped Kanban for shared resource rows, custom Vue card renderers, async creation, and precise card or column ordering.',
176
+ icon: 'M4 5h5v14H4V5Zm7 0h5v9h-5V5Zm7 0h2v11h-2V5Z',
177
+ events: [
178
+ { name: 'update:rows', payload: 'Array<Record<string, unknown>>', description: 'Immutable local projection after a completed card move or creation. Use v-model:rows for local boards.' },
179
+ { name: 'update:columns', payload: 'Array<KanbanColumn>', description: 'Immutable ordered columns after a completed reorder or creation.' },
180
+ { name: 'card-move', payload: 'KanbanCardMove', typeRef: 'KanbanCardMove', description: 'Fired after a valid pointer or keyboard move. Persist changes and orders to a resource API.' },
181
+ { name: 'card-drop', payload: 'KanbanCardMove', typeRef: 'KanbanCardMove', description: 'Alias emitted with card-move for applications that model drag/drop events.' },
182
+ { name: 'card-create-request', payload: '({ column, columnKey, index, draft, suggestedRow })', description: 'Fired before the optional createCard hook runs.' },
183
+ { name: 'card-create', payload: '({ column, columnKey, index, draft, result, row, nextRows })', description: 'Fired when the creation hook succeeds or a local suggested row is accepted.' },
184
+ { name: 'card-create-error', payload: '({ column, columnKey, draft, error })', description: 'Fired when createCard rejects.' },
185
+ { name: 'column-move', payload: '({ column, columnKey, sourceIndex, targetIndex, columnKeys, nextColumns, input })', description: 'Fired after a valid pointer or keyboard column reorder.' },
186
+ { name: 'column-create-request', payload: '({ label, suggestedColumn })', description: 'Fired before the optional createColumn hook runs.' },
187
+ { name: 'column-create', payload: '({ label, result, column, nextColumns })', description: 'Fired when the column creation hook succeeds or a local suggestion is accepted.' },
188
+ { name: 'column-create-error', payload: '({ label, error })', description: 'Fired when createColumn rejects.' },
189
+ { name: 'card-click', payload: '({ row, rowKey, column, columnKey, index, event })', description: 'Fired when a card shell is activated without using an inner interactive control.' },
190
+ { name: 'card-action', payload: '({ row, rowKey, column, columnKey, index, action, payload })', description: 'Re-emitted when a custom card component emits action.' },
191
+ { name: 'card-drag-start', payload: '({ row, rowKey, source, event })', description: 'Fired when native card dragging begins.' },
192
+ { name: 'card-drag-end', payload: '({ row, rowKey, source, event })', description: 'Fired when native card dragging ends.' },
193
+ ],
194
+ slots: [
195
+ { name: 'card', payload: '{ row, rowKey, column, columnKey, index, drag }', description: 'Custom card renderer. The component retains the accessible focus and drag shell.' },
196
+ { name: 'column-header', payload: '{ column, columnKey, index, rows, count, limit }', description: 'Custom content for a column header.' },
197
+ { name: 'empty', payload: '{ column, columnKey }', description: 'Custom empty-column state.' },
198
+ { name: 'card-composer', payload: '{ column, columnKey, draft, setDraft, submit, cancel, creating }', description: 'Custom card creation form.' },
199
+ { name: 'column-composer', payload: '{ draft, setDraft, submit, cancel, creating }', description: 'Custom column creation form.' },
200
+ { name: 'column-footer', payload: '{ column, columnKey, rows, count }', description: 'Extra content below a column card list.' },
201
+ ],
202
+ keyboard: [
203
+ { keys: 'Alt + Up / Down', action: 'Move the focused card within its current column.' },
204
+ { keys: 'Alt + Left / Right', action: 'Move the focused card into the adjacent column.' },
205
+ { keys: 'Alt + Left / Right on column grip', action: 'Reorder the current column.' },
206
+ { keys: 'Enter', action: 'Submit the built-in card or column composer.' },
207
+ { keys: 'Escape', action: 'Close the active composer.' },
208
+ ],
209
+ },
210
+ });
211
+
212
+ const props = defineProps(kanbanProps);
213
+ const emit = defineEmits([
214
+ 'update:rows',
215
+ 'update:columns',
216
+ 'card-move',
217
+ 'card-drop',
218
+ 'card-create-request',
219
+ 'card-create',
220
+ 'card-create-error',
221
+ 'column-move',
222
+ 'column-create-request',
223
+ 'column-create',
224
+ 'column-create-error',
225
+ 'card-click',
226
+ 'card-action',
227
+ 'card-drag-start',
228
+ 'card-drag-end',
229
+ ]);
230
+
231
+ const titleId = `dom-kanban-${useId()}`;
232
+ const cardDragType = 'application/x-dom-kanban-card';
233
+ const columnDragType = 'application/x-dom-kanban-column';
234
+ const liveMessage = ref('');
235
+ const cardComposerOpen = reactive({});
236
+ const cardDrafts = reactive({});
237
+ const cardCreating = reactive({});
238
+ const cardComposerInputs = new Map();
239
+ const columnComposerOpen = ref(false);
240
+ const columnDraft = ref('');
241
+ const columnCreating = ref(false);
242
+ const suppressPlaceholderTransition = ref(false);
243
+ const cardAutoScrollEdge = 64;
244
+ const cardAutoScrollMaxStep = 18;
245
+ const cardAutoScrollState = {
246
+ frameId: 0,
247
+ columnElement: null,
248
+ columnKey: null,
249
+ clientY: 0,
250
+ };
251
+ const dragState = reactive({
252
+ kind: '',
253
+ row: null,
254
+ rowKey: null,
255
+ sourceColumnKey: null,
256
+ sourceIndex: -1,
257
+ cardHeight: 0,
258
+ sourceColumnIndex: -1,
259
+ targetColumnKey: null,
260
+ targetInsertionIndex: -1,
261
+ targetColumnIndex: -1,
262
+ });
263
+
264
+ onBeforeUnmount(stopCardAutoScroll);
265
+
266
+ const sourceRows = computed(() => Array.isArray(props.rows) ? props.rows : []);
267
+ const normalizedColumns = computed(() => {
268
+ const supplied = Array.isArray(props.columns) ? props.columns : [];
269
+ const definitions = supplied.length ? supplied : inferColumns(sourceRows.value);
270
+ return definitions.map(normalizeColumn).filter(function retainValidColumn(column) {
271
+ return column.key !== undefined && column.key !== null && column.key !== '';
272
+ });
273
+ });
274
+ const columnRecords = computed(() => normalizedColumns.value.map(function createColumnRecord(column, index) {
275
+ return {
276
+ ...column,
277
+ index,
278
+ cards: cardsForColumn(column),
279
+ };
280
+ }));
281
+ const boardStyle = computed(() => ({
282
+ height: cssSize(props.height),
283
+ }));
284
+ const resolvedColumnStyle = computed(() => ({
285
+ width: cssSize(props.columnWidth),
286
+ minWidth: cssSize(props.columnWidth),
287
+ }));
288
+
289
+ /**
290
+ * Converts a number or CSS value into a usable component size.
291
+ *
292
+ * @param {string|number} value Requested size.
293
+ * @returns {string} CSS size value.
294
+ */
295
+ function cssSize(value) {
296
+ return typeof value === 'number' ? `${value}px` : value;
297
+ }
298
+
299
+ /**
300
+ * Converts a column key into a safe reactive-object key.
301
+ *
302
+ * @param {string|number} key Column key.
303
+ * @returns {string} String key for composer maps.
304
+ */
305
+ function columnStateKey(key) {
306
+ return String(key);
307
+ }
308
+
309
+ /**
310
+ * Reads a row field using a string key or consumer resolver.
311
+ *
312
+ * @param {Record<string, unknown>} row Resource row.
313
+ * @param {string|Function} key Field name or resolver.
314
+ * @param {number} index Row index within the current column.
315
+ * @returns {unknown} Resolved field value.
316
+ */
317
+ function fieldValue(row, key, index) {
318
+ return resolveResourceValue(row, key, index);
319
+ }
320
+
321
+ /**
322
+ * Resolves the stable key of a rendered card.
323
+ *
324
+ * @param {Record<string, unknown>} row Resource row.
325
+ * @param {number} index Global row index.
326
+ * @returns {string|number} Stable row key.
327
+ */
328
+ function rowKeyFor(row, index) {
329
+ return resolveResourceKey(row, props.rowKey, index);
330
+ }
331
+
332
+ /**
333
+ * Resolves a card title for the built-in renderer and announcements.
334
+ *
335
+ * @param {Record<string, unknown>} row Resource row.
336
+ * @param {number} index Index within its column.
337
+ * @returns {string} Card title.
338
+ */
339
+ function rowTitle(row, index) {
340
+ return String(fieldValue(row, props.titleKey, index) ?? `Untitled ${props.resourceLabel.slice(0, -1) || 'card'}`);
341
+ }
342
+
343
+ /**
344
+ * Resolves optional supporting copy for the built-in card renderer.
345
+ *
346
+ * @param {Record<string, unknown>} row Resource row.
347
+ * @param {number} index Index within its column.
348
+ * @returns {string} Card description.
349
+ */
350
+ function rowDescription(row, index) {
351
+ return String(fieldValue(row, props.descriptionKey, index) ?? '');
352
+ }
353
+
354
+ /**
355
+ * Creates a readable label from a stored column value.
356
+ *
357
+ * @param {string|number} value Stored column value.
358
+ * @returns {string} Human-readable label.
359
+ */
360
+ function labelFromKey(value) {
361
+ return String(value)
362
+ .replace(/[_-]+/g, ' ')
363
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
364
+ .replace(/\b\w/g, function capitalizeCharacter(character) {
365
+ return character.toUpperCase();
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Normalizes a string or object column definition.
371
+ *
372
+ * @param {KanbanColumn|string|number} definition Consumer-supplied definition.
373
+ * @returns {KanbanColumn} Normalized column.
374
+ */
375
+ function normalizeColumn(definition) {
376
+ const column = definition && typeof definition === 'object'
377
+ ? { ...definition }
378
+ : { key: definition };
379
+ return {
380
+ ...column,
381
+ label: column.label || labelFromKey(column.key),
382
+ component: column.component && typeof column.component === 'object' ? markRaw(column.component) : column.component || null,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Infers columns from the unique stored values in resource order.
388
+ *
389
+ * @param {Array<Record<string, unknown>>} rows Resource rows.
390
+ * @returns {Array<KanbanColumn>} Inferred definitions.
391
+ */
392
+ function inferColumns(rows) {
393
+ const keys = [];
394
+ rows.forEach(function collectColumnValue(row) {
395
+ const key = row?.[props.columnKey];
396
+ if (key === undefined || key === null || keys.includes(key)) return;
397
+ keys.push(key);
398
+ });
399
+ return keys.map(function createInferredColumn(key) {
400
+ return { key, label: labelFromKey(key) };
401
+ });
402
+ }
403
+
404
+ /**
405
+ * Compares two cards by their explicit order and stable source index.
406
+ *
407
+ * @param {{ row: Record<string, unknown>, globalIndex: number }} left Left card record.
408
+ * @param {{ row: Record<string, unknown>, globalIndex: number }} right Right card record.
409
+ * @returns {number} Sort comparison.
410
+ */
411
+ function compareCardOrder(left, right) {
412
+ const leftOrder = Number(left.row?.[props.orderKey]);
413
+ const rightOrder = Number(right.row?.[props.orderKey]);
414
+ const leftHasOrder = Number.isFinite(leftOrder);
415
+ const rightHasOrder = Number.isFinite(rightOrder);
416
+ if (leftHasOrder && rightHasOrder && leftOrder !== rightOrder) return leftOrder - rightOrder;
417
+ if (leftHasOrder !== rightHasOrder) return leftHasOrder ? -1 : 1;
418
+ return left.globalIndex - right.globalIndex;
419
+ }
420
+
421
+ /**
422
+ * Builds ordered card records for a board column.
423
+ *
424
+ * @param {KanbanColumn} column Column definition.
425
+ * @returns {Array<{ row: Record<string, unknown>, rowKey: string|number, globalIndex: number, index: number }>} Ordered cards.
426
+ */
427
+ function cardsForColumn(column) {
428
+ return sourceRows.value
429
+ .map(function mapSourceRow(row, globalIndex) {
430
+ return { row, globalIndex, rowKey: rowKeyFor(row, globalIndex) };
431
+ })
432
+ .filter(function retainColumnCard(card) {
433
+ return card.row?.[props.columnKey] === column.key;
434
+ })
435
+ .sort(compareCardOrder)
436
+ .map(function addColumnIndex(card, index) {
437
+ return { ...card, index };
438
+ });
439
+ }
440
+
441
+ /**
442
+ * Resolves the custom component selected for a column.
443
+ *
444
+ * @param {KanbanColumn} column Column definition.
445
+ * @returns {object|Function|string|null} Vue component or null.
446
+ */
447
+ function cardComponentFor(column) {
448
+ return column.component || props.cardComponent || null;
449
+ }
450
+
451
+ /**
452
+ * Reports whether the current card uses a slot or dynamic component renderer.
453
+ *
454
+ * @param {KanbanColumn} column Column definition.
455
+ * @returns {boolean} True when the built-in card content is replaced.
456
+ */
457
+ function hasCustomCardRenderer(column) {
458
+ return Boolean(cardComponentFor(column));
459
+ }
460
+
461
+ /**
462
+ * Checks whether a card can begin a drag interaction.
463
+ *
464
+ * @param {object} card Rendered card record.
465
+ * @param {KanbanColumn} column Source column.
466
+ * @returns {boolean} True when the card is draggable.
467
+ */
468
+ function canDragCard(card, column) {
469
+ if (!props.dragCards || props.disabled || column.disabled) return false;
470
+ if (typeof props.cardDraggable === 'function') {
471
+ return Boolean(props.cardDraggable({
472
+ row: card.row,
473
+ rowKey: card.rowKey,
474
+ column,
475
+ columnKey: column.key,
476
+ index: card.index,
477
+ }));
478
+ }
479
+ return props.cardDraggable !== false;
480
+ }
481
+
482
+ /**
483
+ * Checks board, column, and capacity rules for a candidate card target.
484
+ *
485
+ * @param {object} card Dragged card record.
486
+ * @param {KanbanColumn} sourceColumn Source column.
487
+ * @param {KanbanColumn} targetColumn Target column.
488
+ * @param {number} targetIndex Requested final index.
489
+ * @returns {boolean} True when the target accepts the card.
490
+ */
491
+ function canReceiveCard(card, sourceColumn, targetColumn, targetIndex) {
492
+ if (!card || props.disabled || targetColumn.disabled) return false;
493
+ const targetRecord = columnRecords.value.find(function findTargetColumn(column) {
494
+ return column.key === targetColumn.key;
495
+ });
496
+ if (!targetRecord) return false;
497
+ if (sourceColumn.key !== targetColumn.key && Number.isFinite(targetColumn.limit) && targetRecord.cards.length >= targetColumn.limit) return false;
498
+
499
+ const context = {
500
+ row: card.row,
501
+ rowKey: card.rowKey,
502
+ source: { column: sourceColumn, columnKey: sourceColumn.key, index: card.index },
503
+ target: { column: targetColumn, columnKey: targetColumn.key, index: targetIndex },
504
+ };
505
+ if (typeof targetColumn.canDrop === 'function' && !targetColumn.canDrop(context)) return false;
506
+ if (typeof props.canDrop === 'function' && !props.canDrop(context)) return false;
507
+ return true;
508
+ }
509
+
510
+ /**
511
+ * Finds a current column record by its stable key.
512
+ *
513
+ * @param {string|number} key Column key.
514
+ * @returns {object|null} Column record or null.
515
+ */
516
+ function columnRecordFor(key) {
517
+ return columnRecords.value.find(function matchColumn(column) {
518
+ return column.key === key;
519
+ }) || null;
520
+ }
521
+
522
+ /**
523
+ * Stops the active long-column drag auto-scroll loop.
524
+ *
525
+ * @returns {void}
526
+ */
527
+ function stopCardAutoScroll() {
528
+ if (cardAutoScrollState.frameId) cancelAnimationFrame(cardAutoScrollState.frameId);
529
+ cardAutoScrollState.frameId = 0;
530
+ cardAutoScrollState.columnElement = null;
531
+ cardAutoScrollState.columnKey = null;
532
+ cardAutoScrollState.clientY = 0;
533
+ }
534
+
535
+ /**
536
+ * Clears all pointer drag state after completion or cancellation.
537
+ *
538
+ * @returns {void}
539
+ */
540
+ function resetDragState() {
541
+ stopCardAutoScroll();
542
+ dragState.kind = '';
543
+ dragState.row = null;
544
+ dragState.rowKey = null;
545
+ dragState.sourceColumnKey = null;
546
+ dragState.sourceIndex = -1;
547
+ dragState.cardHeight = 0;
548
+ dragState.sourceColumnIndex = -1;
549
+ dragState.targetColumnKey = null;
550
+ dragState.targetInsertionIndex = -1;
551
+ dragState.targetColumnIndex = -1;
552
+ }
553
+
554
+ /**
555
+ * Stores a serializable native drag payload with a text fallback.
556
+ *
557
+ * @param {DragEvent} event Native drag event.
558
+ * @param {string} type Custom MIME type.
559
+ * @param {Record<string, unknown>} payload Serializable payload.
560
+ * @returns {void}
561
+ */
562
+ function setDragTransfer(event, type, payload) {
563
+ if (!event.dataTransfer) return;
564
+ event.dataTransfer.effectAllowed = 'move';
565
+ try {
566
+ event.dataTransfer.setData(type, JSON.stringify(payload));
567
+ event.dataTransfer.setData('text/plain', String(payload.rowKey ?? payload.columnKey ?? ''));
568
+ } catch {
569
+ // Some browsers restrict custom drag data stores; in-memory state remains authoritative.
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Starts a native card drag and publishes its source context.
575
+ *
576
+ * @param {object} card Rendered card record.
577
+ * @param {KanbanColumn} column Source column.
578
+ * @param {DragEvent} event Native drag event.
579
+ * @returns {void}
580
+ */
581
+ function startCardDrag(card, column, event) {
582
+ if (!canDragCard(card, column)) {
583
+ event.preventDefault();
584
+ return;
585
+ }
586
+ stopCardAutoScroll();
587
+ suppressPlaceholderTransition.value = false;
588
+ dragState.kind = 'card';
589
+ dragState.row = card.row;
590
+ dragState.rowKey = card.rowKey;
591
+ dragState.sourceColumnKey = column.key;
592
+ dragState.sourceIndex = card.index;
593
+ dragState.cardHeight = event.currentTarget instanceof Element
594
+ ? Math.round(event.currentTarget.getBoundingClientRect().height)
595
+ : 0;
596
+ setDragTransfer(event, cardDragType, {
597
+ rowKey: card.rowKey,
598
+ columnKey: column.key,
599
+ index: card.index,
600
+ });
601
+ emit('card-drag-start', {
602
+ row: card.row,
603
+ rowKey: card.rowKey,
604
+ source: { column, columnKey: column.key, index: card.index },
605
+ event,
606
+ });
607
+ }
608
+
609
+ /**
610
+ * Returns the active dragged card with its current source position.
611
+ *
612
+ * @returns {object|null} Active card record or null.
613
+ */
614
+ function activeDraggedCard() {
615
+ if (dragState.kind !== 'card') return null;
616
+ const sourceColumn = columnRecordFor(dragState.sourceColumnKey);
617
+ if (!sourceColumn) return null;
618
+ return sourceColumn.cards.find(function matchDraggedCard(card) {
619
+ return card.rowKey === dragState.rowKey;
620
+ }) || null;
621
+ }
622
+
623
+ /**
624
+ * Validates and stores the current card insertion target.
625
+ *
626
+ * @param {KanbanColumn} targetColumn Target column.
627
+ * @param {number} insertionIndex Index before source-removal normalization.
628
+ * @returns {boolean} True when the column accepts the dragged card.
629
+ */
630
+ function setCardDropTarget(targetColumn, insertionIndex) {
631
+ const card = activeDraggedCard();
632
+ const sourceColumn = columnRecordFor(dragState.sourceColumnKey);
633
+ if (!card || !sourceColumn || !canReceiveCard(card, sourceColumn, targetColumn, insertionIndex)) return false;
634
+ dragState.targetColumnKey = targetColumn.key;
635
+ dragState.targetInsertionIndex = insertionIndex;
636
+ return true;
637
+ }
638
+
639
+ /**
640
+ * Tracks a card insertion zone as the active drop target.
641
+ *
642
+ * @param {KanbanColumn} targetColumn Target column.
643
+ * @param {number} insertionIndex Index before source-removal normalization.
644
+ * @param {DragEvent} event Native drag event.
645
+ * @returns {void}
646
+ */
647
+ function onCardDragEnter(targetColumn, insertionIndex, event) {
648
+ if (!setCardDropTarget(targetColumn, insertionIndex)) return;
649
+ event.preventDefault();
650
+ }
651
+
652
+ /**
653
+ * Keeps a valid card insertion zone droppable for the native API.
654
+ *
655
+ * @param {KanbanColumn} targetColumn Target column.
656
+ * @param {number} insertionIndex Index before source-removal normalization.
657
+ * @param {DragEvent} event Native drag event.
658
+ * @returns {boolean} True when the insertion point remains droppable.
659
+ */
660
+ function onCardDragOver(targetColumn, insertionIndex, event) {
661
+ if (!setCardDropTarget(targetColumn, insertionIndex)) return false;
662
+ event.preventDefault();
663
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
664
+ return true;
665
+ }
666
+
667
+ /**
668
+ * Resolves the nearest insertion point for a vertical point in a column shell.
669
+ *
670
+ * @param {Element} column Column shell element.
671
+ * @param {number} clientY Pointer position in viewport coordinates.
672
+ * @returns {number} Insertion index before source-removal normalization.
673
+ */
674
+ function cardColumnInsertionIndexAtPoint(column, clientY) {
675
+ const cardList = column.querySelector('[data-kanban-card-list]');
676
+ if (!(cardList instanceof Element)) return 0;
677
+ const cardItems = Array.from(cardList.children).filter(function retainCardItem(element) {
678
+ return element instanceof Element
679
+ && element.hasAttribute('data-kanban-card-item')
680
+ && !element.classList.contains('dom-kanban-card-leave-active');
681
+ });
682
+ const matchingIndex = cardItems.findIndex(function findPointerCard(item) {
683
+ const card = item.querySelector('[data-card-key]');
684
+ const bounds = (card || item).getBoundingClientRect();
685
+ return clientY < bounds.top + (bounds.height / 2);
686
+ });
687
+ return matchingIndex === -1 ? cardItems.length : matchingIndex;
688
+ }
689
+
690
+ /**
691
+ * Resolves the nearest insertion point from anywhere over a column shell.
692
+ *
693
+ * @param {DragEvent} event Native drag event whose current target is the column shell.
694
+ * @returns {number} Insertion index before source-removal normalization.
695
+ */
696
+ function cardColumnInsertionIndex(event) {
697
+ const column = event.currentTarget;
698
+ if (!(column instanceof Element)) return 0;
699
+ return cardColumnInsertionIndexAtPoint(column, event.clientY);
700
+ }
701
+
702
+ /**
703
+ * Calculates a smooth vertical scroll step near the card-list edges.
704
+ *
705
+ * @param {Element} cardList Scrollable card-list element.
706
+ * @param {number} clientY Pointer position in viewport coordinates.
707
+ * @returns {number} Signed pixels to scroll during the next animation frame.
708
+ */
709
+ function cardAutoScrollVelocity(cardList, clientY) {
710
+ if (cardList.scrollHeight <= cardList.clientHeight) return 0;
711
+ const bounds = cardList.getBoundingClientRect();
712
+ const edge = Math.min(cardAutoScrollEdge, bounds.height / 2);
713
+ if (!edge) return 0;
714
+ if (clientY < bounds.top + edge) {
715
+ const intensity = Math.min(1, (bounds.top + edge - clientY) / edge);
716
+ return -Math.max(1, Math.round(cardAutoScrollMaxStep * intensity));
717
+ }
718
+ if (clientY > bounds.bottom - edge) {
719
+ const intensity = Math.min(1, (clientY - (bounds.bottom - edge)) / edge);
720
+ return Math.max(1, Math.round(cardAutoScrollMaxStep * intensity));
721
+ }
722
+ return 0;
723
+ }
724
+
725
+ /**
726
+ * Scrolls the active card list and refreshes its insertion target each frame.
727
+ *
728
+ * @returns {void}
729
+ */
730
+ function runCardAutoScrollFrame() {
731
+ cardAutoScrollState.frameId = 0;
732
+ const column = cardAutoScrollState.columnElement;
733
+ if (dragState.kind !== 'card' || !(column instanceof Element)) {
734
+ stopCardAutoScroll();
735
+ return;
736
+ }
737
+ const cardList = column.querySelector('[data-kanban-card-list]');
738
+ if (!(cardList instanceof Element)) {
739
+ stopCardAutoScroll();
740
+ return;
741
+ }
742
+ const velocity = cardAutoScrollVelocity(cardList, cardAutoScrollState.clientY);
743
+ if (!velocity) return;
744
+ const previousScrollTop = cardList.scrollTop;
745
+ cardList.scrollTop += velocity;
746
+ if (cardList.scrollTop === previousScrollTop) return;
747
+ const targetColumn = columnRecordFor(cardAutoScrollState.columnKey);
748
+ if (targetColumn) {
749
+ setCardDropTarget(
750
+ targetColumn,
751
+ cardColumnInsertionIndexAtPoint(column, cardAutoScrollState.clientY),
752
+ );
753
+ }
754
+ cardAutoScrollState.frameId = requestAnimationFrame(runCardAutoScrollFrame);
755
+ }
756
+
757
+ /**
758
+ * Starts or updates edge-driven auto-scroll for the active column.
759
+ *
760
+ * @param {KanbanColumn} targetColumn Target column.
761
+ * @param {DragEvent} event Native drag event whose current target is the column shell.
762
+ * @returns {void}
763
+ */
764
+ function trackCardAutoScroll(targetColumn, event) {
765
+ const column = event.currentTarget;
766
+ if (!(column instanceof Element)) {
767
+ stopCardAutoScroll();
768
+ return;
769
+ }
770
+ cardAutoScrollState.columnElement = column;
771
+ cardAutoScrollState.columnKey = targetColumn.key;
772
+ cardAutoScrollState.clientY = event.clientY;
773
+ const cardList = column.querySelector('[data-kanban-card-list]');
774
+ if (!(cardList instanceof Element) || !cardAutoScrollVelocity(cardList, event.clientY)) return;
775
+ if (!cardAutoScrollState.frameId) {
776
+ cardAutoScrollState.frameId = requestAnimationFrame(runCardAutoScrollFrame);
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Makes the complete column shell a card drag-enter surface.
782
+ *
783
+ * @param {KanbanColumn} targetColumn Target column.
784
+ * @param {DragEvent} event Native drag event.
785
+ * @returns {void}
786
+ */
787
+ function onCardColumnDragEnter(targetColumn, event) {
788
+ onCardDragEnter(targetColumn, cardColumnInsertionIndex(event), event);
789
+ }
790
+
791
+ /**
792
+ * Keeps the nearest column insertion point active while the pointer moves.
793
+ *
794
+ * @param {KanbanColumn} targetColumn Target column.
795
+ * @param {DragEvent} event Native drag event.
796
+ * @returns {void}
797
+ */
798
+ function onCardColumnDragOver(targetColumn, event) {
799
+ const insertionIndex = cardColumnInsertionIndex(event);
800
+ if (onCardDragOver(targetColumn, insertionIndex, event)) trackCardAutoScroll(targetColumn, event);
801
+ else stopCardAutoScroll();
802
+ }
803
+
804
+ /**
805
+ * Clears a column target only after the pointer leaves the complete column shell.
806
+ *
807
+ * @param {KanbanColumn} targetColumn Target column.
808
+ * @param {DragEvent} event Native drag event.
809
+ * @returns {void}
810
+ */
811
+ function onCardColumnDragLeave(targetColumn, event) {
812
+ const column = event.currentTarget;
813
+ if (column instanceof Element && event.relatedTarget instanceof Element && column.contains(event.relatedTarget)) return;
814
+ if (cardAutoScrollState.columnKey === targetColumn.key) stopCardAutoScroll();
815
+ if (dragState.targetColumnKey !== targetColumn.key) return;
816
+ dragState.targetColumnKey = null;
817
+ dragState.targetInsertionIndex = -1;
818
+ }
819
+
820
+ /**
821
+ * Completes a native card drop at the nearest point in a greedy column shell.
822
+ *
823
+ * @param {KanbanColumn} targetColumn Target column.
824
+ * @param {DragEvent} event Native drop event.
825
+ * @returns {void}
826
+ */
827
+ function onCardColumnDrop(targetColumn, event) {
828
+ stopCardAutoScroll();
829
+ onCardDrop(targetColumn, cardColumnInsertionIndex(event), event);
830
+ }
831
+
832
+ /**
833
+ * Builds complete affected-column orders for a final card placement.
834
+ *
835
+ * @param {object} card Dragged card record.
836
+ * @param {object} sourceColumn Source column record.
837
+ * @param {object} targetColumn Target column record.
838
+ * @param {number} targetIndex Final target index.
839
+ * @returns {Array<KanbanOrder>} Complete affected orders.
840
+ */
841
+ function cardOrders(card, sourceColumn, targetColumn, targetIndex) {
842
+ const sourceKeys = sourceColumn.cards.map(function mapSourceCard(item) {
843
+ return item.rowKey;
844
+ }).filter(function removeDraggedKey(key) {
845
+ return key !== card.rowKey;
846
+ });
847
+ if (sourceColumn.key === targetColumn.key) {
848
+ sourceKeys.splice(targetIndex, 0, card.rowKey);
849
+ return [{ columnKey: sourceColumn.key, rowKeys: sourceKeys }];
850
+ }
851
+
852
+ const targetKeys = targetColumn.cards.map(function mapTargetCard(item) {
853
+ return item.rowKey;
854
+ });
855
+ targetKeys.splice(targetIndex, 0, card.rowKey);
856
+ return [
857
+ { columnKey: sourceColumn.key, rowKeys: sourceKeys },
858
+ { columnKey: targetColumn.key, rowKeys: targetKeys },
859
+ ];
860
+ }
861
+
862
+ /**
863
+ * Emits a controlled card move and its immutable v-model projection.
864
+ *
865
+ * @param {object} card Dragged card record.
866
+ * @param {object} sourceColumn Source column record.
867
+ * @param {object} targetColumn Target column record.
868
+ * @param {number} requestedIndex Final target index.
869
+ * @param {Event} event Interaction event.
870
+ * @param {'pointer'|'keyboard'} input Interaction type.
871
+ * @returns {void}
872
+ */
873
+ function emitCardMove(card, sourceColumn, targetColumn, requestedIndex, event, input) {
874
+ const targetLength = targetColumn.cards.length - (sourceColumn.key === targetColumn.key ? 1 : 0);
875
+ const targetIndex = Math.max(0, Math.min(requestedIndex, targetLength));
876
+ if (!canReceiveCard(card, sourceColumn, targetColumn, targetIndex)) return;
877
+ if (sourceColumn.key === targetColumn.key && card.index === targetIndex) return;
878
+
879
+ const changes = {};
880
+ if (props.columnKey) changes[props.columnKey] = targetColumn.key;
881
+ if (props.orderKey) changes[props.orderKey] = targetIndex;
882
+ const orders = cardOrders(card, sourceColumn, targetColumn, targetIndex);
883
+ const basePayload = {
884
+ row: card.row,
885
+ rowKey: card.rowKey,
886
+ source: { column: sourceColumn, columnKey: sourceColumn.key, index: card.index },
887
+ target: { column: targetColumn, columnKey: targetColumn.key, index: targetIndex },
888
+ changes,
889
+ orders,
890
+ input,
891
+ event,
892
+ };
893
+ const nextRows = applyKanbanCardMove(sourceRows.value, basePayload, {
894
+ rowKey: props.rowKey,
895
+ columnKey: props.columnKey,
896
+ orderKey: props.orderKey,
897
+ });
898
+ const payload = { ...basePayload, nextRows };
899
+ emit('update:rows', nextRows);
900
+ emit('card-move', payload);
901
+ emit('card-drop', payload);
902
+ liveMessage.value = `Moved ${rowTitle(card.row, card.index)} to ${targetColumn.label}, position ${targetIndex + 1}.`;
903
+ }
904
+
905
+ /**
906
+ * Disables the placeholder exit transition for the frame that commits a drop.
907
+ *
908
+ * @returns {void}
909
+ */
910
+ function suppressCommittedPlaceholderExit() {
911
+ suppressPlaceholderTransition.value = true;
912
+ requestAnimationFrame(function waitForCommittedDropPaint() {
913
+ requestAnimationFrame(function restorePlaceholderTransition() {
914
+ suppressPlaceholderTransition.value = false;
915
+ });
916
+ });
917
+ }
918
+
919
+ /**
920
+ * Completes a native card drop at an insertion zone.
921
+ *
922
+ * @param {KanbanColumn} targetColumn Target column.
923
+ * @param {number} insertionIndex Index before source removal.
924
+ * @param {DragEvent} event Native drop event.
925
+ * @returns {void}
926
+ */
927
+ function onCardDrop(targetColumn, insertionIndex, event) {
928
+ const card = activeDraggedCard();
929
+ const sourceColumn = columnRecordFor(dragState.sourceColumnKey);
930
+ const currentTarget = columnRecordFor(targetColumn.key);
931
+ if (!card || !sourceColumn || !currentTarget || !canReceiveCard(card, sourceColumn, currentTarget, insertionIndex)) return;
932
+ event.preventDefault();
933
+ let finalIndex = insertionIndex;
934
+ if (sourceColumn.key === currentTarget.key && card.index < insertionIndex) finalIndex -= 1;
935
+ suppressCommittedPlaceholderExit();
936
+ emitCardMove(card, sourceColumn, currentTarget, finalIndex, event, 'pointer');
937
+ resetDragState();
938
+ }
939
+
940
+ /**
941
+ * Emits drag completion context and clears native drag state.
942
+ *
943
+ * @param {object} card Dragged card record.
944
+ * @param {KanbanColumn} column Source column.
945
+ * @param {DragEvent} event Native drag event.
946
+ * @returns {void}
947
+ */
948
+ function endCardDrag(card, column, event) {
949
+ emit('card-drag-end', {
950
+ row: card.row,
951
+ rowKey: card.rowKey,
952
+ source: { column, columnKey: column.key, index: card.index },
953
+ event,
954
+ });
955
+ resetDragState();
956
+ }
957
+
958
+ /**
959
+ * Reports whether a rendered card is the current drag source.
960
+ *
961
+ * @param {object} card Rendered card record.
962
+ * @returns {boolean} True for the source card.
963
+ */
964
+ function isDraggedCard(card) {
965
+ return dragState.kind === 'card' && dragState.rowKey === card.rowKey;
966
+ }
967
+
968
+ /**
969
+ * Reports whether an insertion zone is the current card target.
970
+ *
971
+ * @param {KanbanColumn} column Target column.
972
+ * @param {number} index Insertion index.
973
+ * @returns {boolean} True for the active drop zone.
974
+ */
975
+ function isCardDropTarget(column, index) {
976
+ return dragState.kind === 'card'
977
+ && dragState.targetColumnKey === column.key
978
+ && dragState.targetInsertionIndex === index;
979
+ }
980
+
981
+ /**
982
+ * Expands the active insertion zone to the measured height of the dragged card.
983
+ *
984
+ * @param {KanbanColumn} column Target column.
985
+ * @param {number} index Insertion index.
986
+ * @returns {Record<string, string>|undefined} Inline placeholder size when active.
987
+ */
988
+ function cardDropPlaceholderStyle(column, index) {
989
+ const style = {};
990
+ if (isCardDropTarget(column, index)) style.height = `${dragState.cardHeight || 72}px`;
991
+ if (suppressPlaceholderTransition.value) style.transition = 'none';
992
+ return Object.keys(style).length ? style : undefined;
993
+ }
994
+
995
+ /**
996
+ * Locks a departing card wrapper to its rendered height before it collapses.
997
+ *
998
+ * @param {Element} element Card wrapper entering the Vue leave transition.
999
+ * @returns {void}
1000
+ */
1001
+ function prepareCardLeave(element) {
1002
+ if (!(element instanceof HTMLElement)) return;
1003
+ element.style.height = `${element.getBoundingClientRect().height}px`;
1004
+ }
1005
+
1006
+ /**
1007
+ * Handles accessible Alt+Arrow card movement from the focused card shell.
1008
+ *
1009
+ * @param {object} card Focused card record.
1010
+ * @param {KanbanColumn} column Current column.
1011
+ * @param {KeyboardEvent} event Native keyboard event.
1012
+ * @returns {void}
1013
+ */
1014
+ function onCardKeydown(card, column, event) {
1015
+ if (event.target !== event.currentTarget || !event.altKey || !canDragCard(card, column)) return;
1016
+ const sourceColumn = columnRecordFor(column.key);
1017
+ if (!sourceColumn) return;
1018
+ let targetColumn = sourceColumn;
1019
+ let targetIndex = card.index;
1020
+
1021
+ if (event.key === 'ArrowUp') targetIndex -= 1;
1022
+ else if (event.key === 'ArrowDown') targetIndex += 1;
1023
+ else if (event.key === 'ArrowLeft') targetColumn = columnRecords.value[sourceColumn.index - 1];
1024
+ else if (event.key === 'ArrowRight') targetColumn = columnRecords.value[sourceColumn.index + 1];
1025
+ else return;
1026
+
1027
+ if (!targetColumn) return;
1028
+ if (targetColumn.key !== sourceColumn.key) targetIndex = Math.min(card.index, targetColumn.cards.length);
1029
+ if (targetIndex < 0 || (targetColumn.key === sourceColumn.key && targetIndex >= sourceColumn.cards.length)) return;
1030
+ event.preventDefault();
1031
+ emitCardMove(card, sourceColumn, targetColumn, targetIndex, event, 'keyboard');
1032
+ }
1033
+
1034
+ /**
1035
+ * Emits card-click unless an interactive descendant handled the activation.
1036
+ *
1037
+ * @param {object} card Clicked card record.
1038
+ * @param {KanbanColumn} column Current column.
1039
+ * @param {MouseEvent} event Native click event.
1040
+ * @returns {void}
1041
+ */
1042
+ function onCardClick(card, column, event) {
1043
+ if (event.target instanceof Element && event.target.closest('button, a, input, textarea, select, [role="button"]')) return;
1044
+ emit('card-click', {
1045
+ row: card.row,
1046
+ rowKey: card.rowKey,
1047
+ column,
1048
+ columnKey: column.key,
1049
+ index: card.index,
1050
+ event,
1051
+ });
1052
+ }
1053
+
1054
+ /**
1055
+ * Re-emits an action from a dynamic card component with its resource context.
1056
+ *
1057
+ * @param {object} card Card record.
1058
+ * @param {KanbanColumn} column Current column.
1059
+ * @param {unknown} payload Custom component action payload.
1060
+ * @returns {void}
1061
+ */
1062
+ function onCardAction(card, column, payload) {
1063
+ emit('card-action', {
1064
+ row: card.row,
1065
+ rowKey: card.rowKey,
1066
+ column,
1067
+ columnKey: column.key,
1068
+ index: card.index,
1069
+ action: payload?.action ?? payload?.type ?? payload,
1070
+ payload,
1071
+ });
1072
+ }
1073
+
1074
+ /**
1075
+ * Opens a built-in card composer for one column.
1076
+ *
1077
+ * @param {KanbanColumn} column Target column.
1078
+ * @returns {Promise<void>} Resolves after the composer input receives focus.
1079
+ */
1080
+ async function openCardComposer(column) {
1081
+ if (props.disabled || column.disabled) return;
1082
+ const key = columnStateKey(column.key);
1083
+ cardComposerOpen[key] = true;
1084
+ cardDrafts[key] ||= '';
1085
+ await nextTick();
1086
+ cardComposerInputs.get(key)?.focus();
1087
+ }
1088
+
1089
+ /**
1090
+ * Tracks the built-in title input associated with a column composer.
1091
+ *
1092
+ * @param {KanbanColumn} column Composer column.
1093
+ * @param {HTMLInputElement|null} element Mounted input element or null during cleanup.
1094
+ * @returns {void}
1095
+ */
1096
+ function setCardComposerInput(column, element) {
1097
+ const key = columnStateKey(column.key);
1098
+ if (element) cardComposerInputs.set(key, element);
1099
+ else cardComposerInputs.delete(key);
1100
+ }
1101
+
1102
+ /**
1103
+ * Closes and clears a built-in card composer.
1104
+ *
1105
+ * @param {KanbanColumn} column Target column.
1106
+ * @returns {void}
1107
+ */
1108
+ function closeCardComposer(column) {
1109
+ const key = columnStateKey(column.key);
1110
+ cardComposerOpen[key] = false;
1111
+ cardDrafts[key] = '';
1112
+ }
1113
+
1114
+ /**
1115
+ * Updates a card composer draft from a custom slot.
1116
+ *
1117
+ * @param {KanbanColumn} column Target column.
1118
+ * @param {string} value New draft title.
1119
+ * @returns {void}
1120
+ */
1121
+ function setCardDraft(column, value) {
1122
+ cardDrafts[columnStateKey(column.key)] = String(value ?? '');
1123
+ }
1124
+
1125
+ /**
1126
+ * Creates a browser-safe optimistic key for local card or column creation.
1127
+ *
1128
+ * @param {string} prefix Key prefix.
1129
+ * @returns {string} Stable optimistic key.
1130
+ */
1131
+ function generatedKey(prefix) {
1132
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
1133
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1134
+ }
1135
+
1136
+ /**
1137
+ * Resolves the writable row-key field used by suggested records.
1138
+ *
1139
+ * @returns {string} Row key field.
1140
+ */
1141
+ function writableRowKey() {
1142
+ return typeof props.rowKey === 'string' ? props.rowKey : 'id';
1143
+ }
1144
+
1145
+ /**
1146
+ * Normalizes a createCard hook result into a complete resource row.
1147
+ *
1148
+ * @param {unknown} result Hook result.
1149
+ * @param {Record<string, unknown>} suggestedRow Suggested resource fields.
1150
+ * @returns {Record<string, unknown>} Created row.
1151
+ */
1152
+ function normalizeCreatedRow(result, suggestedRow) {
1153
+ if (result && typeof result === 'object' && !Array.isArray(result)) return { ...suggestedRow, ...result };
1154
+ if (typeof result === 'string' || typeof result === 'number') {
1155
+ return { ...suggestedRow, [writableRowKey()]: result };
1156
+ }
1157
+ return suggestedRow;
1158
+ }
1159
+
1160
+ /**
1161
+ * Submits a card draft through the optional async persistence hook.
1162
+ *
1163
+ * @param {KanbanColumn} column Target column.
1164
+ * @returns {Promise<void>} Resolves after creation succeeds or fails.
1165
+ */
1166
+ async function submitCard(column) {
1167
+ const key = columnStateKey(column.key);
1168
+ const title = String(cardDrafts[key] ?? '').trim();
1169
+ if (!title || cardCreating[key] || props.disabled || column.disabled) return;
1170
+ const currentColumn = columnRecordFor(column.key);
1171
+ const index = currentColumn?.cards.length || 0;
1172
+ const suggestedRow = {
1173
+ [writableRowKey()]: generatedKey('card'),
1174
+ [props.columnKey]: column.key,
1175
+ [props.orderKey]: index,
1176
+ [typeof props.titleKey === 'string' ? props.titleKey : 'title']: title,
1177
+ };
1178
+ const request = {
1179
+ column,
1180
+ columnKey: column.key,
1181
+ index,
1182
+ draft: { title },
1183
+ suggestedRow,
1184
+ };
1185
+ emit('card-create-request', request);
1186
+ cardCreating[key] = true;
1187
+ try {
1188
+ const result = props.createCard ? await props.createCard(request) : suggestedRow;
1189
+ const row = normalizeCreatedRow(result, suggestedRow);
1190
+ const nextRows = [...sourceRows.value, row];
1191
+ emit('update:rows', nextRows);
1192
+ emit('card-create', { ...request, result, row, nextRows });
1193
+ liveMessage.value = `Added ${title} to ${column.label}.`;
1194
+ closeCardComposer(column);
1195
+ } catch (error) {
1196
+ emit('card-create-error', { ...request, error });
1197
+ liveMessage.value = `Could not add ${title} to ${column.label}.`;
1198
+ } finally {
1199
+ cardCreating[key] = false;
1200
+ }
1201
+ }
1202
+
1203
+ /**
1204
+ * Starts a native column drag from its dedicated grip.
1205
+ *
1206
+ * @param {KanbanColumn} column Source column.
1207
+ * @param {number} index Source column index.
1208
+ * @param {DragEvent} event Native drag event.
1209
+ * @returns {void}
1210
+ */
1211
+ function startColumnDrag(column, index, event) {
1212
+ if (!props.reorderColumns || props.disabled || column.disabled) {
1213
+ event.preventDefault();
1214
+ return;
1215
+ }
1216
+ dragState.kind = 'column';
1217
+ dragState.sourceColumnKey = column.key;
1218
+ dragState.sourceColumnIndex = index;
1219
+ setDragTransfer(event, columnDragType, { columnKey: column.key, index });
1220
+ }
1221
+
1222
+ /**
1223
+ * Tracks a column insertion zone as the active drop target.
1224
+ *
1225
+ * @param {number} insertionIndex Index before source removal.
1226
+ * @param {DragEvent} event Native drag event.
1227
+ * @returns {void}
1228
+ */
1229
+ function onColumnDragEnter(insertionIndex, event) {
1230
+ if (dragState.kind !== 'column') return;
1231
+ event.preventDefault();
1232
+ dragState.targetColumnIndex = insertionIndex;
1233
+ }
1234
+
1235
+ /**
1236
+ * Keeps a column insertion zone active for native drag/drop.
1237
+ *
1238
+ * @param {DragEvent} event Native drag event.
1239
+ * @returns {void}
1240
+ */
1241
+ function onColumnDragOver(event) {
1242
+ if (dragState.kind !== 'column') return;
1243
+ event.preventDefault();
1244
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
1245
+ }
1246
+
1247
+ /**
1248
+ * Moves an array item without mutating the source.
1249
+ *
1250
+ * @template T
1251
+ * @param {Array<T>} items Source items.
1252
+ * @param {number} sourceIndex Existing index.
1253
+ * @param {number} targetIndex Final index.
1254
+ * @returns {Array<T>} Reordered items.
1255
+ */
1256
+ function moveArrayItem(items, sourceIndex, targetIndex) {
1257
+ const nextItems = [...items];
1258
+ const [item] = nextItems.splice(sourceIndex, 1);
1259
+ nextItems.splice(targetIndex, 0, item);
1260
+ return nextItems;
1261
+ }
1262
+
1263
+ /**
1264
+ * Emits a controlled column reorder and immutable v-model projection.
1265
+ *
1266
+ * @param {KanbanColumn} column Moved column.
1267
+ * @param {number} sourceIndex Existing index.
1268
+ * @param {number} requestedIndex Final index.
1269
+ * @param {Event} event Interaction event.
1270
+ * @param {'pointer'|'keyboard'} input Interaction type.
1271
+ * @returns {void}
1272
+ */
1273
+ function emitColumnMove(column, sourceIndex, requestedIndex, event, input) {
1274
+ const targetIndex = Math.max(0, Math.min(requestedIndex, normalizedColumns.value.length - 1));
1275
+ if (sourceIndex === targetIndex) return;
1276
+ const nextColumns = moveArrayItem(normalizedColumns.value, sourceIndex, targetIndex);
1277
+ const columnKeys = nextColumns.map(function mapColumnKey(item) {
1278
+ return item.key;
1279
+ });
1280
+ const payload = {
1281
+ column,
1282
+ columnKey: column.key,
1283
+ sourceIndex,
1284
+ targetIndex,
1285
+ columnKeys,
1286
+ nextColumns,
1287
+ input,
1288
+ event,
1289
+ };
1290
+ emit('update:columns', nextColumns);
1291
+ emit('column-move', payload);
1292
+ liveMessage.value = `Moved ${column.label} to column ${targetIndex + 1}.`;
1293
+ }
1294
+
1295
+ /**
1296
+ * Completes a native column reorder at an insertion zone.
1297
+ *
1298
+ * @param {number} insertionIndex Index before source removal.
1299
+ * @param {DragEvent} event Native drop event.
1300
+ * @returns {void}
1301
+ */
1302
+ function onColumnDrop(insertionIndex, event) {
1303
+ if (dragState.kind !== 'column') return;
1304
+ event.preventDefault();
1305
+ const sourceIndex = dragState.sourceColumnIndex;
1306
+ const column = normalizedColumns.value[sourceIndex];
1307
+ let targetIndex = insertionIndex;
1308
+ if (sourceIndex < insertionIndex) targetIndex -= 1;
1309
+ if (column) emitColumnMove(column, sourceIndex, targetIndex, event, 'pointer');
1310
+ resetDragState();
1311
+ }
1312
+
1313
+ /**
1314
+ * Reports whether a column insertion zone is active.
1315
+ *
1316
+ * @param {number} index Insertion index.
1317
+ * @returns {boolean} True for the active column target.
1318
+ */
1319
+ function isColumnDropTarget(index) {
1320
+ return dragState.kind === 'column' && dragState.targetColumnIndex === index;
1321
+ }
1322
+
1323
+ /**
1324
+ * Handles accessible Alt+Left/Right reordering from a column grip.
1325
+ *
1326
+ * @param {KanbanColumn} column Current column.
1327
+ * @param {number} index Current index.
1328
+ * @param {KeyboardEvent} event Native keyboard event.
1329
+ * @returns {void}
1330
+ */
1331
+ function onColumnKeydown(column, index, event) {
1332
+ if (!event.altKey || !props.reorderColumns || props.disabled || column.disabled) return;
1333
+ let targetIndex = index;
1334
+ if (event.key === 'ArrowLeft') targetIndex -= 1;
1335
+ else if (event.key === 'ArrowRight') targetIndex += 1;
1336
+ else return;
1337
+ if (targetIndex < 0 || targetIndex >= normalizedColumns.value.length) return;
1338
+ event.preventDefault();
1339
+ emitColumnMove(column, index, targetIndex, event, 'keyboard');
1340
+ }
1341
+
1342
+ /**
1343
+ * Creates a unique slug-like column key from a label.
1344
+ *
1345
+ * @param {string} label New column label.
1346
+ * @returns {string} Unique suggested key.
1347
+ */
1348
+ function suggestedColumnKey(label) {
1349
+ const base = label.toLowerCase().trim()
1350
+ .replace(/[^a-z0-9]+/g, '-')
1351
+ .replace(/^-|-$/g, '') || 'column';
1352
+ const used = new Set(normalizedColumns.value.map(function collectColumnKey(column) {
1353
+ return String(column.key);
1354
+ }));
1355
+ let key = base;
1356
+ let suffix = 2;
1357
+ while (used.has(key)) {
1358
+ key = `${base}-${suffix}`;
1359
+ suffix += 1;
1360
+ }
1361
+ return key;
1362
+ }
1363
+
1364
+ /**
1365
+ * Normalizes a createColumn hook result into a complete column definition.
1366
+ *
1367
+ * @param {unknown} result Hook result.
1368
+ * @param {KanbanColumn} suggestedColumn Suggested definition.
1369
+ * @returns {KanbanColumn} Created column.
1370
+ */
1371
+ function normalizeCreatedColumn(result, suggestedColumn) {
1372
+ if (result && typeof result === 'object' && !Array.isArray(result)) return normalizeColumn({ ...suggestedColumn, ...result });
1373
+ if (typeof result === 'string' || typeof result === 'number') return normalizeColumn({ ...suggestedColumn, key: result });
1374
+ return normalizeColumn(suggestedColumn);
1375
+ }
1376
+
1377
+ /**
1378
+ * Updates a column composer draft from a custom slot.
1379
+ *
1380
+ * @param {string} value New column label.
1381
+ * @returns {void}
1382
+ */
1383
+ function setColumnDraft(value) {
1384
+ columnDraft.value = String(value ?? '');
1385
+ }
1386
+
1387
+ /**
1388
+ * Closes and clears the board column composer.
1389
+ *
1390
+ * @returns {void}
1391
+ */
1392
+ function closeColumnComposer() {
1393
+ columnComposerOpen.value = false;
1394
+ columnDraft.value = '';
1395
+ }
1396
+
1397
+ /**
1398
+ * Submits a column draft through the optional async persistence hook.
1399
+ *
1400
+ * @returns {Promise<void>} Resolves after creation succeeds or fails.
1401
+ */
1402
+ async function submitColumn() {
1403
+ const label = columnDraft.value.trim();
1404
+ if (!label || columnCreating.value || props.disabled) return;
1405
+ const suggestedColumn = { key: suggestedColumnKey(label), label };
1406
+ const request = { label, suggestedColumn };
1407
+ emit('column-create-request', request);
1408
+ columnCreating.value = true;
1409
+ try {
1410
+ const result = props.createColumn ? await props.createColumn(request) : suggestedColumn;
1411
+ const column = normalizeCreatedColumn(result, suggestedColumn);
1412
+ const nextColumns = [...normalizedColumns.value, column];
1413
+ emit('update:columns', nextColumns);
1414
+ emit('column-create', { ...request, result, column, nextColumns });
1415
+ liveMessage.value = `Added ${column.label} column.`;
1416
+ closeColumnComposer();
1417
+ } catch (error) {
1418
+ emit('column-create-error', { ...request, error });
1419
+ liveMessage.value = `Could not add ${label} column.`;
1420
+ } finally {
1421
+ columnCreating.value = false;
1422
+ }
1423
+ }
1424
+ </script>
1425
+
1426
+ <template>
1427
+ <section
1428
+ class="relative w-full min-w-0 overflow-hidden rounded-xl border border-border bg-secondary/25 text-canvas-fg shadow-sm"
1429
+ :aria-labelledby="titleId"
1430
+ :data-disabled="disabled ? '' : undefined"
1431
+ >
1432
+ <h2 :id="titleId" class="sr-only">{{ boardLabel }}</h2>
1433
+ <p class="sr-only" aria-live="polite">{{ liveMessage }}</p>
1434
+
1435
+ <div class="overflow-x-auto overflow-y-hidden overscroll-x-contain" :style="boardStyle">
1436
+ <div role="list" :aria-label="boardLabel" class="flex h-full min-w-max items-stretch p-3">
1437
+ <template v-for="(column, columnIndex) in columnRecords" :key="column.key">
1438
+ <div
1439
+ aria-hidden="true"
1440
+ class="mx-1 w-2 shrink-0 rounded-full transition-colors"
1441
+ :class="isColumnDropTarget(columnIndex) ? 'bg-primary' : 'bg-transparent'"
1442
+ @dragenter="onColumnDragEnter(columnIndex, $event)"
1443
+ @dragover="onColumnDragOver"
1444
+ @drop="onColumnDrop(columnIndex, $event)"
1445
+ ></div>
1446
+
1447
+ <section
1448
+ role="listitem"
1449
+ class="flex h-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-secondary/55"
1450
+ :class="column.class"
1451
+ :style="resolvedColumnStyle"
1452
+ :data-column-key="column.key"
1453
+ @dragenter.capture="onCardColumnDragEnter(column, $event)"
1454
+ @dragover.capture="onCardColumnDragOver(column, $event)"
1455
+ @dragleave.capture="onCardColumnDragLeave(column, $event)"
1456
+ @drop.capture="onCardColumnDrop(column, $event)"
1457
+ >
1458
+ <header class="flex shrink-0 items-start gap-2 border-b border-border bg-canvas/90 px-3 py-3 backdrop-blur">
1459
+ <div class="min-w-0 flex-1">
1460
+ <slot
1461
+ name="column-header"
1462
+ :column="column"
1463
+ :column-key="column.key"
1464
+ :index="columnIndex"
1465
+ :rows="column.cards.map((card) => card.row)"
1466
+ :count="column.cards.length"
1467
+ :limit="column.limit"
1468
+ >
1469
+ <div class="flex min-w-0 items-center gap-2">
1470
+ <h3 class="truncate text-sm font-semibold tracking-tight">{{ column.label }}</h3>
1471
+ <span class="inline-flex min-w-6 items-center justify-center rounded-full bg-secondary px-1.5 py-0.5 text-[11px] font-semibold text-muted-fg ring-1 ring-border">
1472
+ {{ column.cards.length }}<template v-if="Number.isFinite(column.limit)">/{{ column.limit }}</template>
1473
+ </span>
1474
+ </div>
1475
+ <p v-if="column.description" class="mt-1 line-clamp-2 text-xs leading-4 text-muted-fg">{{ column.description }}</p>
1476
+ </slot>
1477
+ </div>
1478
+
1479
+ <button
1480
+ v-if="reorderColumns"
1481
+ type="button"
1482
+ class="grid size-7 shrink-0 cursor-grab place-items-center rounded-md text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 active:cursor-grabbing disabled:pointer-events-none disabled:opacity-40"
1483
+ :disabled="disabled || column.disabled"
1484
+ :draggable="!disabled && !column.disabled"
1485
+ :aria-label="`Reorder ${column.label} column`"
1486
+ :title="`Drag to reorder ${column.label}; Alt+Left or Alt+Right also works`"
1487
+ @dragstart.stop="startColumnDrag(column, columnIndex, $event)"
1488
+ @dragend="resetDragState"
1489
+ @keydown="onColumnKeydown(column, columnIndex, $event)"
1490
+ >
1491
+ <svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
1492
+ <circle cx="8" cy="7" r="1.3" /><circle cx="16" cy="7" r="1.3" />
1493
+ <circle cx="8" cy="12" r="1.3" /><circle cx="16" cy="12" r="1.3" />
1494
+ <circle cx="8" cy="17" r="1.3" /><circle cx="16" cy="17" r="1.3" />
1495
+ </svg>
1496
+ </button>
1497
+ </header>
1498
+
1499
+ <div role="list" :aria-label="`${column.label} ${resourceLabel}`" class="min-h-0 flex-1 overflow-y-auto px-2" data-kanban-card-list>
1500
+ <TransitionGroup name="dom-kanban-card" @before-leave="prepareCardLeave">
1501
+ <div
1502
+ v-for="(card, cardIndex) in column.cards"
1503
+ :key="card.rowKey"
1504
+ role="presentation"
1505
+ class="dom-kanban-card-item"
1506
+ data-kanban-card-item
1507
+ >
1508
+ <div
1509
+ aria-hidden="true"
1510
+ class="h-2 rounded-lg border border-transparent transition-[height,margin,background-color,border-color] duration-150"
1511
+ :class="isCardDropTarget(column, cardIndex) ? 'my-1 border-dashed border-primary/60 bg-primary/10 shadow-inner' : 'bg-transparent'"
1512
+ :style="cardDropPlaceholderStyle(column, cardIndex)"
1513
+ :data-kanban-drop-placeholder="isCardDropTarget(column, cardIndex) ? '' : undefined"
1514
+ ></div>
1515
+
1516
+ <article
1517
+ role="listitem"
1518
+ tabindex="0"
1519
+ class="group/card relative cursor-default rounded-lg outline-none transition focus-visible:ring-2 focus-visible:ring-ring/60"
1520
+ :class="[
1521
+ hasCustomCardRenderer(column) || $slots.card
1522
+ ? 'bg-transparent'
1523
+ : 'border border-border bg-canvas p-3 shadow-xs hover:border-ring/35 hover:shadow-sm',
1524
+ canDragCard(card, column) && 'cursor-grab active:cursor-grabbing',
1525
+ isDraggedCard(card) && 'opacity-45',
1526
+ column.cardClass,
1527
+ ]"
1528
+ :draggable="canDragCard(card, column)"
1529
+ :data-card-key="card.rowKey"
1530
+ :aria-label="`${rowTitle(card.row, cardIndex)}. ${column.label}, position ${cardIndex + 1}. Hold Alt and use arrow keys to move.`"
1531
+ @dragstart.stop="startCardDrag(card, column, $event)"
1532
+ @dragend="endCardDrag(card, column, $event)"
1533
+ @keydown="onCardKeydown(card, column, $event)"
1534
+ @click="onCardClick(card, column, $event)"
1535
+ >
1536
+ <slot
1537
+ name="card"
1538
+ :row="card.row"
1539
+ :row-key="card.rowKey"
1540
+ :column="column"
1541
+ :column-key="column.key"
1542
+ :index="cardIndex"
1543
+ :drag="{ active: dragState.kind === 'card', source: isDraggedCard(card) }"
1544
+ >
1545
+ <component
1546
+ :is="cardComponentFor(column)"
1547
+ v-if="cardComponentFor(column)"
1548
+ :row="card.row"
1549
+ :row-key="card.rowKey"
1550
+ :column="column"
1551
+ :column-key="column.key"
1552
+ :index="cardIndex"
1553
+ @action="onCardAction(card, column, $event)"
1554
+ />
1555
+ <div v-else>
1556
+ <h4 class="text-sm font-semibold leading-5 text-canvas-fg">{{ rowTitle(card.row, cardIndex) }}</h4>
1557
+ <p v-if="rowDescription(card.row, cardIndex)" class="mt-1.5 line-clamp-3 text-xs leading-5 text-muted-fg">
1558
+ {{ rowDescription(card.row, cardIndex) }}
1559
+ </p>
1560
+ </div>
1561
+ </slot>
1562
+ </article>
1563
+ </div>
1564
+ </TransitionGroup>
1565
+
1566
+ <div
1567
+ aria-hidden="true"
1568
+ class="h-2 rounded-lg border border-transparent transition-[height,margin,background-color,border-color] duration-150"
1569
+ :class="isCardDropTarget(column, column.cards.length) ? 'my-1 border-dashed border-primary/60 bg-primary/10 shadow-inner' : 'bg-transparent'"
1570
+ :style="cardDropPlaceholderStyle(column, column.cards.length)"
1571
+ :data-kanban-drop-placeholder="isCardDropTarget(column, column.cards.length) ? '' : undefined"
1572
+ ></div>
1573
+
1574
+ <div v-if="!column.cards.length && dragState.kind !== 'card'" class="rounded-lg border border-dashed border-border bg-canvas/50 px-4 py-8 text-center text-xs leading-5 text-muted-fg">
1575
+ <slot name="empty" :column="column" :column-key="column.key">{{ emptyText }}</slot>
1576
+ </div>
1577
+ </div>
1578
+
1579
+ <footer class="shrink-0 border-t border-border bg-secondary/40 p-2">
1580
+ <slot name="column-footer" :column="column" :column-key="column.key" :rows="column.cards.map((card) => card.row)" :count="column.cards.length" />
1581
+
1582
+ <template v-if="allowAddCards && column.addable !== false">
1583
+ <template v-if="cardComposerOpen[columnStateKey(column.key)]">
1584
+ <slot
1585
+ name="card-composer"
1586
+ :column="column"
1587
+ :column-key="column.key"
1588
+ :draft="cardDrafts[columnStateKey(column.key)]"
1589
+ :set-draft="(value) => setCardDraft(column, value)"
1590
+ :submit="() => submitCard(column)"
1591
+ :cancel="() => closeCardComposer(column)"
1592
+ :creating="Boolean(cardCreating[columnStateKey(column.key)])"
1593
+ >
1594
+ <form class="grid gap-2" @submit.prevent="submitCard(column)">
1595
+ <label class="grid gap-1.5">
1596
+ <span class="sr-only">Card title</span>
1597
+ <input
1598
+ :ref="(element) => setCardComposerInput(column, element)"
1599
+ v-model="cardDrafts[columnStateKey(column.key)]"
1600
+ type="text"
1601
+ class="skin-input min-h-20 resize-none rounded-lg px-3 py-2 text-sm"
1602
+ placeholder="Card title"
1603
+ :disabled="Boolean(cardCreating[columnStateKey(column.key)])"
1604
+ @keydown.escape.prevent="closeCardComposer(column)"
1605
+ />
1606
+ </label>
1607
+ <div class="flex items-center gap-2">
1608
+ <button type="submit" class="skin-action inline-flex h-8 items-center rounded-md px-3 text-xs font-semibold transition hover:opacity-90 disabled:pointer-events-none disabled:opacity-50" :disabled="!String(cardDrafts[columnStateKey(column.key)] || '').trim() || Boolean(cardCreating[columnStateKey(column.key)])">
1609
+ {{ cardCreating[columnStateKey(column.key)] ? 'Adding…' : 'Add card' }}
1610
+ </button>
1611
+ <button type="button" class="inline-flex h-8 items-center rounded-md px-3 text-xs font-semibold text-muted-fg transition hover:bg-secondary hover:text-canvas-fg" :disabled="Boolean(cardCreating[columnStateKey(column.key)])" @click="closeCardComposer(column)">
1612
+ Cancel
1613
+ </button>
1614
+ </div>
1615
+ </form>
1616
+ </slot>
1617
+ </template>
1618
+ <button
1619
+ v-else
1620
+ type="button"
1621
+ class="flex h-9 w-full items-center gap-2 rounded-lg px-2.5 text-left text-xs font-semibold text-muted-fg transition hover:bg-canvas hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40"
1622
+ :disabled="disabled || column.disabled || (Number.isFinite(column.limit) && column.cards.length >= column.limit)"
1623
+ @click="openCardComposer(column)"
1624
+ >
1625
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
1626
+ {{ addCardLabel }}
1627
+ </button>
1628
+ </template>
1629
+ </footer>
1630
+ </section>
1631
+ </template>
1632
+
1633
+ <div
1634
+ aria-hidden="true"
1635
+ class="mx-1 w-2 shrink-0 rounded-full transition-colors"
1636
+ :class="isColumnDropTarget(columnRecords.length) ? 'bg-primary' : 'bg-transparent'"
1637
+ @dragenter="onColumnDragEnter(columnRecords.length, $event)"
1638
+ @dragover="onColumnDragOver"
1639
+ @drop="onColumnDrop(columnRecords.length, $event)"
1640
+ ></div>
1641
+
1642
+ <div v-if="allowAddColumns" class="shrink-0" :style="resolvedColumnStyle">
1643
+ <template v-if="columnComposerOpen">
1644
+ <slot name="column-composer" :draft="columnDraft" :set-draft="setColumnDraft" :submit="submitColumn" :cancel="closeColumnComposer" :creating="columnCreating">
1645
+ <form class="grid gap-2 rounded-xl border border-border bg-secondary/70 p-3 shadow-sm" @submit.prevent="submitColumn">
1646
+ <label class="grid gap-1.5">
1647
+ <span class="text-xs font-semibold text-muted-fg">Column name</span>
1648
+ <input v-model="columnDraft" type="text" class="skin-input h-10 rounded-lg" placeholder="For example: Review" autofocus :disabled="columnCreating" @keydown.escape.prevent="closeColumnComposer" />
1649
+ </label>
1650
+ <div class="flex items-center gap-2">
1651
+ <button type="submit" class="skin-action inline-flex h-8 items-center rounded-md px-3 text-xs font-semibold transition hover:opacity-90 disabled:pointer-events-none disabled:opacity-50" :disabled="!columnDraft.trim() || columnCreating">
1652
+ {{ columnCreating ? 'Adding…' : 'Add column' }}
1653
+ </button>
1654
+ <button type="button" class="inline-flex h-8 items-center rounded-md px-3 text-xs font-semibold text-muted-fg transition hover:bg-canvas hover:text-canvas-fg" :disabled="columnCreating" @click="closeColumnComposer">Cancel</button>
1655
+ </div>
1656
+ </form>
1657
+ </slot>
1658
+ </template>
1659
+ <button
1660
+ v-else
1661
+ type="button"
1662
+ class="flex h-12 w-full items-center gap-2 rounded-xl border border-border bg-secondary/70 px-4 text-left text-sm font-semibold text-muted-fg shadow-sm transition hover:bg-canvas hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40"
1663
+ :disabled="disabled"
1664
+ @click="columnComposerOpen = true"
1665
+ >
1666
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
1667
+ {{ addColumnLabel }}
1668
+ </button>
1669
+ </div>
1670
+ </div>
1671
+ </div>
1672
+
1673
+ <div v-if="loading" class="pointer-events-none absolute inset-0 grid place-items-center bg-canvas/55 backdrop-blur-[1px]" aria-live="polite">
1674
+ <span class="inline-flex items-center gap-2 rounded-full border border-border bg-canvas px-3 py-2 text-xs font-semibold text-muted-fg shadow-sm">
1675
+ <svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-opacity=".25" /><path d="M21 12a9 9 0 0 1-9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>
1676
+ Loading {{ resourceLabel }}…
1677
+ </span>
1678
+ </div>
1679
+ </section>
1680
+ </template>
1681
+
1682
+ <style scoped>
1683
+ .dom-kanban-card-leave-active {
1684
+ min-height: 0;
1685
+ overflow: hidden;
1686
+ transition:
1687
+ height 180ms cubic-bezier(0.32, 0.72, 0, 1),
1688
+ opacity 140ms ease-out,
1689
+ transform 180ms cubic-bezier(0.32, 0.72, 0, 1);
1690
+ }
1691
+
1692
+ .dom-kanban-card-leave-to {
1693
+ height: 0 !important;
1694
+ opacity: 0;
1695
+ transform: translateY(-0.25rem);
1696
+ }
1697
+
1698
+ @media (prefers-reduced-motion: reduce) {
1699
+ .dom-kanban-card-leave-active {
1700
+ transition-duration: 1ms;
1701
+ }
1702
+ }
1703
+ </style>