@nexxtmove/ui 1.36.0 → 1.38.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.
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  computed,
4
4
  nextTick,
5
+ onBeforeUnmount,
5
6
  onMounted,
6
7
  ref,
7
8
  useId,
@@ -12,6 +13,17 @@ import {
12
13
  } from 'vue'
13
14
  import { useResizeObserver } from '@vueuse/core'
14
15
  import NexxtIcon, { type NexxtIconName } from '../../atoms/Icon/Icon.vue'
16
+ import {
17
+ ghostSlot,
18
+ hitTest,
19
+ isNoOp,
20
+ resolveDrop,
21
+ samePlace,
22
+ type DropLayout,
23
+ type RestSlot,
24
+ type TreeDrop,
25
+ type TreeRow as Row,
26
+ } from './tree-drop'
15
27
 
16
28
  export interface NexxtTreeNode {
17
29
  /** Unique within the tree; this is what `selected` and `collapsed` hold. */
@@ -20,6 +32,19 @@ export interface NexxtTreeNode {
20
32
  icon?: NexxtIconName
21
33
  /** A node with children gets a chevron and can be folded. */
22
34
  children?: NexxtTreeNode[]
35
+ /** Whether this node accepts drops when it currently has no children. */
36
+ canHaveChildren?: boolean
37
+ /** Whether this node can be dragged when the tree's `draggable` prop is enabled. */
38
+ draggable?: boolean
39
+ }
40
+
41
+ export interface NexxtTreeMove {
42
+ /** The node being moved. */
43
+ id: string
44
+ /** The node that will contain the moved node, or `null` for the tree root. */
45
+ parentId: string | null
46
+ /** The zero-based position in the target parent's children. */
47
+ index: number
23
48
  }
24
49
 
25
50
  export interface NexxtTreeProps {
@@ -27,6 +52,17 @@ export interface NexxtTreeProps {
27
52
  nodes: NexxtTreeNode[]
28
53
  /** Accessible name for the tree, e.g. the heading above it. */
29
54
  label: string
55
+ /** Enables native drag-and-drop for rows and emits the intended new position. */
56
+ draggable?: boolean
57
+ /**
58
+ * Decides per spot whether the dragged node may land there, on top of the built-in rules (no
59
+ * drop into its own subtree, only into nodes that can have children). Gets the same shape as
60
+ * the `move` payload. A refused spot shows no ghost row and drops nothing, like letting go
61
+ * outside the tree. It runs on every `dragover` — which the browser keeps firing while the
62
+ * pointer rests — and on every touch move, so keep it cheap and pure: the same spot must always
63
+ * get the same answer.
64
+ */
65
+ canDrop?: (target: NexxtTreeMove) => boolean
30
66
  }
31
67
 
32
68
  export type NexxtTreeSlots = {
@@ -34,20 +70,11 @@ export type NexxtTreeSlots = {
34
70
  'node-actions'?(props: { node: NexxtTreeNode; tabindex: 0 | -1 }): unknown
35
71
  }
36
72
 
37
- interface Row {
38
- node: NexxtTreeNode
39
- depth: number
40
- parentId: string | null
41
- expandable: boolean
42
- expanded: boolean
43
- position: number
44
- setSize: number
45
- }
46
-
47
73
  defineOptions({ name: 'NexxtTree' })
48
74
 
49
75
  const props = defineProps<NexxtTreeProps>()
50
76
  const slots = defineSlots<NexxtTreeSlots>()
77
+ const emit = defineEmits<{ move: [move: NexxtTreeMove] }>()
51
78
 
52
79
  const selected = defineModel<string | null>('selected', { default: null })
53
80
  /** Ids of the nodes whose children are hidden. Tracking the collapsed ones makes "everything open" the default. */
@@ -65,6 +92,7 @@ const rows = computed(() => {
65
92
  const walk = (nodes: NexxtTreeNode[], depth: number, parentId: string | null) => {
66
93
  nodes.forEach((node, index) => {
67
94
  const expandable = !!node.children?.length
95
+ const canHaveChildren = node.canHaveChildren ?? !!node.children
68
96
  const expanded = expandable && !collapsed.value.includes(node.id)
69
97
 
70
98
  result.push({
@@ -72,6 +100,8 @@ const rows = computed(() => {
72
100
  depth,
73
101
  parentId,
74
102
  expandable,
103
+ canHaveChildren,
104
+ draggable: props.draggable === true && node.draggable !== false,
75
105
  expanded,
76
106
  position: index + 1,
77
107
  setSize: nodes.length,
@@ -86,6 +116,408 @@ const rows = computed(() => {
86
116
  return result
87
117
  })
88
118
 
119
+ const draggingId = ref<string | null>(null)
120
+ /**
121
+ * The id behind the drag's looks: dimming the source and turning off hover. For a native drag it
122
+ * lags `draggingId` by a task, as the browser takes its drag image after `dragstart`, and that
123
+ * image should show the row at full strength.
124
+ */
125
+ const dragVisualId = ref<string | null>(null)
126
+ const activeDrop = ref<TreeDrop | null>(null)
127
+ const pointerDrag = ref<{
128
+ id: string
129
+ pointerId: number
130
+ startX: number
131
+ startY: number
132
+ element: HTMLElement
133
+ /** Ended by Escape while the finger is still down: the rest of the gesture is ignored. */
134
+ cancelled: boolean
135
+ } | null>(null)
136
+ /**
137
+ * Swallows the click a touch drag can end in, so letting go does not also select the row. A new
138
+ * `pointerdown` clears it: a gesture that ends without a click must not cost the next real one.
139
+ */
140
+ let suppressClick = false
141
+
142
+ const sourceRow = computed(() => rows.value.find((row) => row.node.id === draggingId.value))
143
+
144
+ /**
145
+ * Only a different spot is written: one that lands the node in the same place, picked through
146
+ * another row, would still re-render the rows and restart every move that is under way.
147
+ */
148
+ const setDrop = (next: TreeDrop | null) => {
149
+ if (!samePlace(activeDrop.value, next)) activeDrop.value = next
150
+ }
151
+
152
+ /** Rows that are leaving are out of the flow — `absolute` marks them — and take no space. */
153
+ const isLeaving = (element: Element) => element.classList.contains('absolute')
154
+
155
+ /**
156
+ * Where the rows land without the ghost row, from the layout rather than from what is on screen:
157
+ * `offsetTop` ignores the transform of a row that is still sliding.
158
+ */
159
+ const measureLayout = (): DropLayout => {
160
+ const container = root.value?.querySelector('[data-tree-id]')?.parentElement
161
+ const elements = Array.from(container?.children ?? []).filter(
162
+ (element): element is HTMLElement => element instanceof HTMLElement && !isLeaving(element),
163
+ )
164
+ const slots: RestSlot[] = []
165
+ let ghost: DropLayout['ghost'] = null
166
+
167
+ elements.forEach((element, index) => {
168
+ const top = element.offsetTop
169
+ const next = elements[index + 1]
170
+ const gap = parseFloat(getComputedStyle(element).marginBottom) || 0
171
+ const bottom = next ? next.offsetTop : top + element.offsetHeight + gap
172
+
173
+ if ('treeGhost' in element.dataset) {
174
+ ghost = { top, bottom }
175
+
176
+ return
177
+ }
178
+
179
+ const shift = ghost ? ghost.bottom - ghost.top : 0
180
+ slots.push({
181
+ id: element.dataset.treeId!,
182
+ top: top - shift,
183
+ height: element.offsetHeight,
184
+ bottom: bottom - shift,
185
+ })
186
+ })
187
+
188
+ return { slots, ghost }
189
+ }
190
+
191
+ const isInsideTree = (clientX: number, clientY: number) => {
192
+ const rect = root.value?.getBoundingClientRect()
193
+
194
+ return (
195
+ !!rect &&
196
+ clientX >= rect.left &&
197
+ clientX < rect.right &&
198
+ clientY >= rect.top &&
199
+ clientY < rect.bottom
200
+ )
201
+ }
202
+
203
+ /**
204
+ * The spot is only ever cleared by leaving the tree, by hovering the dragged rows, or by a spot
205
+ * that is refused or already the node's own. The ghost row and the gaps keep the spot it has.
206
+ */
207
+ const updateDropAt = (clientX: number, clientY: number) => {
208
+ const source = sourceRow.value
209
+ if (!source || !isInsideTree(clientX, clientY)) {
210
+ setDrop(null)
211
+
212
+ return
213
+ }
214
+
215
+ const hit = hitTest(measureLayout(), clientY - root.value!.getBoundingClientRect().top)
216
+ if (hit.kind === 'keep') return
217
+
218
+ const row = rows.value.find((candidate) => candidate.node.id === hit.id)
219
+ const drop = row ? resolveDrop(row, hit.ratio, source.node) : null
220
+
221
+ setDrop(drop && !isNoOp(drop, source) && isAllowed(source, drop) ? drop : null)
222
+ }
223
+
224
+ const isAllowed = (source: Row, drop: TreeDrop) =>
225
+ props.canDrop?.({ id: source.node.id, parentId: drop.parentId, index: drop.index }) ?? true
226
+
227
+ /** The ghost row's spot among the rows; `null` while there is none. */
228
+ const ghost = computed(() => {
229
+ const source = sourceRow.value
230
+ const slot = source ? ghostSlot(rows.value, props.nodes, activeDrop.value) : null
231
+
232
+ return source && slot ? { ...slot, node: source.node } : null
233
+ })
234
+
235
+ /** A drop inside a collapsed group gets no ghost row: the group row marks itself instead. */
236
+ const collapsedTargetId = computed(() => {
237
+ const parentId = activeDrop.value?.parentId
238
+ if (!parentId || ghost.value) return null
239
+
240
+ const parent = rows.value.find((row) => row.node.id === parentId)
241
+
242
+ return parent?.expandable && !parent.expanded ? parentId : null
243
+ })
244
+
245
+ /** A symbol, so the ghost row's key can never be a node id. */
246
+ const GHOST_KEY = Symbol('ghost')
247
+
248
+ /** The rows with the ghost row in between. */
249
+ type Item =
250
+ | { key: string; ghost: false; row: Row }
251
+ | { key: symbol; ghost: true; depth: number; node: NexxtTreeNode }
252
+
253
+ const items = computed(() => {
254
+ const list: Item[] = rows.value.map((row) => ({ key: row.node.id, ghost: false, row }))
255
+
256
+ if (ghost.value) {
257
+ const { at, depth, node } = ghost.value
258
+ list.splice(at, 0, { key: GHOST_KEY, ghost: true, depth, node })
259
+ }
260
+
261
+ return list
262
+ })
263
+
264
+ /** The dragged row and its visible subtree, dimmed on their old spot for as long as the drag lasts. */
265
+ const dimmedIds = computed(() => {
266
+ const at = rows.value.findIndex((row) => row.node.id === dragVisualId.value)
267
+ const source = rows.value[at]
268
+ if (!source) return new Set<string>()
269
+
270
+ const ids = new Set([source.node.id])
271
+ for (let index = at + 1; rows.value[index] && rows.value[index]!.depth > source.depth; index++) {
272
+ ids.add(rows.value[index]!.node.id)
273
+ }
274
+
275
+ return ids
276
+ })
277
+
278
+ /**
279
+ * A collapsed group as drop target takes on the ghost row's surface, as there is no ghost row
280
+ * under it. The dashed outline is what sets both apart from the selection, which is blue-50 too.
281
+ */
282
+ const GHOST_SURFACE =
283
+ 'bg-cornflower-blue-50 outline-1 -outline-offset-1 outline-cornflower-blue-600 outline-dashed'
284
+
285
+ /** Rows do not respond to hover during a drag: the pointer is carrying a row, not pointing. */
286
+ const rowClasses = (row: Row) => {
287
+ const id = row.node.id
288
+ const isSelected = selected.value === id
289
+
290
+ return [
291
+ row.draggable ? 'cursor-grab touch-none active:cursor-grabbing' : 'cursor-pointer',
292
+ isSelected ? 'text-cornflower-blue-600' : 'text-gray-800',
293
+ !isSelected && !dragVisualId.value && 'hover:bg-gray-100',
294
+ dimmedIds.value.has(id) && 'opacity-40',
295
+ collapsedTargetId.value === id && GHOST_SURFACE,
296
+ ]
297
+ }
298
+
299
+ /**
300
+ * The row that was just dropped. It lands where the ghost row was, so it should not fly over the
301
+ * other labels from its old spot as the `move` transition would have it.
302
+ */
303
+ let landing: { id: string; parentId: string | null; position: number } | null = null
304
+
305
+ const onDocumentDragOver = (event: DragEvent) => {
306
+ if (!root.value?.contains(event.target as Node | null)) setDrop(null)
307
+ }
308
+
309
+ /**
310
+ * Escape belongs to the drag while one is under way, so a dialog around the tree does not close
311
+ * along with it. Caught on `window` in the capture phase, ahead of any listener on the page. A
312
+ * native drag ends itself on Escape (with a `dragend` and no `drop`); a touch drag is ended here,
313
+ * and the finger that is still down is ignored until it lifts.
314
+ */
315
+ const onWindowKeydown = (event: KeyboardEvent) => {
316
+ if (event.key !== 'Escape' || !draggingId.value) return
317
+
318
+ event.preventDefault()
319
+ event.stopImmediatePropagation()
320
+
321
+ const pointer = pointerDrag.value
322
+ if (!pointer) return
323
+
324
+ pointer.element.releasePointerCapture?.(pointer.pointerId)
325
+ pointer.cancelled = true
326
+ endDrag()
327
+ }
328
+
329
+ const startDrag = (id: string) => {
330
+ draggingId.value = id
331
+ landing = null
332
+ setDrop(null)
333
+ window.addEventListener('keydown', onWindowKeydown, true)
334
+ document.addEventListener('dragover', onDocumentDragOver)
335
+ }
336
+
337
+ const endDrag = () => {
338
+ draggingId.value = null
339
+ dragVisualId.value = null
340
+ setDrop(null)
341
+ window.removeEventListener('keydown', onWindowKeydown, true)
342
+ document.removeEventListener('dragover', onDocumentDragOver)
343
+ }
344
+
345
+ const land = (source: Row, drop: TreeDrop) => {
346
+ landing = { id: source.node.id, parentId: source.parentId, position: source.position }
347
+ emit('move', { id: source.node.id, parentId: drop.parentId, index: drop.index })
348
+ }
349
+
350
+ const isTouchPointer = (event: PointerEvent) =>
351
+ event.pointerType === 'touch' || event.pointerType === 'pen'
352
+
353
+ /** Whether `target` sits in a row's `node-actions`, where a press is meant for the action. */
354
+ const isInActions = (target: EventTarget | null) =>
355
+ target instanceof Element && !!target.closest('[data-tree-actions]')
356
+
357
+ /**
358
+ * Whether the last press started in the actions. A mouse drag reports the row as its target, not
359
+ * the button it started on, so `dragstart` cannot tell by itself.
360
+ */
361
+ let pressedInActions = false
362
+
363
+ const onPointerDown = (event: PointerEvent, row: Row) => {
364
+ suppressClick = false
365
+ pressedInActions = isInActions(event.target)
366
+ if (!row.draggable || !isTouchPointer(event) || pressedInActions) return
367
+
368
+ const element = event.currentTarget as HTMLElement
369
+ pointerDrag.value = {
370
+ id: row.node.id,
371
+ pointerId: event.pointerId,
372
+ startX: event.clientX,
373
+ startY: event.clientY,
374
+ element,
375
+ cancelled: false,
376
+ }
377
+ element.setPointerCapture?.(event.pointerId)
378
+ }
379
+
380
+ const onPointerMove = (event: PointerEvent) => {
381
+ const pointer = pointerDrag.value
382
+ if (!pointer || pointer.cancelled || pointer.pointerId !== event.pointerId) return
383
+
384
+ if (!draggingId.value) {
385
+ const distance = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY)
386
+ if (distance < 8) return
387
+
388
+ // no drag image to wait for: the finger covers the row
389
+ startDrag(pointer.id)
390
+ dragVisualId.value = pointer.id
391
+ }
392
+
393
+ event.preventDefault()
394
+ updateDropAt(event.clientX, event.clientY)
395
+ }
396
+
397
+ /**
398
+ * Lifting the finger can still fire a click on the row, which should not select it; a
399
+ * `pointercancel` never does, so it leaves the next click alone.
400
+ */
401
+ const finishPointerDrag = (event: PointerEvent, cancelled = false) => {
402
+ const pointer = pointerDrag.value
403
+ if (!pointer || pointer.pointerId !== event.pointerId) return
404
+
405
+ if (pointer.cancelled) {
406
+ suppressClick = !cancelled
407
+ } else if (draggingId.value === pointer.id) {
408
+ event.preventDefault()
409
+
410
+ const source = sourceRow.value
411
+ const drop = activeDrop.value
412
+ suppressClick = !cancelled
413
+ endDrag()
414
+
415
+ if (!cancelled && source && drop) land(source, drop)
416
+ }
417
+
418
+ pointerDrag.value = null
419
+ }
420
+
421
+ const onPointerUp = (event: PointerEvent) => finishPointerDrag(event)
422
+ const onPointerCancel = (event: PointerEvent) => finishPointerDrag(event, true)
423
+
424
+ /**
425
+ * Only the row itself drags. A press in the actions does not, nor does anything in it that the
426
+ * browser drags on its own, such as a link or an image: then the target is not the row.
427
+ */
428
+ const onDragStart = (event: DragEvent, row: Row) => {
429
+ if (!row.draggable) return
430
+
431
+ if (pressedInActions || event.target !== event.currentTarget) {
432
+ event.preventDefault()
433
+
434
+ return
435
+ }
436
+
437
+ startDrag(row.node.id)
438
+ // After the browser has taken the drag image, which should show the row at full strength
439
+ setTimeout(() => {
440
+ if (draggingId.value === row.node.id) dragVisualId.value = row.node.id
441
+ })
442
+
443
+ if (event.dataTransfer) {
444
+ event.dataTransfer.effectAllowed = 'move'
445
+ event.dataTransfer.setData('text/plain', row.node.id)
446
+ }
447
+ }
448
+
449
+ /** Also what Escape and a drop outside the tree end in: the browser fires no `drop` for those. */
450
+ const onDragEnd = () => endDrag()
451
+
452
+ const onDragOver = (event: DragEvent) => {
453
+ if (!draggingId.value) return
454
+
455
+ updateDropAt(event.clientX, event.clientY)
456
+ if (!activeDrop.value) {
457
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'none'
458
+
459
+ return
460
+ }
461
+
462
+ event.preventDefault()
463
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
464
+ }
465
+
466
+ /** Leaving the tree, not a row: going from a row to a gap or the ghost row keeps the spot. */
467
+ const onDragLeave = (event: DragEvent) => {
468
+ const into = event.relatedTarget as Node | null
469
+ const leftTree = into ? !root.value?.contains(into) : !isInsideTree(event.clientX, event.clientY)
470
+
471
+ if (leftTree) setDrop(null)
472
+ }
473
+
474
+ const onDrop = (event: DragEvent) => {
475
+ if (!draggingId.value) return
476
+ event.preventDefault()
477
+
478
+ const source = sourceRow.value
479
+ const drop = activeDrop.value
480
+ endDrag()
481
+
482
+ if (source && drop) land(source, drop)
483
+ }
484
+
485
+ // The dropped row lands on the ghost row's spot, but the group still slides it there from its
486
+ // old one. Ending that move right after it was set up — before anything is painted — puts it in
487
+ // place at once, while the rows that close the gap behind it keep sliding.
488
+ watch(
489
+ rows,
490
+ async (current) => {
491
+ const target = landing
492
+ if (!target) return
493
+
494
+ const row = current.find((item) => item.node.id === target.id)
495
+ if (row && row.parentId === target.parentId && row.position === target.position) return
496
+
497
+ landing = null
498
+ await nextTick()
499
+ rowElement(target.id)?.dispatchEvent(
500
+ new TransitionEvent('transitionend', { propertyName: 'transform' }),
501
+ )
502
+ },
503
+ { flush: 'post' },
504
+ )
505
+
506
+ onBeforeUnmount(() => {
507
+ window.removeEventListener('keydown', onWindowKeydown, true)
508
+ document.removeEventListener('dragover', onDocumentDragOver)
509
+ })
510
+
511
+ const onRowClick = (id: string) => {
512
+ if (suppressClick) {
513
+ suppressClick = false
514
+
515
+ return
516
+ }
517
+
518
+ selected.value = id
519
+ }
520
+
89
521
  // A row is only highlighted while it is actually on screen; collapsing its parent fades the
90
522
  // highlight out rather than dropping it, so reopening slides it back from the same spot
91
523
  const selectionVisible = computed(
@@ -146,8 +578,13 @@ const jumpTo = async (reposition: () => Promise<void>) => {
146
578
  motion.value = previous
147
579
  }
148
580
 
149
- // Rows above the selection can appear or disappear, so every row change is a reposition too
150
- watch([selected, rows], ([id], [previousId]) => {
581
+ /** Where the ghost row is, as a value: a spot picked again through another row leaves it equal. */
582
+ const ghostPlace = computed(() => (ghost.value ? `${ghost.value.at}:${ghost.value.depth}` : null))
583
+
584
+ // Rows above the selection can appear or disappear, so every row change is a reposition too. So
585
+ // is the ghost row: it pushes the rows below it aside without being one of them, and the resize
586
+ // it causes would otherwise jump the highlight straight to its end position.
587
+ watch([selected, rows, ghostPlace], ([id], [previousId]) => {
151
588
  motion.value = id === previousId ? 'move' : 'select'
152
589
  updateHighlight()
153
590
  })
@@ -184,10 +621,32 @@ const ENTER_TRANSITION = 'transform 200ms var(--ease-in-out-strong), opacity 80m
184
621
  /** Ids of the rows that are still unfolding; `focusNode` holds its scroll until they have landed. */
185
622
  const unfolding = new Set<string>()
186
623
 
624
+ /**
625
+ * The ghost row's surface is there at once; only its label comes up, on the same timing as an
626
+ * unfolding row's, so it reads once the rows below have all but made room.
627
+ */
628
+ const GHOST_ENTER_TRANSITION = 'opacity 80ms linear 120ms'
629
+
630
+ const ghostContent = (element: HTMLElement) =>
631
+ 'treeGhost' in element.dataset
632
+ ? element.querySelector<HTMLElement>('[data-tree-ghost-content]')
633
+ : null
634
+
187
635
  const onEnter = (element: Element) => {
188
636
  const row = element as HTMLElement
189
637
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
190
638
 
639
+ const content = ghostContent(row)
640
+ if (content) {
641
+ content.style.transition = 'none'
642
+ content.style.opacity = '0'
643
+ content.getBoundingClientRect()
644
+ content.style.transition = GHOST_ENTER_TRANSITION
645
+ content.style.opacity = ''
646
+
647
+ return
648
+ }
649
+
191
650
  unfolding.add(row.dataset.treeId!)
192
651
 
193
652
  const parent = rowElement(
@@ -215,6 +674,13 @@ let scrollOnLanding: string | null = null
215
674
  const onEnterDone = (element: Element) => {
216
675
  const row = element as HTMLElement
217
676
 
677
+ const content = ghostContent(row)
678
+ if (content) {
679
+ content.style.transition = ''
680
+
681
+ return
682
+ }
683
+
218
684
  row.style.transition = ''
219
685
  row.style.pointerEvents = ''
220
686
  unfolding.delete(row.dataset.treeId!)
@@ -381,7 +847,20 @@ defineExpose({ focusNode })
381
847
  </script>
382
848
 
383
849
  <template>
384
- <div ref="root" role="tree" :aria-label="label" class="relative isolate" @keydown="onKeydown">
850
+ <div
851
+ ref="root"
852
+ role="tree"
853
+ :aria-label="label"
854
+ class="relative isolate"
855
+ @keydown="onKeydown"
856
+ @pointermove="onPointerMove"
857
+ @pointerup="onPointerUp"
858
+ @pointercancel="onPointerCancel"
859
+ @dragenter="onDragOver"
860
+ @dragover="onDragOver"
861
+ @dragleave="onDragLeave"
862
+ @drop="onDrop"
863
+ >
385
864
  <!-- One highlight for the whole tree: selecting another row slides it instead of
386
865
  repainting two backgrounds. Behind the rows, so their hover still reads. -->
387
866
  <div
@@ -407,69 +886,91 @@ defineExpose({ focusNode })
407
886
  @after-enter="onEnterDone"
408
887
  @enter-cancelled="onEnterDone"
409
888
  >
410
- <!-- Every row keeps `small-normal`: switching the selected one to semibold widened its
411
- label, which made the text jump. Colour carries the selection instead. -->
412
- <div
413
- v-for="row in rows"
414
- :key="row.node.id"
415
- role="treeitem"
416
- :aria-level="row.depth + 1"
417
- :aria-posinset="row.position"
418
- :aria-setsize="row.setSize"
419
- :aria-selected="selected === row.node.id"
420
- :aria-expanded="row.expandable ? row.expanded : undefined"
421
- :tabindex="tabindexOf(row.node.id)"
422
- :aria-labelledby="labelId(row.node.id)"
423
- :data-tree-id="row.node.id"
424
- class="group mb-0.5 flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-2 small-normal transition-colors duration-150 select-none focus-visible:outline-2 focus-visible:outline-cornflower-blue-500 motion-reduce:transition-none"
425
- :class="
426
- selected === row.node.id ? 'text-cornflower-blue-600' : 'text-gray-800 hover:bg-gray-100'
427
- "
428
- :style="{ paddingLeft: `${row.depth * INDENT + 4}px` }"
429
- @click="selected = row.node.id"
430
- @focus="focusedId = row.node.id"
431
- >
432
- <!-- Out of the tab order: the arrow keys expand and collapse for keyboard users. A click
433
- does not focus it either, as focus inside `aria-hidden` is blocked; see
434
- `onChevronClick` for where the focus goes instead. Stays grey on the selected row:
435
- the chevron is a control, not part of the label. -->
436
- <button
437
- v-if="row.expandable"
438
- type="button"
439
- tabindex="-1"
440
- aria-hidden="true"
441
- class="flex size-5 shrink-0 cursor-pointer items-center justify-center text-gray-400 transition-colors duration-150 group-hover:text-gray-600 hover:text-gray-600 motion-reduce:transition-none"
442
- @mousedown.prevent
443
- @click.stop="onChevronClick(row.node.id)"
889
+ <template v-for="item in items" :key="item.key">
890
+ <!-- The drop indicator: where the dragged node lands, at the depth it lands on. Out of
891
+ the accessibility tree and without `data-tree-id`, so it is no row to the keyboard,
892
+ the tab stop or the highlight. One element for the whole drag, that changes spots. -->
893
+ <div v-if="item.ghost" aria-hidden="true" class="mb-0.5" data-tree-ghost>
894
+ <div
895
+ class="flex rounded-md bg-cornflower-blue-50 py-1 pr-2 pl-1 small-normal text-gray-700 outline-1 -outline-offset-1 outline-cornflower-blue-600 transition-[margin-left] duration-200 ease-in-out-strong outline-dashed motion-reduce:transition-none"
896
+ :style="{ marginLeft: `${item.depth * INDENT}px` }"
897
+ >
898
+ <span data-tree-ghost-content class="flex min-w-0 items-center gap-1.5">
899
+ <span class="size-5 shrink-0" />
900
+ <NexxtIcon v-if="item.node.icon" :name="item.node.icon" class="shrink-0" />
901
+ <span class="truncate">{{ item.node.label }}</span>
902
+ </span>
903
+ </div>
904
+ </div>
905
+
906
+ <!-- Every row keeps `small-normal`: switching the selected one to semibold widened its
907
+ label, which made the text jump. Colour carries the selection instead. -->
908
+ <div
909
+ v-else
910
+ role="treeitem"
911
+ :aria-level="item.row.depth + 1"
912
+ :aria-posinset="item.row.position"
913
+ :aria-setsize="item.row.setSize"
914
+ :aria-selected="selected === item.row.node.id"
915
+ :aria-expanded="item.row.expandable ? item.row.expanded : undefined"
916
+ :tabindex="tabindexOf(item.row.node.id)"
917
+ :aria-labelledby="labelId(item.row.node.id)"
918
+ :data-tree-id="item.row.node.id"
919
+ :data-tree-drop="collapsedTargetId === item.row.node.id ? 'inside' : undefined"
920
+ :draggable="item.row.draggable"
921
+ class="group mb-0.5 flex items-center gap-1.5 rounded-md py-1 pr-2 small-normal transition-[color,background-color,outline-color,opacity] duration-150 select-none focus-visible:outline-2 focus-visible:outline-cornflower-blue-500 motion-reduce:transition-none"
922
+ :class="rowClasses(item.row)"
923
+ :style="{ paddingLeft: `${item.row.depth * INDENT + 4}px` }"
924
+ @click="onRowClick(item.row.node.id)"
925
+ @focus="focusedId = item.row.node.id"
926
+ @pointerdown="onPointerDown($event, item.row)"
927
+ @dragstart="onDragStart($event, item.row)"
928
+ @dragend="onDragEnd"
444
929
  >
930
+ <!-- Out of the tab order: the arrow keys expand and collapse for keyboard users. A click
931
+ does not focus it either, as focus inside `aria-hidden` is blocked; see
932
+ `onChevronClick` for where the focus goes instead. Stays grey on the selected row:
933
+ the chevron is a control, not part of the label. -->
934
+ <button
935
+ v-if="item.row.expandable"
936
+ type="button"
937
+ tabindex="-1"
938
+ aria-hidden="true"
939
+ class="flex size-5 shrink-0 cursor-pointer items-center justify-center text-gray-400 transition-colors duration-150 motion-reduce:transition-none"
940
+ :class="!dragVisualId && 'group-hover:text-gray-600 hover:text-gray-600'"
941
+ @mousedown.prevent
942
+ @click.stop="onChevronClick(item.row.node.id)"
943
+ >
944
+ <NexxtIcon
945
+ name="chevron-down"
946
+ class="extra-small-normal transition-transform duration-150 ease-out motion-reduce:transition-none"
947
+ :class="!item.row.expanded && '-rotate-90'"
948
+ />
949
+ </button>
950
+ <span v-else class="size-5 shrink-0" />
951
+
445
952
  <NexxtIcon
446
- name="chevron-down"
447
- class="extra-small-normal transition-transform duration-150 ease-out motion-reduce:transition-none"
448
- :class="!row.expanded && '-rotate-90'"
953
+ v-if="item.row.node.icon"
954
+ :name="item.row.node.icon"
955
+ class="shrink-0 transition-colors duration-150 motion-reduce:transition-none"
956
+ :class="selected === item.row.node.id ? 'text-cornflower-blue-600' : 'text-gray-600'"
449
957
  />
450
- </button>
451
- <span v-else class="size-5 shrink-0" />
452
-
453
- <NexxtIcon
454
- v-if="row.node.icon"
455
- :name="row.node.icon"
456
- class="shrink-0 transition-colors duration-150 motion-reduce:transition-none"
457
- :class="selected === row.node.id ? 'text-cornflower-blue-600' : 'text-gray-600'"
458
- />
459
- <span :id="labelId(row.node.id)" class="truncate">{{ row.node.label }}</span>
460
-
461
- <!-- Revealed by opacity rather than removed, so the row keeps its layout while it
462
- appears. Pulled into the row's padding so a 24px button leaves the row at 28px.
463
- Stopping here keeps the row's click and the tree's keys away from the actions. -->
464
- <div
465
- v-if="slots['node-actions']"
466
- class="-my-0.5 ml-auto flex shrink-0 items-center opacity-0 transition-opacity duration-150 ease-out group-focus-within:opacity-100 group-hover:opacity-100 motion-reduce:transition-none pointer-coarse:opacity-100"
467
- @click.stop
468
- @keydown.stop
469
- >
470
- <NodeActions :node="row.node" :tabindex="tabindexOf(row.node.id)" />
958
+ <span :id="labelId(item.row.node.id)" class="truncate">{{ item.row.node.label }}</span>
959
+
960
+ <!-- Revealed by opacity rather than removed, so the row keeps its layout while it
961
+ appears. Pulled into the row's padding so a 24px button leaves the row at 28px.
962
+ Stopping here keeps the row's click and the tree's keys away from the actions. -->
963
+ <div
964
+ v-if="slots['node-actions']"
965
+ data-tree-actions
966
+ class="-my-0.5 ml-auto flex shrink-0 items-center opacity-0 transition-opacity duration-150 ease-out group-focus-within:opacity-100 group-hover:opacity-100 motion-reduce:transition-none pointer-coarse:opacity-100"
967
+ @click.stop
968
+ @keydown.stop
969
+ >
970
+ <NodeActions :node="item.row.node" :tabindex="tabindexOf(item.row.node.id)" />
971
+ </div>
471
972
  </div>
472
- </div>
973
+ </template>
473
974
  </TransitionGroup>
474
975
  </div>
475
976
  </template>