@svgrid/grid 2.1.22 → 2.2.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 (295) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/FlexRender.svelte +96 -96
  7. package/dist/GridFooter.svelte +178 -178
  8. package/dist/GridMenus.svelte +83 -3
  9. package/dist/SvAutoComplete.svelte +5 -3
  10. package/dist/SvCarousel.svelte +141 -163
  11. package/dist/SvColorInput.svelte +187 -186
  12. package/dist/SvColorInput.svelte.d.ts +1 -1
  13. package/dist/SvComboBox.svelte +7 -3
  14. package/dist/SvCommand.svelte +28 -85
  15. package/dist/SvContextMenu.svelte +116 -116
  16. package/dist/SvCountryInput.svelte +1 -1
  17. package/dist/SvDateRangeInput.svelte +1 -1
  18. package/dist/SvDateTimePicker.svelte +9 -3
  19. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  20. package/dist/SvDockLayout.svelte +244 -0
  21. package/dist/SvDockLayout.svelte.d.ts +77 -0
  22. package/dist/SvDockManager.svelte +922 -0
  23. package/dist/SvDockManager.svelte.d.ts +106 -0
  24. package/dist/SvDrawer.svelte +250 -228
  25. package/dist/SvDrawer.svelte.d.ts +4 -0
  26. package/dist/SvDropDownList.svelte +85 -13
  27. package/dist/SvDropDownList.svelte.d.ts +3 -0
  28. package/dist/SvField.svelte +21 -4
  29. package/dist/SvField.svelte.d.ts +1 -0
  30. package/dist/SvForm.svelte +201 -91
  31. package/dist/SvForm.svelte.d.ts +15 -22
  32. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  33. package/dist/SvGrid.controller.svelte.js +156 -27
  34. package/dist/SvGrid.css +193 -5
  35. package/dist/SvGrid.svelte +3131 -2786
  36. package/dist/SvGrid.types.d.ts +452 -1
  37. package/dist/SvGridBoard.svelte +2339 -2339
  38. package/dist/SvGridChart.svelte +1724 -1724
  39. package/dist/SvGridChartPanel.svelte +485 -485
  40. package/dist/SvGridDropdown.svelte +689 -682
  41. package/dist/SvGridSelect.svelte +1 -0
  42. package/dist/SvGroupCell.svelte +109 -109
  43. package/dist/SvListBox.svelte +36 -14
  44. package/dist/SvMaskedInput.svelte +4 -3
  45. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  46. package/dist/SvMenu.svelte +124 -124
  47. package/dist/SvMenuList.svelte +117 -165
  48. package/dist/SvMenuList.svelte.d.ts +2 -13
  49. package/dist/SvModal.svelte +12 -29
  50. package/dist/SvMultiSelect.svelte +1 -0
  51. package/dist/SvNumberInput.svelte +131 -130
  52. package/dist/SvNumberInput.svelte.d.ts +1 -1
  53. package/dist/SvOtpInput.svelte +158 -158
  54. package/dist/SvPagination.svelte +18 -18
  55. package/dist/SvPasswordInput.svelte +3 -2
  56. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  57. package/dist/SvPhoneInput.svelte +3 -2
  58. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  59. package/dist/SvPopover.svelte +139 -139
  60. package/dist/SvRowGroupPanel.svelte +170 -170
  61. package/dist/SvScrollArea.svelte +61 -61
  62. package/dist/SvStepper.svelte +6 -9
  63. package/dist/SvTagsInput.svelte +3 -2
  64. package/dist/SvTagsInput.svelte.d.ts +1 -1
  65. package/dist/SvTooltip.svelte +127 -138
  66. package/dist/SvTour.svelte +204 -176
  67. package/dist/SvTree.svelte +377 -377
  68. package/dist/SvTreeSelect.svelte +230 -229
  69. package/dist/build-api.js +76 -54
  70. package/dist/cdn/svgrid.js +10913 -6450
  71. package/dist/cdn/svgrid.svelte-external.js +9628 -5481
  72. package/dist/chart-export.js +8 -8
  73. package/dist/columns.js +6 -0
  74. package/dist/conditional-formatting.d.ts +67 -3
  75. package/dist/conditional-formatting.js +124 -17
  76. package/dist/createCarousel.svelte.d.ts +72 -0
  77. package/dist/createCarousel.svelte.js +80 -0
  78. package/dist/createCommand.svelte.d.ts +62 -0
  79. package/dist/createCommand.svelte.js +109 -0
  80. package/dist/createForm.svelte.d.ts +62 -0
  81. package/dist/createForm.svelte.js +374 -0
  82. package/dist/createMenu.svelte.d.ts +88 -0
  83. package/dist/createMenu.svelte.js +153 -0
  84. package/dist/createOverlay.svelte.d.ts +38 -0
  85. package/dist/createOverlay.svelte.js +68 -0
  86. package/dist/createPagination.d.ts +74 -0
  87. package/dist/createPagination.js +71 -0
  88. package/dist/createPopoverSelect.svelte.js +6 -12
  89. package/dist/createStepper.d.ts +44 -0
  90. package/dist/createStepper.js +26 -0
  91. package/dist/createTooltip.svelte.d.ts +32 -0
  92. package/dist/createTooltip.svelte.js +61 -0
  93. package/dist/datetime/timezone.d.ts +36 -0
  94. package/dist/datetime/timezone.js +110 -0
  95. package/dist/dock-context.d.ts +52 -0
  96. package/dist/dock-context.js +1 -0
  97. package/dist/dock-manager-model.d.ts +136 -0
  98. package/dist/dock-manager-model.js +514 -0
  99. package/dist/dock-model.d.ts +105 -0
  100. package/dist/dock-model.js +288 -0
  101. package/dist/dock-popout.d.ts +30 -0
  102. package/dist/dock-popout.js +81 -0
  103. package/dist/editor-contract.d.ts +2 -0
  104. package/dist/filter-operators.d.ts +6 -0
  105. package/dist/filter-operators.js +35 -2
  106. package/dist/filtering/excel-filters.d.ts +18 -1
  107. package/dist/filtering/excel-filters.js +56 -0
  108. package/dist/form-field.d.ts +73 -0
  109. package/dist/form-field.js +15 -0
  110. package/dist/index.d.ts +25 -5
  111. package/dist/index.js +40 -4
  112. package/dist/list-nav.d.ts +18 -0
  113. package/dist/list-nav.js +30 -0
  114. package/dist/list-option.d.ts +2 -0
  115. package/dist/menu-item.d.ts +20 -0
  116. package/dist/menu-item.js +1 -0
  117. package/dist/menus.d.ts +5 -0
  118. package/dist/menus.js +68 -16
  119. package/dist/panel-resize.d.ts +25 -0
  120. package/dist/panel-resize.js +40 -0
  121. package/dist/popover.d.ts +29 -3
  122. package/dist/popover.js +22 -6
  123. package/dist/recurrence.d.ts +33 -7
  124. package/dist/recurrence.js +151 -13
  125. package/dist/row-drag.d.ts +8 -0
  126. package/dist/row-drag.js +11 -0
  127. package/dist/scheduler-ical.d.ts +30 -0
  128. package/dist/scheduler-ical.js +180 -0
  129. package/dist/scheduler-model.d.ts +269 -0
  130. package/dist/scheduler-model.js +602 -0
  131. package/dist/scheduler-view.svelte.d.ts +22 -0
  132. package/dist/scheduler-view.svelte.js +13 -0
  133. package/dist/spreadsheet.js +20 -12
  134. package/dist/ui-app.types.d.ts +3 -0
  135. package/package.json +14 -10
  136. package/src/DockNodeView.svelte +306 -0
  137. package/src/DockPopoutHost.svelte +17 -0
  138. package/src/FlexRender.svelte +96 -96
  139. package/src/GridFooter.svelte +178 -178
  140. package/src/GridMenus.svelte +83 -3
  141. package/src/SvAutoComplete.svelte +5 -3
  142. package/src/SvCarousel.svelte +141 -163
  143. package/src/SvCarousel.test.ts +68 -0
  144. package/src/SvColorInput.svelte +187 -186
  145. package/src/SvComboBox.svelte +7 -3
  146. package/src/SvCommand.svelte +28 -85
  147. package/src/SvCommand.test.ts +78 -0
  148. package/src/SvContextMenu.svelte +116 -116
  149. package/src/SvCountryInput.svelte +1 -1
  150. package/src/SvDateRangeInput.svelte +1 -1
  151. package/src/SvDateTimePicker.svelte +9 -3
  152. package/src/SvDockLayout.svelte +244 -0
  153. package/src/SvDockManager.svelte +922 -0
  154. package/src/SvDrawer.svelte +250 -228
  155. package/src/SvDropDownList.svelte +85 -13
  156. package/src/SvField.svelte +21 -4
  157. package/src/SvForm.svelte +201 -91
  158. package/src/SvForm.test.ts +299 -0
  159. package/src/SvGrid.controller.svelte.ts +3158 -3034
  160. package/src/SvGrid.css +193 -5
  161. package/src/SvGrid.svelte +3131 -2786
  162. package/src/SvGrid.types.ts +1489 -1027
  163. package/src/SvGridBoard.svelte +2339 -2339
  164. package/src/SvGridChart.svelte +1724 -1724
  165. package/src/SvGridChartPanel.svelte +485 -485
  166. package/src/SvGridDropdown.svelte +689 -682
  167. package/src/SvGridSelect.svelte +1 -0
  168. package/src/SvGroupCell.svelte +109 -109
  169. package/src/SvListBox.svelte +36 -14
  170. package/src/SvMaskedInput.svelte +4 -3
  171. package/src/SvMenu.svelte +124 -124
  172. package/src/SvMenu.test.ts +97 -73
  173. package/src/SvMenuList.svelte +117 -165
  174. package/src/SvModal.svelte +12 -29
  175. package/src/SvModal.test.ts +82 -0
  176. package/src/SvMultiSelect.svelte +1 -0
  177. package/src/SvNumberInput.svelte +131 -130
  178. package/src/SvOtpInput.svelte +158 -158
  179. package/src/SvPagination.svelte +18 -18
  180. package/src/SvPasswordInput.svelte +3 -2
  181. package/src/SvPhoneInput.svelte +3 -2
  182. package/src/SvPopover.svelte +139 -139
  183. package/src/SvRowGroupPanel.svelte +170 -170
  184. package/src/SvScrollArea.svelte +61 -61
  185. package/src/SvStepper.svelte +6 -9
  186. package/src/SvTagsInput.svelte +3 -2
  187. package/src/SvTooltip.svelte +127 -138
  188. package/src/SvTooltip.test.ts +73 -0
  189. package/src/SvTour.svelte +204 -176
  190. package/src/SvTree.svelte +377 -377
  191. package/src/SvTreeSelect.svelte +230 -229
  192. package/src/a11y.contract.test.ts +49 -49
  193. package/src/a11y.test.ts +59 -59
  194. package/src/a11y.ts +61 -61
  195. package/src/build-api.ts +870 -847
  196. package/src/cell-formatting.ts +169 -169
  197. package/src/cell-render.ts +469 -469
  198. package/src/chart-export.ts +201 -201
  199. package/src/chart.ts +2296 -2296
  200. package/src/collaboration.test.ts +104 -104
  201. package/src/collaboration.ts +167 -167
  202. package/src/columns.ts +5 -0
  203. package/src/conditional-formatting.test.ts +104 -0
  204. package/src/conditional-formatting.ts +176 -19
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1111 -1111
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCarousel.svelte.ts +104 -0
  209. package/src/createCombobox.svelte.ts +179 -179
  210. package/src/createCommand.svelte.ts +125 -0
  211. package/src/createCountryInput.svelte.ts +157 -157
  212. package/src/createDropdownList.svelte.ts +162 -162
  213. package/src/createForm.svelte.ts +333 -0
  214. package/src/createGrid.svelte.ts +42 -42
  215. package/src/createGrid.test.ts +10 -10
  216. package/src/createGridState.svelte.ts +17 -17
  217. package/src/createMenu.svelte.ts +202 -0
  218. package/src/createOverlay.svelte.ts +101 -0
  219. package/src/createPagination.test.ts +65 -0
  220. package/src/createPagination.ts +84 -0
  221. package/src/createPopoverSelect.svelte.ts +206 -210
  222. package/src/createStepper.test.ts +62 -0
  223. package/src/createStepper.ts +61 -0
  224. package/src/createTooltip.svelte.ts +86 -0
  225. package/src/createTree.svelte.ts +319 -319
  226. package/src/datetime/timezone.test.ts +69 -0
  227. package/src/datetime/timezone.ts +134 -0
  228. package/src/dock-context.ts +47 -0
  229. package/src/dock-manager-model.test.ts +275 -0
  230. package/src/dock-manager-model.ts +593 -0
  231. package/src/dock-model.test.ts +163 -0
  232. package/src/dock-model.ts +368 -0
  233. package/src/dock-popout.ts +94 -0
  234. package/src/editing.test.ts +859 -859
  235. package/src/editing.ts +675 -675
  236. package/src/editor-contract.ts +2 -0
  237. package/src/export-data-api.test.ts +126 -126
  238. package/src/export-format.test.ts +107 -107
  239. package/src/export-format.ts +598 -598
  240. package/src/filter-operators.test.ts +59 -0
  241. package/src/filter-operators.ts +35 -2
  242. package/src/filtering/excel-filters.test.ts +86 -1
  243. package/src/filtering/excel-filters.ts +65 -0
  244. package/src/flex-render.ts +3 -3
  245. package/src/form-field.ts +87 -0
  246. package/src/index.ts +623 -469
  247. package/src/keyboard.test.ts +59 -59
  248. package/src/keyboard.ts +97 -97
  249. package/src/list-nav.test.ts +49 -0
  250. package/src/list-nav.ts +29 -0
  251. package/src/list-option.ts +2 -0
  252. package/src/menu-item.ts +21 -0
  253. package/src/menus.test.ts +48 -0
  254. package/src/menus.ts +639 -582
  255. package/src/merge-objects.ts +48 -48
  256. package/src/overlays.test.ts +90 -90
  257. package/src/panel-resize.ts +52 -0
  258. package/src/popover.bounds.test.ts +93 -0
  259. package/src/popover.ts +51 -7
  260. package/src/recurrence.test.ts +88 -1
  261. package/src/recurrence.ts +166 -15
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag.ts +10 -0
  264. package/src/scheduler-ical.test.ts +68 -0
  265. package/src/scheduler-ical.ts +180 -0
  266. package/src/scheduler-model.test.ts +546 -0
  267. package/src/scheduler-model.ts +853 -0
  268. package/src/scheduler-view.svelte.ts +33 -0
  269. package/src/selection.test.ts +754 -754
  270. package/src/selection.ts +600 -600
  271. package/src/server-data-source.test.ts +289 -289
  272. package/src/server-data-source.ts +413 -413
  273. package/src/sparkline.test.ts +68 -68
  274. package/src/sparkline.ts +169 -169
  275. package/src/spreadsheet.test.ts +489 -489
  276. package/src/spreadsheet.ts +312 -304
  277. package/src/static-functions.ts +11 -11
  278. package/src/subscribe.ts +38 -38
  279. package/src/svgrid-wrapper.types.ts +501 -501
  280. package/src/svgrid.behavior.test.ts +706 -706
  281. package/src/svgrid.charting.test.ts +527 -527
  282. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  283. package/src/svgrid.features.test.ts +157 -157
  284. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  285. package/src/svgrid.wrapper.test.ts +40 -40
  286. package/src/test-setup.ts +62 -62
  287. package/src/themes/index.ts +159 -159
  288. package/src/ui-app.types.ts +3 -0
  289. package/src/virtual.test.ts +6 -4
  290. package/src/virtualization/column-virtualizer.test.ts +27 -27
  291. package/src/virtualization/column-virtualizer.ts +30 -30
  292. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  293. package/src/virtualization/types.ts +30 -30
  294. package/src/virtualization/virtualizer.test.ts +47 -47
  295. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,2339 +1,2339 @@
