@nexxtmove/ui 1.37.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.
- package/dist/components/molecules/Tree/Tree.vue.d.ts +25 -0
- package/dist/components/molecules/Tree/tree-drop.d.ts +81 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +786 -508
- package/package.json +1 -1
- package/src/components/molecules/Tree/Tree.vue +573 -72
- package/src/components/molecules/Tree/tree-drop.ts +155 -0
- package/src/index.ts +1 -0
|
@@ -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
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
</
|
|
973
|
+
</template>
|
|
473
974
|
</TransitionGroup>
|
|
474
975
|
</div>
|
|
475
976
|
</template>
|