@f-ewald/components 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/custom-elements.json +2329 -301
- package/dist/form-select.d.ts +5 -4
- package/dist/form-select.d.ts.map +1 -1
- package/dist/form-select.js +6 -5
- package/dist/form-select.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/kanban-board.d.ts +110 -0
- package/dist/kanban-board.d.ts.map +1 -0
- package/dist/kanban-board.js +619 -0
- package/dist/kanban-board.js.map +1 -0
- package/dist/kanban-card.d.ts +35 -0
- package/dist/kanban-card.d.ts.map +1 -0
- package/dist/kanban-card.js +163 -0
- package/dist/kanban-card.js.map +1 -0
- package/dist/kanban-column.d.ts +30 -0
- package/dist/kanban-column.d.ts.map +1 -0
- package/dist/kanban-column.js +166 -0
- package/dist/kanban-column.js.map +1 -0
- package/dist/multi-select.d.ts +142 -0
- package/dist/multi-select.d.ts.map +1 -0
- package/dist/multi-select.js +1304 -0
- package/dist/multi-select.js.map +1 -0
- package/dist/tokens.css +3 -0
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +2 -0
- package/dist/tokens.js.map +1 -1
- package/docs/design-language.md +8 -0
- package/docs/form-select.md +5 -4
- package/docs/kanban-board.md +96 -0
- package/docs/kanban-card.md +56 -0
- package/docs/kanban-column.md +58 -0
- package/docs/multi-select.md +120 -0
- package/llms.txt +182 -4
- package/package.json +1 -1
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { LitElement, css, html, nothing } from "lit";
|
|
8
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
9
|
+
import { repeat } from "lit/directives/repeat.js";
|
|
10
|
+
import { iconCalendar, iconClock, iconTag } from "./icons.js";
|
|
11
|
+
import { tokens } from "./tokens.js";
|
|
12
|
+
import "./kanban-column.js";
|
|
13
|
+
import "./kanban-card.js";
|
|
14
|
+
import "./popover-panel.js";
|
|
15
|
+
import "./radio-pills.js";
|
|
16
|
+
import "./relative-time.js";
|
|
17
|
+
/** Duration (ms) of the warm "just moved" highlight flashed on a card after it lands. */
|
|
18
|
+
const HIGHLIGHT_MS = 1400;
|
|
19
|
+
/**
|
|
20
|
+
* A configurable kanban board: a horizontally scrolling row of columns, each
|
|
21
|
+
* holding cards. **A card's column is its state** — moving a card to another
|
|
22
|
+
* column (by drag-and-drop, keyboard, or the detail popover's state selector)
|
|
23
|
+
* changes its state, and the board emits a single `card-move` for all three.
|
|
24
|
+
*
|
|
25
|
+
* Data-driven: set the `columns` property to `KanbanColumnData[]`. The board
|
|
26
|
+
* keeps its own working copy and mutates it optimistically on every move, so it
|
|
27
|
+
* works standalone; re-assign `columns` at any time to stay controlled from a
|
|
28
|
+
* store. Cards show only their ticket number and title in the overview; the
|
|
29
|
+
* full detail (description, state, created/updated timestamps) opens in a
|
|
30
|
+
* screen-centered `popover-panel`.
|
|
31
|
+
*
|
|
32
|
+
* Pointer drag-and-drop supports both cross-column moves and within-column
|
|
33
|
+
* reordering with a live drop indicator (set `reorderable` false to keep only
|
|
34
|
+
* cross-column moves when intra-column order isn't persisted). Keyboard parity:
|
|
35
|
+
* focus a card and press Space to pick it up, arrow keys to move it (left/right
|
|
36
|
+
* across columns, up/down within a column), Space to drop, or Escape to cancel;
|
|
37
|
+
* Enter opens the detail. Moves are announced in a polite live region, and the
|
|
38
|
+
* moved card briefly flashes a warm highlight so you can see where it landed.
|
|
39
|
+
*
|
|
40
|
+
* Set `manual` for a server-authoritative board (API + WebSocket/SSE): every
|
|
41
|
+
* move still emits `card-move`, but the board does NOT apply it locally, so it
|
|
42
|
+
* reflects only what you assign to `columns` — e.g. the change echoed back over
|
|
43
|
+
* the socket. The default is optimistic local updates.
|
|
44
|
+
*
|
|
45
|
+
* @element kanban-board
|
|
46
|
+
* @fires card-open - A card's detail view was opened; detail: { cardId }.
|
|
47
|
+
* @fires card-move - A card changed column/position; detail: { cardId, fromColumnId, toColumnId, toIndex }.
|
|
48
|
+
*/
|
|
49
|
+
let KanbanBoard = class KanbanBoard extends LitElement {
|
|
50
|
+
constructor() {
|
|
51
|
+
super(...arguments);
|
|
52
|
+
/** Columns (with their cards) to render, in display order. */
|
|
53
|
+
this.columns = [];
|
|
54
|
+
/** Accessible label for the board's group role. */
|
|
55
|
+
this.label = "Board";
|
|
56
|
+
/**
|
|
57
|
+
* Server-authoritative mode. When true, moves emit `card-move` but are not
|
|
58
|
+
* applied to the board locally; it reflects only what you assign to
|
|
59
|
+
* `columns` (e.g. echoed back over WebSocket/SSE), keeping the server as the
|
|
60
|
+
* single source of truth. Defaults to optimistic local updates.
|
|
61
|
+
*/
|
|
62
|
+
this.manual = false;
|
|
63
|
+
/**
|
|
64
|
+
* Whether cards can be reordered within a column. Defaults to true. Set false
|
|
65
|
+
* when intra-column order isn't persisted (no server `rank`): drag and
|
|
66
|
+
* keyboard still move cards *between* columns (appended to the target), but
|
|
67
|
+
* reordering inside a column is disabled, so the UI only offers what sticks.
|
|
68
|
+
*/
|
|
69
|
+
this.reorderable = true;
|
|
70
|
+
this._cols = [];
|
|
71
|
+
this._openCardId = null;
|
|
72
|
+
this._drag = null;
|
|
73
|
+
this._pointer = null;
|
|
74
|
+
this._grab = null;
|
|
75
|
+
this._liveMessage = "";
|
|
76
|
+
this._highlighted = new Set();
|
|
77
|
+
this.#refocus = null;
|
|
78
|
+
this.#pendingHighlight = null;
|
|
79
|
+
this.#highlightTimers = new Map();
|
|
80
|
+
}
|
|
81
|
+
#refocus;
|
|
82
|
+
#pendingHighlight;
|
|
83
|
+
#highlightTimers;
|
|
84
|
+
static { this.styles = [
|
|
85
|
+
tokens,
|
|
86
|
+
css `
|
|
87
|
+
:host {
|
|
88
|
+
display: block;
|
|
89
|
+
font-family: var(
|
|
90
|
+
--ui-font,
|
|
91
|
+
ui-sans-serif,
|
|
92
|
+
system-ui,
|
|
93
|
+
sans-serif,
|
|
94
|
+
"Apple Color Emoji",
|
|
95
|
+
"Segoe UI Emoji",
|
|
96
|
+
"Segoe UI Symbol",
|
|
97
|
+
"Noto Color Emoji"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
.board {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: flex-start;
|
|
103
|
+
gap: 1rem;
|
|
104
|
+
overflow-x: auto;
|
|
105
|
+
padding-bottom: 0.25rem;
|
|
106
|
+
}
|
|
107
|
+
.drop-indicator {
|
|
108
|
+
height: 0.125rem;
|
|
109
|
+
background: var(--ui-primary, #4f46e5);
|
|
110
|
+
border-radius: 999px;
|
|
111
|
+
}
|
|
112
|
+
.detail {
|
|
113
|
+
display: flex;
|
|
114
|
+
flex-direction: column;
|
|
115
|
+
gap: 0.75rem;
|
|
116
|
+
padding: 0.75rem;
|
|
117
|
+
}
|
|
118
|
+
.detail-desc {
|
|
119
|
+
margin: 0;
|
|
120
|
+
font-size: var(--ui-font-size, 0.875rem);
|
|
121
|
+
line-height: var(--ui-line-height-normal, 1.5);
|
|
122
|
+
color: var(--ui-text, #0f172a);
|
|
123
|
+
}
|
|
124
|
+
.detail-muted {
|
|
125
|
+
color: var(--ui-text-muted, #64748b);
|
|
126
|
+
}
|
|
127
|
+
.detail-field {
|
|
128
|
+
display: flex;
|
|
129
|
+
flex-direction: column;
|
|
130
|
+
gap: 0.25rem;
|
|
131
|
+
}
|
|
132
|
+
.detail-label {
|
|
133
|
+
font-size: var(--ui-font-size-sm, 0.75rem);
|
|
134
|
+
font-weight: var(--ui-font-weight-medium, 500);
|
|
135
|
+
color: var(--ui-text-muted, #64748b);
|
|
136
|
+
}
|
|
137
|
+
.detail-meta {
|
|
138
|
+
margin: 0;
|
|
139
|
+
display: flex;
|
|
140
|
+
flex-direction: column;
|
|
141
|
+
gap: 0.25rem;
|
|
142
|
+
}
|
|
143
|
+
.detail-meta-row {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: space-between;
|
|
147
|
+
gap: 0.5rem;
|
|
148
|
+
font-size: var(--ui-font-size-sm, 0.75rem);
|
|
149
|
+
}
|
|
150
|
+
.detail-meta-row dt {
|
|
151
|
+
display: inline-flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: 0.25rem;
|
|
154
|
+
color: var(--ui-text-muted, #64748b);
|
|
155
|
+
font-weight: var(--ui-font-weight-medium, 500);
|
|
156
|
+
}
|
|
157
|
+
.detail-meta-row dt svg {
|
|
158
|
+
display: inline-flex;
|
|
159
|
+
}
|
|
160
|
+
.detail-meta-row dd {
|
|
161
|
+
margin: 0;
|
|
162
|
+
color: var(--ui-text, #0f172a);
|
|
163
|
+
}
|
|
164
|
+
.sr-only {
|
|
165
|
+
position: absolute;
|
|
166
|
+
width: 1px;
|
|
167
|
+
height: 1px;
|
|
168
|
+
padding: 0;
|
|
169
|
+
margin: -1px;
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
clip: rect(0 0 0 0);
|
|
172
|
+
clip-path: inset(50%);
|
|
173
|
+
white-space: nowrap;
|
|
174
|
+
border: 0;
|
|
175
|
+
}
|
|
176
|
+
`,
|
|
177
|
+
]; }
|
|
178
|
+
willUpdate(changed) {
|
|
179
|
+
if (changed.has("columns")) {
|
|
180
|
+
this._cols = this.columns.map((column) => ({
|
|
181
|
+
id: column.id,
|
|
182
|
+
title: column.title,
|
|
183
|
+
cards: column.cards.map((card) => ({ ...card })),
|
|
184
|
+
}));
|
|
185
|
+
// In manual mode the highlight is deferred until the moved card actually
|
|
186
|
+
// arrives via a `columns` reassignment (e.g. the socket echo).
|
|
187
|
+
if (this.#pendingHighlight && this.#findCard(this.#pendingHighlight)) {
|
|
188
|
+
this.#highlight(this.#pendingHighlight);
|
|
189
|
+
}
|
|
190
|
+
this.#pendingHighlight = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
updated() {
|
|
194
|
+
if (this.#refocus) {
|
|
195
|
+
const card = this.renderRoot.querySelector(`kanban-card[data-card-id="${this.#refocus}"]`);
|
|
196
|
+
card?.focus();
|
|
197
|
+
this.#refocus = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
disconnectedCallback() {
|
|
201
|
+
super.disconnectedCallback();
|
|
202
|
+
this._grab = null;
|
|
203
|
+
this._drag = null;
|
|
204
|
+
this._pointer = null;
|
|
205
|
+
for (const timer of this.#highlightTimers.values())
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
this.#highlightTimers.clear();
|
|
208
|
+
}
|
|
209
|
+
/** Returns the card, its column, and index for a card id, or null if unknown. */
|
|
210
|
+
#findCard(cardId) {
|
|
211
|
+
for (const column of this._cols) {
|
|
212
|
+
const index = column.cards.findIndex((card) => card.id === cardId);
|
|
213
|
+
if (index >= 0)
|
|
214
|
+
return { card: column.cards[index], column, index };
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/** Number of cards currently in a column (0 if unknown). */
|
|
219
|
+
#columnCount(columnId) {
|
|
220
|
+
return this._cols.find((column) => column.id === columnId)?.cards.length ?? 0;
|
|
221
|
+
}
|
|
222
|
+
/** Flashes the warm "just moved" highlight on a card, clearing it after HIGHLIGHT_MS. */
|
|
223
|
+
#highlight(cardId) {
|
|
224
|
+
const existing = this.#highlightTimers.get(cardId);
|
|
225
|
+
if (existing)
|
|
226
|
+
clearTimeout(existing);
|
|
227
|
+
const next = new Set(this._highlighted);
|
|
228
|
+
next.add(cardId);
|
|
229
|
+
this._highlighted = next;
|
|
230
|
+
this.#highlightTimers.set(cardId, setTimeout(() => {
|
|
231
|
+
const remaining = new Set(this._highlighted);
|
|
232
|
+
remaining.delete(cardId);
|
|
233
|
+
this._highlighted = remaining;
|
|
234
|
+
this.#highlightTimers.delete(cardId);
|
|
235
|
+
}, HIGHLIGHT_MS));
|
|
236
|
+
}
|
|
237
|
+
/** Applies a move to the working copy and emits `card-move`. `toIndex` is the destination index after removal. */
|
|
238
|
+
#move(cardId, fromColumnId, toColumnId, toIndex) {
|
|
239
|
+
const cols = this._cols.map((column) => ({ ...column, cards: [...column.cards] }));
|
|
240
|
+
const from = cols.find((column) => column.id === fromColumnId);
|
|
241
|
+
const to = cols.find((column) => column.id === toColumnId);
|
|
242
|
+
if (!from || !to)
|
|
243
|
+
return;
|
|
244
|
+
const index = from.cards.findIndex((card) => card.id === cardId);
|
|
245
|
+
if (index < 0)
|
|
246
|
+
return;
|
|
247
|
+
const [card] = from.cards.splice(index, 1);
|
|
248
|
+
const target = Math.max(0, Math.min(toIndex, to.cards.length));
|
|
249
|
+
to.cards.splice(target, 0, card);
|
|
250
|
+
if (!this.manual)
|
|
251
|
+
this._cols = cols;
|
|
252
|
+
this.dispatchEvent(new CustomEvent("card-move", {
|
|
253
|
+
detail: { cardId, fromColumnId, toColumnId, toIndex: target },
|
|
254
|
+
bubbles: true,
|
|
255
|
+
composed: true,
|
|
256
|
+
}));
|
|
257
|
+
// Optimistic: highlight now (the card is already at its new position).
|
|
258
|
+
// Manual: defer until the move is echoed back via `columns`.
|
|
259
|
+
if (this.manual)
|
|
260
|
+
this.#pendingHighlight = cardId;
|
|
261
|
+
else
|
|
262
|
+
this.#highlight(cardId);
|
|
263
|
+
}
|
|
264
|
+
/** Converts a DOM insertion index (which counts the moving card) into a destination index after removal. */
|
|
265
|
+
#toDestIndex(fromColumnId, originIndex, toColumnId, domIndex) {
|
|
266
|
+
return fromColumnId === toColumnId && domIndex > originIndex ? domIndex - 1 : domIndex;
|
|
267
|
+
}
|
|
268
|
+
/** Computes the DOM insertion index within a column from the pointer's vertical position. */
|
|
269
|
+
#pointerIndex(columnId, clientY) {
|
|
270
|
+
const cards = [
|
|
271
|
+
...this.renderRoot.querySelectorAll(`kanban-card[data-column-id="${columnId}"]`),
|
|
272
|
+
];
|
|
273
|
+
for (let i = 0; i < cards.length; i++) {
|
|
274
|
+
const rect = cards[i].getBoundingClientRect();
|
|
275
|
+
if (clientY < rect.top + rect.height / 2)
|
|
276
|
+
return i;
|
|
277
|
+
}
|
|
278
|
+
return cards.length;
|
|
279
|
+
}
|
|
280
|
+
#onDragStart(event, columnId, cardId) {
|
|
281
|
+
const column = this._cols.find((entry) => entry.id === columnId);
|
|
282
|
+
const originIndex = column ? column.cards.findIndex((card) => card.id === cardId) : 0;
|
|
283
|
+
this._grab = null;
|
|
284
|
+
this._drag = { cardId, fromColumnId: columnId, originIndex };
|
|
285
|
+
this._pointer = { columnId, index: originIndex };
|
|
286
|
+
if (event.dataTransfer) {
|
|
287
|
+
event.dataTransfer.effectAllowed = "move";
|
|
288
|
+
try {
|
|
289
|
+
event.dataTransfer.setData("text/plain", cardId);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
/* some browsers disallow setData outside trusted events */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
#onDragOver(event, columnId) {
|
|
297
|
+
if (!this._drag)
|
|
298
|
+
return;
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
if (event.dataTransfer)
|
|
301
|
+
event.dataTransfer.dropEffect = "move";
|
|
302
|
+
const index = this.#pointerIndex(columnId, event.clientY);
|
|
303
|
+
if (this._pointer?.columnId !== columnId || this._pointer.index !== index) {
|
|
304
|
+
this._pointer = { columnId, index };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
#onDrop(event, columnId) {
|
|
308
|
+
if (!this._drag)
|
|
309
|
+
return;
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
const { cardId, fromColumnId, originIndex } = this._drag;
|
|
312
|
+
this._drag = null;
|
|
313
|
+
this._pointer = null;
|
|
314
|
+
if (!this.reorderable) {
|
|
315
|
+
// Intra-column reordering is off: ignore same-column drops, and append
|
|
316
|
+
// cross-column moves to the end of the destination.
|
|
317
|
+
if (columnId === fromColumnId)
|
|
318
|
+
return;
|
|
319
|
+
this.#move(cardId, fromColumnId, columnId, this.#columnCount(columnId));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const domIndex = this.#pointerIndex(columnId, event.clientY);
|
|
323
|
+
const dest = this.#toDestIndex(fromColumnId, originIndex, columnId, domIndex);
|
|
324
|
+
this.#move(cardId, fromColumnId, columnId, dest);
|
|
325
|
+
}
|
|
326
|
+
#onDragEnd() {
|
|
327
|
+
this._drag = null;
|
|
328
|
+
this._pointer = null;
|
|
329
|
+
}
|
|
330
|
+
#onCardKeydown(event, columnId, cardId) {
|
|
331
|
+
const grabbing = this._grab?.cardId === cardId;
|
|
332
|
+
if (event.key === "Enter") {
|
|
333
|
+
if (grabbing)
|
|
334
|
+
return;
|
|
335
|
+
event.preventDefault();
|
|
336
|
+
this.#openDetail(cardId);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (event.key === " " || event.key === "Spacebar") {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
if (grabbing)
|
|
342
|
+
this.#commitGrab();
|
|
343
|
+
else
|
|
344
|
+
this.#startGrab(columnId, cardId);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!grabbing)
|
|
348
|
+
return;
|
|
349
|
+
switch (event.key) {
|
|
350
|
+
case "Escape":
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
this.#cancelGrab();
|
|
353
|
+
break;
|
|
354
|
+
case "ArrowUp":
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
this.#grabMove(0, -1);
|
|
357
|
+
break;
|
|
358
|
+
case "ArrowDown":
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
this.#grabMove(0, 1);
|
|
361
|
+
break;
|
|
362
|
+
case "ArrowLeft":
|
|
363
|
+
event.preventDefault();
|
|
364
|
+
this.#grabMove(-1, 0);
|
|
365
|
+
break;
|
|
366
|
+
case "ArrowRight":
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
this.#grabMove(1, 0);
|
|
369
|
+
break;
|
|
370
|
+
default:
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
#startGrab(columnId, cardId) {
|
|
375
|
+
const column = this._cols.find((entry) => entry.id === columnId);
|
|
376
|
+
const index = column ? column.cards.findIndex((card) => card.id === cardId) : 0;
|
|
377
|
+
this._grab = { cardId, fromColumnId: columnId, originIndex: index, columnId, index };
|
|
378
|
+
this.#refocus = cardId;
|
|
379
|
+
const ticket = column?.cards[index]?.ticket ?? "card";
|
|
380
|
+
this.#announce(`Picked up ${ticket}. Use the arrow keys to move it, space to drop, or escape to cancel.`);
|
|
381
|
+
}
|
|
382
|
+
#grabMove(columnDelta, indexDelta) {
|
|
383
|
+
const grab = this._grab;
|
|
384
|
+
if (!grab)
|
|
385
|
+
return;
|
|
386
|
+
let columnIndex = this._cols.findIndex((column) => column.id === grab.columnId);
|
|
387
|
+
if (columnIndex < 0)
|
|
388
|
+
return;
|
|
389
|
+
let index = grab.index;
|
|
390
|
+
if (columnDelta !== 0) {
|
|
391
|
+
const next = columnIndex + columnDelta;
|
|
392
|
+
if (next < 0 || next >= this._cols.length)
|
|
393
|
+
return;
|
|
394
|
+
columnIndex = next;
|
|
395
|
+
// With reordering off, a cross-column move lands at the end.
|
|
396
|
+
index = this.reorderable
|
|
397
|
+
? Math.min(index, this._cols[columnIndex].cards.length)
|
|
398
|
+
: this._cols[columnIndex].cards.length;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
if (!this.reorderable)
|
|
402
|
+
return; // no intra-column reordering
|
|
403
|
+
const max = this._cols[columnIndex].cards.length;
|
|
404
|
+
index = Math.max(0, Math.min(max, index + indexDelta));
|
|
405
|
+
}
|
|
406
|
+
const column = this._cols[columnIndex];
|
|
407
|
+
this._grab = { ...grab, columnId: column.id, index };
|
|
408
|
+
this.#refocus = grab.cardId;
|
|
409
|
+
const ticket = this.#findCard(grab.cardId)?.card.ticket ?? "Card";
|
|
410
|
+
this.#announce(`${ticket} moved to ${column.title}, position ${index + 1}.`);
|
|
411
|
+
}
|
|
412
|
+
#commitGrab() {
|
|
413
|
+
const grab = this._grab;
|
|
414
|
+
if (!grab)
|
|
415
|
+
return;
|
|
416
|
+
if (!this.reorderable && grab.columnId === grab.fromColumnId) {
|
|
417
|
+
// Nothing to persist for a same-column drop when reordering is off.
|
|
418
|
+
this._grab = null;
|
|
419
|
+
this.#refocus = grab.cardId;
|
|
420
|
+
this.#announce("Returned to its position.");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const dest = this.#toDestIndex(grab.fromColumnId, grab.originIndex, grab.columnId, grab.index);
|
|
424
|
+
const column = this._cols.find((entry) => entry.id === grab.columnId);
|
|
425
|
+
const ticket = this.#findCard(grab.cardId)?.card.ticket ?? "Card";
|
|
426
|
+
this._grab = null;
|
|
427
|
+
this.#move(grab.cardId, grab.fromColumnId, grab.columnId, dest);
|
|
428
|
+
this.#refocus = grab.cardId;
|
|
429
|
+
this.#announce(`${ticket} dropped in ${column?.title ?? ""}.`);
|
|
430
|
+
}
|
|
431
|
+
#cancelGrab() {
|
|
432
|
+
const grab = this._grab;
|
|
433
|
+
this._grab = null;
|
|
434
|
+
this.#refocus = grab?.cardId ?? null;
|
|
435
|
+
this.#announce("Move cancelled.");
|
|
436
|
+
}
|
|
437
|
+
#announce(message) {
|
|
438
|
+
this._liveMessage = message;
|
|
439
|
+
}
|
|
440
|
+
#openDetail(cardId) {
|
|
441
|
+
if (this._grab)
|
|
442
|
+
return;
|
|
443
|
+
this._openCardId = cardId;
|
|
444
|
+
this.dispatchEvent(new CustomEvent("card-open", {
|
|
445
|
+
detail: { cardId },
|
|
446
|
+
bubbles: true,
|
|
447
|
+
composed: true,
|
|
448
|
+
}));
|
|
449
|
+
}
|
|
450
|
+
#closeDetail() {
|
|
451
|
+
this._openCardId = null;
|
|
452
|
+
}
|
|
453
|
+
#onStateChange(cardId, fromColumnId, toColumnId) {
|
|
454
|
+
if (!toColumnId || toColumnId === fromColumnId)
|
|
455
|
+
return;
|
|
456
|
+
const dest = this._cols.find((column) => column.id === toColumnId);
|
|
457
|
+
this.#move(cardId, fromColumnId, toColumnId, dest ? dest.cards.length : 0);
|
|
458
|
+
}
|
|
459
|
+
#renderColumn(column, indicator, dragCardId) {
|
|
460
|
+
let indicatorIndex = -1;
|
|
461
|
+
if (indicator?.columnId === column.id) {
|
|
462
|
+
if (this.reorderable) {
|
|
463
|
+
indicatorIndex = indicator.index;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
// Reordering off: only show an append indicator on a *different* column.
|
|
467
|
+
const sourceColumnId = this._grab?.fromColumnId ?? this._drag?.fromColumnId ?? null;
|
|
468
|
+
if (sourceColumnId !== null && sourceColumnId !== column.id) {
|
|
469
|
+
indicatorIndex = column.cards.length;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return html `
|
|
474
|
+
<kanban-column
|
|
475
|
+
.heading=${column.title}
|
|
476
|
+
.count=${column.cards.length}
|
|
477
|
+
.empty=${column.cards.length === 0}
|
|
478
|
+
.dragover=${this._pointer?.columnId === column.id}
|
|
479
|
+
data-column-id=${column.id}
|
|
480
|
+
@dragenter=${(event) => this.#onDragOver(event, column.id)}
|
|
481
|
+
@dragover=${(event) => this.#onDragOver(event, column.id)}
|
|
482
|
+
@drop=${(event) => this.#onDrop(event, column.id)}
|
|
483
|
+
>
|
|
484
|
+
${repeat(column.cards, (card) => card.id, (card, i) => html `
|
|
485
|
+
${indicatorIndex === i
|
|
486
|
+
? html `<div class="drop-indicator" aria-hidden="true"></div>`
|
|
487
|
+
: nothing}
|
|
488
|
+
<kanban-card
|
|
489
|
+
.ticket=${card.ticket}
|
|
490
|
+
.heading=${card.title}
|
|
491
|
+
data-card-id=${card.id}
|
|
492
|
+
data-column-id=${column.id}
|
|
493
|
+
draggable="true"
|
|
494
|
+
?dragging=${dragCardId === card.id && this._drag !== null}
|
|
495
|
+
?grabbed=${this._grab?.cardId === card.id}
|
|
496
|
+
?just-moved=${this._highlighted.has(card.id)}
|
|
497
|
+
@dragstart=${(event) => this.#onDragStart(event, column.id, card.id)}
|
|
498
|
+
@click=${() => this.#openDetail(card.id)}
|
|
499
|
+
@keydown=${(event) => this.#onCardKeydown(event, column.id, card.id)}
|
|
500
|
+
></kanban-card>
|
|
501
|
+
`)}
|
|
502
|
+
${indicatorIndex === column.cards.length
|
|
503
|
+
? html `<div class="drop-indicator" aria-hidden="true"></div>`
|
|
504
|
+
: nothing}
|
|
505
|
+
</kanban-column>
|
|
506
|
+
`;
|
|
507
|
+
}
|
|
508
|
+
#renderDetail() {
|
|
509
|
+
const open = this._openCardId ? this.#findCard(this._openCardId) : null;
|
|
510
|
+
return html `
|
|
511
|
+
<popover-panel
|
|
512
|
+
centered
|
|
513
|
+
?open=${open !== null}
|
|
514
|
+
heading=${open?.card.title ?? ""}
|
|
515
|
+
@panel-close=${() => this.#closeDetail()}
|
|
516
|
+
>
|
|
517
|
+
${open
|
|
518
|
+
? html `
|
|
519
|
+
<div class="detail">
|
|
520
|
+
${open.card.description
|
|
521
|
+
? html `<p class="detail-desc">${open.card.description}</p>`
|
|
522
|
+
: html `<p class="detail-desc detail-muted">No description.</p>`}
|
|
523
|
+
<div class="detail-field">
|
|
524
|
+
<span class="detail-label" id="kb-state-label">State</span>
|
|
525
|
+
<radio-pills
|
|
526
|
+
aria-labelledby="kb-state-label"
|
|
527
|
+
.options=${this._cols.map((column) => ({
|
|
528
|
+
value: column.id,
|
|
529
|
+
label: column.title,
|
|
530
|
+
}))}
|
|
531
|
+
.value=${open.column.id}
|
|
532
|
+
@change=${(event) => this.#onStateChange(open.card.id, open.column.id, event.detail.value)}
|
|
533
|
+
></radio-pills>
|
|
534
|
+
</div>
|
|
535
|
+
<dl class="detail-meta">
|
|
536
|
+
<div class="detail-meta-row">
|
|
537
|
+
<dt><span aria-hidden="true">${iconTag(14)}</span> Ticket</dt>
|
|
538
|
+
<dd>${open.card.ticket}</dd>
|
|
539
|
+
</div>
|
|
540
|
+
${open.card.createdAt
|
|
541
|
+
? html `
|
|
542
|
+
<div class="detail-meta-row">
|
|
543
|
+
<dt><span aria-hidden="true">${iconCalendar(14)}</span> Created</dt>
|
|
544
|
+
<dd><relative-time datetime=${open.card.createdAt}></relative-time></dd>
|
|
545
|
+
</div>
|
|
546
|
+
`
|
|
547
|
+
: nothing}
|
|
548
|
+
${open.card.updatedAt
|
|
549
|
+
? html `
|
|
550
|
+
<div class="detail-meta-row">
|
|
551
|
+
<dt><span aria-hidden="true">${iconClock(14)}</span> Updated</dt>
|
|
552
|
+
<dd><relative-time datetime=${open.card.updatedAt}></relative-time></dd>
|
|
553
|
+
</div>
|
|
554
|
+
`
|
|
555
|
+
: nothing}
|
|
556
|
+
</dl>
|
|
557
|
+
</div>
|
|
558
|
+
`
|
|
559
|
+
: nothing}
|
|
560
|
+
</popover-panel>
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
render() {
|
|
564
|
+
const indicator = this._grab
|
|
565
|
+
? { columnId: this._grab.columnId, index: this._grab.index }
|
|
566
|
+
: this._pointer;
|
|
567
|
+
const dragCardId = this._grab?.cardId ?? this._drag?.cardId ?? null;
|
|
568
|
+
return html `
|
|
569
|
+
<div
|
|
570
|
+
class="board"
|
|
571
|
+
role="group"
|
|
572
|
+
aria-label=${this.label}
|
|
573
|
+
@dragend=${() => this.#onDragEnd()}
|
|
574
|
+
>
|
|
575
|
+
${repeat(this._cols, (column) => column.id, (column) => this.#renderColumn(column, indicator, dragCardId))}
|
|
576
|
+
</div>
|
|
577
|
+
<div class="sr-only" role="status" aria-live="polite">${this._liveMessage}</div>
|
|
578
|
+
${this.#renderDetail()}
|
|
579
|
+
`;
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
__decorate([
|
|
583
|
+
property({ attribute: false })
|
|
584
|
+
], KanbanBoard.prototype, "columns", void 0);
|
|
585
|
+
__decorate([
|
|
586
|
+
property()
|
|
587
|
+
], KanbanBoard.prototype, "label", void 0);
|
|
588
|
+
__decorate([
|
|
589
|
+
property({ type: Boolean, reflect: true })
|
|
590
|
+
], KanbanBoard.prototype, "manual", void 0);
|
|
591
|
+
__decorate([
|
|
592
|
+
property({ type: Boolean, reflect: true })
|
|
593
|
+
], KanbanBoard.prototype, "reorderable", void 0);
|
|
594
|
+
__decorate([
|
|
595
|
+
state()
|
|
596
|
+
], KanbanBoard.prototype, "_cols", void 0);
|
|
597
|
+
__decorate([
|
|
598
|
+
state()
|
|
599
|
+
], KanbanBoard.prototype, "_openCardId", void 0);
|
|
600
|
+
__decorate([
|
|
601
|
+
state()
|
|
602
|
+
], KanbanBoard.prototype, "_drag", void 0);
|
|
603
|
+
__decorate([
|
|
604
|
+
state()
|
|
605
|
+
], KanbanBoard.prototype, "_pointer", void 0);
|
|
606
|
+
__decorate([
|
|
607
|
+
state()
|
|
608
|
+
], KanbanBoard.prototype, "_grab", void 0);
|
|
609
|
+
__decorate([
|
|
610
|
+
state()
|
|
611
|
+
], KanbanBoard.prototype, "_liveMessage", void 0);
|
|
612
|
+
__decorate([
|
|
613
|
+
state()
|
|
614
|
+
], KanbanBoard.prototype, "_highlighted", void 0);
|
|
615
|
+
KanbanBoard = __decorate([
|
|
616
|
+
customElement("kanban-board")
|
|
617
|
+
], KanbanBoard);
|
|
618
|
+
export { KanbanBoard };
|
|
619
|
+
//# sourceMappingURL=kanban-board.js.map
|