@design.estate/dees-catalog 4.10.0 → 4.11.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.
@@ -22,6 +22,8 @@ import { harnessFormatRelativeTime } from '../harness.helpers.js';
22
22
  import '../../00group-utility/dees-icon/dees-icon.js';
23
23
 
24
24
  const { repeat } = directives;
25
+ const dragAutoScrollMaxPixelsPerSecond = 900;
26
+ const dragThresholdPixels = 4;
25
27
 
26
28
  declare global {
27
29
  interface HTMLElementTagNameMap {
@@ -34,6 +36,25 @@ interface IDropSlot {
34
36
  index: number;
35
37
  }
36
38
 
39
+ interface IPointerDragGesture {
40
+ pointerId: number;
41
+ handle: HTMLElement;
42
+ source: HTMLElement;
43
+ sessionId: string;
44
+ sourceGroupId: string | null;
45
+ sourceLogicalIndex: number;
46
+ startClientX: number;
47
+ startClientY: number;
48
+ grabOffsetX: number;
49
+ grabOffsetY: number;
50
+ phase: 'pending' | 'active';
51
+ }
52
+
53
+ interface IKeyboardMoveFocus {
54
+ sessionId: string;
55
+ groupId: string | null;
56
+ }
57
+
37
58
  type TSectionRenderEntry =
38
59
  | { type: 'session'; key: string; session: IHarnessSessionMeta; logicalIndex: number }
39
60
  | { type: 'placeholder'; key: string };
@@ -98,9 +119,27 @@ export class DeesHarnessSessionList extends DeesElement {
98
119
  @state()
99
120
  accessor collapsedGroupIds: ReadonlySet<string> = new Set<string>();
100
121
 
122
+ @state()
123
+ private accessor topScrollShadowVisible = false;
124
+
125
+ @state()
126
+ private accessor bottomScrollShadowVisible = false;
127
+
128
+ @state()
129
+ private accessor moveAnnouncement = '';
130
+
101
131
  private reflowGeneration = 0;
102
132
  private readonly reflowAnimations = new Set<Animation>();
103
- private dragSourceElement: HTMLElement | undefined;
133
+ private pointerDragGesture: IPointerDragGesture | undefined;
134
+ private keyboardMoveFocus: IKeyboardMoveFocus | undefined;
135
+ private scrollResizeObserver: ResizeObserver | undefined;
136
+ private observedScrollContainer: HTMLElement | undefined;
137
+ private observedScrollContent: HTMLElement | undefined;
138
+ private dragAutoScrollFrame: number | undefined;
139
+ private dragAutoScrollClientX = 0;
140
+ private dragAutoScrollClientY = 0;
141
+ private dragAutoScrollVelocity = 0;
142
+ private dragAutoScrollLastFrame = 0;
104
143
 
105
144
  public static styles = [
106
145
  themeDefaultStyles,
@@ -161,12 +200,54 @@ export class DeesHarnessSessionList extends DeesElement {
161
200
  }
162
201
 
163
202
  .list {
164
- flex: 1;
165
- min-height: 0;
203
+ width: 100%;
204
+ height: 100%;
166
205
  overflow-y: auto;
167
206
  scrollbar-width: thin;
168
207
  scrollbar-color: var(--dees-color-scrollbar-thumb) transparent;
169
208
  padding: 0 var(--dees-spacing-md) var(--dees-spacing-md);
209
+ box-sizing: border-box;
210
+ }
211
+
212
+ .listFrame {
213
+ position: relative;
214
+ flex: 1;
215
+ min-height: 0;
216
+ }
217
+
218
+ .scrollShadow {
219
+ position: absolute;
220
+ z-index: 1;
221
+ left: 0;
222
+ right: 0;
223
+ height: 16px;
224
+ opacity: 0;
225
+ pointer-events: none;
226
+ transition: opacity var(--dees-transition-fast) var(--dees-ease-standard);
227
+ }
228
+
229
+ .scrollShadow.visible {
230
+ opacity: 1;
231
+ }
232
+
233
+ .scrollShadow.top {
234
+ top: 0;
235
+ background: linear-gradient(
236
+ to bottom,
237
+ color-mix(in srgb, var(--dees-material-thin-bg-opaque) 74%, transparent),
238
+ transparent
239
+ );
240
+ box-shadow: var(--dees-shadow-sm);
241
+ }
242
+
243
+ .scrollShadow.bottom {
244
+ bottom: 0;
245
+ background: linear-gradient(
246
+ to top,
247
+ color-mix(in srgb, var(--dees-material-thin-bg-opaque) 74%, transparent),
248
+ transparent
249
+ );
250
+ box-shadow: var(--dees-shadow-up-sm);
170
251
  }
171
252
 
172
253
  .section {
@@ -199,29 +280,64 @@ export class DeesHarnessSessionList extends DeesElement {
199
280
 
200
281
  .item:hover {
201
282
  border-color: color-mix(in srgb, var(--card-accent) 42%, var(--dees-color-border-default));
202
- background: color-mix(in srgb, var(--dees-color-bg-primary) 96%, var(--card-accent) 4%);
283
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, var(--card-accent) 10%);
284
+ box-shadow:
285
+ var(--dees-shadow-md),
286
+ inset 3px 0 0 var(--card-accent);
203
287
  }
204
288
 
205
289
  .item.selected {
206
- border-color: color-mix(in srgb, var(--dees-color-accent-primary) 65%, var(--dees-color-border-default));
207
- box-shadow:
208
- 0 0 0 2px color-mix(in srgb, var(--dees-color-accent-primary) 18%, transparent),
209
- var(--dees-shadow-sm),
210
- inset 3px 0 0 var(--card-accent);
290
+ border-color: var(--dees-color-border-default);
291
+ border-left-color: var(--dees-color-accent-primary);
292
+ border-left-width: 3px;
293
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, transparent);
294
+ box-shadow: var(--dees-shadow-md);
295
+ }
296
+
297
+ .item.selected:hover {
298
+ border-color: var(--dees-color-border-default);
299
+ border-left-color: var(--dees-color-accent-primary);
300
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, transparent);
301
+ box-shadow: var(--dees-shadow-md);
302
+ }
303
+
304
+ .item.selected .titleText {
305
+ font-weight: 700;
211
306
  }
212
307
 
213
308
  .item.dragging {
214
- position: absolute;
215
- inset-inline: 0;
216
- z-index: -1;
217
- opacity: 0;
309
+ position: fixed;
310
+ inset: 0 auto auto 0;
311
+ width: var(--drag-preview-width);
312
+ height: var(--drag-preview-height);
313
+ max-width: none;
314
+ margin: 0;
315
+ padding: 0;
316
+ opacity: 0.96;
218
317
  pointer-events: none;
318
+ transform: translate3d(var(--drag-preview-x), var(--drag-preview-y), 0);
319
+ box-shadow: var(--dees-shadow-xl);
219
320
  }
220
321
 
221
322
  .item.state-working {
222
323
  --card-accent: var(--session-working);
223
324
  }
224
325
 
326
+ @keyframes workingDotPulse {
327
+ 0%, 100% {
328
+ transform: scale(0.92);
329
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--session-working) 14%, transparent);
330
+ }
331
+ 50% {
332
+ transform: scale(1.08);
333
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--session-working) 3%, transparent);
334
+ }
335
+ }
336
+
337
+ .item.state-working .itemHeader .stateDot {
338
+ animation: workingDotPulse 2.4s ease-in-out infinite;
339
+ }
340
+
225
341
  .item.state-finished {
226
342
  --card-accent: var(--session-finished);
227
343
  }
