@svgrid/grid 2.2.16 → 2.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/GridFooter.svelte +12 -11
- package/dist/SvAnchor.svelte +65 -0
- package/dist/SvAnchor.svelte.d.ts +24 -0
- package/dist/SvAspectRatio.svelte +38 -0
- package/dist/SvAspectRatio.svelte.d.ts +16 -0
- package/dist/SvAutoComplete.svelte +52 -7
- package/dist/SvAutoComplete.svelte.d.ts +4 -1
- package/dist/SvBlockquote.svelte +43 -0
- package/dist/SvBlockquote.svelte.d.ts +16 -0
- package/dist/SvCalendar.svelte +33 -2
- package/dist/SvCalendar.svelte.d.ts +8 -1
- package/dist/SvCheckBox.svelte +6 -2
- package/dist/SvCode.svelte +52 -0
- package/dist/SvCode.svelte.d.ts +19 -0
- package/dist/SvCollapsible.svelte +75 -0
- package/dist/SvCollapsible.svelte.d.ts +27 -0
- package/dist/SvColorInput.svelte +11 -17
- package/dist/SvColorInput.svelte.d.ts +2 -0
- package/dist/SvComboBox.svelte +85 -8
- package/dist/SvComboBox.svelte.d.ts +14 -0
- package/dist/SvCountryInput.svelte +2 -2
- package/dist/SvCountryInput.svelte.d.ts +1 -1
- package/dist/SvDropDownList.svelte +154 -51
- package/dist/SvDropDownList.svelte.d.ts +17 -3
- package/dist/SvDurationInput.svelte +59 -41
- package/dist/SvDurationInput.svelte.d.ts +18 -0
- package/dist/SvField.svelte +203 -12
- package/dist/SvField.svelte.d.ts +45 -8
- package/dist/SvForm.svelte +195 -21
- package/dist/SvForm.svelte.d.ts +23 -0
- package/dist/SvGrid.controller.svelte.d.ts +12 -1
- package/dist/SvGrid.controller.svelte.js +87 -7
- package/dist/SvGrid.svelte +383 -181
- package/dist/SvGrid.types.d.ts +123 -2
- package/dist/SvGridChartView.svelte +70 -0
- package/dist/SvGridChartView.svelte.d.ts +11 -0
- package/dist/SvGridDropdown.svelte +9 -2
- package/dist/SvGridSelect.svelte +5 -2
- package/dist/SvGridSelect.svelte.d.ts +1 -1
- package/dist/SvGroup.svelte +41 -0
- package/dist/SvGroup.svelte.d.ts +27 -0
- package/dist/SvHoverCard.svelte +70 -0
- package/dist/SvHoverCard.svelte.d.ts +41 -0
- package/dist/SvKbd.svelte +51 -0
- package/dist/SvKbd.svelte.d.ts +18 -0
- package/dist/SvList.svelte +55 -0
- package/dist/SvList.svelte.d.ts +23 -0
- package/dist/SvListBox.svelte +166 -77
- package/dist/SvListBox.svelte.d.ts +17 -4
- package/dist/SvLoadingOverlay.svelte +53 -0
- package/dist/SvLoadingOverlay.svelte.d.ts +28 -0
- package/dist/SvMark.svelte +36 -0
- package/dist/SvMark.svelte.d.ts +14 -0
- package/dist/SvMaskedInput.svelte +50 -35
- package/dist/SvMaskedInput.svelte.d.ts +17 -2
- package/dist/SvMenuList.svelte +32 -3
- package/dist/SvMenubar.svelte +200 -0
- package/dist/SvMenubar.svelte.d.ts +18 -0
- package/dist/SvMultiSelect.svelte +7 -4
- package/dist/SvMultiSelect.svelte.d.ts +1 -1
- package/dist/SvNumberInput.svelte +79 -42
- package/dist/SvNumberInput.svelte.d.ts +25 -0
- package/dist/SvOtpInput.svelte +2 -2
- package/dist/SvPasswordInput.svelte +51 -31
- package/dist/SvPasswordInput.svelte.d.ts +17 -0
- package/dist/SvPhoneInput.svelte +39 -28
- package/dist/SvPhoneInput.svelte.d.ts +4 -0
- package/dist/SvPopconfirm.svelte +79 -0
- package/dist/SvPopconfirm.svelte.d.ts +42 -0
- package/dist/SvPopover.svelte +87 -29
- package/dist/SvPopover.svelte.d.ts +18 -4
- package/dist/SvRadioGroup.svelte +5 -1
- package/dist/SvResult.svelte +73 -0
- package/dist/SvResult.svelte.d.ts +28 -0
- package/dist/SvSegmented.svelte +114 -0
- package/dist/SvSegmented.svelte.d.ts +29 -0
- package/dist/SvSimpleGrid.svelte +41 -0
- package/dist/SvSimpleGrid.svelte.d.ts +25 -0
- package/dist/SvSlider.svelte +23 -10
- package/dist/SvSpinner.svelte +43 -0
- package/dist/SvSpinner.svelte.d.ts +20 -0
- package/dist/SvStack.svelte +37 -0
- package/dist/SvStack.svelte.d.ts +24 -0
- package/dist/SvSwitchButton.svelte +6 -1
- package/dist/SvTagsInput.svelte +9 -5
- package/dist/SvText.svelte +76 -0
- package/dist/SvText.svelte.d.ts +31 -0
- package/dist/SvTextInput.svelte +75 -43
- package/dist/SvTextInput.svelte.d.ts +28 -1
- package/dist/SvTimePicker.svelte +32 -2
- package/dist/SvTimePicker.svelte.d.ts +8 -9
- package/dist/SvTitle.svelte +57 -0
- package/dist/SvTitle.svelte.d.ts +24 -0
- package/dist/SvToaster.svelte +41 -6
- package/dist/SvToggleButton.svelte +4 -1
- package/dist/SvTooltip.svelte +68 -34
- package/dist/SvTooltip.svelte.d.ts +14 -2
- package/dist/SvTree.svelte +81 -21
- package/dist/SvTree.svelte.d.ts +3 -2
- package/dist/SvTreeSelect.svelte +5 -2
- package/dist/SvTreeSelect.svelte.d.ts +1 -1
- package/dist/SvVisuallyHidden.svelte +49 -0
- package/dist/SvVisuallyHidden.svelte.d.ts +19 -0
- package/dist/ai.d.ts +289 -0
- package/dist/ai.js +1013 -0
- package/dist/board-view.svelte.d.ts +22 -0
- package/dist/board-view.svelte.js +13 -0
- package/dist/build-api.js +36 -6
- package/dist/cdn/GridMenus-0AK9O7qG.js +420 -0
- package/dist/cdn/GridMenus-BjUoCXoD.js +416 -0
- package/dist/cdn/SvDateTimePicker-CYC7gRER.js +2176 -0
- package/dist/cdn/SvDateTimePicker-DtQKd4Ns.js +2198 -0
- package/dist/cdn/SvGridChart-BcLCYvw9.js +1195 -0
- package/dist/cdn/SvGridChart-BhnFdLd0.js +1203 -0
- package/dist/cdn/SvGridChartPanel-Cmx9DHAS.js +561 -0
- package/dist/cdn/SvGridChartPanel-DvjwxLvK.js +577 -0
- package/dist/cdn/SvGridChartView-B40DTJSX.js +54 -0
- package/dist/cdn/SvGridChartView-PwqI4s02.js +53 -0
- package/dist/cdn/SvGridDropdown-DjALc_4f.js +255 -0
- package/dist/cdn/SvGridDropdown-hpnHvOGt.js +254 -0
- package/dist/cdn/chart-T1usDN0o.js +1212 -0
- package/dist/cdn/disclose-version-DoD9AFD_.js +2958 -0
- package/dist/cdn/export-format-JT5pWVT8.js +561 -0
- package/dist/cdn/popover-P-9P2YYn.js +69 -0
- package/dist/cdn/rolldown-runtime-Dy4uBu1J.js +11 -0
- package/dist/cdn/src-BLhdLmoN.js +25707 -0
- package/dist/cdn/src-BwnUVj3T.js +25623 -0
- package/dist/cdn/svgrid.js +9 -32897
- package/dist/cdn/svgrid.svelte-external.js +9 -30052
- package/dist/cell-formatting.d.ts +2 -2
- package/dist/cell-render.js +4 -4
- package/dist/chart-view.svelte.d.ts +25 -0
- package/dist/chart-view.svelte.js +14 -0
- package/dist/createCombobox.svelte.d.ts +5 -0
- package/dist/createCombobox.svelte.js +21 -5
- package/dist/createDropdownList.svelte.d.ts +3 -0
- package/dist/createDropdownList.svelte.js +8 -2
- package/dist/createForm.svelte.d.ts +18 -0
- package/dist/createForm.svelte.js +51 -6
- package/dist/createMenu.svelte.js +27 -0
- package/dist/createPopoverSelect.svelte.d.ts +3 -0
- package/dist/createPopoverSelect.svelte.js +9 -1
- package/dist/createSlider.svelte.d.ts +8 -21
- package/dist/createSlider.svelte.js +34 -0
- package/dist/createTooltip.svelte.d.ts +12 -0
- package/dist/createTooltip.svelte.js +72 -9
- package/dist/editor-contract.d.ts +15 -0
- package/dist/export-provider.d.ts +27 -0
- package/dist/export-provider.js +9 -0
- package/dist/filter-operators.d.ts +6 -0
- package/dist/filter-operators.js +33 -0
- package/dist/form-field.d.ts +45 -2
- package/dist/grid-messages.d.ts +61 -0
- package/dist/grid-messages.js +72 -0
- package/dist/group-display.d.ts +62 -0
- package/dist/group-display.js +78 -0
- package/dist/index.d.ts +36 -4
- package/dist/index.js +43 -2
- package/dist/js-scroller.svelte.d.ts +35 -0
- package/dist/js-scroller.svelte.js +154 -0
- package/dist/list-option.d.ts +46 -0
- package/dist/list-option.js +53 -0
- package/dist/menus.js +25 -11
- package/dist/pivot-view.svelte.d.ts +72 -0
- package/dist/pivot-view.svelte.js +13 -0
- package/dist/positioning.d.ts +111 -0
- package/dist/positioning.js +191 -0
- package/dist/scheduler-model.d.ts +3 -0
- package/dist/scheduler-model.js +22 -5
- package/dist/svgrid-wrapper.types.d.ts +18 -0
- package/dist/toast-store.svelte.d.ts +54 -0
- package/dist/toast-store.svelte.js +65 -15
- package/package.json +9 -1
- package/src/GridFooter.svelte +12 -11
- package/src/SvAnchor.svelte +65 -0
- package/src/SvAspectRatio.svelte +38 -0
- package/src/SvAutoComplete.svelte +52 -7
- package/src/SvBlockquote.svelte +43 -0
- package/src/SvCalendar.svelte +33 -2
- package/src/SvCalendar.test.ts +15 -0
- package/src/SvCheckBox.svelte +6 -2
- package/src/SvCode.svelte +52 -0
- package/src/SvCollapsible.svelte +75 -0
- package/src/SvColorInput.svelte +11 -17
- package/src/SvComboBox.svelte +85 -8
- package/src/SvCountryInput.svelte +2 -2
- package/src/SvDropDownList.svelte +154 -51
- package/src/SvDurationInput.svelte +59 -41
- package/src/SvField.svelte +203 -12
- package/src/SvForm.svelte +195 -21
- package/src/SvForm.test.ts +112 -0
- package/src/SvGrid.controller.svelte.ts +88 -7
- package/src/SvGrid.svelte +383 -181
- package/src/SvGrid.types.ts +125 -1
- package/src/SvGridChartView.svelte +70 -0
- package/src/SvGridDropdown.svelte +9 -2
- package/src/SvGridSelect.svelte +5 -2
- package/src/SvGroup.svelte +41 -0
- package/src/SvHoverCard.svelte +70 -0
- package/src/SvKbd.svelte +51 -0
- package/src/SvList.svelte +55 -0
- package/src/SvListBox.svelte +166 -77
- package/src/SvLoadingOverlay.svelte +53 -0
- package/src/SvMark.svelte +36 -0
- package/src/SvMaskedInput.svelte +50 -35
- package/src/SvMenuList.svelte +32 -3
- package/src/SvMenubar.svelte +200 -0
- package/src/SvMultiSelect.svelte +7 -4
- package/src/SvNumberInput.svelte +79 -42
- package/src/SvOtpInput.svelte +2 -2
- package/src/SvPasswordInput.svelte +51 -31
- package/src/SvPhoneInput.svelte +39 -28
- package/src/SvPopconfirm.svelte +79 -0
- package/src/SvPopover.svelte +87 -29
- package/src/SvRadioGroup.svelte +5 -1
- package/src/SvResult.svelte +73 -0
- package/src/SvSegmented.svelte +114 -0
- package/src/SvSimpleGrid.svelte +41 -0
- package/src/SvSlider.svelte +23 -10
- package/src/SvSpinner.svelte +43 -0
- package/src/SvStack.svelte +37 -0
- package/src/SvSwitchButton.svelte +6 -1
- package/src/SvTagsInput.svelte +9 -5
- package/src/SvText.svelte +76 -0
- package/src/SvTextInput.svelte +75 -43
- package/src/SvTimePicker.svelte +32 -2
- package/src/SvTitle.svelte +57 -0
- package/src/SvToaster.svelte +41 -6
- package/src/SvToaster.test.ts +32 -0
- package/src/SvToggleButton.svelte +4 -1
- package/src/SvTooltip.svelte +68 -34
- package/src/SvTree.svelte +81 -21
- package/src/SvTreeSelect.svelte +5 -2
- package/src/SvVisuallyHidden.svelte +49 -0
- package/src/ai.test.ts +502 -0
- package/src/ai.ts +1391 -0
- package/src/board-view.svelte.ts +33 -0
- package/src/build-api.coverage.test.ts +101 -0
- package/src/build-api.ts +43 -18
- package/src/builtin-editors.grid.test.ts +1 -1
- package/src/cell-formatting.ts +5 -5
- package/src/cell-render.ts +4 -4
- package/src/chart-view.svelte.ts +36 -0
- package/src/createCombobox.svelte.ts +17 -5
- package/src/createDropdownList.svelte.ts +8 -2
- package/src/createForm.svelte.ts +59 -6
- package/src/createMenu.svelte.ts +23 -0
- package/src/createPopoverSelect.svelte.ts +8 -1
- package/src/createSlider.svelte.ts +20 -0
- package/src/createTooltip.svelte.ts +67 -9
- package/src/editor-contract.ts +16 -0
- package/src/export-provider.ts +40 -0
- package/src/filter-operators.ts +37 -0
- package/src/form-field.ts +42 -2
- package/src/grid-messages.test.ts +24 -0
- package/src/grid-messages.ts +137 -0
- package/src/group-display.test.ts +77 -0
- package/src/group-display.ts +117 -0
- package/src/i18n.grid.test.ts +73 -0
- package/src/index.ts +91 -2
- package/src/js-scroller.svelte.ts +173 -0
- package/src/list-option.test.ts +56 -0
- package/src/list-option.ts +81 -0
- package/src/menus.test.ts +32 -0
- package/src/menus.ts +25 -12
- package/src/pivot-view.svelte.ts +90 -0
- package/src/pivot-view.test.ts +38 -0
- package/src/pivot.grid.test.ts +102 -0
- package/src/positioning.test.ts +141 -0
- package/src/positioning.ts +266 -0
- package/src/scheduler-model.test.ts +16 -0
- package/src/scheduler-model.ts +24 -5
- package/src/svgrid-wrapper.types.ts +23 -0
- package/src/svgrid.charting.test.ts +8 -1
- package/src/svgrid.comments-autocomplete.test.ts +7 -2
- package/src/svgrid.context-menu.test.ts +8 -3
- package/src/toast-store.svelte.ts +98 -0
- package/src/toast-store.test.ts +62 -1
- package/src/ui-breadth.test.ts +138 -0
- package/src/ui-buttons.test.ts +39 -0
- package/src/ui-inputs.test.ts +2 -2
- package/src/ui-range.test.ts +9 -0
- package/src/ui-selection.test.ts +36 -0
- package/src/ui-tier1.test.ts +1 -1
- package/src/virtual.test.ts +20 -6
- package/dist/SvGridBoard.svelte +0 -2339
- package/dist/SvGridBoard.svelte.d.ts +0 -31
- package/src/SvGridBoard.svelte +0 -2339
- package/src/board.test.ts +0 -927
package/src/SvGridBoard.svelte
DELETED
|
@@ -1,2339 +0,0 @@
|
|
|
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>
|