@design.estate/dees-catalog 13.0.1 → 13.0.2
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/dist_bundle/bundle.js +638 -68
- package/dist_bundle/bundle.js.map +1 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.d.ts +3 -1
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.js +44 -29
- package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.d.ts +4 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.js +37 -8
- package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.d.ts +7 -2
- package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.js +77 -39
- package/dist_ts_web/elements/00group-kanban/kanban.dragcontroller.d.ts +67 -0
- package/dist_ts_web/elements/00group-kanban/kanban.dragcontroller.js +548 -0
- package/package.json +1 -1
- package/readme.md +11 -4
- package/scripts/check-packed-consumer.mjs +1 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.ts +30 -17
- package/ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.ts +12 -5
- package/ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.ts +37 -21
- package/ts_web/elements/00group-kanban/kanban.dragcontroller.ts +481 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import type { DeesKanban } from './dees-kanban/dees-kanban.js';
|
|
2
|
+
import { DeesKanbanCard } from './dees-kanban-card/dees-kanban-card.js';
|
|
3
|
+
import type { DeesKanbanColumn } from './dees-kanban-column/dees-kanban-column.js';
|
|
4
|
+
import type { IKanbanMove, IKanbanTicket } from './interfaces.js';
|
|
5
|
+
import { sortKanbanTickets } from './kanban.helpers.js';
|
|
6
|
+
import { getOverlayContainer } from '../00overlay.js';
|
|
7
|
+
import { zIndexRegistry } from '../00zindex.js';
|
|
8
|
+
|
|
9
|
+
export interface IKanbanMotion {
|
|
10
|
+
move: IKanbanMove;
|
|
11
|
+
ticket: IKanbanTicket;
|
|
12
|
+
height: number;
|
|
13
|
+
placeholder: boolean;
|
|
14
|
+
dimmed: boolean;
|
|
15
|
+
saving: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface IKanbanGesture {
|
|
19
|
+
pointerId: number;
|
|
20
|
+
source: DeesKanbanCard;
|
|
21
|
+
startX: number;
|
|
22
|
+
startY: number;
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
offsetX: number;
|
|
26
|
+
offsetY: number;
|
|
27
|
+
width: number;
|
|
28
|
+
expanded: boolean;
|
|
29
|
+
restoreFocus: boolean;
|
|
30
|
+
phase: 'armed' | 'dragging' | 'landing' | 'pending' | 'settling';
|
|
31
|
+
motion: IKanbanMotion;
|
|
32
|
+
overlay?: DeesKanbanCard;
|
|
33
|
+
settled?: boolean;
|
|
34
|
+
flight?: Animation;
|
|
35
|
+
elevation?: Animation;
|
|
36
|
+
flightRevision: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sameMove = (a: IKanbanMove, b: IKanbanMove): boolean =>
|
|
40
|
+
a.ticketId === b.ticketId && a.columnId === b.columnId && a.beforeTicketId === b.beforeTicketId;
|
|
41
|
+
|
|
42
|
+
/** Owns pointer motion and a render-only placement. The board/provider still owns saved data. */
|
|
43
|
+
export class KanbanDragController {
|
|
44
|
+
private gesture?: IKanbanGesture;
|
|
45
|
+
private listeners?: AbortController;
|
|
46
|
+
private frame = 0;
|
|
47
|
+
private previousFrame = 0;
|
|
48
|
+
private rendering = false;
|
|
49
|
+
private scrollParents: HTMLElement[] = [];
|
|
50
|
+
private scrollSnap = new Map<HTMLElement, string>();
|
|
51
|
+
private animations = new Map<HTMLElement, Animation>();
|
|
52
|
+
private timers = new Set<ReturnType<typeof setTimeout>>();
|
|
53
|
+
private suppressClick?: AbortController;
|
|
54
|
+
private clickTimer?: ReturnType<typeof setTimeout>;
|
|
55
|
+
private resizeObserver?: ResizeObserver;
|
|
56
|
+
private readonly reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
57
|
+
|
|
58
|
+
constructor(private readonly board: DeesKanban, private readonly commit: (move: IKanbanMove) => Promise<boolean>, private readonly canMove: () => boolean) {}
|
|
59
|
+
|
|
60
|
+
public get busy(): boolean { return Boolean(this.gesture && this.gesture.phase !== 'armed'); }
|
|
61
|
+
private easing(name: 'out' | 'spring'): string { return getComputedStyle(this.board).getPropertyValue(`--dees-ease-${name}`).trim(); }
|
|
62
|
+
private get columns(): DeesKanbanColumn[] { return [...this.board.shadowRoot?.querySelectorAll('dees-kanban-column') ?? []]; }
|
|
63
|
+
|
|
64
|
+
public connect(): void { this.board.addEventListener('pointerdown', this.down); }
|
|
65
|
+
|
|
66
|
+
/** Host updates can remove/filter the source or revoke its move permission during a gesture. */
|
|
67
|
+
public sync(): void {
|
|
68
|
+
const gesture = this.gesture;
|
|
69
|
+
if (!gesture || gesture.phase === 'armed') return;
|
|
70
|
+
const visible = this.columns.some(column => column.tickets.some(ticket => ticket.id === gesture.motion.ticket.id));
|
|
71
|
+
const ticket = this.board.tickets.find(item => item.id === gesture.motion.ticket.id);
|
|
72
|
+
if (!visible || !this.columns.some(column => column.column?.id === gesture.motion.move.columnId)) {
|
|
73
|
+
this.fadeAndClear(gesture);
|
|
74
|
+
} else if (gesture.phase === 'dragging' && (!this.board.provider.moveTicket || ticket?.permissions?.move === false)) {
|
|
75
|
+
void this.cancel();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private down = (event: PointerEvent): void => {
|
|
80
|
+
if (this.gesture || !this.canMove() || event.button !== 0 || !event.isPrimary || event.pointerType === 'touch') return;
|
|
81
|
+
const path = event.composedPath();
|
|
82
|
+
if (!this.columns.some(column => path.includes(column))) return;
|
|
83
|
+
const source = path.find(node => node instanceof DeesKanbanCard) as DeesKanbanCard | undefined;
|
|
84
|
+
if (!source?.ticket || !source.movable || source.moveDisabled || source.pending) return;
|
|
85
|
+
// Title/body can drag; every other nested control keeps its normal action.
|
|
86
|
+
if (path.some(node => node instanceof Element && node !== source &&
|
|
87
|
+
node.matches('a, input, textarea, select, summary, dees-button, button:not(.title), [contenteditable=true]'))) return;
|
|
88
|
+
const rect = source.getBoundingClientRect();
|
|
89
|
+
this.gesture = {
|
|
90
|
+
pointerId: event.pointerId, source, startX: event.clientX, startY: event.clientY,
|
|
91
|
+
x: event.clientX, y: event.clientY, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top,
|
|
92
|
+
width: rect.width, expanded: Boolean(source.shadowRoot?.querySelector('details')?.open), restoreFocus: false,
|
|
93
|
+
phase: 'armed', flightRevision: 0,
|
|
94
|
+
motion: { ticket: source.ticket, move: this.authoritativeMove(source.ticket.id)!, height: rect.height, placeholder: true, dimmed: false, saving: false },
|
|
95
|
+
};
|
|
96
|
+
const signal = (this.listeners = new AbortController()).signal;
|
|
97
|
+
window.addEventListener('pointermove', this.move, { signal, passive: false });
|
|
98
|
+
window.addEventListener('pointerup', this.up, { signal });
|
|
99
|
+
window.addEventListener('pointercancel', this.pointerCancel, { signal });
|
|
100
|
+
this.board.addEventListener('lostpointercapture', this.pointerCancel, { signal });
|
|
101
|
+
window.addEventListener('keydown', this.key, { signal, capture: true });
|
|
102
|
+
window.addEventListener('blur', this.blur, { signal });
|
|
103
|
+
window.addEventListener('resize', this.blur, { signal });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
private move = (event: PointerEvent): void => {
|
|
107
|
+
const gesture = this.gesture;
|
|
108
|
+
if (!gesture || event.pointerId !== gesture.pointerId || !['armed', 'dragging'].includes(gesture.phase)) return;
|
|
109
|
+
gesture.x = event.clientX; gesture.y = event.clientY;
|
|
110
|
+
if (gesture.phase === 'armed') {
|
|
111
|
+
if (Math.hypot(gesture.x - gesture.startX, gesture.y - gesture.startY) < 4) return;
|
|
112
|
+
void this.lift(gesture);
|
|
113
|
+
}
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
window.getSelection()?.removeAllRanges();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
private async lift(gesture: IKanbanGesture): Promise<void> {
|
|
119
|
+
gesture.phase = 'dragging';
|
|
120
|
+
gesture.restoreFocus = Boolean(gesture.source.shadowRoot?.activeElement);
|
|
121
|
+
// Capture on the stable board; the source card is replaced by its slot.
|
|
122
|
+
this.board.setPointerCapture(gesture.pointerId);
|
|
123
|
+
const overlay = gesture.overlay = new DeesKanbanCard();
|
|
124
|
+
overlay.dataset.kanbanPreview = '';
|
|
125
|
+
overlay.ticket = gesture.source.ticket; overlay.epic = gesture.source.epic;
|
|
126
|
+
overlay.subtasks = gesture.source.subtasks; overlay.columns = gesture.source.columns;
|
|
127
|
+
overlay.movable = gesture.source.movable;
|
|
128
|
+
overlay.pointerManaged = true;
|
|
129
|
+
overlay.inert = true; overlay.setAttribute('aria-hidden', 'true');
|
|
130
|
+
const styles = getComputedStyle(gesture.source);
|
|
131
|
+
for (const name of styles) if (name.startsWith('--dees-')) overlay.style.setProperty(name, styles.getPropertyValue(name));
|
|
132
|
+
const zIndex = zIndexRegistry.getNextZIndex();
|
|
133
|
+
zIndexRegistry.register(overlay, zIndex);
|
|
134
|
+
Object.assign(overlay.style, { position: 'fixed', margin: '0', pointerEvents: 'none', width: `${gesture.width}px`, zIndex: String(zIndex), transformOrigin: 'center' });
|
|
135
|
+
this.positionPreview(gesture);
|
|
136
|
+
getOverlayContainer(this.board).append(overlay);
|
|
137
|
+
this.collectScrollParents();
|
|
138
|
+
const widths = new Map(this.columns.map(column => [column, column.getBoundingClientRect().width]));
|
|
139
|
+
this.resizeObserver = new ResizeObserver(entries => {
|
|
140
|
+
if (entries.some(entry => Math.abs(entry.borderBoxSize[0].inlineSize - widths.get(entry.target as DeesKanbanColumn)!) > .5)) void this.cancel();
|
|
141
|
+
});
|
|
142
|
+
for (const column of this.columns) this.resizeObserver.observe(column);
|
|
143
|
+
await overlay.updateComplete;
|
|
144
|
+
if (this.gesture !== gesture || gesture.phase !== 'dragging') return;
|
|
145
|
+
const details = overlay.shadowRoot?.querySelector('details');
|
|
146
|
+
if (details) details.open = gesture.expanded;
|
|
147
|
+
const article = overlay.shadowRoot!.querySelector('article')!;
|
|
148
|
+
const resting = { boxShadow: getComputedStyle(article).boxShadow, borderColor: getComputedStyle(article).borderColor };
|
|
149
|
+
article.style.borderColor = 'var(--dees-color-border-strong)';
|
|
150
|
+
article.style.boxShadow = 'var(--dees-shadow-lg)';
|
|
151
|
+
if (!this.reducedMotion.matches) {
|
|
152
|
+
overlay.style.transform = 'scale(1.02)';
|
|
153
|
+
overlay.animate([{ transform: 'scale(1)' }, { transform: 'scale(1.02)' }], { duration: 120, easing: this.easing('out') });
|
|
154
|
+
gesture.elevation = article.animate([resting, { boxShadow: getComputedStyle(article).boxShadow, borderColor: getComputedStyle(article).borderColor }], { duration: 120, easing: this.easing('out') });
|
|
155
|
+
}
|
|
156
|
+
await this.renderMotion();
|
|
157
|
+
if (this.gesture === gesture && gesture.phase === 'dragging') this.frame = requestAnimationFrame(this.tick);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private positionPreview(gesture: IKanbanGesture): void {
|
|
161
|
+
if (!gesture.overlay) return;
|
|
162
|
+
gesture.overlay.style.left = `${gesture.x - gesture.offsetX}px`;
|
|
163
|
+
gesture.overlay.style.top = `${gesture.y - gesture.offsetY}px`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private tick = (time: number): void => {
|
|
167
|
+
const gesture = this.gesture;
|
|
168
|
+
if (!gesture || gesture.phase !== 'dragging') return;
|
|
169
|
+
this.positionPreview(gesture);
|
|
170
|
+
this.autoScroll(gesture, Math.min(2, (time - (this.previousFrame || time - 16.7)) / 16.7));
|
|
171
|
+
this.previousFrame = time;
|
|
172
|
+
if (!this.rendering) {
|
|
173
|
+
const target = this.targetAt(gesture) ?? this.authoritativeMove(gesture.motion.ticket.id);
|
|
174
|
+
if (target && !sameMove(target, gesture.motion.move)) {
|
|
175
|
+
gesture.motion = { ...gesture.motion, move: target };
|
|
176
|
+
void this.renderMotion();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
this.frame = requestAnimationFrame(this.tick);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
private targetAt(gesture: IKanbanGesture): IKanbanMove | undefined {
|
|
183
|
+
const clip = this.visibleRect();
|
|
184
|
+
if (gesture.x < clip.left - 48 || gesture.x > clip.right + 48 || gesture.y < clip.top - 48 || gesture.y > clip.bottom + 48) return;
|
|
185
|
+
const centerX = gesture.x - gesture.offsetX + gesture.width / 2;
|
|
186
|
+
const centerY = gesture.y - gesture.offsetY + gesture.motion.height / 2;
|
|
187
|
+
const lanes = this.columns.filter(column => {
|
|
188
|
+
const rect = column.getBoundingClientRect();
|
|
189
|
+
return rect.right > clip.left && rect.left < clip.right;
|
|
190
|
+
});
|
|
191
|
+
let column = lanes.sort((a, b) => {
|
|
192
|
+
const ar = a.getBoundingClientRect(), br = b.getBoundingClientRect();
|
|
193
|
+
return Math.abs(centerX - (ar.left + ar.width / 2)) - Math.abs(centerX - (br.left + br.width / 2));
|
|
194
|
+
})[0];
|
|
195
|
+
if (!column?.column) return;
|
|
196
|
+
const currentLane = lanes.find(item => item.column?.id === gesture.motion.move.columnId);
|
|
197
|
+
if (currentLane && currentLane !== column) {
|
|
198
|
+
const currentRect = currentLane.getBoundingClientRect(), candidateRect = column.getBoundingClientRect();
|
|
199
|
+
const currentCenter = currentRect.left + currentRect.width / 2, candidateCenter = candidateRect.left + candidateRect.width / 2;
|
|
200
|
+
if (Math.abs(centerX - currentCenter) < Math.abs(candidateCenter - currentCenter) / 2 + 12) column = currentLane;
|
|
201
|
+
}
|
|
202
|
+
const columnId = column.column?.id;
|
|
203
|
+
if (!columnId) return;
|
|
204
|
+
const tickets = sortKanbanTickets(column.tickets).filter(ticket => ticket.id !== gesture.motion.ticket.id);
|
|
205
|
+
// Virtual positions exclude the slot and FLIP transforms, so opening a gap cannot change the hit test.
|
|
206
|
+
let top = column.shadowRoot!.querySelector('.stack')!.getBoundingClientRect().top;
|
|
207
|
+
const currentIndex = gesture.motion.move.columnId === columnId
|
|
208
|
+
? tickets.findIndex(ticket => ticket.id === gesture.motion.move.beforeTicketId) : -1;
|
|
209
|
+
const current = currentIndex < 0 ? tickets.length : currentIndex;
|
|
210
|
+
let index = tickets.length;
|
|
211
|
+
for (let i = 0; i < tickets.length; i++) {
|
|
212
|
+
const node = this.targets(column).find(target => target.dataset.ticketId === tickets[i].id)!;
|
|
213
|
+
const midpoint = top + node.offsetHeight / 2;
|
|
214
|
+
const hysteresis = gesture.motion.move.columnId === columnId ? (i < current ? -6 : 6) : 0;
|
|
215
|
+
if (centerY < midpoint + hysteresis) { index = i; break; }
|
|
216
|
+
top += node.offsetHeight + 8;
|
|
217
|
+
}
|
|
218
|
+
return { ticketId: gesture.motion.ticket.id, columnId, beforeTicketId: tickets[index]?.id };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private up = (event: PointerEvent): void => {
|
|
222
|
+
const gesture = this.gesture;
|
|
223
|
+
if (!gesture || gesture.pointerId !== event.pointerId || !['armed', 'dragging'].includes(gesture.phase)) return;
|
|
224
|
+
if (gesture.phase === 'armed') { this.clear(); return; }
|
|
225
|
+
gesture.x = event.clientX; gesture.y = event.clientY;
|
|
226
|
+
this.blockClick();
|
|
227
|
+
const target = this.targetAt(gesture);
|
|
228
|
+
if (!target) { void this.cancel(); return; }
|
|
229
|
+
gesture.phase = 'landing';
|
|
230
|
+
this.endPointer();
|
|
231
|
+
gesture.motion = { ...gesture.motion, move: target };
|
|
232
|
+
void this.land(gesture);
|
|
233
|
+
if (sameMove(target, this.authoritativeMove(target.ticketId)!)) {
|
|
234
|
+
gesture.settled = true;
|
|
235
|
+
} else {
|
|
236
|
+
// Persistence and landing start together. Only commit() can update board.tickets.
|
|
237
|
+
void this.commit(target).then(saved => this.settle(gesture, saved));
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
private pointerCancel = (event: PointerEvent): void => {
|
|
242
|
+
if (event.pointerId === this.gesture?.pointerId && ['armed', 'dragging'].includes(this.gesture.phase)) void this.cancel();
|
|
243
|
+
};
|
|
244
|
+
private key = (event: KeyboardEvent): void => {
|
|
245
|
+
if (event.key !== 'Escape') return;
|
|
246
|
+
event.preventDefault(); event.stopImmediatePropagation(); void this.cancel();
|
|
247
|
+
};
|
|
248
|
+
private blur = (): void => { void this.cancel(); };
|
|
249
|
+
|
|
250
|
+
private async cancel(): Promise<void> {
|
|
251
|
+
const gesture = this.gesture;
|
|
252
|
+
if (!gesture || !['armed', 'dragging'].includes(gesture.phase)) return;
|
|
253
|
+
if (gesture.phase === 'armed') { this.clear(); return; }
|
|
254
|
+
this.blockClick();
|
|
255
|
+
gesture.phase = 'landing'; gesture.settled = false;
|
|
256
|
+
this.endPointer();
|
|
257
|
+
const origin = this.authoritativeMove(gesture.motion.ticket.id);
|
|
258
|
+
if (!origin) { this.fadeAndClear(gesture); return; }
|
|
259
|
+
gesture.motion = { ...gesture.motion, move: origin };
|
|
260
|
+
const column = this.columns.find(item => item.column?.id === origin.columnId);
|
|
261
|
+
if (gesture.overlay && column) {
|
|
262
|
+
gesture.overlay.style.width = `${column.getBoundingClientRect().width}px`;
|
|
263
|
+
gesture.motion.height = gesture.overlay.offsetHeight;
|
|
264
|
+
}
|
|
265
|
+
await this.land(gesture);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private authoritativeMove(ticketId: string): IKanbanMove | undefined {
|
|
269
|
+
const ticket = this.board.tickets.find(item => item.id === ticketId);
|
|
270
|
+
if (!ticket) return;
|
|
271
|
+
const tickets = sortKanbanTickets(this.columns.find(column => column.column?.id === ticket.columnId)?.tickets ?? []);
|
|
272
|
+
return { ticketId, columnId: ticket.columnId, beforeTicketId: tickets[tickets.findIndex(item => item.id === ticketId) + 1]?.id };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async land(gesture: IKanbanGesture): Promise<void> {
|
|
276
|
+
const revision = ++gesture.flightRevision;
|
|
277
|
+
await this.renderMotion();
|
|
278
|
+
if (this.gesture !== gesture || gesture.flightRevision !== revision) return;
|
|
279
|
+
const target = this.allTargets().find(node => node.dataset.ticketId === gesture.motion.ticket.id);
|
|
280
|
+
const overlay = gesture.overlay;
|
|
281
|
+
if (!target || !overlay) { this.clear(); return; }
|
|
282
|
+
const from = overlay.getBoundingClientRect(), to = target.getBoundingClientRect();
|
|
283
|
+
gesture.flight?.cancel();
|
|
284
|
+
for (const animation of overlay.getAnimations()) animation.cancel();
|
|
285
|
+
Object.assign(overlay.style, { left: `${to.left}px`, top: `${to.top}px`, width: `${to.width}px`, transform: 'none' });
|
|
286
|
+
const article = overlay.shadowRoot?.querySelector('article');
|
|
287
|
+
const raised = article ? { boxShadow: getComputedStyle(article).boxShadow, borderColor: getComputedStyle(article).borderColor } : undefined;
|
|
288
|
+
gesture.elevation?.cancel();
|
|
289
|
+
if (article) { article.style.boxShadow = ''; article.style.borderColor = ''; }
|
|
290
|
+
if (!this.reducedMotion.matches) {
|
|
291
|
+
const duration = Math.min(280, Math.max(160, 120 + .25 * Math.hypot(to.left - from.left, to.top - from.top)));
|
|
292
|
+
if (article && raised) gesture.elevation = article.animate([raised, { boxShadow: getComputedStyle(article).boxShadow, borderColor: getComputedStyle(article).borderColor }], { duration, easing: this.easing('spring') });
|
|
293
|
+
const flight = gesture.flight = overlay.animate([
|
|
294
|
+
{ transform: `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${from.width / to.width}, ${from.height / to.height})`, transformOrigin: 'top left' },
|
|
295
|
+
{ transform: 'none', transformOrigin: 'top left' },
|
|
296
|
+
], { duration, easing: this.easing('spring') });
|
|
297
|
+
const completed = await flight.finished.then(() => true, () => false);
|
|
298
|
+
if (!completed) return;
|
|
299
|
+
}
|
|
300
|
+
if (this.gesture !== gesture || gesture.flightRevision !== revision) return;
|
|
301
|
+
if (gesture.settled !== undefined) { await this.clearMotion(gesture); return; }
|
|
302
|
+
gesture.phase = 'pending';
|
|
303
|
+
gesture.motion = { ...gesture.motion, placeholder: false };
|
|
304
|
+
await this.renderMotion();
|
|
305
|
+
if (this.gesture !== gesture) return;
|
|
306
|
+
this.removeOverlay(gesture);
|
|
307
|
+
this.delay(() => { if (this.gesture === gesture && gesture.phase === 'pending') { gesture.motion = { ...gesture.motion, dimmed: true }; void this.renderMotion(); } }, 180);
|
|
308
|
+
this.delay(() => { if (this.gesture === gesture && gesture.phase === 'pending') { gesture.motion = { ...gesture.motion, saving: true }; void this.renderMotion(); } }, 600);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private async settle(gesture: IKanbanGesture, saved: boolean): Promise<void> {
|
|
312
|
+
if (this.gesture !== gesture) return;
|
|
313
|
+
gesture.settled = saved;
|
|
314
|
+
await this.board.updateComplete;
|
|
315
|
+
if (this.gesture !== gesture) return;
|
|
316
|
+
const move = this.authoritativeMove(gesture.motion.ticket.id);
|
|
317
|
+
if (!move) { this.fadeAndClear(gesture); return; }
|
|
318
|
+
const unchanged = sameMove(move, gesture.motion.move);
|
|
319
|
+
gesture.motion = { ...gesture.motion, move, ticket: this.board.tickets.find(ticket => ticket.id === move.ticketId)!, dimmed: false, saving: false };
|
|
320
|
+
if (gesture.phase === 'landing') { if (!unchanged) await this.land(gesture); }
|
|
321
|
+
else { gesture.phase = 'settling'; await this.clearMotion(gesture, saved ? 200 : 240); }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private targets(column: DeesKanbanColumn): HTMLElement[] { return [...column.shadowRoot?.querySelectorAll<HTMLElement>('.target') ?? []]; }
|
|
325
|
+
private allTargets(): HTMLElement[] { return this.columns.flatMap(column => this.targets(column)); }
|
|
326
|
+
public capture(): Map<string, DOMRect> { return new Map(this.allTargets().map(node => [node.dataset.ticketId!, node.getBoundingClientRect()])); }
|
|
327
|
+
|
|
328
|
+
public async animateFrom(before: Map<string, DOMRect>, duration = 200): Promise<void> {
|
|
329
|
+
await Promise.all(this.columns.map(column => column.updateComplete));
|
|
330
|
+
for (const animation of this.animations.values()) animation.cancel();
|
|
331
|
+
this.animations.clear();
|
|
332
|
+
if (this.reducedMotion.matches || !this.board.isConnected) return;
|
|
333
|
+
const easing = this.easing('spring');
|
|
334
|
+
for (const node of this.allTargets()) {
|
|
335
|
+
if (node.querySelector('.placeholder')) continue;
|
|
336
|
+
const old = before.get(node.dataset.ticketId!), rect = node.getBoundingClientRect();
|
|
337
|
+
if (!old || Math.abs(old.x - rect.x) + Math.abs(old.y - rect.y) < .5) continue;
|
|
338
|
+
const animation = node.animate([{ transform: `translate(${old.x - rect.x}px, ${old.y - rect.y}px)` }, { transform: 'none' }], { duration, easing });
|
|
339
|
+
this.animations.set(node, animation);
|
|
340
|
+
void animation.finished.then(() => { if (this.animations.get(node) === animation) this.animations.delete(node); }, () => {});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async renderMotion(): Promise<void> {
|
|
345
|
+
this.rendering = true;
|
|
346
|
+
const before = this.capture();
|
|
347
|
+
for (const column of this.columns) column.motion = this.gesture?.motion;
|
|
348
|
+
await Promise.all(this.columns.map(column => column.updateComplete));
|
|
349
|
+
const gesture = this.gesture;
|
|
350
|
+
if (gesture && !gesture.motion.placeholder) await this.restoreExpansion(gesture);
|
|
351
|
+
await this.animateFrom(before);
|
|
352
|
+
this.rendering = false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private async restoreExpansion(gesture: IKanbanGesture): Promise<void> {
|
|
356
|
+
const card = this.allTargets().find(node => node.dataset.ticketId === gesture.motion.ticket.id)?.querySelector('dees-kanban-card');
|
|
357
|
+
if (!card) return;
|
|
358
|
+
await card.updateComplete;
|
|
359
|
+
const details = card.shadowRoot?.querySelector('details');
|
|
360
|
+
if (details) details.open = gesture.expanded;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async clearMotion(gesture: IKanbanGesture, duration = 200): Promise<void> {
|
|
364
|
+
const before = this.capture();
|
|
365
|
+
for (const column of this.columns) column.motion = undefined;
|
|
366
|
+
await Promise.all(this.columns.map(column => column.updateComplete));
|
|
367
|
+
if (this.gesture !== gesture) return;
|
|
368
|
+
await this.restoreExpansion(gesture);
|
|
369
|
+
this.removeOverlay(gesture);
|
|
370
|
+
await this.animateFrom(before, duration);
|
|
371
|
+
await Promise.all([...this.animations.values()].map(animation => animation.finished.catch(() => {})));
|
|
372
|
+
if (this.gesture === gesture) this.clear();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private collectScrollParents(): void {
|
|
376
|
+
this.scrollParents = [];
|
|
377
|
+
let node: Node | null = this.board.shadowRoot!.querySelector('.columns');
|
|
378
|
+
while (node) {
|
|
379
|
+
if (node instanceof HTMLElement) {
|
|
380
|
+
const style = getComputedStyle(node);
|
|
381
|
+
if (/(auto|scroll)/.test(style.overflowX + style.overflowY) || node === document.scrollingElement) {
|
|
382
|
+
this.scrollParents.push(node);
|
|
383
|
+
this.scrollSnap.set(node, node.style.scrollSnapType);
|
|
384
|
+
node.style.scrollSnapType = 'none';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
node = (node instanceof Element && node.assignedSlot) || node.parentNode || (node instanceof ShadowRoot ? node.host : null);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private visibleRect(): { left: number; right: number; top: number; bottom: number } {
|
|
392
|
+
const board = this.board.shadowRoot!.querySelector('.columns')!.getBoundingClientRect();
|
|
393
|
+
const clip = { left: Math.max(0, board.left), right: Math.min(innerWidth, board.right), top: Math.max(0, board.top), bottom: Math.min(innerHeight, board.bottom) };
|
|
394
|
+
for (const parent of this.scrollParents) {
|
|
395
|
+
if (parent === document.scrollingElement) continue;
|
|
396
|
+
const rect = parent.getBoundingClientRect();
|
|
397
|
+
clip.left = Math.max(clip.left, rect.left); clip.right = Math.min(clip.right, rect.right);
|
|
398
|
+
clip.top = Math.max(clip.top, rect.top); clip.bottom = Math.min(clip.bottom, rect.bottom);
|
|
399
|
+
}
|
|
400
|
+
return clip;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private autoScroll(gesture: IKanbanGesture, elapsed: number): void {
|
|
404
|
+
const velocity = (point: number, start: number, end: number, edge: number) =>
|
|
405
|
+
point < start + edge ? -Math.min(1, Math.max(0, (start + edge - point) / edge)) * 14 * elapsed :
|
|
406
|
+
point > end - edge ? Math.min(1, Math.max(0, (point - end + edge) / edge)) * 14 * elapsed : 0;
|
|
407
|
+
let movedX = false, movedY = false;
|
|
408
|
+
for (const parent of this.scrollParents) {
|
|
409
|
+
const rect = parent === document.scrollingElement ? { left: 0, top: 0, right: innerWidth, bottom: innerHeight } : parent.getBoundingClientRect();
|
|
410
|
+
const left = Math.max(0, rect.left), top = Math.max(0, rect.top), right = Math.min(innerWidth, rect.right), bottom = Math.min(innerHeight, rect.bottom);
|
|
411
|
+
if (gesture.x < left - 48 || gesture.x > right + 48 || gesture.y < top - 56 || gesture.y > bottom + 56) continue;
|
|
412
|
+
const x = parent.scrollLeft, y = parent.scrollTop;
|
|
413
|
+
if (!movedX) parent.scrollLeft += velocity(gesture.x, left, right, 48);
|
|
414
|
+
if (!movedY) parent.scrollTop += velocity(gesture.y, top, bottom, 56);
|
|
415
|
+
movedX ||= parent.scrollLeft !== x; movedY ||= parent.scrollTop !== y;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private endPointer(): void {
|
|
420
|
+
this.listeners?.abort(); this.listeners = undefined;
|
|
421
|
+
cancelAnimationFrame(this.frame); this.frame = 0; this.previousFrame = 0;
|
|
422
|
+
if (this.gesture && this.board.hasPointerCapture(this.gesture.pointerId)) this.board.releasePointerCapture(this.gesture.pointerId);
|
|
423
|
+
this.resizeObserver?.disconnect(); this.resizeObserver = undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private blockClick(): void {
|
|
427
|
+
this.suppressClick?.abort();
|
|
428
|
+
const controller = this.suppressClick = new AbortController();
|
|
429
|
+
window.addEventListener('click', event => { event.preventDefault(); event.stopImmediatePropagation(); controller.abort(); }, { capture: true, once: true, signal: controller.signal });
|
|
430
|
+
// Pointerup's click is synchronous; do not swallow a later intentional click.
|
|
431
|
+
clearTimeout(this.clickTimer);
|
|
432
|
+
this.clickTimer = setTimeout(() => controller.abort(), 0);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private delay(callback: () => void, duration: number): void {
|
|
436
|
+
const timer = setTimeout(() => { this.timers.delete(timer); callback(); }, duration);
|
|
437
|
+
this.timers.add(timer);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private removeOverlay(gesture: IKanbanGesture): void {
|
|
441
|
+
if (!gesture.overlay) return;
|
|
442
|
+
gesture.elevation?.cancel();
|
|
443
|
+
zIndexRegistry.unregister(gesture.overlay); gesture.overlay.remove(); gesture.overlay = undefined;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private fadeAndClear(gesture: IKanbanGesture): void {
|
|
447
|
+
if (gesture.phase === 'settling') return;
|
|
448
|
+
gesture.phase = 'settling'; this.endPointer();
|
|
449
|
+
const element = gesture.overlay ?? this.allTargets().find(node => node.dataset.ticketId === gesture.motion.ticket.id);
|
|
450
|
+
if (!element || this.reducedMotion.matches) { this.clear(); return; }
|
|
451
|
+
const transform = getComputedStyle(element).transform;
|
|
452
|
+
gesture.flight?.cancel(); element.style.transform = transform;
|
|
453
|
+
const fade = gesture.flight = element.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, fill: 'forwards' });
|
|
454
|
+
void fade.finished.then(() => { if (this.gesture === gesture) void this.clearMotion(gesture); }, () => {});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private clear(): void {
|
|
458
|
+
const gesture = this.gesture;
|
|
459
|
+
this.endPointer(); this.gesture = undefined;
|
|
460
|
+
for (const [parent, value] of this.scrollSnap) parent.style.scrollSnapType = value;
|
|
461
|
+
this.scrollSnap.clear(); this.scrollParents = [];
|
|
462
|
+
for (const timer of this.timers) clearTimeout(timer);
|
|
463
|
+
this.timers.clear();
|
|
464
|
+
for (const animation of this.animations.values()) animation.cancel();
|
|
465
|
+
this.animations.clear();
|
|
466
|
+
for (const column of this.columns) column.motion = undefined;
|
|
467
|
+
if (!gesture) return;
|
|
468
|
+
gesture.flight?.cancel(); this.removeOverlay(gesture);
|
|
469
|
+
let focused = document.activeElement;
|
|
470
|
+
while (focused?.shadowRoot?.activeElement) focused = focused.shadowRoot.activeElement;
|
|
471
|
+
if (gesture.restoreFocus && this.board.isConnected && (focused === document.body || focused === this.board)) {
|
|
472
|
+
const card = this.allTargets().find(node => node.dataset.ticketId === gesture.motion.ticket.id)?.querySelector('dees-kanban-card');
|
|
473
|
+
(card?.shadowRoot?.querySelector('.title') as HTMLButtonElement | null)?.focus({ preventScroll: true });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
public disconnect(): void {
|
|
478
|
+
this.board.removeEventListener('pointerdown', this.down);
|
|
479
|
+
this.suppressClick?.abort(); clearTimeout(this.clickTimer); this.clear();
|
|
480
|
+
}
|
|
481
|
+
}
|