@@ -236,16 +352,10 @@ export class DeesHarnessSessionList extends DeesElement {
236
352
 
237
353
  @keyframes attentionPulse {
238
354
  0%, 100% {
239
- box-shadow:
240
- 0 0 0 0 color-mix(in srgb, var(--session-attention) 0%, transparent),
241
- var(--dees-shadow-sm),
242
- inset 3px 0 0 var(--session-attention);
355
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 94%, var(--session-attention) 6%);
243
356
  }
244
357
  50% {
245
- box-shadow:
246
- 0 0 0 4px color-mix(in srgb, var(--session-attention) 26%, transparent),
247
- 0 7px 18px color-mix(in srgb, var(--session-attention) 16%, transparent),
248
- inset 3px 0 0 var(--session-attention);
358
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 84%, var(--session-attention) 16%);
249
359
  }
250
360
  }
251
361
 
@@ -253,12 +363,43 @@ export class DeesHarnessSessionList extends DeesElement {
253
363
  animation: attentionPulse 1.15s ease-in-out infinite;
254
364
  }
255
365
 
366
+ .item.state-attention:not(.selected):hover {
367
+ animation: none;
368
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 82%, var(--session-attention) 18%);
369
+ }
370
+
256
371
  .itemHeader {
257
372
  display: grid;
258
373
  grid-template-columns: minmax(0, 1fr) 30px;
259
374
  min-height: 46px;
260
375
  }
261
376
 
377
+ .itemHeader.draggable {
378
+ grid-template-columns: 24px minmax(0, 1fr) 30px;
379
+ }
380
+
381
+ .dragHandle {
382
+ appearance: none;
383
+ display: grid;
384
+ place-items: center;
385
+ padding: 0;
386
+ border: 0;
387
+ background: transparent;
388
+ color: var(--dees-color-text-muted);
389
+ cursor: grab;
390
+ touch-action: none;
391
+ }
392
+
393
+ .dragHandle:active {
394
+ cursor: grabbing;
395
+ }
396
+
397
+ .dragHandle:focus-visible {
398
+ outline: 2px solid var(--dees-color-accent-primary);
399
+ outline-offset: -3px;
400
+ border-radius: 8px;
401
+ }
402
+
262
403
  .itemPrimary,