1
- <script
2
- lang="ts"
3
- generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData"
4
- >
5
- // Kanban board renderer for SvGrid. Rendered in place of the table when the
6
- // `board` prop is set. Everything a board needs is built in here:
7
- // - buckets rows into lanes by a field (explicit lanes or derived)
8
- // - renders each row as a card (default template or a `card` snippet)
9
- // - mouse drag-and-drop between and within lanes, with a live insertion
10
- // marker, AND keyboard drag-and-drop (grab with Space, move with arrows)
11
- // - the move itself (lane reassign + reorder) is computed and applied here
12
- // as a per-row overlay keyed by row id, so consumers write no move code;
13
- // `onCardMove` fires purely as a notification for persistence / mirroring
14
- // Themes entirely from the grid's `--sg-*` tokens.
15
- import { tick } from "svelte";
16
- import type { ColumnDef, RowData, TableFeatures } from "./index";
17
- import type { BoardConfig, BoardLane, BoardSubtask, BoardComment } from "./SvGrid.types";
18
- import SvMenuList, { type MenuItem } from "./SvMenuList.svelte";
19
- import SvForm, { type FormField, type FormFieldType } from "./SvForm.svelte";
20
- import SvDrawer from "./SvDrawer.svelte";
21
- import { portalToBody, popIn } from "./popover";
22
- import { createDismissableLayer } from "./a11y/dismissable";
23
-
24
- let {
25
- data,
26
- columns,
27
- board,
28
- getRowId,
29
- }: {
30
- data: ReadonlyArray<TData>;
31
- columns: Array<ColumnDef<TFeatures, TData>>;
32
- board: BoardConfig<TFeatures, TData>;
33
- getRowId?: (row: TData, index: number) => string;
34
- } = $props();
35
-
36
- const groupBy = $derived(board.groupBy);
37
-
38
- // --- value access (casts live here, never in the template) ---
39
- function fieldValue(row: TData, field: string): unknown {
40
- // Saved edits show immediately without mutating the consumer's rows.
41
- const e = edits[key(row)];
42
- if (e && field in e) return e[field];
43
- return (row as Record<string, unknown>)[field];
44
- }
45
- function rawLane(row: TData): string {
46
- const v = fieldValue(row, groupBy);
47
- return v == null ? "" : String(v);
48
- }
49
- function fmt(value: unknown): string {
50
- if (value == null) return "";
51
- if (value instanceof Date) return value.toLocaleDateString();
52
- return String(value);
53
- }
54
-
55
- // --- stable per-row key (getRowId, else a synthetic id pinned to the row
56
- // object) so the move overlay survives data adds/removes and re-sorts ---
57
- const indexOf = $derived(new Map<TData, number>(data.map((r, i) => [r, i])));
58
- const synthetic = new WeakMap<object, string>();
59
- let synthSeq = 0;
60
- function key(row: TData): string {
61
- if (getRowId) return getRowId(row, indexOf.get(row) ?? 0);
62
- const obj = row as unknown as object;
63
- let id = synthetic.get(obj);
64
- if (!id) {
65
- id = `__b${++synthSeq}`;
66
- synthetic.set(obj, id);
67
- }
68
- return id;
69
- }
70
-
71
- // --- the move overlay: rowKey -> lane and rowKey -> sort order. Applied on
72
- // top of the data; never mutates the consumer's rows. The edit overlay does
73
- // the same for saved field values. ---
74
- let laneOf = $state<Record<string, string>>({});
75
- let orderOf = $state<Record<string, number>>({});
76
- let swimOf = $state<Record<string, string>>({});
77
- let edits = $state<Record<string, Record<string, unknown>>>({});
78
- let subDone = $state<Record<string, Record<string, boolean>>>({});
79
- let subAdded = $state<Record<string, BoardSubtask[]>>({});
80
- let laneOrder = $state<string[]>([]);
81
- let collapsedSwim = $state<Record<string, boolean>>({});
82
-
83
- function effectiveLane(row: TData): string {
84
- return laneOf[key(row)] ?? rawLane(row);
85
- }
86
- function ord(row: TData): number {
87
- return orderOf[key(row)] ?? (indexOf.get(row) ?? 0);
88
- }
89
-
90
- // --- swimlanes: an optional second axis (board.swimlaneBy). ---
91
- const swimField = $derived(board.swimlaneBy);
92
- function rawSwim(row: TData): string {
93
- if (!swimField) return "";
94
- const v = fieldValue(row, swimField);
95
- return v == null ? "" : String(v);
96
- }
97
- function effectiveSwim(row: TData): string {
98
- return swimOf[key(row)] ?? rawSwim(row);
99
- }
100
- const swimlanes = $derived.by<Array<{ id: string; title: string }>>(() => {
101
- if (!swimField) return [];
102
- const seen = new Set<string>();
103
- const out: Array<{ id: string; title: string }> = [];
104
- for (const row of data) {
105
- const k = rawSwim(row);
106
- if (!seen.has(k)) {
107
- seen.add(k);
108
- out.push({ id: k, title: k === "" ? "(none)" : k });
109
- }
110
- }
111
- return out;
112
- });
113
-
114
- // --- lane collapse ---
115
- let collapsed = $state<Record<string, boolean>>({});
116
- function toggleCollapse(laneId: string) {
117
- if (board.collapsibleLanes) collapsed[laneId] = !collapsed[laneId];
118
- }
119
- function toggleSwim(swimId: string) {
120
- if (board.collapsibleSwimlanes) collapsedSwim[swimId] = !collapsedSwim[swimId];
121
- }
122
-
123
- // --- inline lane rename (board.onLaneRename) ---
124
- let renamingLane = $state<string | null>(null);
125
- let renameText = $state("");
126
- function startRename(lane: BoardLane) {
127
- if (!board.onLaneRename) return;
128
- renamingLane = lane.id;
129
- renameText = laneTitle(lane);
130
- }
131
- function commitRename(laneId: string) {
132
- const t = renameText.trim();
133
- renamingLane = null;
134
- if (t) board.onLaneRename?.(laneId, t);
135
- }
136
-
137
- // --- layout persistence: the overlay + collapse state, saved to
138
- // localStorage (persistKey) and/or emitted via onLayoutChange. Needs a
139
- // stable getRowId so the keys survive a reload. ---
140
- const canPersist = $derived(!!board.persistKey && !!getRowId);
141
- function serializeLayout() {
142
- return {
143
- laneOf: { ...laneOf },
144
- orderOf: { ...orderOf },
145
- swimOf: { ...swimOf },
146
- edits: { ...edits },
147
- collapsed: { ...collapsed },
148
- subDone: { ...subDone },
149
- subAdded: { ...subAdded },
150
- laneOrder: [...laneOrder],
151
- collapsedSwim: { ...collapsedSwim },
152
- };
153
- }
154
- // Restore once at init (before first paint).
155
- if (board.persistKey && getRowId && typeof localStorage !== "undefined") {
156
- try {
157
- const raw = localStorage.getItem(board.persistKey);
158
- if (raw) {
159
- const s = JSON.parse(raw);
160
- laneOf = s.laneOf ?? {};
161
- orderOf = s.orderOf ?? {};
162
- swimOf = s.swimOf ?? {};
163
- edits = s.edits ?? {};
164
- collapsed = s.collapsed ?? {};
165
- subDone = s.subDone ?? {};
166
- subAdded = s.subAdded ?? {};
167
- laneOrder = s.laneOrder ?? [];
168
- collapsedSwim = s.collapsedSwim ?? {};
169
- }
170
- } catch {
171
- /* ignore corrupt/blocked storage */
172
- }
173
- }
174
- let layoutLoaded = false;
175
- $effect(() => {
176
- const layout = serializeLayout(); // reads every map -> tracks changes
177
- // Skip the initial run so we don't rewrite what we just restored.
178
- if (!layoutLoaded) {
179
- layoutLoaded = true;
180
- return;
181
- }
182
- if (canPersist && typeof localStorage !== "undefined") {
183
- try {
184
- localStorage.setItem(board.persistKey as string, JSON.stringify(layout));
185
- } catch {
186
- /* ignore */
187
- }
188
- }
189
- board.onLayoutChange?.(layout);
190
- });
191
-
192
- // --- per-lane virtualization (fixed-height windowing) ---
193
- const virtualized = $derived(board.virtualized === true);
194
- const cardH = $derived(board.cardHeight ?? 76);
195
- const VOVERSCAN = 4;
196
- const VGAP = 8;
197
- let laneScroll = $state<Record<string, number>>({});
198
- let laneVh = $state<Record<string, number>>({});
199
- const laneBodyEl = new Map<string, HTMLElement>();
200
- function cellKey(laneId: string, swimId: string | null): string {
201
- return `${swimId ?? ""}::${laneId}`;
202
- }
203
- function laneWindow(laneId: string, swimId: string | null, count: number) {
204
- if (!virtualized) return { start: 0, end: count, top: 0, bottom: 0 };
205
- const ck = cellKey(laneId, swimId);
206
- const stride = cardH + VGAP;
207
- const scroll = laneScroll[ck] ?? 0;
208
- const vh = laneVh[ck] || 400;
209
- const start = Math.max(0, Math.floor(scroll / stride) - VOVERSCAN);
210
- const end = Math.min(count, Math.ceil((scroll + vh) / stride) + VOVERSCAN);
211
- return { start, end, top: start * stride, bottom: Math.max(0, (count - end) * stride) };
212
- }
213
- // Svelte action: track a lane body's scroll + height so windowing follows it.
214
- function trackLane(node: HTMLElement, ck: string) {
215
- laneBodyEl.set(ck, node);
216
- const update = () => {
217
- laneScroll[ck] = node.scrollTop;
218
- laneVh[ck] = node.clientHeight;
219
- };
220
- update();
221
- node.addEventListener("scroll", update, { passive: true });
222
- let ro: ResizeObserver | undefined;
223
- if (typeof ResizeObserver !== "undefined") {
224
- ro = new ResizeObserver(update);
225
- ro.observe(node);
226
- }
227
- return {
228
- update(nextCk: string) {
229
- laneBodyEl.delete(ck);
230
- ck = nextCk;
231
- laneBodyEl.set(ck, node);
232
- update();
233
- },
234
- destroy() {
235
- laneBodyEl.delete(ck);
236
- node.removeEventListener("scroll", update);
237
- ro?.disconnect();
238
- },
239
- };
240
- }
241
-
242
- // --- columns lookup + default title/meta fields ---
243
- const fieldColumns = $derived(
244
- columns.filter((c) => typeof c.field === "string"),
245
- );
246
- function headerLabel(field: string): string {
247
- const col = fieldColumns.find((c) => c.field === field);
248
- return col && typeof col.header === "string" ? col.header : field;
249
- }
250
- const titleField = $derived(
251
- board.titleField ?? (fieldColumns[0]?.field as string | undefined),
252
- );
253
- const metaFields = $derived.by(() => {
254
- if (board.cardFields) return board.cardFields.slice(0, 4);
255
- return fieldColumns
256
- .map((c) => c.field as string)
257
- .filter((f) => f !== titleField && f !== groupBy)
258
- .slice(0, 3);
259
- });
260
-
261
- // --- built-in card editor: fields come from columns with an editorType (or
262
- // all fields if none declare one). Edits go to the overlay + `onCardCommit`.
263
- const editable = $derived(board.editable === true);
264
- const editColumns = $derived.by(() => {
265
- const typed = fieldColumns.filter((c) => c.editorType);
266
- return (typed.length ? typed : fieldColumns).filter(
267
- (c) => c.field !== groupBy,
268
- );
269
- });
270
- function editorKind(col: (typeof editColumns)[number]): string {
271
- const t = col.editorType;
272
- if (t === "number") return "number";
273
- if (t === "checkbox") return "checkbox";
274
- if (t === "date") return "date";
275
- return "text";
276
- }
277
-
278
- let editingKey = $state<string | null>(null);
279
- let draft = $state<Record<string, unknown>>({});
280
-
281
- function openEditor(row: TData) {
282
- if (board.onCardEdit) {
283
- board.onCardEdit(row);
284
- return;
285
- }
286
- if (board.drawer) {
287
- drawerRow = row;
288
- return;
289
- }
290
- if (!editable) return;
291
- const next: Record<string, unknown> = {};
292
- for (const col of editColumns) {
293
- const f = col.field as string;
294
- next[f] = fieldValue(row, f);
295
- }
296
- draft = next;
297
- editingKey = key(row);
298
- }
299
- function saveEditor(row: TData) {
300
- const k = key(row);
301
- const changes: Record<string, unknown> = {};
302
- for (const col of editColumns) {
303
- const f = col.field as string;
304
- if (draft[f] !== fieldValue(row, f)) changes[f] = draft[f];
305
- }
306
- edits[k] = { ...(edits[k] ?? {}), ...draft };
307
- editingKey = null;
308
- board.onCardCommit?.({ row, changes, values: { ...draft } });
309
- }
310
- function cancelEditor() {
311
- editingKey = null;
312
- draft = {};
313
- }
314
- function onDraftInput(field: string, kind: string, e: Event) {
315
- const el = e.currentTarget as HTMLInputElement;
316
- draft[field] =
317
- kind === "checkbox"
318
- ? el.checked
319
- : kind === "number"
320
- ? el.value === ""
321
- ? null
322
- : Number(el.value)
323
- : el.value;
324
- }
325
-
326
- // --- built-in detail drawer (board.drawer): an SvDrawer + SvForm of the
327
- // card's fields, rendered with the UI-kit editors. ---
328
- let drawerRow = $state.raw<TData | null>(null);
329
- const drawerCfg = $derived(
330
- board.drawer && typeof board.drawer === "object" ? board.drawer : null,
331
- );
332
- type DrawerCol = ColumnDef<TFeatures, TData>;
333
- const drawerFieldCols = $derived.by<DrawerCol[]>(() => {
334
- const wanted = drawerCfg?.fields;
335
- if (wanted && wanted.length) {
336
- return wanted
337
- .map((f) => fieldColumns.find((c) => c.field === f))
338
- .filter((c): c is DrawerCol => !!c);
339
- }
340
- return fieldColumns;
341
- });
342
- function formType(t: string | undefined): FormFieldType {
343
- switch (t) {
344
- case "number":
345
- return "number";
346
- case "checkbox":
347
- return "checkbox";
348
- case "date":
349
- case "date-native":
350
- case "datetime":
351
- case "datetime-native":
352
- return "date";
353
- case "textarea":
354
- return "textarea";
355
- case "password":
356
- return "password";
357
- case "color":
358
- return "color";
359
- case "list":
360
- case "select":
361
- case "rich-select":
362
- return "select";
363
- default:
364
- return "text";
365
- }
366
- }
367
- function drawerOptions(col: DrawerCol, row: TData) {
368
- const raw =
369
- typeof col.editorOptions === "function" ? col.editorOptions(row) : col.editorOptions;
370
- if (!raw) return undefined;
371
- return raw.map((o) =>
372
- typeof o === "object"
373
- ? { value: o.value, label: o.label ?? String(o.value) }
374
- : { value: o, label: String(o) },
375
- );
376
- }
377
- function drawerForm(row: TData): FormField[] {
378
- return drawerFieldCols.map((col) => {
379
- const f = col.field as string;
380
- const type = formType(col.editorType);
381
- return {
382
- name: f,
383
- label: typeof col.header === "string" ? col.header : f,
384
- type,
385
- options: type === "select" ? drawerOptions(col, row) : undefined,
386
- };
387
- });
388
- }
389
- function drawerInitial(row: TData): Record<string, unknown> {
390
- const out: Record<string, unknown> = {};
391
- for (const col of drawerFieldCols) {
392
- const f = col.field as string;
393
- out[f] = fieldValue(row, f);
394
- }
395
- return out;
396
- }
397
- function drawerTitleOf(row: TData): string {
398
- const t = drawerCfg?.title;
399
- if (typeof t === "function") return t(row);
400
- if (typeof t === "string") return t;
401
- return titleField ? fmt(fieldValue(row, titleField)) : "Details";
402
- }
403
- function onDrawerSubmit(row: TData, values: Record<string, unknown>) {
404
- const k = key(row);
405
- const changes: Record<string, unknown> = {};
406
- for (const [f, v] of Object.entries(values)) {
407
- if (v !== fieldValue(row, f)) changes[f] = v;
408
- }
409
- edits[k] = { ...(edits[k] ?? {}), ...values };
410
- board.onCardCommit?.({ row, changes, values });
411
- drawerRow = null;
412
- }
413
-
414
- // --- sub-tasks (checklist on a card). Toggles/adds live in an overlay so the
415
- // consumer's data is never mutated; callbacks notify. subDone/subAdded are
416
- // declared with the other overlays above (so persistence can read them). ---
417
- let subOpen = $state<Record<string, boolean>>({});
418
- let subSeq = 0;
419
- function baseSubtasks(row: TData): BoardSubtask[] {
420
- if (!board.subtasks) return [];
421
- const v = fieldValue(row, board.subtasks);
422
- return Array.isArray(v) ? (v as BoardSubtask[]) : [];
423
- }
424
- function subtasksOf(row: TData): BoardSubtask[] {
425
- const k = key(row);
426
- const overrides = subDone[k] ?? {};
427
- const base = baseSubtasks(row).map((s) =>
428
- String(s.id) in overrides ? { ...s, done: overrides[String(s.id)]! } : s,
429
- );
430
- return [...base, ...(subAdded[k] ?? [])];
431
- }
432
- function subProgress(row: TData): { done: number; total: number } {
433
- const list = subtasksOf(row);
434
- return { done: list.filter((s) => s.done).length, total: list.length };
435
- }
436
- function toggleSubtask(row: TData, sub: BoardSubtask) {
437
- const k = key(row);
438
- const next = !sub.done;
439
- const added = subAdded[k];
440
- const inAdded = added?.find((s) => String(s.id) === String(sub.id));
441
- if (inAdded) inAdded.done = next;
442
- else subDone[k] = { ...(subDone[k] ?? {}), [String(sub.id)]: next };
443
- board.onSubtaskToggle?.(row, sub.id, next);
444
- }
445
- function addSubtask(row: TData, title: string) {
446
- const t = title.trim();
447
- if (!t) return;
448
- const k = key(row);
449
- subAdded[k] = [...(subAdded[k] ?? []), { id: `s${++subSeq}`, title: t, done: false }];
450
- board.onSubtaskAdd?.(row, t);
451
- }
452
-
453
- // --- card badges (default card): labels, due date, assignees ---
454
- function labelsOf(row: TData): Array<{ text: string; color?: string }> {
455
- if (!board.labelsField) return [];
456
- const v = fieldValue(row, board.labelsField);
457
- if (!Array.isArray(v)) return [];
458
- return v.map((l) =>
459
- typeof l === "string" ? { text: l } : (l as { text: string; color?: string }),
460
- );
461
- }
462
- function assigneesOf(row: TData): string[] {
463
- if (!board.assigneesField) return [];
464
- const v = fieldValue(row, board.assigneesField);
465
- if (Array.isArray(v)) return v.map(String);
466
- return v == null ? [] : [String(v)];
467
- }
468
- function dueOf(row: TData): { label: string; overdue: boolean } | null {
469
- if (!board.dueField) return null;
470
- const v = fieldValue(row, board.dueField);
471
- if (v == null || v === "") return null;
472
- const d = v instanceof Date ? v : new Date(String(v));
473
- if (Number.isNaN(d.getTime())) return { label: String(v), overdue: false };
474
- const overdue = d.getTime() < todayStart;
475
- return { label: d.toLocaleDateString(undefined, { month: "short", day: "numeric" }), overdue };
476
- }
477
- function initials(name: string): string {
478
- return name.split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase();
479
- }
480
- function countOf(row: TData, field: string | undefined): number {
481
- if (!field) return 0;
482
- const v = fieldValue(row, field);
483
- if (Array.isArray(v)) return v.length;
484
- if (typeof v === "number") return v;
485
- return 0;
486
- }
487
- function coverColorOf(row: TData): string | null {
488
- if (!board.coverField) return null;
489
- const v = fieldValue(row, board.coverField);
490
- return v == null || v === "" ? null : String(v);
491
- }
492
- const todayStart = (() => {
493
- const n = new Date();
494
- return new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime();
495
- })();
496
- function isFlagged(row: TData): boolean {
497
- return board.flagField ? !!fieldValue(row, board.flagField) : false;
498
- }
499
- function flagReason(row: TData): string | null {
500
- if (!board.flagField) return null;
501
- const v = fieldValue(row, board.flagField);
502
- return typeof v === "string" && v.trim() ? v : null;
503
- }
504
- // A blocked card is locked from moving unless flagBlocksMoves is false.
505
- const flagBlocksMoves = $derived(board.flagBlocksMoves !== false);
506
- function movable(row: TData): boolean {
507
- return !(flagBlocksMoves && isFlagged(row));
508
- }
509
-
510
- // --- comments (read / write / delete) ---
511
- let commentsOpen = $state<Record<string, boolean>>({});
512
- function commentsOf(row: TData): BoardComment[] {
513
- if (!board.commentsField) return [];
514
- const v = fieldValue(row, board.commentsField);
515
- if (!Array.isArray(v)) return [];
516
- return v.map((c, i) =>
517
- typeof c === "string"
518
- ? { id: i, text: c }
519
- : (c as BoardComment),
520
- );
521
- }
522
- function addComment(row: TData, text: string) {
523
- const t = text.trim();
524
- if (t) board.onCommentAdd?.(row, t);
525
- }
526
- function deleteComment(row: TData, id: string | number) {
527
- board.onCommentDelete?.(row, id);
528
- }
529
- function ageOf(row: TData): string | null {
530
- if (!board.ageField) return null;
531
- const v = fieldValue(row, board.ageField);
532
- if (v == null || v === "") return null;
533
- const d = v instanceof Date ? v : new Date(String(v));
534
- if (Number.isNaN(d.getTime())) return null;
535
- const days = Math.floor((todayStart - d.getTime()) / 86400000);
536
- if (days <= 0) return "today";
537
- if (days < 7) return `${days}d`;
538
- if (days < 30) return `${Math.floor(days / 7)}w`;
539
- return `${Math.floor(days / 30)}mo`;
540
- }
541
-
542
- // --- facet filter bar (board.facets) ---
543
- let facetSel = $state<Record<string, string[]>>({});
544
- function rowFacetValues(row: TData, field: string): string[] {
545
- const v = fieldValue(row, field);
546
- if (Array.isArray(v))
547
- return v.map((el) =>
548
- typeof el === "string" ? el : String((el as { text?: unknown }).text ?? el),
549
- );
550
- return v == null || v === "" ? [] : [String(v)];
551
- }
552
- function facetOptions(field: string): Array<{ value: string; count: number }> {
553
- const counts = new Map<string, number>();
554
- for (const row of data)
555
- for (const val of rowFacetValues(row, field)) counts.set(val, (counts.get(val) ?? 0) + 1);
556
- return [...counts.entries()].map(([value, count]) => ({ value, count }));
557
- }
558
- function toggleFacet(field: string, value: string) {
559
- const cur = facetSel[field] ?? [];
560
- facetSel[field] = cur.includes(value) ? cur.filter((v) => v !== value) : [...cur, value];
561
- }
562
- function clearFacets() {
563
- facetSel = {};
564
- }
565
- const anyFacet = $derived(Object.values(facetSel).some((a) => a.length > 0));
566
- function matchesFacets(row: TData): boolean {
567
- for (const [field, sel] of Object.entries(facetSel)) {
568
- if (!sel.length) continue;
569
- const vals = rowFacetValues(row, field);
570
- if (!sel.some((s) => vals.includes(s))) return false;
571
- }
572
- return true;
573
- }
574
-
575
- // --- multi-select (board.selectable) ---
576
- let selected = $state<Record<string, boolean>>({});
577
- function selectedRows(): TData[] {
578
- return data.filter((r) => selected[key(r)]);
579
- }
580
- function emitSelection() {
581
- board.onSelectionChange?.(selectedRows());
582
- }
583
- function onCardClick(e: MouseEvent, row: TData) {
584
- if (!board.selectable) return;
585
- const k = key(row);
586
- if (e.ctrlKey || e.metaKey) {
587
- selected[k] = !selected[k];
588
- } else {
589
- selected = { [k]: true };
590
- }
591
- emitSelection();
592
- }
593
-
594
- // --- child cards (board.childrenField) - epic -> stories ---
595
- let childOpen = $state<Record<string, boolean>>({});
596
- function childrenOf(row: TData): TData[] {
597
- if (!board.childrenField) return [];
598
- const v = fieldValue(row, board.childrenField);
599
- return Array.isArray(v) ? (v as TData[]) : [];
600
- }
601
-
602
- // --- quick-add composer (per lane) ---
603
- let composerOpen = $state<Record<string, boolean>>({});
604
- let composerText = $state<Record<string, string>>({});
605
- function submitComposer(laneId: string) {
606
- const t = (composerText[laneId] ?? "").trim();
607
- if (t) board.onCardAdd?.(laneId, t);
608
- composerText[laneId] = "";
609
- }
610
- function focusNode(node: HTMLElement) {
611
- node.focus();
612
- }
613
-
614
- // --- lane model: explicit lanes, else distinct field values (first-seen).
615
- // Derived from the RAW field, not the move overlay, so the lane order stays
616
- // stable when a card is moved. (Pass `lanes` to pin the order explicitly.) ---
617
- const lanes = $derived.by<BoardLane[]>(() => {
618
- let base: BoardLane[];
619
- if (board.lanes && board.lanes.length) {
620
- base = [...board.lanes];
621
- } else {
622
- const seen = new Set<string>();
623
- base = [];
624
- for (const row of data) {
625
- const k = rawLane(row);
626
- if (!seen.has(k)) {
627
- seen.add(k);
628
- base.push({ id: k, title: k === "" ? "(empty)" : k });
629
- }
630
- }
631
- }
632
- if (!laneOrder.length) return base;
633
- // Apply the drag-reorder overlay: ordered ids first, then any new lanes.
634
- const byId = new Map(base.map((l) => [l.id, l]));
635
- const ordered = laneOrder.map((id) => byId.get(id)).filter((l): l is BoardLane => !!l);
636
- const rest = base.filter((l) => !laneOrder.includes(l.id));
637
- return [...ordered, ...rest];
638
- });
639
-
640
- // --- lane drag-reorder (drag lane headers) ---
641
- let dragLaneId = $state<string | null>(null);
642
- let overLaneId = $state<string | null>(null);
643
- function onLaneHeaderDragStart(e: DragEvent, laneId: string) {
644
- dragLaneId = laneId;
645
- if (e.dataTransfer) {
646
- e.dataTransfer.effectAllowed = "move";
647
- e.dataTransfer.setData("text/plain", `lane:${laneId}`);
648
- }
649
- }
650
- function onLaneHeaderDragOver(e: DragEvent, laneId: string) {
651
- if (dragLaneId == null || laneId === dragLaneId) return;
652
- e.preventDefault();
653
- overLaneId = laneId;
654
- }
655
- function onLaneHeaderDrop(e: DragEvent, laneId: string) {
656
- if (dragLaneId == null) return;
657
- e.preventDefault();
658
- const ids = lanes.map((l) => l.id);
659
- const to = ids.indexOf(laneId);
660
- if (to > -1) reorderLaneTo(dragLaneId, to);
661
- dragLaneId = null;
662
- overLaneId = null;
663
- }
664
- function onLaneHeaderDragEnd() {
665
- dragLaneId = null;
666
- overLaneId = null;
667
- }
668
-
669
- // Shared by mouse drag-drop (above) and keyboard reorder (below): moves
670
- // `fromId` to `toIndex` in the lane order, persists it, and notifies.
671
- // Returns the new ordered ids, or null if the move was a no-op / invalid.
672
- function reorderLaneTo(fromId: string, toIndex: number): string[] | null {
673
- const ids = lanes.map((l) => l.id);
674
- const from = ids.indexOf(fromId);
675
- if (from < 0 || toIndex < 0 || toIndex >= ids.length || from === toIndex) return null;
676
- ids.splice(toIndex, 0, ids.splice(from, 1)[0]!);
677
- laneOrder = ids;
678
- board.onLaneReorder?.(ids);
679
- return ids;
680
- }
681
- // Keyboard lane reorder: Ctrl/Cmd+ArrowLeft/Right shifts the focused lane
682
- // header by one position, mirroring the drag-reorder above.
683
- function onLaneHeaderKeydown(e: KeyboardEvent, laneId: string) {
684
- if (!board.reorderableLanes) return;
685
- if ((e.key !== "ArrowLeft" && e.key !== "ArrowRight") || !(e.ctrlKey || e.metaKey)) return;
686
- e.preventDefault();
687
- const ids = lanes.map((l) => l.id);
688
- const from = ids.indexOf(laneId);
689
- if (from < 0) return;
690
- const to = from + (e.key === "ArrowRight" ? 1 : -1);
691
- const moved = reorderLaneTo(laneId, to);
692
- if (moved) {
693
- const pos = moved.indexOf(laneId) + 1;
694
- announce(`Moved ${laneTitleById(laneId)} to position ${pos} of ${moved.length}.`);
695
- }
696
- }
697
-
698
- // Runtime group-by change: the position overlay (laneOf/orderOf/laneOrder) is
699
- // keyed to the old field, so reset it when `groupBy` changes. Edits and
700
- // sub-tasks (keyed by row) are kept. Skips the initial run so a restored
701
- // layout isn't wiped on mount.
702
- let gbInitialized = false;
703
- let prevGroupBy: string | undefined;
704
- $effect(() => {
705
- const g = groupBy;
706
- if (!gbInitialized) {
707
- gbInitialized = true;
708
- prevGroupBy = g;
709
- return;
710
- }
711
- if (g !== prevGroupBy) {
712
- prevGroupBy = g;
713
- laneOf = {};
714
- orderOf = {};
715
- laneOrder = [];
716
- }
717
- });
718
-
719
- function laneCards(laneId: string, swimId: string | null = null): TData[] {
720
- return data
721
- .filter(
722
- (r) =>
723
- effectiveLane(r) === laneId &&
724
- (swimId == null || effectiveSwim(r) === swimId) &&
725
- matchesFacets(r),
726
- )
727
- .sort((a, b) => ord(a) - ord(b));
728
- }
729
- function laneTitle(lane: BoardLane): string {
730
- return lane.title ?? (lane.id === "" ? "(empty)" : lane.id);
731
- }
732
- function laneWip(laneId: string): number | undefined {
733
- return lanes.find((l) => l.id === laneId)?.wipLimit ?? undefined;
734
- }
735
- function swimRows(swimId: string): TData[] {
736
- return data.filter((r) => effectiveSwim(r) === swimId && matchesFacets(r));
737
- }
738
- function swimCount(swimId: string): number {
739
- return swimRows(swimId).length;
740
- }
741
-
742
- // --- the one place a move is applied: compute the new order slot and record
743
- // it in the overlay, then notify. `pos` is the insertion index within the
744
- // destination lane (excluding the moved card). Returns false if rejected. ---
745
- function applyMove(
746
- row: TData,
747
- fromLane: string,
748
- toLane: string,
749
- pos: number,
750
- toSwim: string | null = null,
751
- fromSwim: string | null = null,
752
- ): boolean {
753
- const sameCell = fromLane === toLane && (toSwim == null || toSwim === fromSwim);
754
- // WIP enforcement: reject a move INTO a different lane past its limit.
755
- if (board.enforceWip && !sameCell) {
756
- const lim = laneWip(toLane);
757
- if (lim != null && laneCards(toLane, toSwim).filter((r) => r !== row).length >= lim) {
758
- announce(`${laneTitleById(toLane)} is at its WIP limit (${lim}).`);
759
- return false;
760
- }
761
- }
762
- const list = laneCards(toLane, toSwim).filter((r) => r !== row);
763
- const clamped = Math.max(0, Math.min(pos, list.length));
764
- if (sameCell) {
765
- const currentPos = laneCards(fromLane, toSwim).indexOf(row);
766
- if (currentPos === clamped) return false;
767
- }
768
- const before = list[clamped - 1];
769
- const after = list[clamped];
770
- let newOrd: number;
771
- if (before && after) newOrd = (ord(before) + ord(after)) / 2;
772
- else if (before) newOrd = ord(before) + 1;
773
- else if (after) newOrd = ord(after) - 1;
774
- else newOrd = 0;
775
- const k = key(row);
776
- laneOf[k] = toLane;
777
- orderOf[k] = newOrd;
778
- if (swimField && toSwim != null) swimOf[k] = toSwim;
779
- board.onCardMove?.({
780
- row,
781
- fromLane,
782
- toLane,
783
- toIndex: clamped,
784
- ...(swimField ? { fromSwimlane: fromSwim ?? undefined, toSwimlane: toSwim ?? undefined } : {}),
785
- });
786
- return true;
787
- }
788
-
789
- // --- mouse drag & drop ---
790
- // Row-holding state is `$state.raw` so the stored object keeps its identity
791
- // (plain `$state` would proxy it and break `row === dragRow` comparisons).
792
- let dragRow = $state.raw<TData | null>(null);
793
- let dragFromLane = $state<string | null>(null);
794
- let dragFromSwim = $state<string | null>(null);
795
- let overLane = $state<string | null>(null);
796
- let overSwim = $state<string | null>(null);
797
- let overRow = $state.raw<TData | null>(null);
798
- let overAfter = $state(false);
799
-
800
- function markerIndex(laneId: string, swimId: string | null, cards: TData[]): number {
801
- if (!dragRow || overLane !== laneId || overSwim !== swimId) return -1;
802
- if (!overRow) return cards.length;
803
- const i = cards.indexOf(overRow);
804
- if (i < 0) return cards.length;
805
- return overAfter ? i + 1 : i;
806
- }
807
-
808
- function onCardDragStart(e: DragEvent, row: TData, fromLane: string, fromSwim: string | null) {
809
- if (!movable(row)) {
810
- e.preventDefault();
811
- return;
812
- }
813
- // Dragging an unselected card (with multi-select on) reduces the selection
814
- // to just it, so the drag moves what the user grabbed.
815
- if (board.selectable && !selected[key(row)]) {
816
- selected = { [key(row)]: true };
817
- emitSelection();
818
- }
819
- dragRow = row;
820
- dragFromLane = fromLane;
821
- dragFromSwim = fromSwim;
822
- if (e.dataTransfer) {
823
- e.dataTransfer.effectAllowed = "move";
824
- e.dataTransfer.setData("text/plain", key(row));
825
- }
826
- }
827
- function onCardDragOver(e: DragEvent, row: TData, laneId: string, swimId: string | null) {
828
- if (!dragRow) return;
829
- e.preventDefault();
830
- e.stopPropagation();
831
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
832
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
833
- overLane = laneId;
834
- overSwim = swimId;
835
- overRow = row;
836
- overAfter = e.clientY - rect.top > rect.height / 2;
837
- }
838
- function onLaneDragOver(e: DragEvent, laneId: string, swimId: string | null) {
839
- if (!dragRow) return;
840
- e.preventDefault();
841
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
842
- // Only claim the append slot when the pointer is over empty lane space
843
- // (a card's own handler stops propagation for the over-a-card case).
844
- if (e.target === e.currentTarget) {
845
- overLane = laneId;
846
- overSwim = swimId;
847
- overRow = null;
848
- }
849
- }
850
- function onLaneDrop(e: DragEvent, laneId: string, swimId: string | null) {
851
- e.preventDefault();
852
- const row = dragRow;
853
- const fromLane = dragFromLane;
854
- const fromSwim = dragFromSwim;
855
- const target = overRow;
856
- const after = overAfter;
857
- resetDrag();
858
- if (!row || fromLane == null) return;
859
- // Multi-select: dragging a selected card moves the whole selection.
860
- const multi = board.selectable && selected[key(row)] && selectedRows().length > 1;
861
- const movers = multi ? selectedRows() : [row];
862
- const moverKeys = new Set(movers.map((r) => key(r)));
863
- const rest = laneCards(laneId, swimId).filter((r) => !moverKeys.has(key(r)));
864
- let pos: number;
865
- if (!target || moverKeys.has(key(target))) pos = rest.length;
866
- else {
867
- const i = rest.indexOf(target);
868
- pos = i < 0 ? rest.length : after ? i + 1 : i;
869
- }
870
- movers.forEach((r, i) => applyMove(r, effectiveLane(r), laneId, pos + i, swimId, fromSwim));
871
- }
872
- function resetDrag() {
873
- dragRow = null;
874
- dragFromLane = null;
875
- dragFromSwim = null;
876
- overLane = null;
877
- overSwim = null;
878
- overRow = null;
879
- overAfter = false;
880
- }
881
-
882
- // --- keyboard drag & drop (grab with Space/Enter, move with arrows) ---
883
- let grabbed = $state.raw<TData | null>(null);
884
- let grabInfo: { lane: string; order: number; swim: string } | null = null;
885
- let liveMsg = $state("");
886
-
887
- function announce(msg: string) {
888
- liveMsg = msg;
889
- }
890
-
891
- function laneIndex(laneId: string): number {
892
- return lanes.findIndex((l) => l.id === laneId);
893
- }
894
-
895
- function onCardKeydown(e: KeyboardEvent, row: TData, laneId: string) {
896
- if (e.key === "F2" && (editable || board.onCardEdit || board.drawer)) {
897
- e.preventDefault();
898
- openEditor(row);
899
- return;
900
- }
901
- // Keyboard multi-select: Ctrl/Cmd+Space toggles this card's selection,
902
- // mirroring onCardClick's ctrl/meta-click branch. Kept on a modifier so it
903
- // never collides with plain Space, which grabs the card for a move.
904
- if (board.selectable && e.key === " " && (e.ctrlKey || e.metaKey) && grabbed == null) {
905
- e.preventDefault();
906
- const k = key(row);
907
- selected[k] = !selected[k];
908
- emitSelection();
909
- announce(
910
- `${selected[k] ? "Selected" : "Deselected"} ${cardLabel(row)}. ${selectedRows().length} selected.`,
911
- );
912
- return;
913
- }
914
- if (e.key === " " || e.key === "Enter") {
915
- e.preventDefault();
916
- if (grabbed === row) {
917
- announce(`Dropped ${cardLabel(row)} in ${laneTitleById(laneId)}.`);
918
- grabbed = null;
919
- grabInfo = null;
920
- } else if (!movable(row)) {
921
- announce(`${cardLabel(row)} is blocked and can't be moved.`);
922
- } else {
923
- grabbed = row;
924
- grabInfo = { lane: effectiveLane(row), order: ord(row), swim: effectiveSwim(row) };
925
- announce(
926
- `Grabbed ${cardLabel(row)}. Use arrow keys to move, Space to drop, Escape to cancel.`,
927
- );
928
- }
929
- return;
930
- }
931
- if (e.key === "Escape" && grabbed === row) {
932
- e.preventDefault();
933
- if (grabInfo) {
934
- const k = key(row);
935
- laneOf[k] = grabInfo.lane;
936
- orderOf[k] = grabInfo.order;
937
- if (swimField) swimOf[k] = grabInfo.swim;
938
- }
939
- grabbed = null;
940
- grabInfo = null;
941
- announce("Cancelled.");
942
- return;
943
- }
944
- const li = laneIndex(laneId);
945
- // Keyboard move/nav stays within the card's own swimlane band.
946
- const swim = swimField ? effectiveSwim(row) : null;
947
- if (grabbed === row) {
948
- // Grabbed: arrow keys MOVE the card (within its swimlane band).
949
- if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
950
- e.preventDefault();
951
- const dest = lanes[e.key === "ArrowRight" ? li + 1 : li - 1];
952
- if (!dest) return;
953
- const pos = laneCards(dest.id, swim).filter((r) => r !== row).length;
954
- if (applyMove(row, laneId, dest.id, pos, swim, swim)) {
955
- announce(`Moved to ${laneTitle(dest)}.`);
956
- focusCardSoon(row);
957
- }
958
- } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
959
- e.preventDefault();
960
- const cards = laneCards(laneId, swim);
961
- const idx = cards.indexOf(row);
962
- const target = idx + (e.key === "ArrowDown" ? 1 : -1);
963
- if (target < 0 || target >= cards.length) return;
964
- // In the list excluding the moved card, `target` is the insertion slot
965
- // for both directions (indices shift by the card's own removal).
966
- applyMove(row, laneId, laneId, target, swim, swim);
967
- announce(`Moved ${e.key === "ArrowDown" ? "down" : "up"}.`);
968
- focusCardSoon(row);
969
- }
970
- return;
971
- }
972
- // Not grabbed: arrow keys MOVE FOCUS between cards and lanes.
973
- if (e.key === "ArrowUp" || e.key === "ArrowDown") {
974
- e.preventDefault();
975
- const cards = laneCards(laneId, swim);
976
- const idx = cards.indexOf(row);
977
- const next = cards[idx + (e.key === "ArrowDown" ? 1 : -1)];
978
- if (next) focusCardEl(next);
979
- } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
980
- e.preventDefault();
981
- const dest = lanes[e.key === "ArrowRight" ? li + 1 : li - 1];
982
- if (!dest) return;
983
- const idx = Math.max(0, laneCards(laneId, swim).indexOf(row));
984
- const destCards = laneCards(dest.id, swim);
985
- const targetCard = destCards[Math.min(idx, destCards.length - 1)];
986
- if (targetCard) focusCardEl(targetCard);
987
- }
988
- }
989
-
990
- function cardLabel(row: TData): string {
991
- return titleField ? fmt(fieldValue(row, titleField)) : "card";
992
- }
993
- function laneTitleById(laneId: string): string {
994
- const l = lanes.find((x) => x.id === laneId);
995
- return l ? laneTitle(l) : laneId;
996
- }
997
- async function focusCardEl(row: TData) {
998
- let el = rootEl?.querySelector<HTMLElement>(
999
- `[data-card-key="${cssEscape(key(row))}"]`,
1000
- );
1001
- if (!el && virtualized) {
1002
- // Off-screen in a virtualized lane: scroll it into view, then focus.
1003
- const laneId = effectiveLane(row);
1004
- const swimId = swimField ? effectiveSwim(row) : null;
1005
- const idx = laneCards(laneId, swimId).indexOf(row);
1006
- const body = laneBodyEl.get(cellKey(laneId, swimId));
1007
- if (body && idx >= 0) {
1008
- body.scrollTop = idx * (cardH + VGAP);
1009
- laneScroll[cellKey(laneId, swimId)] = body.scrollTop;
1010
- await tick();
1011
- el = rootEl?.querySelector<HTMLElement>(
1012
- `[data-card-key="${cssEscape(key(row))}"]`,
1013
- );
1014
- }
1015
- }
1016
- el?.focus();
1017
- }
1018
- async function focusCardSoon(row: TData) {
1019
- // Re-focus the card after the DOM re-sorts so keyboard flow continues.
1020
- await tick();
1021
- focusCardEl(row);
1022
- }
1023
- function cssEscape(s: string): string {
1024
- return s.replace(/["\\]/g, "\\$&");
1025
- }
1026
-
1027
- let rootEl: HTMLDivElement | undefined;
1028
-
1029
- // --- card context menu (board.cardMenu) ---
1030
- let menuOpen = $state(false);
1031
- let menuPos = $state({ x: 0, y: 0 });
1032
- let menuItems = $state.raw<ReadonlyArray<MenuItem>>([]);
1033
- let menuPanel = $state<HTMLDivElement | null>(null);
1034
- const MENU_W = 200;
1035
- function showMenuAt(e: MouseEvent, items: ReadonlyArray<MenuItem>) {
1036
- if (!items.length) return;
1037
- e.preventDefault();
1038
- e.stopPropagation();
1039
- const estH = Math.min(items.length, 12) * 34 + 8;
1040
- const x = Math.min(e.clientX, window.innerWidth - MENU_W - 6);
1041
- const y = Math.min(e.clientY, window.innerHeight - estH - 6);
1042
- menuPos = { x: Math.max(6, x), y: Math.max(6, y) };
1043
- menuItems = items;
1044
- menuOpen = true;
1045
- }
1046
- function openCardMenu(e: MouseEvent, row: TData) {
1047
- if (!board.cardMenu) return;
1048
- const swim = swimField ? effectiveSwim(row) : null;
1049
- const items = board.cardMenu(row, {
1050
- lanes,
1051
- moveTo: (laneId: string) => {
1052
- const pos = laneCards(laneId, swim).filter((r) => r !== row).length;
1053
- applyMove(row, effectiveLane(row), laneId, pos, swim, swim);
1054
- },
1055
- edit: () => openEditor(row),
1056
- });
1057
- if (items) showMenuAt(e, items);
1058
- }
1059
- function openLaneMenu(e: MouseEvent, laneId: string, cards: TData[]) {
1060
- if (!board.laneMenu) return;
1061
- const items = board.laneMenu(laneId, {
1062
- laneId,
1063
- cards,
1064
- collapsed: collapsed[laneId] === true,
1065
- toggleCollapse: () => toggleCollapse(laneId),
1066
- addCard: (title?: string) => board.onCardAdd?.(laneId, title),
1067
- });
1068
- if (items) showMenuAt(e, items);
1069
- }
1070
- $effect(() => {
1071
- if (!menuOpen) return;
1072
- queueMicrotask(() =>
1073
- menuPanel?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus(),
1074
- );
1075
- const layer = createDismissableLayer({
1076
- element: () => menuPanel,
1077
- onDismiss: () => (menuOpen = false),
1078
- });
1079
- layer.activate();
1080
- const onScroll = () => (menuOpen = false);
1081
- window.addEventListener("scroll", onScroll, true);
1082
- return () => {
1083
- layer.release();
1084
- window.removeEventListener("scroll", onScroll, true);
1085
- };
1086
- });
1087
- </script>
1088
-
1089
- <div
1090
- class="sv-board"
1091
- class:is-swim={!!swimField}
1092
- role="group"
1093
- aria-label="Board"
1094
- bind:this={rootEl}
1095
- >
1096
- {#if board.facets && board.facets.length}
1097
- <div class="sv-board-facets">
1098
- {#each board.facets as field (field)}
1099
- {#each facetOptions(field) as opt (field + ":" + opt.value)}
1100
- <button
1101
- type="button"
1102
- class="sv-board-facet"
1103
- class:is-active={(facetSel[field] ?? []).includes(opt.value)}
1104
- onclick={() => toggleFacet(field, opt.value)}
1105
- >
1106
- {opt.value}<span class="sv-board-facet-count">{opt.count}</span>
1107
- </button>
1108
- {/each}
1109
- {/each}
1110
- {#if anyFacet}
1111
- <button type="button" class="sv-board-facet-clear" onclick={clearFacets}>Clear filters</button>
1112
- {/if}
1113
- </div>
1114
- {/if}
1115
-
1116
- {#if swimField}
1117
- {#each swimlanes as swim (swim.id)}
1118
- {@const swimCollapsed = collapsedSwim[swim.id] === true}
1119
- <section class="sv-board-swim" aria-label={swim.title}>
1120
- <header class="sv-board-swim-head">
1121
- {#if board.collapsibleSwimlanes}
1122
- <button
1123
- type="button"
1124
- class="sv-board-swim-toggle"
1125
- aria-expanded={!swimCollapsed}
1126
- aria-label={swimCollapsed ? `Expand ${swim.title}` : `Collapse ${swim.title}`}
1127
- onclick={() => toggleSwim(swim.id)}>{swimCollapsed ? "▸" : "▾"}</button
1128
- >
1129
- {/if}
1130
- <span class="sv-board-swim-title">{swim.title}</span>
1131
- <span class="sv-board-swim-count">{swimCount(swim.id)}</span>
1132
- {#if board.swimlaneSummary}
1133
- <span class="sv-board-swim-summary">{board.swimlaneSummary(swimRows(swim.id), swim.id)}</span>
1134
- {/if}
1135
- </header>
1136
- {#if !swimCollapsed}
1137
- <div class="sv-board-lanes">
1138
- {#each lanes as lane (lane.id)}
1139
- {@render laneCol(lane, swim.id)}
1140
- {/each}
1141
- </div>
1142
- {/if}
1143
- </section>
1144
- {/each}
1145
- {:else}
1146
- <div class="sv-board-lanes">
1147
- {#each lanes as lane (lane.id)}
1148
- {@render laneCol(lane, null)}
1149
- {/each}
1150
- </div>
1151
- {/if}
1152
-
1153
- <div class="sv-board-sr" aria-live="polite" role="status">{liveMsg}</div>
1154
- </div>
1155
-
1156
- {#if menuOpen}
1157
- <div
1158
- bind:this={menuPanel}
1159
- class="sv-board-ctx"
1160
- use:portalToBody
1161
- use:popIn={{}}
1162
- style:position="fixed"
1163
- style:top={`${menuPos.y}px`}
1164
- style:left={`${menuPos.x}px`}
1165
- aria-label="Card actions"
1166
- >
1167
- <SvMenuList items={menuItems} onclose={() => (menuOpen = false)} onselect={() => (menuOpen = false)} />
1168
- </div>
1169
- {/if}
1170
-
1171
- {#if drawerRow}
1172
- {@const dr = drawerRow}
1173
- <SvDrawer
1174
- open
1175
- onClose={() => (drawerRow = null)}
1176
- side={drawerCfg?.side ?? "right"}
1177
- size={drawerCfg?.size ?? "380px"}
1178
- title={drawerTitleOf(dr)}
1179
- closeOnBackdrop={false}
1180
- >
1181
- {#key key(dr)}
1182
- <SvForm
1183
- fields={drawerForm(dr)}
1184
- initial={drawerInitial(dr)}
1185
- columns={drawerCfg?.columns ?? 1}
1186
- submitLabel={drawerCfg?.submitLabel ?? "Save"}
1187
- onSubmit={(values) => onDrawerSubmit(dr, values)}
1188
- />
1189
- {#if board.commentsField}
1190
- <div class="sv-board-drawer-comments">
1191
- <div class="sv-board-drawer-h">Comments</div>
1192
- {@render commentThread(dr)}
1193
- </div>
1194
- {/if}
1195
- {/key}
1196
- </SvDrawer>
1197
- {/if}
1198
-
1199
- {#snippet commentThread(row: TData)}
1200
- {@const cmts = commentsOf(row)}
1201
- {#each cmts as c (c.id)}
1202
- <div class="sv-board-comment">
1203
- <div class="sv-board-comment-body">
1204
- {#if c.author}<span class="sv-board-comment-author">{c.author}</span>{/if}
1205
- <span class="sv-board-comment-text">{c.text}</span>
1206
- {#if c.at}<span class="sv-board-comment-at">{c.at}</span>{/if}
1207
- </div>
1208
- {#if board.onCommentDelete}
1209
- <button
1210
- type="button"
1211
- class="sv-board-comment-del"
1212
- aria-label="Delete comment"
1213
- onclick={() => deleteComment(row, c.id)}>×</button
1214
- >
1215
- {/if}
1216
- </div>
1217
- {/each}
1218
- {#if !cmts.length}
1219
- <div class="sv-board-comment-empty">No comments yet.</div>
1220
- {/if}
1221
- {#if board.onCommentAdd}
1222
- <input
1223
- class="sv-board-comment-add"
1224
- placeholder="Write a comment..."
1225
- onkeydown={(e) => {
1226
- if (e.key === "Enter") {
1227
- e.preventDefault();
1228
- const el = e.currentTarget;
1229
- addComment(row, el.value);
1230
- el.value = "";
1231
- }
1232
- }}
1233
- />
1234
- {/if}
1235
- {/snippet}
1236
-
1237
- {#snippet laneCol(lane: BoardLane, swimId: string | null)}
1238
- {@const cards = laneCards(lane.id, swimId)}
1239
- {@const over = lane.wipLimit != null && cards.length > lane.wipLimit}
1240
- {@const mark = markerIndex(lane.id, swimId, cards)}
1241
- {@const isCollapsed = collapsed[lane.id] === true}
1242
- <section
1243
- class="sv-board-lane"
1244
- class:is-collapsed={isCollapsed}
1245
- style={lane.color ? `--sv-board-accent: ${lane.color};` : undefined}
1246
- aria-label={laneTitle(lane)}
1247
- >
1248
- <!-- svelte-ignore a11y_no_static_element_interactions -->
1249
- <header
1250
- class="sv-board-lane-head"
1251
- class:is-reorderable={board.reorderableLanes}
1252
- class:is-lane-dropover={overLaneId === lane.id && dragLaneId !== lane.id}
1253
- draggable={board.reorderableLanes ? true : undefined}
1254
- tabindex={board.reorderableLanes ? 0 : undefined}
1255
- role={board.reorderableLanes ? "group" : undefined}
1256
- aria-roledescription={board.reorderableLanes ? "Reorderable lane" : undefined}
1257
- title={board.reorderableLanes ? "Ctrl+Left/Right arrow to reorder this lane" : undefined}
1258
- ondragstart={board.reorderableLanes ? (e) => onLaneHeaderDragStart(e, lane.id) : undefined}
1259
- ondragover={board.reorderableLanes ? (e) => onLaneHeaderDragOver(e, lane.id) : undefined}
1260
- ondrop={board.reorderableLanes ? (e) => onLaneHeaderDrop(e, lane.id) : undefined}
1261
- ondragend={board.reorderableLanes ? onLaneHeaderDragEnd : undefined}
1262
- onkeydown={board.reorderableLanes ? (e) => onLaneHeaderKeydown(e, lane.id) : undefined}
1263
- oncontextmenu={board.laneMenu ? (e) => openLaneMenu(e, lane.id, cards) : undefined}
1264
- >
1265
- {#if board.collapsibleLanes}
1266
- <button
1267
- type="button"
1268
- class="sv-board-lane-toggle"
1269
- aria-expanded={!isCollapsed}
1270
- aria-label={isCollapsed ? `Expand ${laneTitle(lane)}` : `Collapse ${laneTitle(lane)}`}
1271
- onclick={() => toggleCollapse(lane.id)}>{isCollapsed ? "▸" : "▾"}</button
1272
- >
1273
- {/if}
1274
- {#if renamingLane === lane.id}
1275
- <!-- svelte-ignore a11y_autofocus -->
1276
- <input
1277
- class="sv-board-lane-rename"
1278
- bind:value={renameText}
1279
- use:focusNode
1280
- onkeydown={(e) => {
1281
- if (e.key === "Enter") { e.preventDefault(); commitRename(lane.id); }
1282
- else if (e.key === "Escape") { renamingLane = null; }
1283
- }}
1284
- onblur={() => commitRename(lane.id)}
1285
- />
1286
- {:else if board.onLaneRename}
1287
- <button
1288
- type="button"
1289
- class="sv-board-lane-title is-renamable"
1290
- ondblclick={() => startRename(lane)}
1291
- onkeydown={(e) => {
1292
- if (e.key === "Enter" || e.key === "F2") {
1293
- e.preventDefault();
1294
- startRename(lane);
1295
- }
1296
- }}
1297
- title="Double-click, Enter, or F2 to rename"
1298
- >{laneTitle(lane)}</button>
1299
- {:else}
1300
- <span class="sv-board-lane-title">{laneTitle(lane)}</span>
1301
- {/if}
1302
- <span
1303
- class="sv-board-lane-count"
1304
- class:is-over={over}
1305
- title={lane.wipLimit != null ? `WIP limit ${lane.wipLimit}` : undefined}
1306
- >
1307
- {cards.length}{#if lane.wipLimit != null}/{lane.wipLimit}{/if}
1308
- </span>
1309
- {#if board.laneSummary && !isCollapsed}
1310
- <span class="sv-board-lane-summary">{board.laneSummary(cards, lane.id)}</span>
1311
- {/if}
1312
- {#if board.onCardAdd && !isCollapsed}
1313
- <button
1314
- type="button"
1315
- class="sv-board-lane-add"
1316
- aria-label={`Add card to ${laneTitle(lane)}`}
1317
- onclick={() => board.onCardAdd?.(lane.id)}>+</button
1318
- >
1319
- {/if}
1320
- </header>
1321
-
1322
- {#if !isCollapsed}
1323
- {@const win = laneWindow(lane.id, swimId, cards.length)}
1324
- <!-- svelte-ignore a11y_no_static_element_interactions -->
1325
- <div
1326
- class="sv-board-lane-body"
1327
- class:is-virtual={virtualized}
1328
- role="list"
1329
- class:is-dropover={overLane === lane.id && overSwim === swimId}
1330
- use:trackLane={cellKey(lane.id, swimId)}
1331
- ondragover={(e) => onLaneDragOver(e, lane.id, swimId)}
1332
- ondrop={(e) => onLaneDrop(e, lane.id, swimId)}
1333
- >
1334
- {#if win.top > 0}
1335
- <div style={`height:${win.top}px`} aria-hidden="true"></div>
1336
- {/if}
1337
- {#each cards.slice(win.start, win.end) as row, i (key(row))}
1338
- {@const index = win.start + i}
1339
- {#if mark === index}
1340
- <div class="sv-board-drop-marker" aria-hidden="true"></div>
1341
- {/if}
1342
- {@const isEditing = editingKey === key(row)}
1343
- <article
1344
- class="sv-board-card"
1345
- class:is-dragging={dragRow === row}
1346
- class:is-grabbed={grabbed === row}
1347
- class:is-editing={isEditing}
1348
- class:is-flagged={isFlagged(row)}
1349
- class:is-locked={!movable(row)}
1350
- class:is-selected={board.selectable && selected[key(row)]}
1351
- role="listitem"
1352
- tabindex="0"
1353
- data-card-key={key(row)}
1354
- aria-roledescription="Draggable card"
1355
- aria-grabbed={grabbed === row}
1356
- aria-selected={board.selectable ? !!selected[key(row)] : undefined}
1357
- draggable={!isEditing && movable(row)}
1358
- ondragstart={(e) => onCardDragStart(e, row, lane.id, swimId)}
1359
- ondragover={(e) => onCardDragOver(e, row, lane.id, swimId)}
1360
- ondragend={resetDrag}
1361
- onclick={board.selectable ? (e) => onCardClick(e, row) : undefined}
1362
- ondblclick={() => openEditor(row)}
1363
- oncontextmenu={board.cardMenu ? (e) => openCardMenu(e, row) : undefined}
1364
- onkeydown={(e) => onCardKeydown(e, row, lane.id)}
1365
- >
1366
- {#if isFlagged(row)}<span class="sv-board-card-flag" aria-label="Blocked"></span>{/if}
1367
- {#if isEditing}
1368
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1369
- <form
1370
- class="sv-board-card-edit"
1371
- onsubmit={(e) => {
1372
- e.preventDefault();
1373
- saveEditor(row);
1374
- }}
1375
- onkeydown={(e) => {
1376
- if (e.key === "Escape") {
1377
- e.stopPropagation();
1378
- cancelEditor();
1379
- }
1380
- }}
1381
- >
1382
- {#each editColumns as col (col.field)}
1383
- {@const kind = editorKind(col)}
1384
- <label class="sv-board-field">
1385
- <span>{headerLabel(col.field ?? "")}</span>
1386
- {#if kind === "checkbox"}
1387
- <input
1388
- type="checkbox"
1389
- checked={draft[col.field ?? ""] === true}
1390
- oninput={(e) => onDraftInput(col.field ?? "", kind, e)}
1391
- />
1392
- {:else}
1393
- <!-- svelte-ignore a11y_autofocus -->
1394
- <input
1395
- type={kind}
1396
- value={draft[col.field ?? ""] ?? ""}
1397
- oninput={(e) => onDraftInput(col.field ?? "", kind, e)}
1398
- />
1399
- {/if}
1400
- </label>
1401
- {/each}
1402
- <div class="sv-board-card-edit-actions">
1403
- <button type="button" class="sv-board-btn" onclick={cancelEditor}>Cancel</button>
1404
- <button type="submit" class="sv-board-btn is-primary">Save</button>
1405
- </div>
1406
- </form>
1407
- {:else if board.card}
1408
- {@render board.card(row)}
1409
- {@render cardBadges(row)}
1410
- {:else}
1411
- {@const cover = coverColorOf(row)}
1412
- {#if cover}
1413
- <div class="sv-board-card-cover" style={`background:${cover}`} aria-hidden="true"></div>
1414
- {/if}
1415
- {#if titleField}
1416
- <div class="sv-board-card-title">
1417
- {fmt(fieldValue(row, titleField))}
1418
- </div>
1419
- {/if}
1420
- {@render cardBadges(row)}
1421
- {#if metaFields.length}
1422
- <dl class="sv-board-card-meta">
1423
- {#each metaFields as field (field)}
1424
- {@const v = fmt(fieldValue(row, field))}
1425
- {#if v !== ""}
1426
- <div class="sv-board-card-metarow">
1427
- <dt>{headerLabel(field)}</dt>
1428
- <dd>{v}</dd>
1429
- </div>
1430
- {/if}
1431
- {/each}
1432
- </dl>
1433
- {/if}
1434
- {/if}
1435
-
1436
- {#if !isEditing && board.subtasks}
1437
- {@const prog = subProgress(row)}
1438
- {@const opened = subOpen[key(row)] === true}
1439
- <div class="sv-board-subs">
1440
- <button
1441
- type="button"
1442
- class="sv-board-subs-head"
1443
- aria-expanded={opened}
1444
- onclick={() => (subOpen[key(row)] = !opened)}
1445
- >
1446
- <span class="sv-board-subs-chev">{opened ? "▾" : "▸"}</span>
1447
- <span class="sv-board-subs-count">{prog.done}/{prog.total}</span>
1448
- <span class="sv-board-subs-bar" aria-hidden="true">
1449
- <span style={`width:${prog.total ? (prog.done / prog.total) * 100 : 0}%`}></span>
1450
- </span>
1451
- </button>
1452
- {#if opened}
1453
- <ul class="sv-board-subs-list">
1454
- {#each subtasksOf(row) as st (st.id)}
1455
- <li>
1456
- <label class:is-done={st.done}>
1457
- <input type="checkbox" checked={st.done} onchange={() => toggleSubtask(row, st)} />
1458
- <span>{st.title}</span>
1459
- </label>
1460
- </li>
1461
- {/each}
1462
- </ul>
1463
- <input
1464
- class="sv-board-subs-add"
1465
- placeholder="Add subtask..."
1466
- onkeydown={(e) => {
1467
- if (e.key === "Enter") {
1468
- e.preventDefault();
1469
- const el = e.currentTarget;
1470
- addSubtask(row, el.value);
1471
- el.value = "";
1472
- }
1473
- }}
1474
- />
1475
- {/if}
1476
- </div>
1477
- {/if}
1478
-
1479
- {#if !isEditing && board.childrenField}
1480
- {@const kids = childrenOf(row)}
1481
- {#if kids.length}
1482
- {@const kidsOpen = childOpen[key(row)] === true}
1483
- <div class="sv-board-kids">
1484
- <button
1485
- type="button"
1486
- class="sv-board-kids-head"
1487
- aria-expanded={kidsOpen}
1488
- onclick={() => (childOpen[key(row)] = !kidsOpen)}
1489
- >
1490
- <span class="sv-board-subs-chev">{kidsOpen ? "▾" : "▸"}</span>
1491
- {kids.length}
1492
- {kids.length === 1 ? "subtask" : "subtasks"}
1493
- </button>
1494
- {#if kidsOpen}
1495
- <ul class="sv-board-kids-list">
1496
- {#each kids as kid, ki (key(kid) + ":" + ki)}
1497
- <li class="sv-board-kid">
1498
- <span class="sv-board-kid-title"
1499
- >{titleField ? fmt(fieldValue(kid, titleField)) : ""}</span
1500
- >
1501
- <span class="sv-board-kid-lane">{rawLane(kid)}</span>
1502
- </li>
1503
- {/each}
1504
- </ul>
1505
- {/if}
1506
- </div>
1507
- {/if}
1508
- {/if}
1509
-
1510
- {#if !isEditing && board.commentsField && commentsOpen[key(row)]}
1511
- <div class="sv-board-comments">
1512
- {@render commentThread(row)}
1513
- </div>
1514
- {/if}
1515
- </article>
1516
- {/each}
1517
- {#if win.bottom > 0}
1518
- <div style={`height:${win.bottom}px`} aria-hidden="true"></div>
1519
- {:else if mark === cards.length}
1520
- <div class="sv-board-drop-marker" aria-hidden="true"></div>
1521
- {/if}
1522
- {#if cards.length === 0}
1523
- <div class="sv-board-lane-empty">No cards</div>
1524
- {/if}
1525
- </div>
1526
- {#if board.composer && board.onCardAdd}
1527
- {#if composerOpen[lane.id]}
1528
- <input
1529
- class="sv-board-composer-input"
1530
- placeholder="Card title..."
1531
- bind:value={composerText[lane.id]}
1532
- use:focusNode
1533
- onkeydown={(e) => {
1534
- if (e.key === "Enter") {
1535
- e.preventDefault();
1536
- submitComposer(lane.id);
1537
- } else if (e.key === "Escape") {
1538
- composerOpen[lane.id] = false;
1539
- }
1540
- }}
1541
- onblur={() => {
1542
- submitComposer(lane.id);
1543
- composerOpen[lane.id] = false;
1544
- }}
1545
- />
1546
- {:else}
1547
- <button
1548
- type="button"
1549
- class="sv-board-composer-btn"
1550
- onclick={() => (composerOpen[lane.id] = true)}>+ Add a card</button
1551
- >
1552
- {/if}
1553
- {/if}
1554
- {/if}
1555
- </section>
1556
- {/snippet}
1557
-
1558
- {#snippet cardBadges(row: TData)}
1559
- {@const labels = labelsOf(row)}
1560
- {@const due = dueOf(row)}
1561
- {@const asg = assigneesOf(row)}
1562
- {@const comments = countOf(row, board.commentsField)}
1563
- {@const attachments = countOf(row, board.attachmentsField)}
1564
- {@const age = ageOf(row)}
1565
- {@const blocked = isFlagged(row)}
1566
- {#if blocked || labels.length || due || asg.length || comments || attachments || age}
1567
- <div class="sv-board-badges">
1568
- {#if blocked}
1569
- <span class="sv-board-badge is-blocked" title={flagReason(row) ?? "Blocked"}>Blocked</span>
1570
- {/if}
1571
- {#each labels as lb (lb.text)}
1572
- <span class="sv-board-badge is-label" style={lb.color ? `--b:${lb.color}` : undefined}
1573
- >{lb.text}</span
1574
- >
1575
- {/each}
1576
- {#if due}
1577
- <span class="sv-board-badge is-due" class:is-overdue={due.overdue}>{due.label}</span>
1578
- {/if}
1579
- {#if age}
1580
- <span class="sv-board-badge is-meta" title="Age">{age}</span>
1581
- {/if}
1582
- {#if comments || board.onCommentAdd}
1583
- <button
1584
- type="button"
1585
- class="sv-board-badge is-meta is-btn"
1586
- title="Comments"
1587
- onclick={(e) => {
1588
- e.stopPropagation();
1589
- commentsOpen[key(row)] = !commentsOpen[key(row)];
1590
- }}
1591
- >
1592
- <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true"><path d="M2 3h12v8H8l-3 2v-2H2z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>
1593
- {comments}
1594
- </button>
1595
- {/if}
1596
- {#if attachments}
1597
- <span class="sv-board-badge is-meta" title={`${attachments} attachments`}>
1598
- <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true"><path d="M11 5.5 6.5 10a2 2 0 0 1-3-3l5-5a3 3 0 0 1 4 4l-5 5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
1599
- {attachments}
1600
- </span>
1601
- {/if}
1602
- {#if asg.length}
1603
- <span class="sv-board-avatars">
1604
- {#each asg.slice(0, 3) as name (name)}
1605
- <span class="sv-board-avatar" title={name}>{initials(name)}</span>
1606
- {/each}
1607
- </span>
1608
- {/if}
1609
- </div>
1610
- {/if}
1611
- {/snippet}
1612
-
1613
- <style>
1614
- .sv-board {
1615
- height: 100%;
1616
- box-sizing: border-box;
1617
- display: flex;
1618
- flex-direction: column;
1619
- background: var(--sg-bg, #fff);
1620
- color: var(--sg-fg, #101828);
1621
- font-family: var(--sg-font, inherit);
1622
- }
1623
- .sv-board.is-swim {
1624
- overflow-y: auto;
1625
- gap: 14px;
1626
- }
1627
- .sv-board-lanes {
1628
- display: flex;
1629
- gap: 12px;
1630
- align-items: flex-start;
1631
- overflow-x: auto;
1632
- padding: 4px;
1633
- flex: 1 1 auto;
1634
- min-height: 0;
1635
- scrollbar-color: var(--sg-scrollbar-thumb, color-mix(in srgb, var(--sg-fg, #101828) 28%, transparent))
1636
- transparent;
1637
- }
1638
- .sv-board.is-swim .sv-board-lanes {
1639
- flex: none;
1640
- }
1641
- .sv-board.is-swim .sv-board-lane {
1642
- max-height: 340px;
1643
- }
1644
- .sv-board-swim {
1645
- display: flex;
1646
- flex-direction: column;
1647
- gap: 4px;
1648
- flex: none;
1649
- }
1650
- .sv-board-swim-head {
1651
- display: flex;
1652
- align-items: center;
1653
- gap: 8px;
1654
- padding: 4px 8px;
1655
- font-weight: 700;
1656
- font-size: 0.78rem;
1657
- color: color-mix(in srgb, var(--sg-fg, #101828) 78%, transparent);
1658
- position: sticky;
1659
- left: 0;
1660
- }
1661
- .sv-board-swim-count {
1662
- font-size: 0.7rem;
1663
- font-weight: 600;
1664
- padding: 0 7px;
1665
- border-radius: 999px;
1666
- background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
1667
- color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
1668
- }
1669
- .sv-board-swim-summary {
1670
- font-size: 0.7rem;
1671
- font-weight: 600;
1672
- color: color-mix(in srgb, var(--sg-accent, #3b82f6) 85%, var(--sg-fg, #101828));
1673
- }
1674
- .sv-board-swim-toggle {
1675
- all: unset;
1676
- cursor: pointer;
1677
- font-size: 0.7rem;
1678
- padding: 2px;
1679
- color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
1680
- }
1681
- .sv-board-lane-title.is-renamable {
1682
- all: unset;
1683
- cursor: text;
1684
- font-weight: 600;
1685
- font-size: 0.82rem;
1686
- flex: 1 1 auto;
1687
- min-width: 0;
1688
- white-space: nowrap;
1689
- overflow: hidden;
1690
- text-overflow: ellipsis;
1691
- text-align: left;
1692
- }
1693
- .sv-board-lane-rename {
1694
- flex: 1 1 auto;
1695
- min-width: 0;
1696
- font: inherit;
1697
- font-weight: 600;
1698
- font-size: 0.82rem;
1699
- padding: 1px 4px;
1700
- border: 1px solid var(--sv-board-accent);
1701
- border-radius: 5px;
1702
- background: var(--sg-bg, #fff);
1703
- color: var(--sg-fg, #101828);
1704
- }
1705
- .sv-board-swim-toggle:hover {
1706
- color: var(--sg-fg, #101828);
1707
- }
1708
- .sv-board-lane {
1709
- flex: 0 0 var(--sg-board-lane-w, 272px);
1710
- max-width: var(--sg-board-lane-w, 272px);
1711
- display: flex;
1712
- flex-direction: column;
1713
- max-height: 100%;
1714
- border: 1px solid var(--sg-border, #e4e7ec);
1715
- border-radius: var(--sg-radius, 8px);
1716
- background: var(--sg-header-bg, color-mix(in srgb, var(--sg-fg, #101828) 3%, var(--sg-bg, #fff)));
1717
- --sv-board-accent: var(--sg-accent, #3b82f6);
1718
- }
1719
- .sv-board-lane-head {
1720
- display: flex;
1721
- align-items: center;
1722
- gap: 8px;
1723
- padding: 8px 10px;
1724
- border-bottom: 1px solid var(--sg-border, #e4e7ec);
1725
- border-top: 3px solid var(--sv-board-accent);
1726
- border-radius: var(--sg-radius, 8px) var(--sg-radius, 8px) 0 0;
1727
- }
1728
- .sv-board-lane-head.is-reorderable {
1729
- cursor: grab;
1730
- }
1731
- .sv-board-lane-head.is-lane-dropover {
1732
- box-shadow: inset 3px 0 0 var(--sv-board-accent);
1733
- }
1734
- .sv-board-lane-title {
1735
- font-weight: 600;
1736
- font-size: 0.82rem;
1737
- flex: 1 1 auto;
1738
- white-space: nowrap;
1739
- overflow: hidden;
1740
- text-overflow: ellipsis;
1741
- }
1742
- .sv-board-lane-toggle {
1743
- all: unset;
1744
- cursor: pointer;
1745
- font-size: 0.7rem;
1746
- line-height: 1;
1747
- color: color-mix(in srgb, var(--sg-fg, #101828) 55%, transparent);
1748
- padding: 2px;
1749
- }
1750
- .sv-board-lane-toggle:hover {
1751
- color: var(--sg-fg, #101828);
1752
- }
1753
- .sv-board-lane.is-collapsed {
1754
- flex: 0 0 auto;
1755
- max-width: none;
1756
- }
1757
- .sv-board-lane.is-collapsed .sv-board-lane-head {
1758
- writing-mode: vertical-rl;
1759
- height: 180px;
1760
- border-top: none;
1761
- border-left: 3px solid var(--sv-board-accent);
1762
- border-radius: 0 var(--sg-radius, 8px) var(--sg-radius, 8px) 0;
1763
- gap: 6px;
1764
- }
1765
- .sv-board-lane-count {
1766
- font-size: 0.72rem;
1767
- font-variant-numeric: tabular-nums;
1768
- padding: 1px 7px;
1769
- border-radius: 999px;
1770
- background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
1771
- color: color-mix(in srgb, var(--sg-fg, #101828) 70%, transparent);
1772
- }
1773
- .sv-board-lane-count.is-over {
1774
- background: color-mix(in srgb, var(--sg-danger, #ef4444) 16%, transparent);
1775
- color: var(--sg-danger, #ef4444);
1776
- font-weight: 700;
1777
- }
1778
- .sv-board-lane-summary {
1779
- font-size: 0.72rem;
1780
- font-weight: 600;
1781
- font-variant-numeric: tabular-nums;
1782
- color: color-mix(in srgb, var(--sv-board-accent) 85%, var(--sg-fg, #101828));
1783
- }
1784
- .sv-board-lane-add {
1785
- all: unset;
1786
- cursor: pointer;
1787
- width: 20px;
1788
- height: 20px;
1789
- display: grid;
1790
- place-items: center;
1791
- border-radius: 5px;
1792
- font-size: 1rem;
1793
- line-height: 1;
1794
- color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
1795
- }
1796
- .sv-board-lane-add:hover {
1797
- background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
1798
- color: var(--sg-fg, #101828);
1799
- }
1800
- .sv-board-lane-body {
1801
- display: flex;
1802
- flex-direction: column;
1803
- gap: 8px;
1804
- padding: 8px;
1805
- overflow-y: auto;
1806
- flex: 1 1 auto;
1807
- min-height: 48px;
1808
- transition: background 0.12s ease;
1809
- }
1810
- .sv-board-lane-body.is-dropover {
1811
- background: color-mix(in srgb, var(--sv-board-accent) 8%, transparent);
1812
- }
1813
- /* Virtualized lanes use block flow + per-card margin (not flex gap) so the
1814
- spacer heights match the windowing math exactly. */
1815
- .sv-board-lane-body.is-virtual {
1816
- display: block;
1817
- gap: 0;
1818
- }
1819
- .sv-board-lane-body.is-virtual .sv-board-card {
1820
- margin-bottom: 8px;
1821
- }
1822
- .sv-board-card {
1823
- background: var(--sg-bg, #fff);
1824
- border: 1px solid var(--sg-border, #e4e7ec);
1825
- border-radius: var(--sg-radius, 8px);
1826
- padding: 9px 11px;
1827
- cursor: grab;
1828
- box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
1829
- transition:
1830
- box-shadow 0.12s ease,
1831
- opacity 0.12s ease,
1832
- transform 0.12s ease;
1833
- }
1834
- .sv-board-card:hover {
1835
- box-shadow: 0 2px 8px rgba(16, 24, 40, 0.12);
1836
- border-color: color-mix(in srgb, var(--sv-board-accent) 45%, var(--sg-border, #e4e7ec));
1837
- }
1838
- .sv-board-card:focus-visible {
1839
- outline: none;
1840
- border-color: var(--sv-board-accent);
1841
- box-shadow: var(--sg-focus-ring, 0 0 0 2px color-mix(in srgb, var(--sv-board-accent) 45%, transparent));
1842
- }
1843
- .sv-board-card.is-dragging {
1844
- opacity: 0.45;
1845
- }
1846
- .sv-board-card.is-grabbed {
1847
- border-color: var(--sv-board-accent);
1848
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--sv-board-accent) 55%, transparent);
1849
- cursor: grabbing;
1850
- }
1851
- .sv-board-card.is-editing {
1852
- cursor: default;
1853
- border-color: var(--sv-board-accent);
1854
- }
1855
- .sv-board-card-edit {
1856
- display: flex;
1857
- flex-direction: column;
1858
- gap: 7px;
1859
- }
1860
- .sv-board-field {
1861
- display: flex;
1862
- flex-direction: column;
1863
- gap: 2px;
1864
- font-size: 0.72rem;
1865
- color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
1866
- }
1867
- .sv-board-field input[type="text"],
1868
- .sv-board-field input[type="number"],
1869
- .sv-board-field input[type="date"] {
1870
- font: inherit;
1871
- font-size: 0.8rem;
1872
- padding: 4px 7px;
1873
- border: 1px solid var(--sg-border, #e4e7ec);
1874
- border-radius: var(--sg-radius, 6px);
1875
- background: var(--sg-bg, #fff);
1876
- color: var(--sg-fg, #101828);
1877
- }
1878
- .sv-board-field input:focus-visible {
1879
- outline: none;
1880
- border-color: var(--sv-board-accent);
1881
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--sv-board-accent) 35%, transparent);
1882
- }
1883
- .sv-board-field input[type="checkbox"] {
1884
- align-self: flex-start;
1885
- width: 15px;
1886
- height: 15px;
1887
- accent-color: var(--sv-board-accent);
1888
- }
1889
- .sv-board-card-edit-actions {
1890
- display: flex;
1891
- justify-content: flex-end;
1892
- gap: 6px;
1893
- margin-top: 2px;
1894
- }
1895
- .sv-board-btn {
1896
- font: inherit;
1897
- font-size: 0.74rem;
1898
- padding: 3px 10px;
1899
- border-radius: var(--sg-radius, 6px);
1900
- border: 1px solid var(--sg-border, #e4e7ec);
1901
- background: var(--sg-bg, #fff);
1902
- color: var(--sg-fg, #101828);
1903
- cursor: pointer;
1904
- }
1905
- .sv-board-btn:hover {
1906
- background: color-mix(in srgb, var(--sg-fg, #101828) 6%, transparent);
1907
- }
1908
- .sv-board-btn.is-primary {
1909
- background: var(--sv-board-accent);
1910
- border-color: var(--sv-board-accent);
1911
- color: #fff;
1912
- }
1913
- .sv-board-btn.is-primary:hover {
1914
- filter: brightness(0.95);
1915
- }
1916
- .sv-board-card-title {
1917
- font-weight: 600;
1918
- font-size: 0.84rem;
1919
- line-height: 1.3;
1920
- }
1921
- .sv-board-card-meta {
1922
- margin: 6px 0 0;
1923
- display: flex;
1924
- flex-direction: column;
1925
- gap: 2px;
1926
- }
1927
- .sv-board-card-metarow {
1928
- display: flex;
1929
- gap: 6px;
1930
- font-size: 0.74rem;
1931
- }
1932
- .sv-board-card-metarow dt {
1933
- color: color-mix(in srgb, var(--sg-fg, #101828) 55%, transparent);
1934
- margin: 0;
1935
- }
1936
- .sv-board-card-metarow dd {
1937
- margin: 0;
1938
- font-weight: 500;
1939
- overflow: hidden;
1940
- text-overflow: ellipsis;
1941
- white-space: nowrap;
1942
- }
1943
- .sv-board-lane-empty {
1944
- text-align: center;
1945
- font-size: 0.76rem;
1946
- color: color-mix(in srgb, var(--sg-fg, #101828) 42%, transparent);
1947
- padding: 14px 0;
1948
- border: 1px dashed var(--sg-border, #e4e7ec);
1949
- border-radius: var(--sg-radius, 8px);
1950
- }
1951
- .sv-board-drop-marker {
1952
- height: 2px;
1953
- border-radius: 2px;
1954
- background: var(--sv-board-accent);
1955
- margin: -3px 0;
1956
- }
1957
- :global(.sv-board-ctx) {
1958
- z-index: 2147483646;
1959
- min-width: 200px;
1960
- background: var(--sg-bg, #fff);
1961
- color: var(--sg-fg, #0f172a);
1962
- border: 1px solid var(--sg-border, #e2e8f0);
1963
- border-radius: 10px;
1964
- box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35);
1965
- font-size: 13px;
1966
- }
1967
- .sv-board-sr {
1968
- position: absolute;
1969
- width: 1px;
1970
- height: 1px;
1971
- padding: 0;
1972
- margin: -1px;
1973
- overflow: hidden;
1974
- clip: rect(0 0 0 0);
1975
- white-space: nowrap;
1976
- border: 0;
1977
- }
1978
- /* Card badges */
1979
- .sv-board-badges {
1980
- display: flex;
1981
- align-items: center;
1982
- flex-wrap: wrap;
1983
- gap: 4px;
1984
- margin-top: 6px;
1985
- }
1986
- .sv-board-badge {
1987
- font-size: 0.66rem;
1988
- font-weight: 600;
1989
- padding: 1px 6px;
1990
- border-radius: 4px;
1991
- line-height: 1.5;
1992
- white-space: nowrap;
1993
- }
1994
- .sv-board-badge.is-label {
1995
- --b: var(--sv-board-accent);
1996
- background: color-mix(in srgb, var(--b) 16%, transparent);
1997
- color: color-mix(in srgb, var(--b) 80%, var(--sg-fg, #101828));
1998
- }
1999
- .sv-board-badge.is-due {
2000
- background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2001
- color: color-mix(in srgb, var(--sg-fg, #101828) 65%, transparent);
2002
- }
2003
- .sv-board-badge.is-due.is-overdue {
2004
- background: color-mix(in srgb, var(--sg-danger, #ef4444) 15%, transparent);
2005
- color: var(--sg-danger, #ef4444);
2006
- }
2007
- .sv-board-badge.is-meta {
2008
- display: inline-flex;
2009
- align-items: center;
2010
- gap: 3px;
2011
- background: transparent;
2012
- padding: 1px 3px;
2013
- color: color-mix(in srgb, var(--sg-fg, #101828) 55%, transparent);
2014
- }
2015
- .sv-board-badge.is-btn {
2016
- border: 0;
2017
- cursor: pointer;
2018
- font: inherit;
2019
- font-size: 0.66rem;
2020
- font-weight: 600;
2021
- border-radius: 5px;
2022
- }
2023
- .sv-board-badge.is-btn:hover {
2024
- background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2025
- color: var(--sg-fg, #101828);
2026
- }
2027
- .sv-board-badge.is-blocked {
2028
- background: var(--sg-danger, #ef4444);
2029
- color: #fff;
2030
- text-transform: uppercase;
2031
- letter-spacing: 0.03em;
2032
- font-size: 0.6rem;
2033
- }
2034
- /* Comments thread */
2035
- .sv-board-comments {
2036
- margin-top: 7px;
2037
- border-top: 1px solid color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2038
- padding-top: 7px;
2039
- display: flex;
2040
- flex-direction: column;
2041
- gap: 5px;
2042
- }
2043
- .sv-board-drawer-comments {
2044
- margin-top: 16px;
2045
- padding-top: 12px;
2046
- border-top: 1px solid var(--sg-border, #e4e7ec);
2047
- display: flex;
2048
- flex-direction: column;
2049
- gap: 6px;
2050
- }
2051
- .sv-board-drawer-h {
2052
- font-weight: 600;
2053
- font-size: 0.82rem;
2054
- margin-bottom: 2px;
2055
- }
2056
- .sv-board-comment {
2057
- display: flex;
2058
- align-items: flex-start;
2059
- gap: 6px;
2060
- font-size: 0.74rem;
2061
- }
2062
- .sv-board-comment-body {
2063
- flex: 1 1 auto;
2064
- min-width: 0;
2065
- }
2066
- .sv-board-comment-author {
2067
- font-weight: 600;
2068
- margin-right: 5px;
2069
- }
2070
- .sv-board-comment-at {
2071
- margin-left: 6px;
2072
- font-size: 0.66rem;
2073
- color: color-mix(in srgb, var(--sg-fg, #101828) 45%, transparent);
2074
- }
2075
- .sv-board-comment-del {
2076
- all: unset;
2077
- cursor: pointer;
2078
- font-size: 0.9rem;
2079
- line-height: 1;
2080
- color: color-mix(in srgb, var(--sg-fg, #101828) 40%, transparent);
2081
- padding: 0 2px;
2082
- }
2083
- .sv-board-comment-del:hover {
2084
- color: var(--sg-danger, #ef4444);
2085
- }
2086
- .sv-board-comment-empty {
2087
- font-size: 0.72rem;
2088
- color: color-mix(in srgb, var(--sg-fg, #101828) 42%, transparent);
2089
- }
2090
- .sv-board-comment-add {
2091
- width: 100%;
2092
- box-sizing: border-box;
2093
- font: inherit;
2094
- font-size: 0.74rem;
2095
- padding: 4px 7px;
2096
- border: 1px solid var(--sg-border, #e4e7ec);
2097
- border-radius: 5px;
2098
- background: var(--sg-bg, #fff);
2099
- color: var(--sg-fg, #101828);
2100
- }
2101
- .sv-board-card-cover {
2102
- height: 6px;
2103
- border-radius: 4px;
2104
- margin: -2px 0 7px;
2105
- }
2106
- .sv-board-avatars {
2107
- display: inline-flex;
2108
- margin-left: auto;
2109
- }
2110
- .sv-board-avatar {
2111
- width: 20px;
2112
- height: 20px;
2113
- border-radius: 50%;
2114
- display: grid;
2115
- place-items: center;
2116
- font-size: 0.6rem;
2117
- font-weight: 700;
2118
- color: #fff;
2119
- background: var(--sv-board-accent);
2120
- border: 1.5px solid var(--sg-bg, #fff);
2121
- margin-left: -6px;
2122
- }
2123
- .sv-board-avatar:first-child {
2124
- margin-left: 0;
2125
- }
2126
- /* Sub-tasks */
2127
- .sv-board-subs {
2128
- margin-top: 7px;
2129
- border-top: 1px solid color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2130
- padding-top: 6px;
2131
- }
2132
- .sv-board-subs-head {
2133
- all: unset;
2134
- cursor: pointer;
2135
- display: flex;
2136
- align-items: center;
2137
- gap: 6px;
2138
- width: 100%;
2139
- font-size: 0.7rem;
2140
- color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
2141
- }
2142
- .sv-board-subs-chev {
2143
- font-size: 0.6rem;
2144
- }
2145
- .sv-board-subs-count {
2146
- font-variant-numeric: tabular-nums;
2147
- font-weight: 600;
2148
- }
2149
- .sv-board-subs-bar {
2150
- flex: 1 1 auto;
2151
- height: 4px;
2152
- border-radius: 2px;
2153
- background: color-mix(in srgb, var(--sg-fg, #101828) 10%, transparent);
2154
- overflow: hidden;
2155
- }
2156
- .sv-board-subs-bar span {
2157
- display: block;
2158
- height: 100%;
2159
- background: var(--sv-board-accent);
2160
- transition: width 0.15s ease;
2161
- }
2162
- .sv-board-subs-list {
2163
- list-style: none;
2164
- margin: 6px 0 0;
2165
- padding: 0;
2166
- display: flex;
2167
- flex-direction: column;
2168
- gap: 3px;
2169
- }
2170
- .sv-board-subs-list label {
2171
- display: flex;
2172
- align-items: center;
2173
- gap: 6px;
2174
- font-size: 0.74rem;
2175
- cursor: pointer;
2176
- }
2177
- .sv-board-subs-list label.is-done span {
2178
- text-decoration: line-through;
2179
- color: color-mix(in srgb, var(--sg-fg, #101828) 45%, transparent);
2180
- }
2181
- .sv-board-subs-list input {
2182
- accent-color: var(--sv-board-accent);
2183
- }
2184
- .sv-board-subs-add {
2185
- margin-top: 5px;
2186
- width: 100%;
2187
- box-sizing: border-box;
2188
- font: inherit;
2189
- font-size: 0.74rem;
2190
- padding: 3px 6px;
2191
- border: 1px solid var(--sg-border, #e4e7ec);
2192
- border-radius: 5px;
2193
- background: var(--sg-bg, #fff);
2194
- color: var(--sg-fg, #101828);
2195
- }
2196
- /* Quick-add composer */
2197
- .sv-board-composer-btn {
2198
- all: unset;
2199
- cursor: pointer;
2200
- display: block;
2201
- margin: 6px 8px 8px;
2202
- padding: 5px 8px;
2203
- border-radius: var(--sg-radius, 6px);
2204
- font-size: 0.76rem;
2205
- color: color-mix(in srgb, var(--sg-fg, #101828) 55%, transparent);
2206
- }
2207
- .sv-board-composer-btn:hover {
2208
- background: color-mix(in srgb, var(--sg-fg, #101828) 6%, transparent);
2209
- color: var(--sg-fg, #101828);
2210
- }
2211
- .sv-board-composer-input {
2212
- margin: 6px 8px 8px;
2213
- width: calc(100% - 16px);
2214
- box-sizing: border-box;
2215
- font: inherit;
2216
- font-size: 0.8rem;
2217
- padding: 6px 8px;
2218
- border: 1px solid var(--sv-board-accent);
2219
- border-radius: var(--sg-radius, 6px);
2220
- background: var(--sg-bg, #fff);
2221
- color: var(--sg-fg, #101828);
2222
- }
2223
- /* Facet filter bar */
2224
- .sv-board-facets {
2225
- display: flex;
2226
- flex-wrap: wrap;
2227
- gap: 6px;
2228
- padding: 4px 4px 8px;
2229
- align-items: center;
2230
- }
2231
- .sv-board-facet {
2232
- all: unset;
2233
- cursor: pointer;
2234
- display: inline-flex;
2235
- align-items: center;
2236
- gap: 5px;
2237
- font-size: 0.73rem;
2238
- padding: 2px 9px;
2239
- border-radius: 999px;
2240
- border: 1px solid var(--sg-border, #e4e7ec);
2241
- color: color-mix(in srgb, var(--sg-fg, #101828) 70%, transparent);
2242
- }
2243
- .sv-board-facet:hover {
2244
- border-color: var(--sg-accent, #3b82f6);
2245
- }
2246
- .sv-board-facet.is-active {
2247
- background: var(--sg-accent, #3b82f6);
2248
- border-color: var(--sg-accent, #3b82f6);
2249
- color: #fff;
2250
- }
2251
- .sv-board-facet-count {
2252
- font-size: 0.66rem;
2253
- opacity: 0.7;
2254
- font-variant-numeric: tabular-nums;
2255
- }
2256
- .sv-board-facet-clear {
2257
- all: unset;
2258
- cursor: pointer;
2259
- font-size: 0.72rem;
2260
- color: var(--sg-accent, #3b82f6);
2261
- padding: 2px 6px;
2262
- }
2263
- /* Blocked flag + selection */
2264
- .sv-board-card {
2265
- position: relative;
2266
- }
2267
- .sv-board-card-flag {
2268
- position: absolute;
2269
- top: 0;
2270
- right: 0;
2271
- width: 0;
2272
- height: 0;
2273
- border-top: 14px solid var(--sg-danger, #ef4444);
2274
- border-left: 14px solid transparent;
2275
- border-top-right-radius: var(--sg-radius, 8px);
2276
- }
2277
- .sv-board-card.is-selected {
2278
- border-color: var(--sv-board-accent);
2279
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--sv-board-accent) 55%, transparent);
2280
- }
2281
- .sv-board-card.is-locked {
2282
- cursor: default;
2283
- }
2284
- /* Child cards (epic -> stories) */
2285
- .sv-board-kids {
2286
- margin-top: 7px;
2287
- border-top: 1px solid color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2288
- padding-top: 6px;
2289
- }
2290
- .sv-board-kids-head {
2291
- all: unset;
2292
- cursor: pointer;
2293
- display: flex;
2294
- align-items: center;
2295
- gap: 5px;
2296
- font-size: 0.7rem;
2297
- color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
2298
- }
2299
- .sv-board-kids-list {
2300
- list-style: none;
2301
- margin: 6px 0 0;
2302
- padding: 0;
2303
- display: flex;
2304
- flex-direction: column;
2305
- gap: 4px;
2306
- }
2307
- .sv-board-kid {
2308
- display: flex;
2309
- align-items: center;
2310
- gap: 6px;
2311
- font-size: 0.74rem;
2312
- padding: 4px 7px;
2313
- border: 1px solid var(--sg-border, #e4e7ec);
2314
- border-radius: 6px;
2315
- background: var(--sg-bg, #fff);
2316
- }
2317
- .sv-board-kid-title {
2318
- flex: 1 1 auto;
2319
- overflow: hidden;
2320
- text-overflow: ellipsis;
2321
- white-space: nowrap;
2322
- }
2323
- .sv-board-kid-lane {
2324
- font-size: 0.64rem;
2325
- font-weight: 600;
2326
- padding: 0 6px;
2327
- border-radius: 999px;
2328
- background: color-mix(in srgb, var(--sv-board-accent) 15%, transparent);
2329
- color: color-mix(in srgb, var(--sv-board-accent) 85%, var(--sg-fg, #101828));
2330
- text-transform: capitalize;
2331
- }
2332
- @media (prefers-reduced-motion: reduce) {
2333
- .sv-board-card,
2334
- .sv-board-lane-body,
2335
- .sv-board-subs-bar span {
2336
- transition: none;
2337
- }
2338
- }
2339
- </style>
1
+ <script
2
+ lang="ts"
3
+ generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData"
4
+ >
5
+ // Kanban board renderer for SvGrid. Rendered in place of the table when the
6
+ // `board` prop is set. Everything a board needs is built in here:
7
+ // - buckets rows into lanes by a field (explicit lanes or derived)
8
+ // - renders each row as a card (default template or a `card` snippet)
9
+ // - mouse drag-and-drop between and within lanes, with a live insertion
10
+ // marker, AND keyboard drag-and-drop (grab with Space, move with arrows)
11
+ // - the move itself (lane reassign + reorder) is computed and applied here
12
+ // as a per-row overlay keyed by row id, so consumers write no move code;
13
+ // `onCardMove` fires purely as a notification for persistence / mirroring
14
+ // Themes entirely from the grid's `--sg-*` tokens.
15
+ import { tick } from "svelte";
16
+ import type { ColumnDef, RowData, TableFeatures } from "./index";
17
+ import type { BoardConfig, BoardLane, BoardSubtask, BoardComment } from "./SvGrid.types";
18
+ import SvMenuList, { type MenuItem } from "./SvMenuList.svelte";
19
+ import SvForm, { type FormField, type FormFieldType } from "./SvForm.svelte";
20
+ import SvDrawer from "./SvDrawer.svelte";
21
+ import { portalToBody, popIn } from "./popover";
22
+ import { createDismissableLayer } from "./a11y/dismissable";
23
+
24
+ let {
25
+ data,
26
+ columns,
27
+ board,
28
+ getRowId,
29
+ }: {
30
+ data: ReadonlyArray<TData>;
31
+ columns: Array<ColumnDef<TFeatures, TData>>;
32
+ board: BoardConfig<TFeatures, TData>;
33
+ getRowId?: (row: TData, index: number) => string;
34
+ } = $props();
35
+
36
+ const groupBy = $derived(board.groupBy);
37
+
38
+ // --- value access (casts live here, never in the template) ---
39
+ function fieldValue(row: TData, field: string): unknown {
40
+ // Saved edits show immediately without mutating the consumer's rows.
41
+ const e = edits[key(row)];
42
+ if (e && field in e) return e[field];
43
+ return (row as Record<string, unknown>)[field];
44
+ }
45
+ function rawLane(row: TData): string {
46
+ const v = fieldValue(row, groupBy);
47
+ return v == null ? "" : String(v);
48
+ }
49
+ function fmt(value: unknown): string {
50
+ if (value == null) return "";
51
+ if (value instanceof Date) return value.toLocaleDateString();
52
+ return String(value);
53
+ }
54
+
55
+ // --- stable per-row key (getRowId, else a synthetic id pinned to the row
56
+ // object) so the move overlay survives data adds/removes and re-sorts ---
57
+ const indexOf = $derived(new Map<TData, number>(data.map((r, i) => [r, i])));
58
+ const synthetic = new WeakMap<object, string>();
59
+ let synthSeq = 0;
60
+ function key(row: TData): string {
61
+ if (getRowId) return getRowId(row, indexOf.get(row) ?? 0);
62
+ const obj = row as unknown as object;
63
+ let id = synthetic.get(obj);
64
+ if (!id) {
65
+ id = `__b${++synthSeq}`;
66
+ synthetic.set(obj, id);
67
+ }
68
+ return id;
69
+ }
70
+
71
+ // --- the move overlay: rowKey -> lane and rowKey -> sort order. Applied on
72
+ // top of the data; never mutates the consumer's rows. The edit overlay does
73
+ // the same for saved field values. ---
74
+ let laneOf = $state<Record<string, string>>({});
75
+ let orderOf = $state<Record<string, number>>({});
76
+ let swimOf = $state<Record<string, string>>({});
77
+ let edits = $state<Record<string, Record<string, unknown>>>({});
78
+ let subDone = $state<Record<string, Record<string, boolean>>>({});
79
+ let subAdded = $state<Record<string, BoardSubtask[]>>({});
80
+ let laneOrder = $state<string[]>([]);
81
+ let collapsedSwim = $state<Record<string, boolean>>({});
82
+
83
+ function effectiveLane(row: TData): string {
84
+ return laneOf[key(row)] ?? rawLane(row);
85
+ }
86
+ function ord(row: TData): number {
87
+ return orderOf[key(row)] ?? (indexOf.get(row) ?? 0);
88
+ }
89
+
90
+ // --- swimlanes: an optional second axis (board.swimlaneBy). ---
91
+ const swimField = $derived(board.swimlaneBy);
92
+ function rawSwim(row: TData): string {
93
+ if (!swimField) return "";
94
+ const v = fieldValue(row, swimField);
95
+ return v == null ? "" : String(v);
96
+ }
97
+ function effectiveSwim(row: TData): string {
98
+ return swimOf[key(row)] ?? rawSwim(row);
99
+ }
100
+ const swimlanes = $derived.by<Array<{ id: string; title: string }>>(() => {
101
+ if (!swimField) return [];
102
+ const seen = new Set<string>();
103
+ const out: Array<{ id: string; title: string }> = [];
104
+ for (const row of data) {
105
+ const k = rawSwim(row);
106
+ if (!seen.has(k)) {
107
+ seen.add(k);
108
+ out.push({ id: k, title: k === "" ? "(none)" : k });
109
+ }
110
+ }
111
+ return out;
112
+ });
113
+
114
+ // --- lane collapse ---
115
+ let collapsed = $state<Record<string, boolean>>({});
116
+ function toggleCollapse(laneId: string) {
117
+ if (board.collapsibleLanes) collapsed[laneId] = !collapsed[laneId];
118
+ }
119
+ function toggleSwim(swimId: string) {
120
+ if (board.collapsibleSwimlanes) collapsedSwim[swimId] = !collapsedSwim[swimId];
121
+ }
122
+
123
+ // --- inline lane rename (board.onLaneRename) ---
124
+ let renamingLane = $state<string | null>(null);
125
+ let renameText = $state("");
126
+ function startRename(lane: BoardLane) {
127
+ if (!board.onLaneRename) return;
128
+ renamingLane = lane.id;
129
+ renameText = laneTitle(lane);
130
+ }
131
+ function commitRename(laneId: string) {
132
+ const t = renameText.trim();
133
+ renamingLane = null;
134
+ if (t) board.onLaneRename?.(laneId, t);
135
+ }
136
+
137
+ // --- layout persistence: the overlay + collapse state, saved to
138
+ // localStorage (persistKey) and/or emitted via onLayoutChange. Needs a
139
+ // stable getRowId so the keys survive a reload. ---
140
+ const canPersist = $derived(!!board.persistKey && !!getRowId);
141
+ function serializeLayout() {
142
+ return {
143
+ laneOf: { ...laneOf },
144
+ orderOf: { ...orderOf },
145
+ swimOf: { ...swimOf },
146
+ edits: { ...edits },
147
+ collapsed: { ...collapsed },
148
+ subDone: { ...subDone },
149
+ subAdded: { ...subAdded },
150
+ laneOrder: [...laneOrder],
151
+ collapsedSwim: { ...collapsedSwim },
152
+ };
153
+ }
154
+ // Restore once at init (before first paint).
155
+ if (board.persistKey && getRowId && typeof localStorage !== "undefined") {
156
+ try {
157
+ const raw = localStorage.getItem(board.persistKey);
158
+ if (raw) {
159
+ const s = JSON.parse(raw);
160
+ laneOf = s.laneOf ?? {};
161
+ orderOf = s.orderOf ?? {};
162
+ swimOf = s.swimOf ?? {};
163
+ edits = s.edits ?? {};
164
+ collapsed = s.collapsed ?? {};
165
+ subDone = s.subDone ?? {};
166
+ subAdded = s.subAdded ?? {};
167
+ laneOrder = s.laneOrder ?? [];
168
+ collapsedSwim = s.collapsedSwim ?? {};
169
+ }
170
+ } catch {
171
+ /* ignore corrupt/blocked storage */
172
+ }
173
+ }
174
+ let layoutLoaded = false;
175
+ $effect(() => {
176
+ const layout = serializeLayout(); // reads every map -> tracks changes
177
+ // Skip the initial run so we don't rewrite what we just restored.
178
+ if (!layoutLoaded) {
179
+ layoutLoaded = true;
180
+ return;
181
+ }
182
+ if (canPersist && typeof localStorage !== "undefined") {
183
+ try {
184
+ localStorage.setItem(board.persistKey as string, JSON.stringify(layout));
185
+ } catch {
186
+ /* ignore */
187
+ }
188
+ }
189
+ board.onLayoutChange?.(layout);
190
+ });
191
+
192
+ // --- per-lane virtualization (fixed-height windowing) ---
193
+ const virtualized = $derived(board.virtualized === true);
194
+ const cardH = $derived(board.cardHeight ?? 76);
195
+ const VOVERSCAN = 4;
196
+ const VGAP = 8;
197
+ let laneScroll = $state<Record<string, number>>({});
198
+ let laneVh = $state<Record<string, number>>({});
199
+ const laneBodyEl = new Map<string, HTMLElement>();
200
+ function cellKey(laneId: string, swimId: string | null): string {
201
+ return `${swimId ?? ""}::${laneId}`;
202
+ }
203
+ function laneWindow(laneId: string, swimId: string | null, count: number) {
204
+ if (!virtualized) return { start: 0, end: count, top: 0, bottom: 0 };
205
+ const ck = cellKey(laneId, swimId);
206
+ const stride = cardH + VGAP;
207
+ const scroll = laneScroll[ck] ?? 0;
208
+ const vh = laneVh[ck] || 400;
209
+ const start = Math.max(0, Math.floor(scroll / stride) - VOVERSCAN);
210
+ const end = Math.min(count, Math.ceil((scroll + vh) / stride) + VOVERSCAN);
211
+ return { start, end, top: start * stride, bottom: Math.max(0, (count - end) * stride) };
212
+ }
213
+ // Svelte action: track a lane body's scroll + height so windowing follows it.
214
+ function trackLane(node: HTMLElement, ck: string) {
215
+ laneBodyEl.set(ck, node);
216
+ const update = () => {
217
+ laneScroll[ck] = node.scrollTop;
218
+ laneVh[ck] = node.clientHeight;
219
+ };
220
+ update();
221
+ node.addEventListener("scroll", update, { passive: true });
222
+ let ro: ResizeObserver | undefined;
223
+ if (typeof ResizeObserver !== "undefined") {
224
+ ro = new ResizeObserver(update);
225
+ ro.observe(node);
226
+ }
227
+ return {
228
+ update(nextCk: string) {
229
+ laneBodyEl.delete(ck);
230
+ ck = nextCk;
231
+ laneBodyEl.set(ck, node);
232
+ update();
233
+ },
234
+ destroy() {
235
+ laneBodyEl.delete(ck);
236
+ node.removeEventListener("scroll", update);
237
+ ro?.disconnect();
238
+ },
239
+ };
240
+ }
241
+
242
+ // --- columns lookup + default title/meta fields ---
243
+ const fieldColumns = $derived(
244
+ columns.filter((c) => typeof c.field === "string"),
245
+ );
246
+ function headerLabel(field: string): string {
247
+ const col = fieldColumns.find((c) => c.field === field);
248
+ return col && typeof col.header === "string" ? col.header : field;
249
+ }
250
+ const titleField = $derived(
251
+ board.titleField ?? (fieldColumns[0]?.field as string | undefined),
252
+ );
253
+ const metaFields = $derived.by(() => {
254
+ if (board.cardFields) return board.cardFields.slice(0, 4);
255
+ return fieldColumns
256
+ .map((c) => c.field as string)
257
+ .filter((f) => f !== titleField && f !== groupBy)
258
+ .slice(0, 3);
259
+ });
260
+
261
+ // --- built-in card editor: fields come from columns with an editorType (or
262
+ // all fields if none declare one). Edits go to the overlay + `onCardCommit`.
263
+ const editable = $derived(board.editable === true);
264
+ const editColumns = $derived.by(() => {
265
+ const typed = fieldColumns.filter((c) => c.editorType);
266
+ return (typed.length ? typed : fieldColumns).filter(
267
+ (c) => c.field !== groupBy,
268
+ );
269
+ });
270
+ function editorKind(col: (typeof editColumns)[number]): string {
271
+ const t = col.editorType;
272
+ if (t === "number") return "number";
273
+ if (t === "checkbox") return "checkbox";
274
+ if (t === "date") return "date";
275
+ return "text";
276
+ }
277
+
278
+ let editingKey = $state<string | null>(null);
279
+ let draft = $state<Record<string, unknown>>({});
280
+
281
+ function openEditor(row: TData) {
282
+ if (board.onCardEdit) {
283
+ board.onCardEdit(row);
284
+ return;
285
+ }
286
+ if (board.drawer) {
287
+ drawerRow = row;
288
+ return;
289
+ }
290
+ if (!editable) return;
291
+ const next: Record<string, unknown> = {};
292
+ for (const col of editColumns) {
293
+ const f = col.field as string;
294
+ next[f] = fieldValue(row, f);
295
+ }
296
+ draft = next;
297
+ editingKey = key(row);
298
+ }
299
+ function saveEditor(row: TData) {
300
+ const k = key(row);
301
+ const changes: Record<string, unknown> = {};
302
+ for (const col of editColumns) {
303
+ const f = col.field as string;
304
+ if (draft[f] !== fieldValue(row, f)) changes[f] = draft[f];
305
+ }
306
+ edits[k] = { ...(edits[k] ?? {}), ...draft };
307
+ editingKey = null;
308
+ board.onCardCommit?.({ row, changes, values: { ...draft } });
309
+ }
310
+ function cancelEditor() {
311
+ editingKey = null;
312
+ draft = {};
313
+ }
314
+ function onDraftInput(field: string, kind: string, e: Event) {
315
+ const el = e.currentTarget as HTMLInputElement;
316
+ draft[field] =
317
+ kind === "checkbox"
318
+ ? el.checked
319
+ : kind === "number"
320
+ ? el.value === ""
321
+ ? null
322
+ : Number(el.value)
323
+ : el.value;
324
+ }
325
+
326
+ // --- built-in detail drawer (board.drawer): an SvDrawer + SvForm of the
327
+ // card's fields, rendered with the UI-kit editors. ---
328
+ let drawerRow = $state.raw<TData | null>(null);
329
+ const drawerCfg = $derived(
330
+ board.drawer && typeof board.drawer === "object" ? board.drawer : null,
331
+ );
332
+ type DrawerCol = ColumnDef<TFeatures, TData>;
333
+ const drawerFieldCols = $derived.by<DrawerCol[]>(() => {
334
+ const wanted = drawerCfg?.fields;
335
+ if (wanted && wanted.length) {
336
+ return wanted
337
+ .map((f) => fieldColumns.find((c) => c.field === f))
338
+ .filter((c): c is DrawerCol => !!c);
339
+ }
340
+ return fieldColumns;
341
+ });
342
+ function formType(t: string | undefined): FormFieldType {
343
+ switch (t) {
344
+ case "number":
345
+ return "number";
346
+ case "checkbox":
347
+ return "checkbox";
348
+ case "date":
349
+ case "date-native":
350
+ case "datetime":
351
+ case "datetime-native":
352
+ return "date";
353
+ case "textarea":
354
+ return "textarea";
355
+ case "password":
356
+ return "password";
357
+ case "color":
358
+ return "color";
359
+ case "list":
360
+ case "select":
361
+ case "rich-select":
362
+ return "select";
363
+ default:
364
+ return "text";
365
+ }
366
+ }
367
+ function drawerOptions(col: DrawerCol, row: TData) {
368
+ const raw =
369
+ typeof col.editorOptions === "function" ? col.editorOptions(row) : col.editorOptions;
370
+ if (!raw) return undefined;
371
+ return raw.map((o) =>
372
+ typeof o === "object"
373
+ ? { value: o.value, label: o.label ?? String(o.value) }
374
+ : { value: o, label: String(o) },
375
+ );
376
+ }
377
+ function drawerForm(row: TData): FormField[] {
378
+ return drawerFieldCols.map((col) => {
379
+ const f = col.field as string;
380
+ const type = formType(col.editorType);
381
+ return {
382
+ name: f,
383
+ label: typeof col.header === "string" ? col.header : f,
384
+ type,
385
+ options: type === "select" ? drawerOptions(col, row) : undefined,
386
+ };
387
+ });
388
+ }
389
+ function drawerInitial(row: TData): Record<string, unknown> {
390
+ const out: Record<string, unknown> = {};
391
+ for (const col of drawerFieldCols) {
392
+ const f = col.field as string;
393
+ out[f] = fieldValue(row, f);
394
+ }
395
+ return out;
396
+ }
397
+ function drawerTitleOf(row: TData): string {
398
+ const t = drawerCfg?.title;
399
+ if (typeof t === "function") return t(row);
400
+ if (typeof t === "string") return t;
401
+ return titleField ? fmt(fieldValue(row, titleField)) : "Details";
402
+ }
403
+ function onDrawerSubmit(row: TData, values: Record<string, unknown>) {
404
+ const k = key(row);
405
+ const changes: Record<string, unknown> = {};
406
+ for (const [f, v] of Object.entries(values)) {
407
+ if (v !== fieldValue(row, f)) changes[f] = v;
408
+ }
409
+ edits[k] = { ...(edits[k] ?? {}), ...values };
410
+ board.onCardCommit?.({ row, changes, values });
411
+ drawerRow = null;
412
+ }
413
+
414
+ // --- sub-tasks (checklist on a card). Toggles/adds live in an overlay so the
415
+ // consumer's data is never mutated; callbacks notify. subDone/subAdded are
416
+ // declared with the other overlays above (so persistence can read them). ---
417
+ let subOpen = $state<Record<string, boolean>>({});
418
+ let subSeq = 0;
419
+ function baseSubtasks(row: TData): BoardSubtask[] {
420
+ if (!board.subtasks) return [];
421
+ const v = fieldValue(row, board.subtasks);
422
+ return Array.isArray(v) ? (v as BoardSubtask[]) : [];
423
+ }
424
+ function subtasksOf(row: TData): BoardSubtask[] {
425
+ const k = key(row);
426
+ const overrides = subDone[k] ?? {};
427
+ const base = baseSubtasks(row).map((s) =>
428
+ String(s.id) in overrides ? { ...s, done: overrides[String(s.id)]! } : s,
429
+ );
430
+ return [...base, ...(subAdded[k] ?? [])];
431
+ }
432
+ function subProgress(row: TData): { done: number; total: number } {
433
+ const list = subtasksOf(row);
434
+ return { done: list.filter((s) => s.done).length, total: list.length };
435
+ }
436
+ function toggleSubtask(row: TData, sub: BoardSubtask) {
437
+ const k = key(row);
438
+ const next = !sub.done;
439
+ const added = subAdded[k];
440
+ const inAdded = added?.find((s) => String(s.id) === String(sub.id));
441
+ if (inAdded) inAdded.done = next;
442
+ else subDone[k] = { ...(subDone[k] ?? {}), [String(sub.id)]: next };
443
+ board.onSubtaskToggle?.(row, sub.id, next);
444
+ }
445
+ function addSubtask(row: TData, title: string) {
446
+ const t = title.trim();
447
+ if (!t) return;
448
+ const k = key(row);
449
+ subAdded[k] = [...(subAdded[k] ?? []), { id: `s${++subSeq}`, title: t, done: false }];
450
+ board.onSubtaskAdd?.(row, t);
451
+ }
452
+
453
+ // --- card badges (default card): labels, due date, assignees ---
454
+ function labelsOf(row: TData): Array<{ text: string; color?: string }> {
455
+ if (!board.labelsField) return [];
456
+ const v = fieldValue(row, board.labelsField);
457
+ if (!Array.isArray(v)) return [];
458
+ return v.map((l) =>
459
+ typeof l === "string" ? { text: l } : (l as { text: string; color?: string }),
460
+ );
461
+ }
462
+ function assigneesOf(row: TData): string[] {
463
+ if (!board.assigneesField) return [];
464
+ const v = fieldValue(row, board.assigneesField);
465
+ if (Array.isArray(v)) return v.map(String);
466
+ return v == null ? [] : [String(v)];
467
+ }
468
+ function dueOf(row: TData): { label: string; overdue: boolean } | null {
469
+ if (!board.dueField) return null;
470
+ const v = fieldValue(row, board.dueField);
471
+ if (v == null || v === "") return null;
472
+ const d = v instanceof Date ? v : new Date(String(v));
473
+ if (Number.isNaN(d.getTime())) return { label: String(v), overdue: false };
474
+ const overdue = d.getTime() < todayStart;
475
+ return { label: d.toLocaleDateString(undefined, { month: "short", day: "numeric" }), overdue };
476
+ }
477
+ function initials(name: string): string {
478
+ return name.split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase();
479
+ }
480
+ function countOf(row: TData, field: string | undefined): number {
481
+ if (!field) return 0;
482
+ const v = fieldValue(row, field);
483
+ if (Array.isArray(v)) return v.length;
484
+ if (typeof v === "number") return v;
485
+ return 0;
486
+ }
487
+ function coverColorOf(row: TData): string | null {
488
+ if (!board.coverField) return null;
489
+ const v = fieldValue(row, board.coverField);
490
+ return v == null || v === "" ? null : String(v);
491
+ }
492
+ const todayStart = (() => {
493
+ const n = new Date();
494
+ return new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime();
495
+ })();
496
+ function isFlagged(row: TData): boolean {
497
+ return board.flagField ? !!fieldValue(row, board.flagField) : false;
498
+ }
499
+ function flagReason(row: TData): string | null {
500
+ if (!board.flagField) return null;
501
+ const v = fieldValue(row, board.flagField);
502
+ return typeof v === "string" && v.trim() ? v : null;
503
+ }
504
+ // A blocked card is locked from moving unless flagBlocksMoves is false.
505
+ const flagBlocksMoves = $derived(board.flagBlocksMoves !== false);
506
+ function movable(row: TData): boolean {
507
+ return !(flagBlocksMoves && isFlagged(row));
508
+ }
509
+
510
+ // --- comments (read / write / delete) ---
511
+ let commentsOpen = $state<Record<string, boolean>>({});
512
+ function commentsOf(row: TData): BoardComment[] {
513
+ if (!board.commentsField) return [];
514
+ const v = fieldValue(row, board.commentsField);
515
+ if (!Array.isArray(v)) return [];
516
+ return v.map((c, i) =>
517
+ typeof c === "string"
518
+ ? { id: i, text: c }
519
+ : (c as BoardComment),
520
+ );
521
+ }
522
+ function addComment(row: TData, text: string) {
523
+ const t = text.trim();
524
+ if (t) board.onCommentAdd?.(row, t);
525
+ }
526
+ function deleteComment(row: TData, id: string | number) {
527
+ board.onCommentDelete?.(row, id);
528
+ }
529
+ function ageOf(row: TData): string | null {
530
+ if (!board.ageField) return null;
531
+ const v = fieldValue(row, board.ageField);
532
+ if (v == null || v === "") return null;
533
+ const d = v instanceof Date ? v : new Date(String(v));
534
+ if (Number.isNaN(d.getTime())) return null;
535
+ const days = Math.floor((todayStart - d.getTime()) / 86400000);
536
+ if (days <= 0) return "today";
537
+ if (days < 7) return `${days}d`;
538
+ if (days < 30) return `${Math.floor(days / 7)}w`;
539
+ return `${Math.floor(days / 30)}mo`;
540
+ }
541
+
542
+ // --- facet filter bar (board.facets) ---
543
+ let facetSel = $state<Record<string, string[]>>({});
544
+ function rowFacetValues(row: TData, field: string): string[] {
545
+ const v = fieldValue(row, field);
546
+ if (Array.isArray(v))
547
+ return v.map((el) =>
548
+ typeof el === "string" ? el : String((el as { text?: unknown }).text ?? el),
549
+ );
550
+ return v == null || v === "" ? [] : [String(v)];
551
+ }
552
+ function facetOptions(field: string): Array<{ value: string; count: number }> {
553
+ const counts = new Map<string, number>();
554
+ for (const row of data)
555
+ for (const val of rowFacetValues(row, field)) counts.set(val, (counts.get(val) ?? 0) + 1);
556
+ return [...counts.entries()].map(([value, count]) => ({ value, count }));
557
+ }
558
+ function toggleFacet(field: string, value: string) {
559
+ const cur = facetSel[field] ?? [];
560
+ facetSel[field] = cur.includes(value) ? cur.filter((v) => v !== value) : [...cur, value];
561
+ }
562
+ function clearFacets() {
563
+ facetSel = {};
564
+ }
565
+ const anyFacet = $derived(Object.values(facetSel).some((a) => a.length > 0));
566
+ function matchesFacets(row: TData): boolean {
567
+ for (const [field, sel] of Object.entries(facetSel)) {
568
+ if (!sel.length) continue;
569
+ const vals = rowFacetValues(row, field);
570
+ if (!sel.some((s) => vals.includes(s))) return false;
571
+ }
572
+ return true;
573
+ }
574
+
575
+ // --- multi-select (board.selectable) ---
576
+ let selected = $state<Record<string, boolean>>({});
577
+ function selectedRows(): TData[] {
578
+ return data.filter((r) => selected[key(r)]);
579
+ }
580
+ function emitSelection() {
581
+ board.onSelectionChange?.(selectedRows());
582
+ }
583
+ function onCardClick(e: MouseEvent, row: TData) {
584
+ if (!board.selectable) return;
585
+ const k = key(row);
586
+ if (e.ctrlKey || e.metaKey) {
587
+ selected[k] = !selected[k];
588
+ } else {
589
+ selected = { [k]: true };
590
+ }
591
+ emitSelection();
592
+ }
593
+
594
+ // --- child cards (board.childrenField) - epic -> stories ---
595
+ let childOpen = $state<Record<string, boolean>>({});
596
+ function childrenOf(row: TData): TData[] {
597
+ if (!board.childrenField) return [];
598
+ const v = fieldValue(row, board.childrenField);
599
+ return Array.isArray(v) ? (v as TData[]) : [];
600
+ }
601
+
602
+ // --- quick-add composer (per lane) ---
603
+ let composerOpen = $state<Record<string, boolean>>({});
604
+ let composerText = $state<Record<string, string>>({});
605
+ function submitComposer(laneId: string) {
606
+ const t = (composerText[laneId] ?? "").trim();
607
+ if (t) board.onCardAdd?.(laneId, t);
608
+ composerText[laneId] = "";
609
+ }
610
+ function focusNode(node: HTMLElement) {
611
+ node.focus();
612
+ }
613
+
614
+ // --- lane model: explicit lanes, else distinct field values (first-seen).
615
+ // Derived from the RAW field, not the move overlay, so the lane order stays
616
+ // stable when a card is moved. (Pass `lanes` to pin the order explicitly.) ---
617
+ const lanes = $derived.by<BoardLane[]>(() => {
618
+ let base: BoardLane[];
619
+ if (board.lanes && board.lanes.length) {
620
+ base = [...board.lanes];
621
+ } else {
622
+ const seen = new Set<string>();
623
+ base = [];
624
+ for (const row of data) {
625
+ const k = rawLane(row);
626
+ if (!seen.has(k)) {
627
+ seen.add(k);
628
+ base.push({ id: k, title: k === "" ? "(empty)" : k });
629
+ }
630
+ }
631
+ }
632
+ if (!laneOrder.length) return base;
633
+ // Apply the drag-reorder overlay: ordered ids first, then any new lanes.
634
+ const byId = new Map(base.map((l) => [l.id, l]));
635
+ const ordered = laneOrder.map((id) => byId.get(id)).filter((l): l is BoardLane => !!l);
636
+ const rest = base.filter((l) => !laneOrder.includes(l.id));
637
+ return [...ordered, ...rest];
638
+ });
639
+
640
+ // --- lane drag-reorder (drag lane headers) ---
641
+ let dragLaneId = $state<string | null>(null);
642
+ let overLaneId = $state<string | null>(null);
643
+ function onLaneHeaderDragStart(e: DragEvent, laneId: string) {
644
+ dragLaneId = laneId;
645
+ if (e.dataTransfer) {
646
+ e.dataTransfer.effectAllowed = "move";
647
+ e.dataTransfer.setData("text/plain", `lane:${laneId}`);
648
+ }
649
+ }
650
+ function onLaneHeaderDragOver(e: DragEvent, laneId: string) {
651
+ if (dragLaneId == null || laneId === dragLaneId) return;
652
+ e.preventDefault();
653
+ overLaneId = laneId;
654
+ }
655
+ function onLaneHeaderDrop(e: DragEvent, laneId: string) {
656
+ if (dragLaneId == null) return;
657
+ e.preventDefault();
658
+ const ids = lanes.map((l) => l.id);
659
+ const to = ids.indexOf(laneId);
660
+ if (to > -1) reorderLaneTo(dragLaneId, to);
661
+ dragLaneId = null;
662
+ overLaneId = null;
663
+ }
664
+ function onLaneHeaderDragEnd() {
665
+ dragLaneId = null;
666
+ overLaneId = null;
667
+ }
668
+
669
+ // Shared by mouse drag-drop (above) and keyboard reorder (below): moves
670
+ // `fromId` to `toIndex` in the lane order, persists it, and notifies.
671
+ // Returns the new ordered ids, or null if the move was a no-op / invalid.
672
+ function reorderLaneTo(fromId: string, toIndex: number): string[] | null {
673
+ const ids = lanes.map((l) => l.id);
674
+ const from = ids.indexOf(fromId);
675
+ if (from < 0 || toIndex < 0 || toIndex >= ids.length || from === toIndex) return null;
676
+ ids.splice(toIndex, 0, ids.splice(from, 1)[0]!);
677
+ laneOrder = ids;
678
+ board.onLaneReorder?.(ids);
679
+ return ids;
680
+ }
681
+ // Keyboard lane reorder: Ctrl/Cmd+ArrowLeft/Right shifts the focused lane
682
+ // header by one position, mirroring the drag-reorder above.
683
+ function onLaneHeaderKeydown(e: KeyboardEvent, laneId: string) {
684
+ if (!board.reorderableLanes) return;
685
+ if ((e.key !== "ArrowLeft" && e.key !== "ArrowRight") || !(e.ctrlKey || e.metaKey)) return;
686
+ e.preventDefault();
687
+ const ids = lanes.map((l) => l.id);
688
+ const from = ids.indexOf(laneId);
689
+ if (from < 0) return;
690
+ const to = from + (e.key === "ArrowRight" ? 1 : -1);
691
+ const moved = reorderLaneTo(laneId, to);
692
+ if (moved) {
693
+ const pos = moved.indexOf(laneId) + 1;
694
+ announce(`Moved ${laneTitleById(laneId)} to position ${pos} of ${moved.length}.`);
695
+ }
696
+ }
697
+
698
+ // Runtime group-by change: the position overlay (laneOf/orderOf/laneOrder) is
699
+ // keyed to the old field, so reset it when `groupBy` changes. Edits and
700
+ // sub-tasks (keyed by row) are kept. Skips the initial run so a restored
701
+ // layout isn't wiped on mount.
702
+ let gbInitialized = false;
703
+ let prevGroupBy: string | undefined;
704
+ $effect(() => {
705
+ const g = groupBy;
706
+ if (!gbInitialized) {
707
+ gbInitialized = true;
708
+ prevGroupBy = g;
709
+ return;
710
+ }
711
+ if (g !== prevGroupBy) {
712
+ prevGroupBy = g;
713
+ laneOf = {};
714
+ orderOf = {};
715
+ laneOrder = [];
716
+ }
717
+ });
718
+
719
+ function laneCards(laneId: string, swimId: string | null = null): TData[] {
720
+ return data
721
+ .filter(
722
+ (r) =>
723
+ effectiveLane(r) === laneId &&
724
+ (swimId == null || effectiveSwim(r) === swimId) &&
725
+ matchesFacets(r),
726
+ )
727
+ .sort((a, b) => ord(a) - ord(b));
728
+ }
729
+ function laneTitle(lane: BoardLane): string {
730
+ return lane.title ?? (lane.id === "" ? "(empty)" : lane.id);
731
+ }
732
+ function laneWip(laneId: string): number | undefined {
733
+ return lanes.find((l) => l.id === laneId)?.wipLimit ?? undefined;
734
+ }
735
+ function swimRows(swimId: string): TData[] {
736
+ return data.filter((r) => effectiveSwim(r) === swimId && matchesFacets(r));
737
+ }
738
+ function swimCount(swimId: string): number {
739
+ return swimRows(swimId).length;
740
+ }
741
+
742
+ // --- the one place a move is applied: compute the new order slot and record
743
+ // it in the overlay, then notify. `pos` is the insertion index within the
744
+ // destination lane (excluding the moved card). Returns false if rejected. ---
745
+ function applyMove(
746
+ row: TData,
747
+ fromLane: string,
748
+ toLane: string,
749
+ pos: number,
750
+ toSwim: string | null = null,
751
+ fromSwim: string | null = null,
752
+ ): boolean {
753
+ const sameCell = fromLane === toLane && (toSwim == null || toSwim === fromSwim);
754
+ // WIP enforcement: reject a move INTO a different lane past its limit.
755
+ if (board.enforceWip && !sameCell) {
756
+ const lim = laneWip(toLane);
757
+ if (lim != null && laneCards(toLane, toSwim).filter((r) => r !== row).length >= lim) {
758
+ announce(`${laneTitleById(toLane)} is at its WIP limit (${lim}).`);
759
+ return false;
760
+ }
761
+ }
762
+ const list = laneCards(toLane, toSwim).filter((r) => r !== row);
763
+ const clamped = Math.max(0, Math.min(pos, list.length));
764
+ if (sameCell) {
765
+ const currentPos = laneCards(fromLane, toSwim).indexOf(row);
766
+ if (currentPos === clamped) return false;
767
+ }
768
+ const before = list[clamped - 1];
769
+ const after = list[clamped];
770
+ let newOrd: number;
771
+ if (before && after) newOrd = (ord(before) + ord(after)) / 2;
772
+ else if (before) newOrd = ord(before) + 1;
773
+ else if (after) newOrd = ord(after) - 1;
774
+ else newOrd = 0;
775
+ const k = key(row);
776
+ laneOf[k] = toLane;
777
+ orderOf[k] = newOrd;
778
+ if (swimField && toSwim != null) swimOf[k] = toSwim;
779
+ board.onCardMove?.({
780
+ row,
781
+ fromLane,
782
+ toLane,
783
+ toIndex: clamped,
784
+ ...(swimField ? { fromSwimlane: fromSwim ?? undefined, toSwimlane: toSwim ?? undefined } : {}),
785
+ });
786
+ return true;
787
+ }
788
+
789
+ // --- mouse drag & drop ---
790
+ // Row-holding state is `$state.raw` so the stored object keeps its identity
791
+ // (plain `$state` would proxy it and break `row === dragRow` comparisons).
792
+ let dragRow = $state.raw<TData | null>(null);
793
+ let dragFromLane = $state<string | null>(null);
794
+ let dragFromSwim = $state<string | null>(null);
795
+ let overLane = $state<string | null>(null);
796
+ let overSwim = $state<string | null>(null);
797
+ let overRow = $state.raw<TData | null>(null);
798
+ let overAfter = $state(false);
799
+
800
+ function markerIndex(laneId: string, swimId: string | null, cards: TData[]): number {
801
+ if (!dragRow || overLane !== laneId || overSwim !== swimId) return -1;
802
+ if (!overRow) return cards.length;
803
+ const i = cards.indexOf(overRow);
804
+ if (i < 0) return cards.length;
805
+ return overAfter ? i + 1 : i;
806
+ }
807
+
808
+ function onCardDragStart(e: DragEvent, row: TData, fromLane: string, fromSwim: string | null) {
809
+ if (!movable(row)) {
810
+ e.preventDefault();
811
+ return;
812
+ }
813
+ // Dragging an unselected card (with multi-select on) reduces the selection
814
+ // to just it, so the drag moves what the user grabbed.
815
+ if (board.selectable && !selected[key(row)]) {
816
+ selected = { [key(row)]: true };
817
+ emitSelection();
818
+ }
819
+ dragRow = row;
820
+ dragFromLane = fromLane;
821
+ dragFromSwim = fromSwim;
822
+ if (e.dataTransfer) {
823
+ e.dataTransfer.effectAllowed = "move";
824
+ e.dataTransfer.setData("text/plain", key(row));
825
+ }
826
+ }
827
+ function onCardDragOver(e: DragEvent, row: TData, laneId: string, swimId: string | null) {
828
+ if (!dragRow) return;
829
+ e.preventDefault();
830
+ e.stopPropagation();
831
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
832
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
833
+ overLane = laneId;
834
+ overSwim = swimId;
835
+ overRow = row;
836
+ overAfter = e.clientY - rect.top > rect.height / 2;
837
+ }
838
+ function onLaneDragOver(e: DragEvent, laneId: string, swimId: string | null) {
839
+ if (!dragRow) return;
840
+ e.preventDefault();
841
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
842
+ // Only claim the append slot when the pointer is over empty lane space
843
+ // (a card's own handler stops propagation for the over-a-card case).
844
+ if (e.target === e.currentTarget) {
845
+ overLane = laneId;
846
+ overSwim = swimId;
847
+ overRow = null;
848
+ }
849
+ }
850
+ function onLaneDrop(e: DragEvent, laneId: string, swimId: string | null) {
851
+ e.preventDefault();
852
+ const row = dragRow;
853
+ const fromLane = dragFromLane;
854
+ const fromSwim = dragFromSwim;
855
+ const target = overRow;
856
+ const after = overAfter;
857
+ resetDrag();
858
+ if (!row || fromLane == null) return;
859
+ // Multi-select: dragging a selected card moves the whole selection.
860
+ const multi = board.selectable && selected[key(row)] && selectedRows().length > 1;
861
+ const movers = multi ? selectedRows() : [row];
862
+ const moverKeys = new Set(movers.map((r) => key(r)));
863
+ const rest = laneCards(laneId, swimId).filter((r) => !moverKeys.has(key(r)));
864
+ let pos: number;
865
+ if (!target || moverKeys.has(key(target))) pos = rest.length;
866
+ else {
867
+ const i = rest.indexOf(target);
868
+ pos = i < 0 ? rest.length : after ? i + 1 : i;
869
+ }
870
+ movers.forEach((r, i) => applyMove(r, effectiveLane(r), laneId, pos + i, swimId, fromSwim));
871
+ }
872
+ function resetDrag() {
873
+ dragRow = null;
874
+ dragFromLane = null;
875
+ dragFromSwim = null;
876
+ overLane = null;
877
+ overSwim = null;
878
+ overRow = null;
879
+ overAfter = false;
880
+ }
881
+
882
+ // --- keyboard drag & drop (grab with Space/Enter, move with arrows) ---
883
+ let grabbed = $state.raw<TData | null>(null);
884
+ let grabInfo: { lane: string; order: number; swim: string } | null = null;
885
+ let liveMsg = $state("");
886
+
887
+ function announce(msg: string) {
888
+ liveMsg = msg;
889
+ }
890
+
891
+ function laneIndex(laneId: string): number {
892
+ return lanes.findIndex((l) => l.id === laneId);
893
+ }
894
+
895
+ function onCardKeydown(e: KeyboardEvent, row: TData, laneId: string) {
896
+ if (e.key === "F2" && (editable || board.onCardEdit || board.drawer)) {
897
+ e.preventDefault();
898
+ openEditor(row);
899
+ return;
900
+ }
901
+ // Keyboard multi-select: Ctrl/Cmd+Space toggles this card's selection,
902
+ // mirroring onCardClick's ctrl/meta-click branch. Kept on a modifier so it
903
+ // never collides with plain Space, which grabs the card for a move.
904
+ if (board.selectable && e.key === " " && (e.ctrlKey || e.metaKey) && grabbed == null) {
905
+ e.preventDefault();
906
+ const k = key(row);
907
+ selected[k] = !selected[k];
908
+ emitSelection();
909
+ announce(
910
+ `${selected[k] ? "Selected" : "Deselected"} ${cardLabel(row)}. ${selectedRows().length} selected.`,
911
+ );
912
+ return;
913
+ }
914
+ if (e.key === " " || e.key === "Enter") {
915
+ e.preventDefault();
916
+ if (grabbed === row) {
917
+ announce(`Dropped ${cardLabel(row)} in ${laneTitleById(laneId)}.`);
918
+ grabbed = null;
919
+ grabInfo = null;
920
+ } else if (!movable(row)) {
921
+ announce(`${cardLabel(row)} is blocked and can't be moved.`);
922
+ } else {
923
+ grabbed = row;
924
+ grabInfo = { lane: effectiveLane(row), order: ord(row), swim: effectiveSwim(row) };
925
+ announce(
926
+ `Grabbed ${cardLabel(row)}. Use arrow keys to move, Space to drop, Escape to cancel.`,
927
+ );
928
+ }
929
+ return;
930
+ }
931
+ if (e.key === "Escape" && grabbed === row) {
932
+ e.preventDefault();
933
+ if (grabInfo) {
934
+ const k = key(row);
935
+ laneOf[k] = grabInfo.lane;
936
+ orderOf[k] = grabInfo.order;
937
+ if (swimField) swimOf[k] = grabInfo.swim;
938
+ }
939
+ grabbed = null;
940
+ grabInfo = null;
941
+ announce("Cancelled.");
942
+ return;
943
+ }
944
+ const li = laneIndex(laneId);
945
+ // Keyboard move/nav stays within the card's own swimlane band.
946
+ const swim = swimField ? effectiveSwim(row) : null;
947
+ if (grabbed === row) {
948
+ // Grabbed: arrow keys MOVE the card (within its swimlane band).
949
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
950
+ e.preventDefault();
951
+ const dest = lanes[e.key === "ArrowRight" ? li + 1 : li - 1];
952
+ if (!dest) return;
953
+ const pos = laneCards(dest.id, swim).filter((r) => r !== row).length;
954
+ if (applyMove(row, laneId, dest.id, pos, swim, swim)) {
955
+ announce(`Moved to ${laneTitle(dest)}.`);
956
+ focusCardSoon(row);
957
+ }
958
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
959
+ e.preventDefault();
960
+ const cards = laneCards(laneId, swim);
961
+ const idx = cards.indexOf(row);
962
+ const target = idx + (e.key === "ArrowDown" ? 1 : -1);
963
+ if (target < 0 || target >= cards.length) return;
964
+ // In the list excluding the moved card, `target` is the insertion slot
965
+ // for both directions (indices shift by the card's own removal).
966
+ applyMove(row, laneId, laneId, target, swim, swim);
967
+ announce(`Moved ${e.key === "ArrowDown" ? "down" : "up"}.`);
968
+ focusCardSoon(row);
969
+ }
970
+ return;
971
+ }
972
+ // Not grabbed: arrow keys MOVE FOCUS between cards and lanes.
973
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
974
+ e.preventDefault();
975
+ const cards = laneCards(laneId, swim);
976
+ const idx = cards.indexOf(row);
977
+ const next = cards[idx + (e.key === "ArrowDown" ? 1 : -1)];
978
+ if (next) focusCardEl(next);
979
+ } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
980
+ e.preventDefault();
981
+ const dest = lanes[e.key === "ArrowRight" ? li + 1 : li - 1];
982
+ if (!dest) return;
983
+ const idx = Math.max(0, laneCards(laneId, swim).indexOf(row));
984
+ const destCards = laneCards(dest.id, swim);
985
+ const targetCard = destCards[Math.min(idx, destCards.length - 1)];
986
+ if (targetCard) focusCardEl(targetCard);
987
+ }
988
+ }
989
+
990
+ function cardLabel(row: TData): string {
991
+ return titleField ? fmt(fieldValue(row, titleField)) : "card";
992
+ }
993
+ function laneTitleById(laneId: string): string {
994
+ const l = lanes.find((x) => x.id === laneId);
995
+ return l ? laneTitle(l) : laneId;
996
+ }
997
+ async function focusCardEl(row: TData) {
998
+ let el = rootEl?.querySelector<HTMLElement>(
999
+ `[data-card-key="${cssEscape(key(row))}"]`,
1000
+ );
1001
+ if (!el && virtualized) {
1002
+ // Off-screen in a virtualized lane: scroll it into view, then focus.
1003
+ const laneId = effectiveLane(row);
1004
+ const swimId = swimField ? effectiveSwim(row) : null;
1005
+ const idx = laneCards(laneId, swimId).indexOf(row);
1006
+ const body = laneBodyEl.get(cellKey(laneId, swimId));
1007
+ if (body && idx >= 0) {
1008
+ body.scrollTop = idx * (cardH + VGAP);
1009
+ laneScroll[cellKey(laneId, swimId)] = body.scrollTop;
1010
+ await tick();
1011
+ el = rootEl?.querySelector<HTMLElement>(
1012
+ `[data-card-key="${cssEscape(key(row))}"]`,
1013
+ );
1014
+ }
1015
+ }
1016
+ el?.focus();
1017
+ }
1018
+ async function focusCardSoon(row: TData) {
1019
+ // Re-focus the card after the DOM re-sorts so keyboard flow continues.
1020
+ await tick();
1021
+ focusCardEl(row);
1022
+ }
1023
+ function cssEscape(s: string): string {
1024
+ return s.replace(/["\\]/g, "\\$&");
1025
+ }
1026
+
1027
+ let rootEl: HTMLDivElement | undefined;
1028
+
1029
+ // --- card context menu (board.cardMenu) ---
1030
+ let menuOpen = $state(false);
1031
+ let menuPos = $state({ x: 0, y: 0 });
1032
+ let menuItems = $state.raw<ReadonlyArray<MenuItem>>([]);
1033
+ let menuPanel = $state<HTMLDivElement | null>(null);
1034
+ const MENU_W = 200;
1035
+ function showMenuAt(e: MouseEvent, items: ReadonlyArray<MenuItem>) {
1036
+ if (!items.length) return;
1037
+ e.preventDefault();
1038
+ e.stopPropagation();
1039
+ const estH = Math.min(items.length, 12) * 34 + 8;
1040
+ const x = Math.min(e.clientX, window.innerWidth - MENU_W - 6);
1041
+ const y = Math.min(e.clientY, window.innerHeight - estH - 6);
1042
+ menuPos = { x: Math.max(6, x), y: Math.max(6, y) };
1043
+ menuItems = items;
1044
+ menuOpen = true;
1045
+ }
1046
+ function openCardMenu(e: MouseEvent, row: TData) {
1047
+ if (!board.cardMenu) return;
1048
+ const swim = swimField ? effectiveSwim(row) : null;
1049
+ const items = board.cardMenu(row, {
1050
+ lanes,
1051
+ moveTo: (laneId: string) => {
1052
+ const pos = laneCards(laneId, swim).filter((r) => r !== row).length;
1053
+ applyMove(row, effectiveLane(row), laneId, pos, swim, swim);
1054
+ },
1055
+ edit: () => openEditor(row),
1056
+ });
1057
+ if (items) showMenuAt(e, items);
1058
+ }
1059
+ function openLaneMenu(e: MouseEvent, laneId: string, cards: TData[]) {
1060
+ if (!board.laneMenu) return;
1061
+ const items = board.laneMenu(laneId, {
1062
+ laneId,
1063
+ cards,
1064
+ collapsed: collapsed[laneId] === true,
1065
+ toggleCollapse: () => toggleCollapse(laneId),
1066
+ addCard: (title?: string) => board.onCardAdd?.(laneId, title),
1067
+ });
1068
+ if (items) showMenuAt(e, items);
1069
+ }
1070
+ $effect(() => {
1071
+ if (!menuOpen) return;
1072
+ queueMicrotask(() =>
1073
+ menuPanel?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus(),
1074
+ );
1075
+ const layer = createDismissableLayer({
1076
+ element: () => menuPanel,
1077
+ onDismiss: () => (menuOpen = false),
1078
+ });
1079
+ layer.activate();
1080
+ const onScroll = () => (menuOpen = false);
1081
+ window.addEventListener("scroll", onScroll, true);
1082
+ return () => {
1083
+ layer.release();
1084
+ window.removeEventListener("scroll", onScroll, true);
1085
+ };
1086
+ });
1087
+ </script>
1088
+
1089
+ <div
1090
+ class="sv-board"
1091
+ class:is-swim={!!swimField}
1092
+ role="group"
1093
+ aria-label="Board"
1094
+ bind:this={rootEl}
1095
+ >
1096
+ {#if board.facets && board.facets.length}
1097
+ <div class="sv-board-facets">
1098
+ {#each board.facets as field (field)}
1099
+ {#each facetOptions(field) as opt (field + ":" + opt.value)}
1100
+ <button
1101
+ type="button"
1102
+ class="sv-board-facet"
1103
+ class:is-active={(facetSel[field] ?? []).includes(opt.value)}
1104
+ onclick={() => toggleFacet(field, opt.value)}
1105
+ >
1106
+ {opt.value}<span class="sv-board-facet-count">{opt.count}</span>
1107
+ </button>
1108
+ {/each}
1109
+ {/each}
1110
+ {#if anyFacet}
1111
+ <button type="button" class="sv-board-facet-clear" onclick={clearFacets}>Clear filters</button>
1112
+ {/if}
1113
+ </div>
1114
+ {/if}
1115
+
1116
+ {#if swimField}
1117
+ {#each swimlanes as swim (swim.id)}
1118
+ {@const swimCollapsed = collapsedSwim[swim.id] === true}
1119
+ <section class="sv-board-swim" aria-label={swim.title}>
1120
+ <header class="sv-board-swim-head">
1121
+ {#if board.collapsibleSwimlanes}
1122
+ <button
1123
+ type="button"
1124
+ class="sv-board-swim-toggle"
1125
+ aria-expanded={!swimCollapsed}
1126
+ aria-label={swimCollapsed ? `Expand ${swim.title}` : `Collapse ${swim.title}`}
1127
+ onclick={() => toggleSwim(swim.id)}>{swimCollapsed ? "▸" : "▾"}</button
1128
+ >
1129
+ {/if}
1130
+ <span class="sv-board-swim-title">{swim.title}</span>
1131
+ <span class="sv-board-swim-count">{swimCount(swim.id)}</span>
1132
+ {#if board.swimlaneSummary}
1133
+ <span class="sv-board-swim-summary">{board.swimlaneSummary(swimRows(swim.id), swim.id)}</span>
1134
+ {/if}
1135
+ </header>
1136
+ {#if !swimCollapsed}
1137
+ <div class="sv-board-lanes">
1138
+ {#each lanes as lane (lane.id)}
1139
+ {@render laneCol(lane, swim.id)}
1140
+ {/each}
1141
+ </div>
1142
+ {/if}
1143
+ </section>
1144
+ {/each}
1145
+ {:else}
1146
+ <div class="sv-board-lanes">
1147
+ {#each lanes as lane (lane.id)}
1148
+ {@render laneCol(lane, null)}
1149
+ {/each}
1150
+ </div>
1151
+ {/if}
1152
+
1153
+ <div class="sv-board-sr" aria-live="polite" role="status">{liveMsg}</div>
1154
+ </div>
1155
+
1156
+ {#if menuOpen}
1157
+ <div
1158
+ bind:this={menuPanel}
1159
+ class="sv-board-ctx"
1160
+ use:portalToBody
1161
+ use:popIn={{}}
1162
+ style:position="fixed"
1163
+ style:top={`${menuPos.y}px`}
1164
+ style:left={`${menuPos.x}px`}
1165
+ aria-label="Card actions"
1166
+ >
1167
+ <SvMenuList items={menuItems} onclose={() => (menuOpen = false)} onselect={() => (menuOpen = false)} />
1168
+ </div>
1169
+ {/if}
1170
+
1171
+ {#if drawerRow}
1172
+ {@const dr = drawerRow}
1173
+ <SvDrawer
1174
+ open
1175
+ onClose={() => (drawerRow = null)}
1176
+ side={drawerCfg?.side ?? "right"}
1177
+ size={drawerCfg?.size ?? "380px"}
1178
+ title={drawerTitleOf(dr)}
1179
+ closeOnBackdrop={false}
1180
+ >
1181
+ {#key key(dr)}
1182
+ <SvForm
1183
+ fields={drawerForm(dr)}
1184
+ initial={drawerInitial(dr)}
1185
+ columns={drawerCfg?.columns ?? 1}
1186
+ submitLabel={drawerCfg?.submitLabel ?? "Save"}
1187
+ onSubmit={(values) => onDrawerSubmit(dr, values)}
1188
+ />
1189
+ {#if board.commentsField}
1190
+ <div class="sv-board-drawer-comments">
1191
+ <div class="sv-board-drawer-h">Comments</div>
1192
+ {@render commentThread(dr)}
1193
+ </div>
1194
+ {/if}
1195
+ {/key}
1196
+ </SvDrawer>
1197
+ {/if}
1198
+
1199
+ {#snippet commentThread(row: TData)}
1200
+ {@const cmts = commentsOf(row)}
1201
+ {#each cmts as c (c.id)}
1202
+ <div class="sv-board-comment">
1203
+ <div class="sv-board-comment-body">
1204
+ {#if c.author}<span class="sv-board-comment-author">{c.author}</span>{/if}
1205
+ <span class="sv-board-comment-text">{c.text}</span>
1206
+ {#if c.at}<span class="sv-board-comment-at">{c.at}</span>{/if}
1207
+ </div>
1208
+ {#if board.onCommentDelete}
1209
+ <button
1210
+ type="button"
1211
+ class="sv-board-comment-del"
1212
+ aria-label="Delete comment"
1213
+ onclick={() => deleteComment(row, c.id)}>×</button
1214
+ >
1215
+ {/if}
1216
+ </div>
1217
+ {/each}
1218
+ {#if !cmts.length}
1219
+ <div class="sv-board-comment-empty">No comments yet.</div>
1220
+ {/if}
1221
+ {#if board.onCommentAdd}
1222
+ <input
1223
+ class="sv-board-comment-add"
1224
+ placeholder="Write a comment..."
1225
+ onkeydown={(e) => {
1226
+ if (e.key === "Enter") {
1227
+ e.preventDefault();
1228
+ const el = e.currentTarget;
1229
+ addComment(row, el.value);
1230
+ el.value = "";
1231
+ }
1232
+ }}
1233
+ />
1234
+ {/if}
1235
+ {/snippet}
1236
+
1237
+ {#snippet laneCol(lane: BoardLane, swimId: string | null)}
1238
+ {@const cards = laneCards(lane.id, swimId)}
1239
+ {@const over = lane.wipLimit != null && cards.length > lane.wipLimit}
1240
+ {@const mark = markerIndex(lane.id, swimId, cards)}
1241
+ {@const isCollapsed = collapsed[lane.id] === true}
1242
+ <section
1243
+ class="sv-board-lane"
1244
+ class:is-collapsed={isCollapsed}
1245
+ style={lane.color ? `--sv-board-accent: ${lane.color};` : undefined}
1246
+ aria-label={laneTitle(lane)}
1247
+ >
1248
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1249
+ <header
1250
+ class="sv-board-lane-head"
1251
+ class:is-reorderable={board.reorderableLanes}
1252
+ class:is-lane-dropover={overLaneId === lane.id && dragLaneId !== lane.id}
1253
+ draggable={board.reorderableLanes ? true : undefined}
1254
+ tabindex={board.reorderableLanes ? 0 : undefined}
1255
+ role={board.reorderableLanes ? "group" : undefined}
1256
+ aria-roledescription={board.reorderableLanes ? "Reorderable lane" : undefined}
1257
+ title={board.reorderableLanes ? "Ctrl+Left/Right arrow to reorder this lane" : undefined}
1258
+ ondragstart={board.reorderableLanes ? (e) => onLaneHeaderDragStart(e, lane.id) : undefined}
1259
+ ondragover={board.reorderableLanes ? (e) => onLaneHeaderDragOver(e, lane.id) : undefined}
1260
+ ondrop={board.reorderableLanes ? (e) => onLaneHeaderDrop(e, lane.id) : undefined}
1261
+ ondragend={board.reorderableLanes ? onLaneHeaderDragEnd : undefined}
1262
+ onkeydown={board.reorderableLanes ? (e) => onLaneHeaderKeydown(e, lane.id) : undefined}
1263
+ oncontextmenu={board.laneMenu ? (e) => openLaneMenu(e, lane.id, cards) : undefined}
1264
+ >
1265
+ {#if board.collapsibleLanes}
1266
+ <button
1267
+ type="button"
1268
+ class="sv-board-lane-toggle"
1269
+ aria-expanded={!isCollapsed}
1270
+ aria-label={isCollapsed ? `Expand ${laneTitle(lane)}` : `Collapse ${laneTitle(lane)}`}
1271
+ onclick={() => toggleCollapse(lane.id)}>{isCollapsed ? "▸" : "▾"}</button
1272
+ >
1273
+ {/if}
1274
+ {#if renamingLane === lane.id}
1275
+ <!-- svelte-ignore a11y_autofocus -->
1276
+ <input
1277
+ class="sv-board-lane-rename"
1278
+ bind:value={renameText}
1279
+ use:focusNode
1280
+ onkeydown={(e) => {
1281
+ if (e.key === "Enter") { e.preventDefault(); commitRename(lane.id); }
1282
+ else if (e.key === "Escape") { renamingLane = null; }
1283
+ }}
1284
+ onblur={() => commitRename(lane.id)}
1285
+ />
1286
+ {:else if board.onLaneRename}
1287
+ <button
1288
+ type="button"
1289
+ class="sv-board-lane-title is-renamable"
1290
+ ondblclick={() => startRename(lane)}
1291
+ onkeydown={(e) => {
1292
+ if (e.key === "Enter" || e.key === "F2") {
1293
+ e.preventDefault();
1294
+ startRename(lane);
1295
+ }
1296
+ }}
1297
+ title="Double-click, Enter, or F2 to rename"
1298
+ >{laneTitle(lane)}</button>
1299
+ {:else}
1300
+ <span class="sv-board-lane-title">{laneTitle(lane)}</span>
1301
+ {/if}
1302
+ <span
1303
+ class="sv-board-lane-count"
1304
+ class:is-over={over}
1305
+ title={lane.wipLimit != null ? `WIP limit ${lane.wipLimit}` : undefined}
1306
+ >
1307
+ {cards.length}{#if lane.wipLimit != null}/{lane.wipLimit}{/if}
1308
+ </span>
1309
+ {#if board.laneSummary && !isCollapsed}
1310
+ <span class="sv-board-lane-summary">{board.laneSummary(cards, lane.id)}</span>
1311
+ {/if}
1312
+ {#if board.onCardAdd && !isCollapsed}
1313
+ <button
1314
+ type="button"
1315
+ class="sv-board-lane-add"
1316
+ aria-label={`Add card to ${laneTitle(lane)}`}
1317
+ onclick={() => board.onCardAdd?.(lane.id)}>+</button
1318
+ >
1319
+ {/if}
1320
+ </header>
1321
+
1322
+ {#if !isCollapsed}
1323
+ {@const win = laneWindow(lane.id, swimId, cards.length)}
1324
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1325
+ <div
1326
+ class="sv-board-lane-body"
1327
+ class:is-virtual={virtualized}
1328
+ role="list"
1329
+ class:is-dropover={overLane === lane.id && overSwim === swimId}
1330
+ use:trackLane={cellKey(lane.id, swimId)}
1331
+ ondragover={(e) => onLaneDragOver(e, lane.id, swimId)}
1332
+ ondrop={(e) => onLaneDrop(e, lane.id, swimId)}
1333
+ >
1334
+ {#if win.top > 0}
1335
+ <div style={`height:${win.top}px`} aria-hidden="true"></div>
1336
+ {/if}
1337
+ {#each cards.slice(win.start, win.end) as row, i (key(row))}
1338
+ {@const index = win.start + i}
1339
+ {#if mark === index}
1340
+ <div class="sv-board-drop-marker" aria-hidden="true"></div>
1341
+ {/if}
1342
+ {@const isEditing = editingKey === key(row)}
1343
+ <article
1344
+ class="sv-board-card"
1345
+ class:is-dragging={dragRow === row}
1346
+ class:is-grabbed={grabbed === row}
1347
+ class:is-editing={isEditing}
1348
+ class:is-flagged={isFlagged(row)}
1349
+ class:is-locked={!movable(row)}
1350
+ class:is-selected={board.selectable && selected[key(row)]}
1351
+ role="listitem"
1352
+ tabindex="0"
1353
+ data-card-key={key(row)}
1354
+ aria-roledescription="Draggable card"
1355
+ aria-grabbed={grabbed === row}
1356
+ aria-selected={board.selectable ? !!selected[key(row)] : undefined}
1357
+ draggable={!isEditing && movable(row)}
1358
+ ondragstart={(e) => onCardDragStart(e, row, lane.id, swimId)}
1359
+ ondragover={(e) => onCardDragOver(e, row, lane.id, swimId)}
1360
+ ondragend={resetDrag}
1361
+ onclick={board.selectable ? (e) => onCardClick(e, row) : undefined}
1362
+ ondblclick={() => openEditor(row)}
1363
+ oncontextmenu={board.cardMenu ? (e) => openCardMenu(e, row) : undefined}
1364
+ onkeydown={(e) => onCardKeydown(e, row, lane.id)}
1365
+ >
1366
+ {#if isFlagged(row)}<span class="sv-board-card-flag" aria-label="Blocked"></span>{/if}
1367
+ {#if isEditing}
1368
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1369
+ <form
1370
+ class="sv-board-card-edit"
1371
+ onsubmit={(e) => {
1372
+ e.preventDefault();
1373
+ saveEditor(row);
1374
+ }}
1375
+ onkeydown={(e) => {
1376
+ if (e.key === "Escape") {
1377
+ e.stopPropagation();
1378
+ cancelEditor();
1379
+ }
1380
+ }}
1381
+ >
1382
+ {#each editColumns as col (col.field)}
1383
+ {@const kind = editorKind(col)}
1384
+ <label class="sv-board-field">
1385
+ <span>{headerLabel(col.field ?? "")}</span>
1386
+ {#if kind === "checkbox"}
1387
+ <input
1388
+ type="checkbox"
1389
+ checked={draft[col.field ?? ""] === true}
1390
+ oninput={(e) => onDraftInput(col.field ?? "", kind, e)}
1391
+ />
1392
+ {:else}
1393
+ <!-- svelte-ignore a11y_autofocus -->
1394
+ <input
1395
+ type={kind}
1396
+ value={draft[col.field ?? ""] ?? ""}
1397
+ oninput={(e) => onDraftInput(col.field ?? "", kind, e)}
1398
+ />
1399
+ {/if}
1400
+ </label>
1401
+ {/each}
1402
+ <div class="sv-board-card-edit-actions">
1403
+ <button type="button" class="sv-board-btn" onclick={cancelEditor}>Cancel</button>
1404
+ <button type="submit" class="sv-board-btn is-primary">Save</button>
1405
+ </div>
1406
+ </form>
1407
+ {:else if board.card}
1408
+ {@render board.card(row)}
1409
+ {@render cardBadges(row)}
1410
+ {:else}
1411
+ {@const cover = coverColorOf(row)}
1412
+ {#if cover}
1413
+ <div class="sv-board-card-cover" style={`background:${cover}`} aria-hidden="true"></div>
1414
+ {/if}
1415
+ {#if titleField}
1416
+ <div class="sv-board-card-title">
1417
+ {fmt(fieldValue(row, titleField))}
1418
+ </div>
1419
+ {/if}
1420
+ {@render cardBadges(row)}
1421
+ {#if metaFields.length}
1422
+ <dl class="sv-board-card-meta">
1423
+ {#each metaFields as field (field)}
1424
+ {@const v = fmt(fieldValue(row, field))}
1425
+ {#if v !== ""}
1426
+ <div class="sv-board-card-metarow">
1427
+ <dt>{headerLabel(field)}</dt>
1428
+ <dd>{v}</dd>
1429
+ </div>
1430
+ {/if}
1431
+ {/each}
1432
+ </dl>
1433
+ {/if}
1434
+ {/if}
1435
+
1436
+ {#if !isEditing && board.subtasks}
1437
+ {@const prog = subProgress(row)}
1438
+ {@const opened = subOpen[key(row)] === true}
1439
+ <div class="sv-board-subs">
1440
+ <button
1441
+ type="button"
1442
+ class="sv-board-subs-head"
1443
+ aria-expanded={opened}
1444
+ onclick={() => (subOpen[key(row)] = !opened)}
1445
+ >
1446
+ <span class="sv-board-subs-chev">{opened ? "▾" : "▸"}</span>
1447
+ <span class="sv-board-subs-count">{prog.done}/{prog.total}</span>
1448
+ <span class="sv-board-subs-bar" aria-hidden="true">
1449
+ <span style={`width:${prog.total ? (prog.done / prog.total) * 100 : 0}%`}></span>
1450
+ </span>
1451
+ </button>
1452
+ {#if opened}
1453
+ <ul class="sv-board-subs-list">
1454
+ {#each subtasksOf(row) as st (st.id)}
1455
+ <li>
1456
+ <label class:is-done={st.done}>
1457
+ <input type="checkbox" checked={st.done} onchange={() => toggleSubtask(row, st)} />
1458
+ <span>{st.title}</span>
1459
+ </label>
1460
+ </li>
1461
+ {/each}
1462
+ </ul>
1463
+ <input
1464
+ class="sv-board-subs-add"
1465
+ placeholder="Add subtask..."
1466
+ onkeydown={(e) => {
1467
+ if (e.key === "Enter") {
1468
+ e.preventDefault();
1469
+ const el = e.currentTarget;
1470
+ addSubtask(row, el.value);
1471
+ el.value = "";
1472
+ }
1473
+ }}
1474
+ />
1475
+ {/if}
1476
+ </div>
1477
+ {/if}
1478
+
1479
+ {#if !isEditing && board.childrenField}
1480
+ {@const kids = childrenOf(row)}
1481
+ {#if kids.length}
1482
+ {@const kidsOpen = childOpen[key(row)] === true}
1483
+ <div class="sv-board-kids">
1484
+ <button
1485
+ type="button"
1486
+ class="sv-board-kids-head"
1487
+ aria-expanded={kidsOpen}
1488
+ onclick={() => (childOpen[key(row)] = !kidsOpen)}
1489
+ >
1490
+ <span class="sv-board-subs-chev">{kidsOpen ? "▾" : "▸"}</span>
1491
+ {kids.length}
1492
+ {kids.length === 1 ? "subtask" : "subtasks"}
1493
+ </button>
1494
+ {#if kidsOpen}
1495
+ <ul class="sv-board-kids-list">
1496
+ {#each kids as kid, ki (key(kid) + ":" + ki)}
1497
+ <li class="sv-board-kid">
1498
+ <span class="sv-board-kid-title"
1499
+ >{titleField ? fmt(fieldValue(kid, titleField)) : ""}</span
1500
+ >
1501
+ <span class="sv-board-kid-lane">{rawLane(kid)}</span>
1502
+ </li>
1503
+ {/each}
1504
+ </ul>
1505
+ {/if}
1506
+ </div>
1507
+ {/if}
1508
+ {/if}
1509
+
1510
+ {#if !isEditing && board.commentsField && commentsOpen[key(row)]}
1511
+ <div class="sv-board-comments">
1512
+ {@render commentThread(row)}
1513
+ </div>
1514
+ {/if}
1515
+ </article>
1516
+ {/each}
1517
+ {#if win.bottom > 0}
1518
+ <div style={`height:${win.bottom}px`} aria-hidden="true"></div>
1519
+ {:else if mark === cards.length}
1520
+ <div class="sv-board-drop-marker" aria-hidden="true"></div>
1521
+ {/if}
1522
+ {#if cards.length === 0}
1523
+ <div class="sv-board-lane-empty">No cards</div>
1524
+ {/if}
1525
+ </div>
1526
+ {#if board.composer && board.onCardAdd}
1527
+ {#if composerOpen[lane.id]}
1528
+ <input
1529
+ class="sv-board-composer-input"
1530
+ placeholder="Card title..."
1531
+ bind:value={composerText[lane.id]}
1532
+ use:focusNode
1533
+ onkeydown={(e) => {
1534
+ if (e.key === "Enter") {
1535
+ e.preventDefault();
1536
+ submitComposer(lane.id);
1537
+ } else if (e.key === "Escape") {
1538
+ composerOpen[lane.id] = false;
1539
+ }
1540
+ }}
1541
+ onblur={() => {
1542
+ submitComposer(lane.id);
1543
+ composerOpen[lane.id] = false;
1544
+ }}
1545
+ />
1546
+ {:else}
1547
+ <button
1548
+ type="button"
1549
+ class="sv-board-composer-btn"
1550
+ onclick={() => (composerOpen[lane.id] = true)}>+ Add a card</button
1551
+ >
1552
+ {/if}
1553
+ {/if}
1554
+ {/if}
1555
+ </section>
1556
+ {/snippet}
1557
+
1558
+ {#snippet cardBadges(row: TData)}
1559
+ {@const labels = labelsOf(row)}
1560
+ {@const due = dueOf(row)}
1561
+ {@const asg = assigneesOf(row)}
1562
+ {@const comments = countOf(row, board.commentsField)}
1563
+ {@const attachments = countOf(row, board.attachmentsField)}
1564
+ {@const age = ageOf(row)}
1565
+ {@const blocked = isFlagged(row)}
1566
+ {#if blocked || labels.length || due || asg.length || comments || attachments || age}
1567
+ <div class="sv-board-badges">
1568
+ {#if blocked}
1569
+ <span class="sv-board-badge is-blocked" title={flagReason(row) ?? "Blocked"}>Blocked</span>
1570
+ {/if}
1571
+ {#each labels as lb (lb.text)}
1572
+ <span class="sv-board-badge is-label" style={lb.color ? `--b:${lb.color}` : undefined}
1573
+ >{lb.text}</span
1574
+ >
1575
+ {/each}
1576
+ {#if due}
1577
+ <span class="sv-board-badge is-due" class:is-overdue={due.overdue}>{due.label}</span>
1578
+ {/if}
1579
+ {#if age}
1580
+ <span class="sv-board-badge is-meta" title="Age">{age}</span>
1581
+ {/if}
1582
+ {#if comments || board.onCommentAdd}
1583
+ <button
1584
+ type="button"
1585
+ class="sv-board-badge is-meta is-btn"
1586
+ title="Comments"
1587
+ onclick={(e) => {
1588
+ e.stopPropagation();
1589
+ commentsOpen[key(row)] = !commentsOpen[key(row)];
1590
+ }}
1591
+ >
1592
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true"><path d="M2 3h12v8H8l-3 2v-2H2z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>
1593
+ {comments}
1594
+ </button>
1595
+ {/if}
1596
+ {#if attachments}
1597
+ <span class="sv-board-badge is-meta" title={`${attachments} attachments`}>
1598
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true"><path d="M11 5.5 6.5 10a2 2 0 0 1-3-3l5-5a3 3 0 0 1 4 4l-5 5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
1599
+ {attachments}
1600
+ </span>
1601
+ {/if}
1602
+ {#if asg.length}
1603
+ <span class="sv-board-avatars">
1604
+ {#each asg.slice(0, 3) as name (name)}
1605
+ <span class="sv-board-avatar" title={name}>{initials(name)}</span>
1606
+ {/each}
1607
+ </span>
1608
+ {/if}
1609
+ </div>
1610
+ {/if}
1611
+ {/snippet}
1612
+
1613
+ <style>
1614
+ .sv-board {
1615
+ height: 100%;
1616
+ box-sizing: border-box;
1617
+ display: flex;
1618
+ flex-direction: column;
1619
+ background: var(--sg-bg, #fff);
1620
+ color: var(--sg-fg, #101828);
1621
+ font-family: var(--sg-font, inherit);
1622
+ }
1623
+ .sv-board.is-swim {
1624
+ overflow-y: auto;
1625
+ gap: 14px;
1626
+ }
1627
+ .sv-board-lanes {
1628
+ display: flex;
1629
+ gap: 12px;
1630
+ align-items: flex-start;
1631
+ overflow-x: auto;
1632
+ padding: 4px;
1633
+ flex: 1 1 auto;
1634
+ min-height: 0;
1635
+ scrollbar-color: var(--sg-scrollbar-thumb, color-mix(in srgb, var(--sg-fg, #101828) 28%, transparent))
1636
+ transparent;
1637
+ }
1638
+ .sv-board.is-swim .sv-board-lanes {
1639
+ flex: none;
1640
+ }
1641
+ .sv-board.is-swim .sv-board-lane {
1642
+ max-height: 340px;
1643
+ }
1644
+ .sv-board-swim {
1645
+ display: flex;
1646
+ flex-direction: column;
1647
+ gap: 4px;
1648
+ flex: none;
1649
+ }
1650
+ .sv-board-swim-head {
1651
+ display: flex;
1652
+ align-items: center;
1653
+ gap: 8px;
1654
+ padding: 4px 8px;
1655
+ font-weight: 700;
1656
+ font-size: 0.78rem;
1657
+ color: color-mix(in srgb, var(--sg-fg, #101828) 78%, transparent);
1658
+ position: sticky;
1659
+ left: 0;
1660
+ }
1661
+ .sv-board-swim-count {
1662
+ font-size: 0.7rem;
1663
+ font-weight: 600;
1664
+ padding: 0 7px;
1665
+ border-radius: 999px;
1666
+ background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
1667
+ color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
1668
+ }
1669
+ .sv-board-swim-summary {
1670
+ font-size: 0.7rem;
1671
+ font-weight: 600;
1672
+ color: color-mix(in srgb, var(--sg-accent, #3b82f6) 85%, var(--sg-fg, #101828));
1673
+ }
1674
+ .sv-board-swim-toggle {
1675
+ all: unset;
1676
+ cursor: pointer;
1677
+ font-size: 0.7rem;
1678
+ padding: 2px;
1679
+ color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
1680
+ }
1681
+ .sv-board-lane-title.is-renamable {
1682
+ all: unset;
1683
+ cursor: text;
1684
+ font-weight: 600;
1685
+ font-size: 0.82rem;
1686
+ flex: 1 1 auto;
1687
+ min-width: 0;
1688
+ white-space: nowrap;
1689
+ overflow: hidden;
1690
+ text-overflow: ellipsis;
1691
+ text-align: left;
1692
+ }
1693
+ .sv-board-lane-rename {
1694
+ flex: 1 1 auto;
1695
+ min-width: 0;
1696
+ font: inherit;
1697
+ font-weight: 600;
1698
+ font-size: 0.82rem;
1699
+ padding: 1px 4px;
1700
+ border: 1px solid var(--sv-board-accent);
1701
+ border-radius: 5px;
1702
+ background: var(--sg-bg, #fff);
1703
+ color: var(--sg-fg, #101828);
1704
+ }
1705
+ .sv-board-swim-toggle:hover {
1706
+ color: var(--sg-fg, #101828);
1707
+ }
1708
+ .sv-board-lane {
1709
+ flex: 0 0 var(--sg-board-lane-w, 272px);
1710
+ max-width: var(--sg-board-lane-w, 272px);
1711
+ display: flex;
1712
+ flex-direction: column;
1713
+ max-height: 100%;
1714
+ border: 1px solid var(--sg-border, #e4e7ec);
1715
+ border-radius: var(--sg-radius, 8px);
1716
+ background: var(--sg-header-bg, color-mix(in srgb, var(--sg-fg, #101828) 3%, var(--sg-bg, #fff)));
1717
+ --sv-board-accent: var(--sg-accent, #3b82f6);
1718
+ }
1719
+ .sv-board-lane-head {
1720
+ display: flex;
1721
+ align-items: center;
1722
+ gap: 8px;
1723
+ padding: 8px 10px;
1724
+ border-bottom: 1px solid var(--sg-border, #e4e7ec);
1725
+ border-top: 3px solid var(--sv-board-accent);
1726
+ border-radius: var(--sg-radius, 8px) var(--sg-radius, 8px) 0 0;
1727
+ }
1728
+ .sv-board-lane-head.is-reorderable {
1729
+ cursor: grab;
1730
+ }
1731
+ .sv-board-lane-head.is-lane-dropover {
1732
+ box-shadow: inset 3px 0 0 var(--sv-board-accent);
1733
+ }
1734
+ .sv-board-lane-title {
1735
+ font-weight: 600;
1736
+ font-size: 0.82rem;
1737
+ flex: 1 1 auto;
1738
+ white-space: nowrap;
1739
+ overflow: hidden;
1740
+ text-overflow: ellipsis;
1741
+ }
1742
+ .sv-board-lane-toggle {
1743
+ all: unset;
1744
+ cursor: pointer;
1745
+ font-size: 0.7rem;
1746
+ line-height: 1;
1747
+ color: color-mix(in srgb, var(--sg-fg, #101828) 55%, transparent);
1748
+ padding: 2px;
1749
+ }
1750
+ .sv-board-lane-toggle:hover {
1751
+ color: var(--sg-fg, #101828);
1752
+ }
1753
+ .sv-board-lane.is-collapsed {
1754
+ flex: 0 0 auto;
1755
+ max-width: none;
1756
+ }
1757
+ .sv-board-lane.is-collapsed .sv-board-lane-head {
1758
+ writing-mode: vertical-rl;
1759
+ height: 180px;
1760
+ border-top: none;
1761
+ border-left: 3px solid var(--sv-board-accent);
1762
+ border-radius: 0 var(--sg-radius, 8px) var(--sg-radius, 8px) 0;
1763
+ gap: 6px;
1764
+ }
1765
+ .sv-board-lane-count {
1766
+ font-size: 0.72rem;
1767
+ font-variant-numeric: tabular-nums;
1768
+ padding: 1px 7px;
1769
+ border-radius: 999px;
1770
+ background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
1771
+ color: color-mix(in srgb, var(--sg-fg, #101828) 70%, transparent);
1772
+ }
1773
+ .sv-board-lane-count.is-over {
1774
+ background: color-mix(in srgb, var(--sg-danger, #ef4444) 16%, transparent);
1775
+ color: var(--sg-danger, #ef4444);
1776
+ font-weight: 700;
1777
+ }
1778
+ .sv-board-lane-summary {
1779
+ font-size: 0.72rem;
1780
+ font-weight: 600;
1781
+ font-variant-numeric: tabular-nums;
1782
+ color: color-mix(in srgb, var(--sv-board-accent) 85%, var(--sg-fg, #101828));
1783
+ }
1784
+ .sv-board-lane-add {
1785
+ all: unset;
1786
+ cursor: pointer;
1787
+ width: 20px;
1788
+ height: 20px;
1789
+ display: grid;
1790
+ place-items: center;
1791
+ border-radius: 5px;
1792
+ font-size: 1rem;
1793
+ line-height: 1;
1794
+ color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
1795
+ }
1796
+ .sv-board-lane-add:hover {
1797
+ background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
1798
+ color: var(--sg-fg, #101828);
1799
+ }
1800
+ .sv-board-lane-body {
1801
+ display: flex;
1802
+ flex-direction: column;
1803
+ gap: 8px;
1804
+ padding: 8px;
1805
+ overflow-y: auto;
1806
+ flex: 1 1 auto;
1807
+ min-height: 48px;
1808
+ transition: background 0.12s ease;
1809
+ }
1810
+ .sv-board-lane-body.is-dropover {
1811
+ background: color-mix(in srgb, var(--sv-board-accent) 8%, transparent);
1812
+ }
1813
+ /* Virtualized lanes use block flow + per-card margin (not flex gap) so the
1814
+ spacer heights match the windowing math exactly. */
1815
+ .sv-board-lane-body.is-virtual {
1816
+ display: block;
1817
+ gap: 0;
1818
+ }
1819
+ .sv-board-lane-body.is-virtual .sv-board-card {
1820
+ margin-bottom: 8px;
1821
+ }
1822
+ .sv-board-card {
1823
+ background: var(--sg-bg, #fff);
1824
+ border: 1px solid var(--sg-border, #e4e7ec);
1825
+ border-radius: var(--sg-radius, 8px);
1826
+ padding: 9px 11px;
1827
+ cursor: grab;
1828
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
1829
+ transition:
1830
+ box-shadow 0.12s ease,
1831
+ opacity 0.12s ease,
1832
+ transform 0.12s ease;
1833
+ }
1834
+ .sv-board-card:hover {
1835
+ box-shadow: 0 2px 8px rgba(16, 24, 40, 0.12);
1836
+ border-color: color-mix(in srgb, var(--sv-board-accent) 45%, var(--sg-border, #e4e7ec));
1837
+ }
1838
+ .sv-board-card:focus-visible {
1839
+ outline: none;
1840
+ border-color: var(--sv-board-accent);
1841
+ box-shadow: var(--sg-focus-ring, 0 0 0 2px color-mix(in srgb, var(--sv-board-accent) 45%, transparent));
1842
+ }
1843
+ .sv-board-card.is-dragging {
1844
+ opacity: 0.45;
1845
+ }
1846
+ .sv-board-card.is-grabbed {
1847
+ border-color: var(--sv-board-accent);
1848
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--sv-board-accent) 55%, transparent);
1849
+ cursor: grabbing;
1850
+ }
1851
+ .sv-board-card.is-editing {
1852
+ cursor: default;
1853
+ border-color: var(--sv-board-accent);
1854
+ }
1855
+ .sv-board-card-edit {
1856
+ display: flex;
1857
+ flex-direction: column;
1858
+ gap: 7px;
1859
+ }
1860
+ .sv-board-field {
1861
+ display: flex;
1862
+ flex-direction: column;
1863
+ gap: 2px;
1864
+ font-size: 0.72rem;
1865
+ color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
1866
+ }
1867
+ .sv-board-field input[type="text"],
1868
+ .sv-board-field input[type="number"],
1869
+ .sv-board-field input[type="date"] {
1870
+ font: inherit;
1871
+ font-size: 0.8rem;
1872
+ padding: 4px 7px;
1873
+ border: 1px solid var(--sg-border, #e4e7ec);
1874
+ border-radius: var(--sg-radius, 6px);
1875
+ background: var(--sg-bg, #fff);
1876
+ color: var(--sg-fg, #101828);
1877
+ }
1878
+ .sv-board-field input:focus-visible {
1879
+ outline: none;
1880
+ border-color: var(--sv-board-accent);
1881
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--sv-board-accent) 35%, transparent);
1882
+ }
1883
+ .sv-board-field input[type="checkbox"] {
1884
+ align-self: flex-start;
1885
+ width: 15px;
1886
+ height: 15px;
1887
+ accent-color: var(--sv-board-accent);
1888
+ }
1889
+ .sv-board-card-edit-actions {
1890
+ display: flex;
1891
+ justify-content: flex-end;
1892
+ gap: 6px;
1893
+ margin-top: 2px;
1894
+ }
1895
+ .sv-board-btn {
1896
+ font: inherit;
1897
+ font-size: 0.74rem;
1898
+ padding: 3px 10px;
1899
+ border-radius: var(--sg-radius, 6px);
1900
+ border: 1px solid var(--sg-border, #e4e7ec);
1901
+ background: var(--sg-bg, #fff);
1902
+ color: var(--sg-fg, #101828);
1903
+ cursor: pointer;
1904
+ }
1905
+ .sv-board-btn:hover {
1906
+ background: color-mix(in srgb, var(--sg-fg, #101828) 6%, transparent);
1907
+ }
1908
+ .sv-board-btn.is-primary {
1909
+ background: var(--sv-board-accent);
1910
+ border-color: var(--sv-board-accent);
1911
+ color: #fff;
1912
+ }
1913
+ .sv-board-btn.is-primary:hover {
1914
+ filter: brightness(0.95);
1915
+ }
1916
+ .sv-board-card-title {
1917
+ font-weight: 600;
1918
+ font-size: 0.84rem;
1919
+ line-height: 1.3;
1920
+ }
1921
+ .sv-board-card-meta {
1922
+ margin: 6px 0 0;
1923
+ display: flex;
1924
+ flex-direction: column;
1925
+ gap: 2px;
1926
+ }
1927
+ .sv-board-card-metarow {
1928
+ display: flex;
1929
+ gap: 6px;
1930
+ font-size: 0.74rem;
1931
+ }
1932
+ .sv-board-card-metarow dt {
1933
+ color: color-mix(in srgb, var(--sg-fg, #101828) 55%, transparent);
1934
+ margin: 0;
1935
+ }
1936
+ .sv-board-card-metarow dd {
1937
+ margin: 0;
1938
+ font-weight: 500;
1939
+ overflow: hidden;
1940
+ text-overflow: ellipsis;
1941
+ white-space: nowrap;
1942
+ }
1943
+ .sv-board-lane-empty {
1944
+ text-align: center;
1945
+ font-size: 0.76rem;
1946
+ color: color-mix(in srgb, var(--sg-fg, #101828) 42%, transparent);
1947
+ padding: 14px 0;
1948
+ border: 1px dashed var(--sg-border, #e4e7ec);
1949
+ border-radius: var(--sg-radius, 8px);
1950
+ }
1951
+ .sv-board-drop-marker {
1952
+ height: 2px;
1953
+ border-radius: 2px;
1954
+ background: var(--sv-board-accent);
1955
+ margin: -3px 0;
1956
+ }
1957
+ :global(.sv-board-ctx) {
1958
+ z-index: 2147483646;
1959
+ min-width: 200px;
1960
+ background: var(--sg-bg, #fff);
1961
+ color: var(--sg-fg, #0f172a);
1962
+ border: 1px solid var(--sg-border, #e2e8f0);
1963
+ border-radius: 10px;
1964
+ box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35);
1965
+ font-size: 13px;
1966
+ }
1967
+ .sv-board-sr {
1968
+ position: absolute;
1969
+ width: 1px;
1970
+ height: 1px;
1971
+ padding: 0;
1972
+ margin: -1px;
1973
+ overflow: hidden;
1974
+ clip: rect(0 0 0 0);
1975
+ white-space: nowrap;
1976
+ border: 0;
1977
+ }
1978
+ /* Card badges */
1979
+ .sv-board-badges {
1980
+ display: flex;
1981
+ align-items: center;
1982
+ flex-wrap: wrap;
1983
+ gap: 4px;
1984
+ margin-top: 6px;
1985
+ }
1986
+ .sv-board-badge {
1987
+ font-size: 0.66rem;
1988
+ font-weight: 600;
1989
+ padding: 1px 6px;
1990
+ border-radius: 4px;
1991
+ line-height: 1.5;
1992
+ white-space: nowrap;
1993
+ }
1994
+ .sv-board-badge.is-label {
1995
+ --b: var(--sv-board-accent);
1996
+ background: color-mix(in srgb, var(--b) 16%, transparent);
1997
+ color: color-mix(in srgb, var(--b) 80%, var(--sg-fg, #101828));
1998
+ }
1999
+ .sv-board-badge.is-due {
2000
+ background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2001
+ color: color-mix(in srgb, var(--sg-fg, #101828) 65%, transparent);
2002
+ }
2003
+ .sv-board-badge.is-due.is-overdue {
2004
+ background: color-mix(in srgb, var(--sg-danger, #ef4444) 15%, transparent);
2005
+ color: var(--sg-danger, #ef4444);
2006
+ }
2007
+ .sv-board-badge.is-meta {
2008
+ display: inline-flex;
2009
+ align-items: center;
2010
+ gap: 3px;
2011
+ background: transparent;
2012
+ padding: 1px 3px;
2013
+ color: color-mix(in srgb, var(--sg-fg, #101828) 55%, transparent);
2014
+ }
2015
+ .sv-board-badge.is-btn {
2016
+ border: 0;
2017
+ cursor: pointer;
2018
+ font: inherit;
2019
+ font-size: 0.66rem;
2020
+ font-weight: 600;
2021
+ border-radius: 5px;
2022
+ }
2023
+ .sv-board-badge.is-btn:hover {
2024
+ background: color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2025
+ color: var(--sg-fg, #101828);
2026
+ }
2027
+ .sv-board-badge.is-blocked {
2028
+ background: var(--sg-danger, #ef4444);
2029
+ color: #fff;
2030
+ text-transform: uppercase;
2031
+ letter-spacing: 0.03em;
2032
+ font-size: 0.6rem;
2033
+ }
2034
+ /* Comments thread */
2035
+ .sv-board-comments {
2036
+ margin-top: 7px;
2037
+ border-top: 1px solid color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2038
+ padding-top: 7px;
2039
+ display: flex;
2040
+ flex-direction: column;
2041
+ gap: 5px;
2042
+ }
2043
+ .sv-board-drawer-comments {
2044
+ margin-top: 16px;
2045
+ padding-top: 12px;
2046
+ border-top: 1px solid var(--sg-border, #e4e7ec);
2047
+ display: flex;
2048
+ flex-direction: column;
2049
+ gap: 6px;
2050
+ }
2051
+ .sv-board-drawer-h {
2052
+ font-weight: 600;
2053
+ font-size: 0.82rem;
2054
+ margin-bottom: 2px;
2055
+ }
2056
+ .sv-board-comment {
2057
+ display: flex;
2058
+ align-items: flex-start;
2059
+ gap: 6px;
2060
+ font-size: 0.74rem;
2061
+ }
2062
+ .sv-board-comment-body {
2063
+ flex: 1 1 auto;
2064
+ min-width: 0;
2065
+ }
2066
+ .sv-board-comment-author {
2067
+ font-weight: 600;
2068
+ margin-right: 5px;
2069
+ }
2070
+ .sv-board-comment-at {
2071
+ margin-left: 6px;
2072
+ font-size: 0.66rem;
2073
+ color: color-mix(in srgb, var(--sg-fg, #101828) 45%, transparent);
2074
+ }
2075
+ .sv-board-comment-del {
2076
+ all: unset;
2077
+ cursor: pointer;
2078
+ font-size: 0.9rem;
2079
+ line-height: 1;
2080
+ color: color-mix(in srgb, var(--sg-fg, #101828) 40%, transparent);
2081
+ padding: 0 2px;
2082
+ }
2083
+ .sv-board-comment-del:hover {
2084
+ color: var(--sg-danger, #ef4444);
2085
+ }
2086
+ .sv-board-comment-empty {
2087
+ font-size: 0.72rem;
2088
+ color: color-mix(in srgb, var(--sg-fg, #101828) 42%, transparent);
2089
+ }
2090
+ .sv-board-comment-add {
2091
+ width: 100%;
2092
+ box-sizing: border-box;
2093
+ font: inherit;
2094
+ font-size: 0.74rem;
2095
+ padding: 4px 7px;
2096
+ border: 1px solid var(--sg-border, #e4e7ec);
2097
+ border-radius: 5px;
2098
+ background: var(--sg-bg, #fff);
2099
+ color: var(--sg-fg, #101828);
2100
+ }
2101
+ .sv-board-card-cover {
2102
+ height: 6px;
2103
+ border-radius: 4px;
2104
+ margin: -2px 0 7px;
2105
+ }
2106
+ .sv-board-avatars {
2107
+ display: inline-flex;
2108
+ margin-left: auto;
2109
+ }
2110
+ .sv-board-avatar {
2111
+ width: 20px;
2112
+ height: 20px;
2113
+ border-radius: 50%;
2114
+ display: grid;
2115
+ place-items: center;
2116
+ font-size: 0.6rem;
2117
+ font-weight: 700;
2118
+ color: #fff;
2119
+ background: var(--sv-board-accent);
2120
+ border: 1.5px solid var(--sg-bg, #fff);
2121
+ margin-left: -6px;
2122
+ }
2123
+ .sv-board-avatar:first-child {
2124
+ margin-left: 0;
2125
+ }
2126
+ /* Sub-tasks */
2127
+ .sv-board-subs {
2128
+ margin-top: 7px;
2129
+ border-top: 1px solid color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2130
+ padding-top: 6px;
2131
+ }
2132
+ .sv-board-subs-head {
2133
+ all: unset;
2134
+ cursor: pointer;
2135
+ display: flex;
2136
+ align-items: center;
2137
+ gap: 6px;
2138
+ width: 100%;
2139
+ font-size: 0.7rem;
2140
+ color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
2141
+ }
2142
+ .sv-board-subs-chev {
2143
+ font-size: 0.6rem;
2144
+ }
2145
+ .sv-board-subs-count {
2146
+ font-variant-numeric: tabular-nums;
2147
+ font-weight: 600;
2148
+ }
2149
+ .sv-board-subs-bar {
2150
+ flex: 1 1 auto;
2151
+ height: 4px;
2152
+ border-radius: 2px;
2153
+ background: color-mix(in srgb, var(--sg-fg, #101828) 10%, transparent);
2154
+ overflow: hidden;
2155
+ }
2156
+ .sv-board-subs-bar span {
2157
+ display: block;
2158
+ height: 100%;
2159
+ background: var(--sv-board-accent);
2160
+ transition: width 0.15s ease;
2161
+ }
2162
+ .sv-board-subs-list {
2163
+ list-style: none;
2164
+ margin: 6px 0 0;
2165
+ padding: 0;
2166
+ display: flex;
2167
+ flex-direction: column;
2168
+ gap: 3px;
2169
+ }
2170
+ .sv-board-subs-list label {
2171
+ display: flex;
2172
+ align-items: center;
2173
+ gap: 6px;
2174
+ font-size: 0.74rem;
2175
+ cursor: pointer;
2176
+ }
2177
+ .sv-board-subs-list label.is-done span {
2178
+ text-decoration: line-through;
2179
+ color: color-mix(in srgb, var(--sg-fg, #101828) 45%, transparent);
2180
+ }
2181
+ .sv-board-subs-list input {
2182
+ accent-color: var(--sv-board-accent);
2183
+ }
2184
+ .sv-board-subs-add {
2185
+ margin-top: 5px;
2186
+ width: 100%;
2187
+ box-sizing: border-box;
2188
+ font: inherit;
2189
+ font-size: 0.74rem;
2190
+ padding: 3px 6px;
2191
+ border: 1px solid var(--sg-border, #e4e7ec);
2192
+ border-radius: 5px;
2193
+ background: var(--sg-bg, #fff);
2194
+ color: var(--sg-fg, #101828);
2195
+ }
2196
+ /* Quick-add composer */
2197
+ .sv-board-composer-btn {
2198
+ all: unset;
2199
+ cursor: pointer;
2200
+ display: block;
2201
+ margin: 6px 8px 8px;
2202
+ padding: 5px 8px;
2203
+ border-radius: var(--sg-radius, 6px);
2204
+ font-size: 0.76rem;
2205
+ color: color-mix(in srgb, var(--sg-fg, #101828) 55%, transparent);
2206
+ }
2207
+ .sv-board-composer-btn:hover {
2208
+ background: color-mix(in srgb, var(--sg-fg, #101828) 6%, transparent);
2209
+ color: var(--sg-fg, #101828);
2210
+ }
2211
+ .sv-board-composer-input {
2212
+ margin: 6px 8px 8px;
2213
+ width: calc(100% - 16px);
2214
+ box-sizing: border-box;
2215
+ font: inherit;
2216
+ font-size: 0.8rem;
2217
+ padding: 6px 8px;
2218
+ border: 1px solid var(--sv-board-accent);
2219
+ border-radius: var(--sg-radius, 6px);
2220
+ background: var(--sg-bg, #fff);
2221
+ color: var(--sg-fg, #101828);
2222
+ }
2223
+ /* Facet filter bar */
2224
+ .sv-board-facets {
2225
+ display: flex;
2226
+ flex-wrap: wrap;
2227
+ gap: 6px;
2228
+ padding: 4px 4px 8px;
2229
+ align-items: center;
2230
+ }
2231
+ .sv-board-facet {
2232
+ all: unset;
2233
+ cursor: pointer;
2234
+ display: inline-flex;
2235
+ align-items: center;
2236
+ gap: 5px;
2237
+ font-size: 0.73rem;
2238
+ padding: 2px 9px;
2239
+ border-radius: 999px;
2240
+ border: 1px solid var(--sg-border, #e4e7ec);
2241
+ color: color-mix(in srgb, var(--sg-fg, #101828) 70%, transparent);
2242
+ }
2243
+ .sv-board-facet:hover {
2244
+ border-color: var(--sg-accent, #3b82f6);
2245
+ }
2246
+ .sv-board-facet.is-active {
2247
+ background: var(--sg-accent, #3b82f6);
2248
+ border-color: var(--sg-accent, #3b82f6);
2249
+ color: #fff;
2250
+ }
2251
+ .sv-board-facet-count {
2252
+ font-size: 0.66rem;
2253
+ opacity: 0.7;
2254
+ font-variant-numeric: tabular-nums;
2255
+ }
2256
+ .sv-board-facet-clear {
2257
+ all: unset;
2258
+ cursor: pointer;
2259
+ font-size: 0.72rem;
2260
+ color: var(--sg-accent, #3b82f6);
2261
+ padding: 2px 6px;
2262
+ }
2263
+ /* Blocked flag + selection */
2264
+ .sv-board-card {
2265
+ position: relative;
2266
+ }
2267
+ .sv-board-card-flag {
2268
+ position: absolute;
2269
+ top: 0;
2270
+ right: 0;
2271
+ width: 0;
2272
+ height: 0;
2273
+ border-top: 14px solid var(--sg-danger, #ef4444);
2274
+ border-left: 14px solid transparent;
2275
+ border-top-right-radius: var(--sg-radius, 8px);
2276
+ }
2277
+ .sv-board-card.is-selected {
2278
+ border-color: var(--sv-board-accent);
2279
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--sv-board-accent) 55%, transparent);
2280
+ }
2281
+ .sv-board-card.is-locked {
2282
+ cursor: default;
2283
+ }
2284
+ /* Child cards (epic -> stories) */
2285
+ .sv-board-kids {
2286
+ margin-top: 7px;
2287
+ border-top: 1px solid color-mix(in srgb, var(--sg-fg, #101828) 8%, transparent);
2288
+ padding-top: 6px;
2289
+ }
2290
+ .sv-board-kids-head {
2291
+ all: unset;
2292
+ cursor: pointer;
2293
+ display: flex;
2294
+ align-items: center;
2295
+ gap: 5px;
2296
+ font-size: 0.7rem;
2297
+ color: color-mix(in srgb, var(--sg-fg, #101828) 60%, transparent);
2298
+ }
2299
+ .sv-board-kids-list {
2300
+ list-style: none;
2301
+ margin: 6px 0 0;
2302
+ padding: 0;
2303
+ display: flex;
2304
+ flex-direction: column;
2305
+ gap: 4px;
2306
+ }
2307
+ .sv-board-kid {
2308
+ display: flex;
2309
+ align-items: center;
2310
+ gap: 6px;
2311
+ font-size: 0.74rem;
2312
+ padding: 4px 7px;
2313
+ border: 1px solid var(--sg-border, #e4e7ec);
2314
+ border-radius: 6px;
2315
+ background: var(--sg-bg, #fff);
2316
+ }
2317
+ .sv-board-kid-title {
2318
+ flex: 1 1 auto;
2319
+ overflow: hidden;
2320
+ text-overflow: ellipsis;
2321
+ white-space: nowrap;
2322
+ }
2323
+ .sv-board-kid-lane {
2324
+ font-size: 0.64rem;
2325
+ font-weight: 600;
2326
+ padding: 0 6px;
2327
+ border-radius: 999px;
2328
+ background: color-mix(in srgb, var(--sv-board-accent) 15%, transparent);
2329
+ color: color-mix(in srgb, var(--sv-board-accent) 85%, var(--sg-fg, #101828));
2330
+ text-transform: capitalize;
2331
+ }
2332
+ @media (prefers-reduced-motion: reduce) {
2333
+ .sv-board-card,
2334
+ .sv-board-lane-body,
2335
+ .sv-board-subs-bar span {
2336
+ transition: none;
2337
+ }
2338
+ }
2339
+ </style>