263
404
  .expandToggle {
264
405
  appearance: none;
@@ -498,6 +639,8 @@ export class DeesHarnessSessionList extends DeesElement {
498
639
  @media (prefers-reduced-motion: reduce) {
499
640
  .item,
500
641
  .item.state-attention,
642
+ .item.state-working .itemHeader .stateDot,
643
+ .scrollShadow,
501
644
  .expandToggle dees-icon {
502
645
  animation: none;
503
646
  transition: none;
@@ -557,15 +700,21 @@ export class DeesHarnessSessionList extends DeesElement {
557
700
 
558
701
  public updated(changedProperties: Map<PropertyKey, unknown>): void {
559
702
  super.updated(changedProperties);
560
- if (this.draggingSessionId && !this.dragSourceElement?.isConnected) {
561
- this.clearDragState();
703
+ this.observeScrollGeometry();
704
+ this.syncScrollShadowState();
705
+ this.restoreKeyboardMoveFocus();
706
+ if (
707
+ this.pointerDragGesture
708
+ && (!this.pointerDragGesture.source.isConnected || !this.pointerDragGesture.handle.isConnected)
709
+ ) {
710
+ this.finishPointerDrag(false);
562
711
  }
563
712
  if (!changedProperties.has('sessions')) return;
564
713
  const previousSessions = changedProperties.get('sessions') as IHarnessSessionMeta[] | undefined;
565
714
  const previousById = new Map((previousSessions ?? []).map((session) => [session.id, session]));
566
715
  const knownIds = new Set(this.sessions.map((session) => session.id));
567
- if (this.draggingSessionId && !knownIds.has(this.draggingSessionId)) {
568
- this.clearDragState();
716
+ if (this.pointerDragGesture && !knownIds.has(this.pointerDragGesture.sessionId)) {
717
+ this.finishPointerDrag(false);
569
718
  }
570
719
  const nextExpanded = new Set(
571
720
  [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
@@ -584,8 +733,20 @@ export class DeesHarnessSessionList extends DeesElement {
584
733
  }
585
734
  }
586
735
 
736
+ public async connectedCallback(): Promise<void> {
737
+ await super.connectedCallback();
738
+ await this.updateComplete;
739
+ if (!this.isConnected) return;
740
+ this.observeScrollGeometry();
741
+ this.syncScrollShadowState();
742
+ }
743
+
587
744
  public async disconnectedCallback(): Promise<void> {
588
- this.clearDragState();
745
+ this.finishPointerDrag(false);
746
+ this.scrollResizeObserver?.disconnect();
747
+ this.scrollResizeObserver = undefined;
748
+ this.observedScrollContainer = undefined;
749
+ this.observedScrollContent = undefined;
589
750
  await super.disconnectedCallback();
590
751
  }
591
752
 
@@ -602,13 +763,23 @@ export class DeesHarnessSessionList extends DeesElement {
602
763
  }}
603
764
  />
604
765
  </div>
605
- <div class="list">
606
- ${this.loading
607
- ? html`<div class="emptyNote">Loading conversations…</div>`
608
- : groupingActive
609
- ? this.renderGroupedList()
610
- : this.renderFlatList()}
766
+ <div class="listFrame">
767
+ <div class="scrollShadow top ${this.topScrollShadowVisible ? 'visible' : ''}"></div>
768
+ <div
769
+ class="list"
770
+ @scroll=${this.syncScrollShadowState}
771
+ >
772
+ <div class="listContent">
773
+ ${this.loading
774
+ ? html`<div class="emptyNote">Loading conversations…</div>`
775
+ : groupingActive
776
+ ? this.renderGroupedList()
777
+ : this.renderFlatList()}
778
+ </div>
779
+ </div>
780
+ <div class="scrollShadow bottom ${this.bottomScrollShadowVisible ? 'visible' : ''}"></div>
611
781
  </div>
782
+ <div class="visuallyHidden" aria-live="polite">${this.moveAnnouncement}</div>
612
783
  ${groupingActive
613
784
  ? html`
614
785
  <div class="newGroupRow">
@@ -670,6 +841,9 @@ export class DeesHarnessSessionList extends DeesElement {
670
841
  return html`
671
842
  <button
672
843
  class="groupHeader ${collapsed ? 'collapsed' : ''} ${isHeaderDrop ? 'dropTarget' : ''}"
844
+ data-drop-group-id=${group.id}
845
+ data-drop-index=${this.sortableSessions(members).length}
846
+ data-drop-kind="slot"
673
847
  type="button"
674
848
  @click=${() => this.toggleGroupCollapsed(group.id)}
675
849
  @contextmenu=${(event: MouseEvent) => {
@@ -684,16 +858,6 @@ export class DeesHarnessSessionList extends DeesElement {
684
858
  new CustomEvent('harness-group-context', { detail, bubbles: true, composed: true }),
685
859
  );
686
860
  }}
687
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(
688
- event,
689
- group.id,
690
- this.sortableSessions(members).length,
691
- )}
692
- @drop=${(event: DragEvent) => this.handleDrop(
693
- event,
694
- group.id,
695
- this.sortableSessions(members).length,
696
- )}
697
861
  >
698
862
  <dees-icon class="chevron" icon="lucide:ChevronDown" iconSize="12"></dees-icon>
699
863
  <span>${group.name}</span>
@@ -708,10 +872,11 @@ export class DeesHarnessSessionList extends DeesElement {
708
872
  ? html`
709
873
  <button
710
874
  class="groupHeader"
875
+ data-drop-group-id=""
876
+ data-drop-index="0"
877
+ data-drop-kind="slot"
711
878
  type="button"
712
879
  tabindex="-1"
713
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, null, 0)}
714
- @drop=${(event: DragEvent) => this.handleDrop(event, null, 0)}
715
880
  >
716
881
  <span>Ungrouped</span>
717
882
  <span class="groupCount">${ungrouped.length}</span>
@@ -766,8 +931,9 @@ export class DeesHarnessSessionList extends DeesElement {
766
931
  ? html`
767
932
  <div
768
933
  class="emptyDropZone"
769
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, groupId, 0)}
770
- @drop=${(event: DragEvent) => this.handleDrop(event, groupId, 0)}
934
+ data-drop-group-id=${groupId ?? ''}
935
+ data-drop-index="0"
936
+ data-drop-kind="slot"
771
937
  ></div>
772
938
  `
773
939
  : ''}
@@ -779,9 +945,10 @@ export class DeesHarnessSessionList extends DeesElement {
779
945
  return html`
780
946
  <div
781
947
  class="dropPlaceholder"
948
+ data-drop-group-id=${groupId ?? ''}
949
+ data-drop-index=${index}
950
+ data-drop-kind="slot"
782
951
  style=${`height: ${Math.max(46, this.draggedItemHeight)}px`}
783
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, groupId, index)}
784
- @drop=${(event: DragEvent) => this.handleDrop(event, groupId, index)}
785
952
  ></div>
786
953
  `;
787
954
  }
@@ -795,11 +962,11 @@ export class DeesHarnessSessionList extends DeesElement {
795
962
  this.enableGrouping
796
963
  && session.draggable !== false
797
964
  && index >= 0
798
- && !globalThis.matchMedia?.('(pointer: coarse)').matches
799
965
  );
800
966
  const expanded = this.expandedSessionIds.has(session.id);
801
967
  const state = session.state ?? 'normal';
802
968
  const detailsId = `session-details-${session.id}`;
969
+ const dropIndex = index < 0 ? 0 : index;
803
970
  return html`
804
971
  <article
805
972
  class="item state-${state} ${expanded ? 'expanded' : ''} ${
@@ -808,9 +975,11 @@ export class DeesHarnessSessionList extends DeesElement {
808
975
  this.draggingSessionId === session.id ? 'dragging' : ''
809
976
  }"
810
977
  data-session-id=${session.id}
811
- .draggable=${draggable}
978
+ data-drop-group-id=${groupId ?? ''}
979
+ data-drop-index=${dropIndex}
980
+ data-drop-kind=${index < 0 ? 'slot' : 'card'}
812
981
  @dblclick=${(event: MouseEvent) => {
813
- if (this.eventTargetsExpandToggle(event)) return;
982
+ if (this.eventTargetsExpandToggle(event) || this.eventTargetsDragHandle(event)) return;
814
983
  this.emit('harness-session-open', session);
815
984
  }}
816
985
  @contextmenu=${(event: MouseEvent) => {
@@ -826,25 +995,31 @@ export class DeesHarnessSessionList extends DeesElement {
826
995
  new CustomEvent('harness-session-context', { detail, bubbles: true, composed: true }),
827
996
  );
828
997
  }}
829
- @dragstart=${(event: DragEvent) => {
830
- this.handleDragStart(event, session, groupId, index, draggable);
831
- }}
832
- @dragend=${() => this.clearDragState()}
833
- @dragover=${(event: DragEvent) => {
834
- if (!this.draggingSessionId || index < 0 || session.id === this.draggingSessionId) return;
835
- event.preventDefault();
836
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
837
- const before = event.clientY < rect.top + rect.height / 2;
838
- this.setDropSlot(groupId, before ? index : index + 1);
839
- }}
840
- @drop=${(event: DragEvent) => {
841
- if (index < 0 || session.id === this.draggingSessionId) return;
842
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
843
- const before = event.clientY < rect.top + rect.height / 2;
844
- this.handleDrop(event, groupId, before ? index : index + 1);
845
- }}
846
998
  >
847
- <div class="itemHeader">
999
+ <div class="itemHeader ${draggable ? 'draggable' : ''}">
1000
+ ${draggable
1001
+ ? html`
1002
+ <button
1003
+ class="dragHandle"
1004
+ type="button"
1005
+ title="Drag to reorder; use arrow, Home, or End keys for keyboard reordering"
1006
+ aria-label=${`Reorder ${session.title || session.id}`}
1007
+ aria-keyshortcuts="ArrowUp ArrowDown Home End"
1008
+ @pointerdown=${(event: PointerEvent) => {
1009
+ this.handlePointerDown(event, session, groupId, index);
1010
+ }}
1011
+ @pointermove=${this.handlePointerMove}
1012
+ @pointerup=${this.handlePointerUp}
1013
+ @pointercancel=${this.handlePointerCancel}
1014
+ @lostpointercapture=${this.handleLostPointerCapture}
1015
+ @keydown=${(event: KeyboardEvent) => {
1016
+ this.handleKeyboardMove(event, session, groupId);
1017
+ }}
1018
+ >
1019
+ <dees-icon icon="lucide:GripVertical" iconSize="14"></dees-icon>
1020
+ </button>
1021
+ `
1022
+ : ''}
848
1023
  <button
849
1024
  class="itemPrimary"
850
1025
  type="button"
@@ -906,6 +1081,12 @@ export class DeesHarnessSessionList extends DeesElement {
906
1081
  );
907
1082
  }
908
1083
 
1084
+ private eventTargetsDragHandle(event: Event): boolean {
1085
+ return event.composedPath().some(
1086
+ (target) => target instanceof HTMLElement && target.classList.contains('dragHandle'),
1087
+ );
1088
+ }
1089
+
909
1090
  private sessionStateLabel(state: NonNullable<IHarnessSessionMeta['state']>): string {
910
1091
  switch (state) {
911
1092
  case 'working': return 'Working';
@@ -953,27 +1134,139 @@ export class DeesHarnessSessionList extends DeesElement {
953
1134
  .filter((session): session is IHarnessSessionMeta => session !== undefined);
954
1135
  }
955
1136
 
956
- private handleDragStart(
957
- event: DragEvent,
958
- session: IHarnessSessionMeta,
959
- groupId: string | null,
960
- logicalIndex: number,
961
- draggable: boolean,
1137
+ private handlePointerDown(
1138
+ eventArg: PointerEvent,
1139
+ sessionArg: IHarnessSessionMeta,
1140
+ groupIdArg: string | null,
1141
+ logicalIndexArg: number,
962
1142
  ): void {
963
- if (!draggable || logicalIndex < 0) {
964
- event.preventDefault();
1143
+ if (
1144
+ this.pointerDragGesture
1145
+ || eventArg.button !== 0
1146
+ || !eventArg.isPrimary
1147
+ || logicalIndexArg < 0
1148
+ ) return;
1149
+ const handle = eventArg.currentTarget as HTMLElement;
1150
+ const source = handle.closest<HTMLElement>('.item');
1151
+ if (!source) return;
1152
+ const sourceRect = source.getBoundingClientRect();
1153
+ this.pointerDragGesture = {
1154
+ pointerId: eventArg.pointerId,
1155
+ handle,
1156
+ source,
1157
+ sessionId: sessionArg.id,
1158
+ sourceGroupId: groupIdArg,
1159
+ sourceLogicalIndex: logicalIndexArg,
1160
+ startClientX: eventArg.clientX,
1161
+ startClientY: eventArg.clientY,
1162
+ grabOffsetX: eventArg.clientX - sourceRect.left,
1163
+ grabOffsetY: eventArg.clientY - sourceRect.top,
1164
+ phase: 'pending',
1165
+ };
1166
+ window.addEventListener('keydown', this.handleDragKeyDown, true);
1167
+ window.addEventListener('blur', this.handleDragWindowBlur);
1168
+ try {
1169
+ handle.setPointerCapture(eventArg.pointerId);
1170
+ } catch {
1171
+ this.finishPointerDrag(false);
1172
+ }
1173
+ }
1174
+
1175
+ private readonly handlePointerMove = (eventArg: PointerEvent): void => {
1176
+ const gesture = this.pointerDragGesture;
1177
+ if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1178
+ if (!gesture.source.isConnected || !gesture.handle.isConnected) {
1179
+ this.finishPointerDrag(false);
1180
+ return;
1181
+ }
1182
+ if (
1183
+ gesture.phase === 'pending'
1184
+ && Math.hypot(
1185
+ eventArg.clientX - gesture.startClientX,
1186
+ eventArg.clientY - gesture.startClientY,
1187
+ ) < dragThresholdPixels
1188
+ ) return;
1189
+ if (gesture.phase === 'pending' && !this.startPointerDrag(eventArg, gesture)) return;
1190
+ eventArg.preventDefault();
1191
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1192
+ this.dragAutoScrollClientX = eventArg.clientX;
1193
+ this.dragAutoScrollClientY = eventArg.clientY;
1194
+ this.updateDragAutoScrollVelocity();
1195
+ this.updateDropSlotFromPointer();
1196
+ };
1197
+
1198
+ private readonly handlePointerUp = (eventArg: PointerEvent): void => {
1199
+ const gesture = this.pointerDragGesture;
1200
+ if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1201
+ if (gesture.phase === 'active') {
1202
+ this.dragAutoScrollClientX = eventArg.clientX;
1203
+ this.dragAutoScrollClientY = eventArg.clientY;
1204
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1205
+ this.updateDropSlotFromPointer();
1206
+ this.finishPointerDrag(true);
965
1207
  return;
966
1208
  }
967
- const item = event.currentTarget as HTMLElement;
1209
+ this.finishPointerDrag(false);
1210
+ };
1211
+
1212
+ private readonly handlePointerCancel = (eventArg: PointerEvent): void => {
1213
+ if (eventArg.pointerId === this.pointerDragGesture?.pointerId) this.finishPointerDrag(false);
1214
+ };
1215
+
1216
+ private readonly handleLostPointerCapture = (eventArg: PointerEvent): void => {
1217
+ if (eventArg.pointerId === this.pointerDragGesture?.pointerId) this.finishPointerDrag(false);
1218
+ };
1219
+
1220
+ private readonly handleDragKeyDown = (eventArg: KeyboardEvent): void => {
1221
+ if (eventArg.key !== 'Escape' || !this.pointerDragGesture) return;
1222
+ eventArg.preventDefault();
1223
+ eventArg.stopPropagation();
1224
+ this.finishPointerDrag(false);
1225
+ };
1226
+
1227
+ private readonly handleDragWindowBlur = (): void => {
1228
+ this.finishPointerDrag(false);
1229
+ };
1230
+
1231
+ private startPointerDrag(eventArg: PointerEvent, gestureArg: IPointerDragGesture): boolean {
1232
+ const sourceRect = gestureArg.source.getBoundingClientRect();
1233
+ gestureArg.source.classList.add('dragging');
1234
+ gestureArg.source.style.setProperty('--drag-preview-width', `${sourceRect.width}px`);
1235
+ gestureArg.source.style.setProperty('--drag-preview-height', `${sourceRect.height}px`);
1236
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gestureArg);
1237
+ gestureArg.source.setAttribute('popover', 'manual');
1238
+ try {
1239
+ gestureArg.source.showPopover();
1240
+ } catch {
1241
+ this.finishPointerDrag(false);
1242
+ return false;
1243
+ }
1244
+ gestureArg.phase = 'active';
968
1245
  this.reflowGeneration += 1;
969
1246
  this.cancelReflowAnimations();
970
- this.draggedItemHeight = Math.ceil(item.getBoundingClientRect().height);
971
- this.dragSourceElement = item;
972
- this.draggingSessionId = session.id;
973
- const remainingCount = this.sortableSessions(this.sessionsForGroup(groupId)).length;
974
- this.dropSlot = { groupId, index: Math.min(logicalIndex, remainingCount) };
975
- event.dataTransfer?.setData('text/plain', session.id);
976
- if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
1247
+ this.draggedItemHeight = Math.ceil(sourceRect.height);
1248
+ this.draggingSessionId = gestureArg.sessionId;
1249
+ const remainingCount = this.sortableSessions(this.sessionsForGroup(gestureArg.sourceGroupId)).length;
1250
+ this.dropSlot = {
1251
+ groupId: gestureArg.sourceGroupId,
1252
+ index: Math.min(gestureArg.sourceLogicalIndex, remainingCount),
1253
+ };
1254
+ return true;
1255
+ }
1256
+
1257
+ private moveDragPreview(
1258
+ clientXArg: number,
1259
+ clientYArg: number,
1260
+ gestureArg: IPointerDragGesture,
1261
+ ): void {
1262
+ gestureArg.source.style.setProperty(
1263
+ '--drag-preview-x',
1264
+ `${clientXArg - gestureArg.grabOffsetX}px`,
1265
+ );
1266
+ gestureArg.source.style.setProperty(
1267
+ '--drag-preview-y',
1268
+ `${clientYArg - gestureArg.grabOffsetY}px`,
1269
+ );
977
1270
  }
978
1271
 
979
1272
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
@@ -985,38 +1278,239 @@ export class DeesHarnessSessionList extends DeesElement {
985
1278
  void this.animateCardReflow(previousRects, generation);
986
1279
  }
987
1280
 
988
- private handleDragOverSlot(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
989
- if (!this.draggingSessionId) return;
990
- event.preventDefault();
991
- if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
992
- this.setDropSlot(groupIdArg, indexArg);
1281
+ private clearDropSlot(): void {
1282
+ if (!this.dropSlot) return;
1283
+ const previousRects = this.captureCardRects();
1284
+ const generation = ++this.reflowGeneration;
1285
+ this.dropSlot = undefined;
1286
+ void this.animateCardReflow(previousRects, generation);
1287
+ }
1288
+
1289
+ private updateDragAutoScrollVelocity(): void {
1290
+ const container = this.scrollContainer;
1291
+ if (!container || !this.draggingSessionId) return;
1292
+ const rect = container.getBoundingClientRect();
1293
+ if (this.dragAutoScrollClientX < rect.left || this.dragAutoScrollClientX > rect.right) {
1294
+ this.stopDragAutoScroll();
1295
+ return;
1296
+ }
1297
+ const edgeSize = Math.min(64, Math.max(32, rect.height * 0.25));
1298
+ const topDepth = Math.max(
1299
+ 0,
1300
+ Math.min(1, (rect.top + edgeSize - this.dragAutoScrollClientY) / edgeSize),
1301
+ );
1302
+ const bottomDepth = Math.max(
1303
+ 0,
1304
+ Math.min(1, (this.dragAutoScrollClientY - (rect.bottom - edgeSize)) / edgeSize),
1305
+ );
1306
+ this.dragAutoScrollVelocity = bottomDepth > 0
1307
+ ? dragAutoScrollMaxPixelsPerSecond * bottomDepth * bottomDepth
1308
+ : topDepth > 0
1309
+ ? -dragAutoScrollMaxPixelsPerSecond * topDepth * topDepth
1310
+ : 0;
1311
+ if (this.dragAutoScrollVelocity === 0) {
1312
+ this.stopDragAutoScroll();
1313
+ return;
1314
+ }
1315
+ if (this.dragAutoScrollFrame === undefined) {
1316
+ this.dragAutoScrollLastFrame = performance.now();
1317
+ this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
1318
+ }
993
1319
  }
994
1320
 
995
- private handleDrop(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
996
- event.preventDefault();
997
- event.stopPropagation();
998
- const sessionId = this.draggingSessionId || event.dataTransfer?.getData('text/plain') || '';
999
- if (!sessionId) {
1000
- this.clearDragState();
1321
+ private readonly runDragAutoScroll = (timestampArg: number): void => {
1322
+ this.dragAutoScrollFrame = undefined;
1323
+ const container = this.scrollContainer;
1324
+ if (!container || !this.draggingSessionId || this.dragAutoScrollVelocity === 0) return;
1325
+ const elapsedMs = Math.min(34, Math.max(0, timestampArg - this.dragAutoScrollLastFrame));
1326
+ this.dragAutoScrollLastFrame = timestampArg;
1327
+ const previousScrollTop = container.scrollTop;
1328
+ container.scrollTop += this.dragAutoScrollVelocity * elapsedMs / 1000;
1329
+ this.syncScrollShadowState();
1330
+ const atScrollBoundary = this.dragAutoScrollVelocity < 0
1331
+ ? container.scrollTop <= 0
1332
+ : container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
1333
+ if (container.scrollTop === previousScrollTop && atScrollBoundary) {
1334
+ this.stopDragAutoScroll();
1001
1335
  return;
1002
1336
  }
1337
+ this.updateDropSlotFromPointer();
1338
+ this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
1339
+ };
1340
+
1341
+ private updateDropSlotFromPointer(): void {
1342
+ const pointerX = this.dragAutoScrollClientX;
1343
+ const pointerY = this.dragAutoScrollClientY;
1344
+ const placeholder = this.shadowRoot?.querySelector<HTMLElement>('.dropPlaceholder');
1345
+ if (placeholder) {
1346
+ const rect = placeholder.getBoundingClientRect();
1347
+ if (
1348
+ pointerX >= rect.left
1349
+ && pointerX <= rect.right
1350
+ && pointerY >= rect.top
1351
+ && pointerY <= rect.bottom
1352
+ ) {
1353
+ this.setDropSlot(
1354
+ placeholder.dataset.dropGroupId || null,
1355
+ Number(placeholder.dataset.dropIndex),
1356
+ );
1357
+ return;
1358
+ }
1359
+ }
1360
+ const selector = '[data-drop-group-id][data-drop-index][data-drop-kind]';
1361
+ let target: HTMLElement | undefined;
1362
+ const offsets = this.dragAutoScrollVelocity === 0
1363
+ ? [0, -8, 8, -16, 16, -32, 32]
1364
+ : [0, 8, 16, 32, 48, 64, 96].map(
1365
+ (value) => value * (this.dragAutoScrollVelocity < 0 ? 1 : -1),
1366
+ );
1367
+ for (const offset of offsets) {
1368
+ const hit = this.shadowRoot?.elementFromPoint(pointerX, pointerY + offset);
1369
+ const candidate = hit?.closest<HTMLElement>(selector);
1370
+ if (
1371
+ candidate
1372
+ && candidate.dataset.sessionId !== this.draggingSessionId
1373
+ && Number(candidate.dataset.dropIndex) >= 0
1374
+ ) {
1375
+ target = candidate;
1376
+ break;
1377
+ }
1378
+ }
1379
+ if (!target) {
1380
+ this.clearDropSlot();
1381
+ return;
1382
+ }
1383
+ const baseIndex = Number(target.dataset.dropIndex);
1384
+ const groupId = target.dataset.dropGroupId || null;
1385
+ const rect = target.getBoundingClientRect();
1386
+ const index = target.dataset.dropKind === 'card' && pointerY >= rect.top + rect.height / 2
1387
+ ? baseIndex + 1
1388
+ : baseIndex;
1389
+ this.setDropSlot(groupId, index);
1390
+ }
1391
+
1392
+ private get scrollContainer(): HTMLElement | undefined {
1393
+ return this.shadowRoot?.querySelector<HTMLElement>('.list') ?? undefined;
1394
+ }
1395
+
1396
+ private observeScrollGeometry(): void {
1397
+ if (!this.isConnected) return;
1398
+ const container = this.scrollContainer;
1399
+ const content = this.shadowRoot?.querySelector<HTMLElement>('.listContent') ?? undefined;
1400
+ if (!container || !content) return;
1401
+ if (
1402
+ this.scrollResizeObserver
1403
+ && this.observedScrollContainer === container
1404
+ && this.observedScrollContent === content
1405
+ ) return;
1406
+ this.scrollResizeObserver?.disconnect();
1407
+ this.scrollResizeObserver = new ResizeObserver(this.syncScrollShadowState);
1408
+ this.scrollResizeObserver.observe(container);
1409
+ this.scrollResizeObserver.observe(content);
1410
+ this.observedScrollContainer = container;
1411
+ this.observedScrollContent = content;
1412
+ }
1413
+
1414
+ private readonly syncScrollShadowState = (): void => {
1415
+ const container = this.scrollContainer;
1416
+ if (!container) return;
1417
+ const topVisible = container.scrollTop > 1;
1418
+ const bottomVisible = container.scrollTop + container.clientHeight < container.scrollHeight - 1;
1419
+ if (this.topScrollShadowVisible !== topVisible) this.topScrollShadowVisible = topVisible;
1420
+ if (this.bottomScrollShadowVisible !== bottomVisible) this.bottomScrollShadowVisible = bottomVisible;
1421
+ };
1422
+
1423
+ private stopDragAutoScroll(): void {
1424
+ if (this.dragAutoScrollFrame !== undefined) cancelAnimationFrame(this.dragAutoScrollFrame);
1425
+ this.dragAutoScrollFrame = undefined;
1426
+ this.dragAutoScrollVelocity = 0;
1427
+ this.dragAutoScrollLastFrame = 0;
1428
+ }
1429
+
1430
+ private createMoveDetail(
1431
+ sessionIdArg: string,
1432
+ groupIdArg: string | null,
1433
+ indexArg: number,
1434
+ ): IHarnessSessionMoveDetail {
1003
1435
  const targetSessions = this.sortableSessions(this.sessionsForGroup(groupIdArg));
1004
1436
  const beforeSessionId = targetSessions[Math.max(0, indexArg)]?.id;
1005
- const sourceGroupId = this.groups.find((group) => group.sessionIds.includes(sessionId))?.id ?? null;
1437
+ const sourceGroupId = this.groups.find((group) => group.sessionIds.includes(sessionIdArg))?.id ?? null;
1006
1438
  const sourceSessions = this.sortableSessions(this.sessionsForGroup(sourceGroupId), false);
1007
- const sourceIndex = sourceSessions.findIndex((session) => session.id === sessionId);
1439
+ const sourceIndex = sourceSessions.findIndex((session) => session.id === sessionIdArg);
1008
1440
  const compatibilityIndex = (
1009
1441
  sourceGroupId === groupIdArg
1010
1442
  && sourceIndex >= 0
1011
1443
  && indexArg > sourceIndex
1012
1444
  ) ? indexArg + 1 : indexArg;
1013
- const detail: IHarnessSessionMoveDetail = {
1014
- sessionId,
1445
+ return {
1446
+ sessionId: sessionIdArg,
1015
1447
  groupId: groupIdArg,
1016
1448
  index: Math.max(0, compatibilityIndex),
1017
1449
  ...(beforeSessionId ? { beforeSessionId } : {}),
1018
1450
  };
1019
- this.clearDragState();
1451
+ }
1452
+
1453
+ private handleKeyboardMove(
1454
+ eventArg: KeyboardEvent,
1455
+ sessionArg: IHarnessSessionMeta,
1456
+ groupIdArg: string | null,
1457
+ ): void {
1458
+ if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(eventArg.key)) return;
1459
+ eventArg.preventDefault();
1460
+ eventArg.stopPropagation();
1461
+ const sessions = this.sortableSessions(this.sessionsForGroup(groupIdArg), false);
1462
+ const sourceIndex = sessions.findIndex((session) => session.id === sessionArg.id);
1463
+ if (sourceIndex < 0) return;
1464
+ const sectionIds: Array<string | null> = [...this.groups.map((group) => group.id), null];
1465
+ const sectionIndex = sectionIds.indexOf(groupIdArg);
1466
+ const adjacentGroupId = eventArg.key === 'ArrowUp' && sourceIndex === 0 && sectionIndex > 0
1467
+ ? sectionIds[sectionIndex - 1]
1468
+ : eventArg.key === 'ArrowDown'
1469
+ && sourceIndex === sessions.length - 1
1470
+ && sectionIndex >= 0
1471
+ && sectionIndex < sectionIds.length - 1
1472
+ ? sectionIds[sectionIndex + 1]
1473
+ : undefined;
1474
+ if (adjacentGroupId !== undefined) {
1475
+ const targetSessions = this.sortableSessions(this.sessionsForGroup(adjacentGroupId), false);
1476
+ const targetIndex = eventArg.key === 'ArrowUp' ? targetSessions.length : 0;
1477
+ const beforeSession = targetSessions[targetIndex];
1478
+ const detail: IHarnessSessionMoveDetail = {
1479
+ sessionId: sessionArg.id,
1480
+ groupId: adjacentGroupId,
1481
+ index: targetIndex,
1482
+ ...(beforeSession ? { beforeSessionId: beforeSession.id } : {}),
1483
+ };
1484
+ const groupName = adjacentGroupId === null
1485
+ ? 'Ungrouped'
1486
+ : this.groups.find((group) => group.id === adjacentGroupId)?.name ?? adjacentGroupId;
1487
+ this.moveAnnouncement = `Moved ${sessionArg.title || sessionArg.id} to ${groupName}.`;
1488
+ this.keyboardMoveFocus = { sessionId: sessionArg.id, groupId: adjacentGroupId };
1489
+ this.dispatchEvent(
1490
+ new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
1491
+ );
1492
+ return;
1493
+ }
1494
+ const remaining = sessions.filter((session) => session.id !== sessionArg.id);
1495
+ const targetIndex = eventArg.key === 'Home'
1496
+ ? 0
1497
+ : eventArg.key === 'End'
1498
+ ? remaining.length
1499
+ : eventArg.key === 'ArrowUp'
1500
+ ? Math.max(0, sourceIndex - 1)
1501
+ : Math.min(remaining.length, sourceIndex + 1);
1502
+ if (targetIndex === sourceIndex) return;
1503
+ const beforeSession = remaining[targetIndex];
1504
+ const compatibilityIndex = targetIndex > sourceIndex ? targetIndex + 1 : targetIndex;
1505
+ const detail: IHarnessSessionMoveDetail = {
1506
+ sessionId: sessionArg.id,
1507
+ groupId: groupIdArg,
1508
+ index: compatibilityIndex,
1509
+ ...(beforeSession ? { beforeSessionId: beforeSession.id } : {}),
1510
+ };
1511
+ this.moveAnnouncement = beforeSession
1512
+ ? `Moved ${sessionArg.title || sessionArg.id} before ${beforeSession.title || beforeSession.id}.`
1513
+ : `Moved ${sessionArg.title || sessionArg.id} to the end.`;
1020
1514
  this.dispatchEvent(
1021
1515
  new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
1022
1516
  );
@@ -1031,6 +1525,31 @@ export class DeesHarnessSessionList extends DeesElement {
1031
1525
  return result;
1032
1526
  }
1033
1527
 
1528
+ private restoreKeyboardMoveFocus(): void {
1529
+ const pending = this.keyboardMoveFocus;
1530
+ if (!pending) return;
1531
+ const currentGroupId = this.groups.find(
1532
+ (group) => group.sessionIds.includes(pending.sessionId),
1533
+ )?.id ?? null;
1534
+ if (currentGroupId !== pending.groupId) return;
1535
+ const item = Array.from(
1536
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? [],
1537
+ ).find((candidate) => candidate.dataset.sessionId === pending.sessionId);
1538
+ const handle = item?.querySelector<HTMLElement>('.dragHandle');
1539
+ if (handle) {
1540
+ handle.focus();
1541
+ this.keyboardMoveFocus = undefined;
1542
+ return;
1543
+ }
1544
+ const groupHeader = Array.from(
1545
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.groupHeader[data-drop-group-id]') ?? [],
1546
+ ).find((candidate) => candidate.dataset.dropGroupId === (pending.groupId ?? ''));
1547
+ if (groupHeader) {
1548
+ groupHeader.focus();
1549
+ this.keyboardMoveFocus = undefined;
1550
+ }
1551
+ }
1552
+
1034
1553
  private async animateCardReflow(
1035
1554
  previousRectsArg: Map<string, DOMRect>,
1036
1555
  generationArg: number,
@@ -1069,13 +1588,42 @@ export class DeesHarnessSessionList extends DeesElement {
1069
1588
  this.reflowAnimations.clear();
1070
1589
  }
1071
1590
 
1072
- private clearDragState(): void {
1591
+ private finishPointerDrag(commitArg: boolean): void {
1592
+ const gesture = this.pointerDragGesture;
1593
+ if (!gesture) return;
1594
+ const detail = commitArg && gesture.phase === 'active' && this.dropSlot
1595
+ ? this.createMoveDetail(gesture.sessionId, this.dropSlot.groupId, this.dropSlot.index)
1596
+ : undefined;
1597
+ this.pointerDragGesture = undefined;
1598
+ window.removeEventListener('keydown', this.handleDragKeyDown, true);
1599
+ window.removeEventListener('blur', this.handleDragWindowBlur);
1600
+ this.stopDragAutoScroll();
1073
1601
  this.reflowGeneration += 1;
1074
1602
  this.cancelReflowAnimations();
1603
+ if (gesture.source.matches(':popover-open')) gesture.source.hidePopover();
1604
+ gesture.source.removeAttribute('popover');
1605
+ gesture.source.classList.remove('dragging');
1606
+ for (const property of [
1607
+ '--drag-preview-width',
1608
+ '--drag-preview-height',
1609
+ '--drag-preview-x',
1610
+ '--drag-preview-y',
1611
+ ]) gesture.source.style.removeProperty(property);
1075
1612
  this.draggingSessionId = '';
1076
1613
  this.draggedItemHeight = 0;
1077
1614
  this.dropSlot = undefined;
1078
- this.dragSourceElement = undefined;
1615
+ try {
1616
+ if (gesture.handle.hasPointerCapture(gesture.pointerId)) {
1617
+ gesture.handle.releasePointerCapture(gesture.pointerId);
1618
+ }
1619
+ } catch {
1620
+ // Capture may already be released or the handle may have disconnected.
1621
+ }
1622
+ if (detail) {
1623
+ this.dispatchEvent(
1624
+ new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
1625
+ );
1626
+ }
1079
1627
  }
1080
1628
 
1081
1629
  private emit(name: string, session: IHarnessSessionMeta): void {