@greendrake/ui 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -11
- package/package.json +5 -5
- package/src/BottomNav.vue +2 -2
- package/src/CircularProgress.vue +6 -2
- package/src/ContextMenu.vue +77 -0
- package/src/Copy.vue +2 -1
- package/src/FieldTip.ts +4 -4
- package/src/InfoBlock.vue +45 -0
- package/src/InfoTip.vue +33 -0
- package/src/MenuButton.vue +3 -1
- package/src/ModalOnClick.vue +13 -6
- package/src/RowSelect.ts +4 -27
- package/src/SplitPane.vue +85 -0
- package/src/SwitchRow.vue +74 -0
- package/src/TabBar.vue +11 -0
- package/src/TabPanels.vue +38 -0
- package/src/{FieldTip.scss → Tip.scss} +7 -7
- package/src/TreeView.vue +368 -69
- package/src/capturePointer.ts +15 -0
- package/src/main.ts +12 -3
- package/src/messages.ts +17 -7
- package/src/useSelection.ts +67 -72
- package/src/useWidthDrag.ts +38 -0
package/src/TreeView.vue
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Directive } from 'vue'
|
|
3
|
+
|
|
2
4
|
// Node shape (whatever the loader returns) must carry at least:
|
|
3
5
|
// - [idField] (default 'key') — unique string/number id
|
|
4
6
|
// - [labelField] (default 'title') — display text
|
|
@@ -13,14 +15,52 @@ export type TreeNodeId = string | number
|
|
|
13
15
|
// loader(parentId | null) => Promise<TreeNode[]> (null → root level)
|
|
14
16
|
export type TreeLoader = (parentId: TreeNodeId | null) => Promise<TreeNode[]>
|
|
15
17
|
|
|
18
|
+
/** One place a node shows: under a parent, or at the root level (null). A node with two parents shows twice. */
|
|
19
|
+
export interface TreeOccurrence {
|
|
20
|
+
node: TreeNodeId
|
|
21
|
+
parent: TreeNodeId | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A dragged occurrence dropped on a row: inside it (the node goes under the
|
|
26
|
+
* row's node), or before/after it among the row's siblings.
|
|
27
|
+
*/
|
|
28
|
+
export type TreeMove = { node: TreeNodeId; from: TreeNodeId | null } & ({ position: 'inside'; to: TreeNodeId } | { position: 'before' | 'after'; to: TreeNodeId | null; relativeTo: TreeNodeId })
|
|
29
|
+
|
|
30
|
+
type DropPosition = TreeMove['position']
|
|
31
|
+
|
|
16
32
|
type Guide = 'through' | 'blank' | 'elbow' | 'elbow-last'
|
|
17
33
|
|
|
18
|
-
type
|
|
34
|
+
type Row = {
|
|
35
|
+
// The row's path of ids from its root, as a key: unique per row.
|
|
36
|
+
key: string
|
|
37
|
+
depth: number
|
|
38
|
+
guides: Guide[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A node's row: one occurrence of it. */
|
|
42
|
+
type NodeRow = Row & {
|
|
19
43
|
id: TreeNodeId
|
|
44
|
+
parent: TreeNodeId | null
|
|
20
45
|
node: TreeNode
|
|
21
|
-
depth: number
|
|
22
46
|
expandable: boolean
|
|
23
|
-
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The input of a new child, last under its parent's row. */
|
|
50
|
+
type NewRow = Row & { parent: TreeNodeId }
|
|
51
|
+
|
|
52
|
+
// Joins a path's ids; no id carries the unit separator. A new child's row
|
|
53
|
+
// ends its parent's path in an empty id, which no node has.
|
|
54
|
+
const pathSeparator = '\u001f'
|
|
55
|
+
const pathKey = (parentKey: string | null, id: TreeNodeId): string => (parentKey === null ? String(id) : parentKey + pathSeparator + String(id))
|
|
56
|
+
const newRowKey = (parentKey: string): string => pathKey(parentKey, '')
|
|
57
|
+
|
|
58
|
+
// Focuses an in-place input as it shows, its text selected.
|
|
59
|
+
const vSelectOnMount: Directive<HTMLInputElement> = {
|
|
60
|
+
mounted: el => {
|
|
61
|
+
el.focus()
|
|
62
|
+
el.select()
|
|
63
|
+
}
|
|
24
64
|
}
|
|
25
65
|
</script>
|
|
26
66
|
<script setup lang="ts">
|
|
@@ -28,10 +68,18 @@ type VisibleEntry = {
|
|
|
28
68
|
// node; expanding/collapsing only re-walks the cache. The whole tree is rendered
|
|
29
69
|
// as one flat list of the currently-visible nodes (each carrying its depth), so
|
|
30
70
|
// selection is a flat, index-addressable problem handled by the shared
|
|
31
|
-
// useSelection composable — the same one the data table uses
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
71
|
+
// useSelection composable — the same one the data table uses: click = single,
|
|
72
|
+
// shift = range, ctrl/cmd = toggle, the arrows move the cursor (shift
|
|
73
|
+
// extending the range), ctrl/cmd+A selects all; there are no checkboxes. The
|
|
74
|
+
// selected node ids are exposed as a v-model array.
|
|
75
|
+
//
|
|
76
|
+
// A node may have several parents: it then shows under each, every row keyed
|
|
77
|
+
// by its path from the root, while the children cache, expansion and selection
|
|
78
|
+
// stay per node. The cursor's row is the focused occurrence — which node, under
|
|
79
|
+
// which parent. With `draggable`, a row dropped on another emits `move`. With
|
|
80
|
+
// onRename, a double-clicked label turns into an input; with onAddChild,
|
|
81
|
+
// addChild() opens one for a new child.
|
|
82
|
+
import { ref, computed, reactive, watch, onMounted, nextTick } from 'vue'
|
|
35
83
|
import useSelection from './useSelection'
|
|
36
84
|
|
|
37
85
|
const props = withDefaults(
|
|
@@ -39,15 +87,29 @@ const props = withDefaults(
|
|
|
39
87
|
loader: TreeLoader
|
|
40
88
|
idField?: string
|
|
41
89
|
labelField?: string
|
|
90
|
+
/** The field a rename edits; the label's by default. */
|
|
91
|
+
editField?: string
|
|
42
92
|
multiSelect?: boolean
|
|
93
|
+
draggable?: boolean
|
|
94
|
+
/** Saves a rename; a rejection keeps the input open. */
|
|
95
|
+
onRename?: (node: TreeNodeId, value: string) => Promise<void>
|
|
96
|
+
/** Saves a new child; a rejection keeps the input open. */
|
|
97
|
+
onAddChild?: (parent: TreeNodeId, value: string) => Promise<void>
|
|
43
98
|
}>(),
|
|
44
99
|
{
|
|
45
100
|
idField: 'key',
|
|
46
101
|
labelField: 'title',
|
|
47
|
-
multiSelect: true
|
|
102
|
+
multiSelect: true,
|
|
103
|
+
draggable: false
|
|
48
104
|
}
|
|
49
105
|
)
|
|
50
106
|
|
|
107
|
+
const emit = defineEmits<{
|
|
108
|
+
move: [move: TreeMove]
|
|
109
|
+
/** A row was right-clicked, after it became the focused one, selected. */
|
|
110
|
+
contextmenu: [event: MouseEvent]
|
|
111
|
+
}>()
|
|
112
|
+
|
|
51
113
|
const selected = defineModel<TreeNodeId[]>('selected', { default: () => [] })
|
|
52
114
|
|
|
53
115
|
const roots = ref<TreeNode[]>([])
|
|
@@ -57,41 +119,71 @@ const childrenCache = reactive(new Map<TreeNodeId, TreeNode[]>())
|
|
|
57
119
|
const expanded = reactive(new Set<TreeNodeId>())
|
|
58
120
|
const childLoading = reactive(new Set<TreeNodeId>())
|
|
59
121
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
122
|
+
// The row whose input shows — a node's being renamed, or a new child's —
|
|
123
|
+
// pending while the consumer saves it.
|
|
124
|
+
const editing = ref<{ key: string; pending: boolean } | null>(null)
|
|
125
|
+
|
|
126
|
+
// Depth-first flatten of every currently-visible row: what the template
|
|
127
|
+
// renders. Its node rows are the index space useSelection operates on (range
|
|
128
|
+
// selection walks contiguous rows there).
|
|
63
129
|
//
|
|
64
|
-
// Each
|
|
130
|
+
// Each row also carries the `guides` it needs to draw the connector lines: one
|
|
65
131
|
// cell per indentation column. `ancestorVerticals[i]` (threaded down the walk)
|
|
66
132
|
// says whether the ancestor sitting in column i still has a sibling below — i.e.
|
|
67
|
-
// whether a vertical line passes through this row at that column. The
|
|
133
|
+
// whether a vertical line passes through this row at that column. The row's own
|
|
68
134
|
// (rightmost) column is the elbow: '├' when more siblings follow, '└' when last.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
135
|
+
// A new child's input closes its level, after the nodes.
|
|
136
|
+
//
|
|
137
|
+
// A node's loaded children are authoritative for whether it expands: a reload
|
|
138
|
+
// that empties or fills its list outdates the flag its parent's load carried.
|
|
139
|
+
const rows = computed(() => {
|
|
140
|
+
const out: (NodeRow | NewRow)[] = []
|
|
141
|
+
const walk = (nodes: TreeNode[], parent: NodeRow | null, ancestorVerticals: boolean[]) => {
|
|
142
|
+
const depth = parent ? parent.depth + 1 : 0
|
|
143
|
+
const adding = parent !== null && editing.value?.key === newRowKey(parent.key)
|
|
144
|
+
const count = nodes.length + (adding ? 1 : 0)
|
|
145
|
+
const guidesAt = (idx: number): Guide[] => {
|
|
146
|
+
const guides: Guide[] = ancestorVerticals.map(v => (v ? 'through' : 'blank'))
|
|
147
|
+
if (depth > 0) guides.push(idx === count - 1 ? 'elbow-last' : 'elbow')
|
|
148
|
+
return guides
|
|
149
|
+
}
|
|
72
150
|
nodes.forEach((node, idx) => {
|
|
73
151
|
const id = node[props.idField] as TreeNodeId
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
out.push({
|
|
152
|
+
const children = childrenCache.get(id)
|
|
153
|
+
const row: NodeRow = {
|
|
154
|
+
key: pathKey(parent?.key ?? null, id),
|
|
78
155
|
id,
|
|
156
|
+
parent: parent?.id ?? null,
|
|
79
157
|
node,
|
|
80
158
|
depth,
|
|
81
|
-
expandable: node.expandable,
|
|
82
|
-
guides
|
|
83
|
-
}
|
|
84
|
-
|
|
159
|
+
expandable: children ? children.length > 0 : node.expandable,
|
|
160
|
+
guides: guidesAt(idx)
|
|
161
|
+
}
|
|
162
|
+
out.push(row)
|
|
85
163
|
if (expanded.has(id) && children) {
|
|
86
|
-
walk(children,
|
|
164
|
+
walk(children, row, depth === 0 ? [] : [...ancestorVerticals, idx < count - 1])
|
|
87
165
|
}
|
|
88
166
|
})
|
|
167
|
+
if (adding) {
|
|
168
|
+
out.push({
|
|
169
|
+
key: newRowKey(parent.key),
|
|
170
|
+
parent: parent.id,
|
|
171
|
+
depth,
|
|
172
|
+
guides: guidesAt(nodes.length)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
89
175
|
}
|
|
90
|
-
walk(roots.value,
|
|
176
|
+
walk(roots.value, null, [])
|
|
91
177
|
return out
|
|
92
178
|
})
|
|
93
179
|
|
|
94
|
-
const
|
|
180
|
+
const nodeRows = computed(() => rows.value.filter((row): row is NodeRow => 'node' in row))
|
|
181
|
+
|
|
182
|
+
const selection = useSelection<TreeNodeId>(nodeRows, 'id', { keyField: 'key', multi: () => props.multiSelect })
|
|
183
|
+
|
|
184
|
+
// The cursor's row and its occurrence, while visible.
|
|
185
|
+
const focusedRow = computed(() => nodeRows.value.find(row => row.key === selection.cursor.value) ?? null)
|
|
186
|
+
const focused = computed<TreeOccurrence | null>(() => focusedRow.value && { node: focusedRow.value.id, parent: focusedRow.value.parent })
|
|
95
187
|
|
|
96
188
|
// Selection is owned by useSelection's Set; the v-model array is a mirror. Seed
|
|
97
189
|
// the Set from any initial model value, then keep both in sync. Each watcher
|
|
@@ -114,60 +206,235 @@ watch(selected, val => {
|
|
|
114
206
|
for (const id of val) set.add(id)
|
|
115
207
|
})
|
|
116
208
|
|
|
117
|
-
async function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (!childrenCache.has(entry.id)) {
|
|
124
|
-
childLoading.add(entry.id)
|
|
125
|
-
try {
|
|
126
|
-
childrenCache.set(entry.id, await props.loader(entry.id))
|
|
127
|
-
} finally {
|
|
128
|
-
childLoading.delete(entry.id)
|
|
129
|
-
}
|
|
209
|
+
async function loadChildren(id: TreeNodeId): Promise<void> {
|
|
210
|
+
childLoading.add(id)
|
|
211
|
+
try {
|
|
212
|
+
childrenCache.set(id, await props.loader(id))
|
|
213
|
+
} finally {
|
|
214
|
+
childLoading.delete(id)
|
|
130
215
|
}
|
|
131
|
-
expanded.add(entry.id)
|
|
132
216
|
}
|
|
133
217
|
|
|
134
|
-
function
|
|
135
|
-
if (props.multiSelect && e.shiftKey) selection.rangeTo(entry.id)
|
|
136
|
-
else if (props.multiSelect && (e.ctrlKey || e.metaKey)) selection.toggle(entry.id)
|
|
137
|
-
else selection.replace(entry.id)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
onMounted(async () => {
|
|
218
|
+
async function loadRoots(): Promise<void> {
|
|
141
219
|
loading.value = true
|
|
142
220
|
try {
|
|
143
221
|
roots.value = await props.loader(null)
|
|
144
222
|
} finally {
|
|
145
223
|
loading.value = false
|
|
146
224
|
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Re-fetches a node's children (null: the root level), keeping what is expanded. */
|
|
228
|
+
async function reload(nodeId: TreeNodeId | null): Promise<void> {
|
|
229
|
+
await (nodeId === null ? loadRoots() : loadChildren(nodeId))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The loaded levels holding the node: the ids of the parents it shows under, null for the root level. */
|
|
233
|
+
function levelsOf(nodeId: TreeNodeId): (TreeNodeId | null)[] {
|
|
234
|
+
const holds = (nodes: TreeNode[]): boolean => nodes.some(n => n[props.idField] === nodeId)
|
|
235
|
+
const levels: (TreeNodeId | null)[] = holds(roots.value) ? [null] : []
|
|
236
|
+
for (const [parent, children] of childrenCache) {
|
|
237
|
+
if (holds(children)) levels.push(parent)
|
|
238
|
+
}
|
|
239
|
+
return levels
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Expands the node wherever it shows, loading its children first if need be. */
|
|
243
|
+
async function expand(nodeId: TreeNodeId): Promise<void> {
|
|
244
|
+
if (!childrenCache.has(nodeId)) {
|
|
245
|
+
await loadChildren(nodeId)
|
|
246
|
+
}
|
|
247
|
+
expanded.add(nodeId)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function toggleExpand(row: NodeRow) {
|
|
251
|
+
if (!row.expandable) return
|
|
252
|
+
if (expanded.has(row.id)) {
|
|
253
|
+
expanded.delete(row.id)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
await expand(row.id)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// A right-click focuses its row, selecting it alone unless it is selected
|
|
260
|
+
// already, before the consumer's menu opens.
|
|
261
|
+
function onRowContextMenu(row: NodeRow, e: MouseEvent): void {
|
|
262
|
+
if (selection.selected.has(row.id)) selection.cursor.value = row.key
|
|
263
|
+
else selection.replace(row.key)
|
|
264
|
+
emit('contextmenu', e)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const list = ref<HTMLElement>()
|
|
268
|
+
|
|
269
|
+
// The cursor's row, moved by a key, scrolls into view.
|
|
270
|
+
async function onKeyDown(e: KeyboardEvent): Promise<void> {
|
|
271
|
+
if (selection.onKeyDown(e) === -1) return
|
|
272
|
+
await nextTick()
|
|
273
|
+
list.value!.querySelector('[aria-current]')!.scrollIntoView({ block: 'nearest' })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- in-place editing ---
|
|
277
|
+
|
|
278
|
+
const editValue = (row: NodeRow | NewRow): string => ('node' in row ? String(row.node[props.editField ?? props.labelField] ?? '') : '')
|
|
279
|
+
|
|
280
|
+
/** Opens the input of a new child last under the focused row, expanding it first. */
|
|
281
|
+
async function addChild(): Promise<void> {
|
|
282
|
+
const row = focusedRow.value!
|
|
283
|
+
await expand(row.id)
|
|
284
|
+
editing.value = { key: newRowKey(row.key), pending: false }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Enter saves the input through the consumer — a rename that changes the
|
|
288
|
+
// value, a new child that has one — and Escape drops it; the input keeps its
|
|
289
|
+
// keys from the tree's. A failed save leaves the input open, focused again.
|
|
290
|
+
async function onEditKey(row: NodeRow | NewRow, e: KeyboardEvent): Promise<void> {
|
|
291
|
+
e.stopPropagation()
|
|
292
|
+
if (e.key === 'Escape') {
|
|
293
|
+
editing.value = null
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (e.key !== 'Enter') return
|
|
297
|
+
const input = e.target as HTMLInputElement
|
|
298
|
+
const value = input.value.trim()
|
|
299
|
+
if (value === editValue(row)) {
|
|
300
|
+
editing.value = null
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
const edit = editing.value!
|
|
304
|
+
edit.pending = true
|
|
305
|
+
try {
|
|
306
|
+
await ('node' in row ? props.onRename!(row.id, value) : props.onAddChild!(row.parent, value))
|
|
307
|
+
editing.value = null
|
|
308
|
+
} catch (err) {
|
|
309
|
+
edit.pending = false
|
|
310
|
+
await nextTick()
|
|
311
|
+
input.focus()
|
|
312
|
+
throw err
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Leaving the input drops it, unless it is saving.
|
|
317
|
+
function onEditBlur(): void {
|
|
318
|
+
if (!editing.value?.pending) editing.value = null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- dragging ---
|
|
322
|
+
|
|
323
|
+
const dragged = ref<NodeRow | null>(null)
|
|
324
|
+
const dropTarget = ref<{ key: string; position: DropPosition } | null>(null)
|
|
325
|
+
|
|
326
|
+
// Only the row itself drags: a text drag out of its input is no move.
|
|
327
|
+
function onDragStart(row: NodeRow, e: DragEvent): void {
|
|
328
|
+
if (e.target !== e.currentTarget) return
|
|
329
|
+
dragged.value = row
|
|
330
|
+
e.dataTransfer!.effectAllowed = 'move'
|
|
331
|
+
e.dataTransfer!.setData('text/plain', String(row.id))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Where over the row the pointer is: its upper third before the row, its
|
|
335
|
+
// lower third after it, the middle inside. A row whose path passes through
|
|
336
|
+
// the dragged node — any occurrence of it, or any row under one — is no
|
|
337
|
+
// target: the drop would put the node under itself.
|
|
338
|
+
function dropPosition(row: NodeRow, e: DragEvent): DropPosition | null {
|
|
339
|
+
if (!dragged.value || row.key.split(pathSeparator).includes(String(dragged.value.id))) return null
|
|
340
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
341
|
+
const at = (e.clientY - rect.top) / rect.height
|
|
342
|
+
return at < 1 / 3 ? 'before' : at > 2 / 3 ? 'after' : 'inside'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function onDragOver(row: NodeRow, e: DragEvent): void {
|
|
346
|
+
const position = dropPosition(row, e)
|
|
347
|
+
if (!position) {
|
|
348
|
+
dropTarget.value = null
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
e.preventDefault()
|
|
352
|
+
e.dataTransfer!.dropEffect = 'move'
|
|
353
|
+
dropTarget.value = { key: row.key, position }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function onDragLeave(row: NodeRow): void {
|
|
357
|
+
if (dropTarget.value?.key === row.key) dropTarget.value = null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function onDrop(row: NodeRow, e: DragEvent): void {
|
|
361
|
+
const position = dropPosition(row, e)
|
|
362
|
+
const head = dragged.value
|
|
363
|
+
onDragEnd()
|
|
364
|
+
if (!position || !head) return
|
|
365
|
+
e.preventDefault()
|
|
366
|
+
const from = { node: head.id, from: head.parent }
|
|
367
|
+
emit(
|
|
368
|
+
'move',
|
|
369
|
+
position === 'inside'
|
|
370
|
+
? {
|
|
371
|
+
...from,
|
|
372
|
+
position,
|
|
373
|
+
to: row.id
|
|
374
|
+
}
|
|
375
|
+
: {
|
|
376
|
+
...from,
|
|
377
|
+
position,
|
|
378
|
+
to: row.parent,
|
|
379
|
+
relativeTo: row.id
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function onDragEnd(): void {
|
|
385
|
+
dragged.value = null
|
|
386
|
+
dropTarget.value = null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
defineExpose({
|
|
390
|
+
reload,
|
|
391
|
+
expand,
|
|
392
|
+
levelsOf,
|
|
393
|
+
focused,
|
|
394
|
+
addChild
|
|
147
395
|
})
|
|
396
|
+
|
|
397
|
+
onMounted(loadRoots)
|
|
148
398
|
</script>
|
|
149
399
|
<template>
|
|
150
|
-
<ul class="TreeView" :class="{ spinner: loading }">
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
400
|
+
<ul ref="list" class="TreeView" :class="{ spinner: loading }" tabindex="0" @keydown="onKeyDown">
|
|
401
|
+
<template v-for="row in rows" :key="row.key">
|
|
402
|
+
<li
|
|
403
|
+
v-if="'node' in row"
|
|
404
|
+
class="TreeView__node"
|
|
405
|
+
:class="{
|
|
406
|
+
'TreeView__node--selected': selection.selected.has(row.id),
|
|
407
|
+
'TreeView__node--leaf': !row.expandable
|
|
408
|
+
}"
|
|
409
|
+
:aria-current="row.key === selection.cursor.value || undefined"
|
|
410
|
+
:draggable="(draggable && editing?.key !== row.key) || undefined"
|
|
411
|
+
:data-drop="dropTarget?.key === row.key ? dropTarget.position : undefined"
|
|
412
|
+
@click="selection.pick(row.key, $event)"
|
|
413
|
+
@contextmenu="onRowContextMenu(row, $event)"
|
|
414
|
+
@dragstart="onDragStart(row, $event)"
|
|
415
|
+
@dragover="onDragOver(row, $event)"
|
|
416
|
+
@dragleave="onDragLeave(row)"
|
|
417
|
+
@drop="onDrop(row, $event)"
|
|
418
|
+
@dragend="onDragEnd"
|
|
419
|
+
>
|
|
420
|
+
<span v-for="(guide, i) in row.guides" :key="i" class="TreeView__guide" :class="`TreeView__guide--${guide}`" />
|
|
421
|
+
<button v-if="row.expandable" type="button" class="TreeView__toggle" :class="{ spinner: childLoading.has(row.id) }" @click.stop="toggleExpand(row)">
|
|
422
|
+
<svg v-if="!childLoading.has(row.id)" class="TreeView__icon" viewBox="0 0 16 16" aria-hidden="true">
|
|
423
|
+
<line x1="4" y1="8" x2="12" y2="8" />
|
|
424
|
+
<line v-if="!expanded.has(row.id)" x1="8" y1="4" x2="8" y2="12" />
|
|
425
|
+
</svg>
|
|
426
|
+
</button>
|
|
427
|
+
<span v-else class="TreeView__spacer" />
|
|
428
|
+
<input v-if="editing?.key === row.key" v-select-on-mount class="TreeView__input" :value="editValue(row)" :disabled="editing.pending" @keydown="onEditKey(row, $event)" @blur="onEditBlur" @click.stop @contextmenu.stop />
|
|
429
|
+
<span v-else class="TreeView__label" @dblclick="onRename && (editing = { key: row.key, pending: false })">{{ row.node[labelField] }}</span>
|
|
430
|
+
</li>
|
|
431
|
+
<!-- A new child's input: its row holds nothing else to act on. -->
|
|
432
|
+
<li v-else class="TreeView__node TreeView__node--leaf">
|
|
433
|
+
<span v-for="(guide, i) in row.guides" :key="i" class="TreeView__guide" :class="`TreeView__guide--${guide}`" />
|
|
434
|
+
<span class="TreeView__spacer" />
|
|
435
|
+
<input v-select-on-mount class="TreeView__input" :disabled="editing!.pending" @keydown="onEditKey(row, $event)" @blur="onEditBlur" />
|
|
436
|
+
</li>
|
|
437
|
+
</template>
|
|
171
438
|
</ul>
|
|
172
439
|
</template>
|
|
173
440
|
<style lang="scss">
|
|
@@ -181,6 +448,7 @@ $line-color: var(--tree-line-color, var(--border-color-light));
|
|
|
181
448
|
margin: 0;
|
|
182
449
|
padding: 0;
|
|
183
450
|
user-select: none;
|
|
451
|
+
outline: none;
|
|
184
452
|
|
|
185
453
|
&.spinner {
|
|
186
454
|
min-height: 40px;
|
|
@@ -194,6 +462,7 @@ $line-color: var(--tree-line-color, var(--border-color-light));
|
|
|
194
462
|
margin: 0;
|
|
195
463
|
cursor: default;
|
|
196
464
|
white-space: nowrap;
|
|
465
|
+
padding: 0 var(--space-1);
|
|
197
466
|
|
|
198
467
|
&:hover {
|
|
199
468
|
background-color: var(--form-background-hover);
|
|
@@ -203,6 +472,18 @@ $line-color: var(--tree-line-color, var(--border-color-light));
|
|
|
203
472
|
// Neutral gray band (classic tree look); themeable via the shared token.
|
|
204
473
|
background-color: var(--row-select-color, rgba(127, 127, 127, 0.32));
|
|
205
474
|
}
|
|
475
|
+
|
|
476
|
+
// Drop targets while dragging: a line on the edge the node lands
|
|
477
|
+
// beside, an outline around the row it lands under.
|
|
478
|
+
&[data-drop='before'] {
|
|
479
|
+
box-shadow: inset 0 2px 0 var(--accent-color);
|
|
480
|
+
}
|
|
481
|
+
&[data-drop='after'] {
|
|
482
|
+
box-shadow: inset 0 -2px 0 var(--accent-color);
|
|
483
|
+
}
|
|
484
|
+
&[data-drop='inside'] {
|
|
485
|
+
box-shadow: inset 0 0 0 1px var(--accent-color);
|
|
486
|
+
}
|
|
206
487
|
}
|
|
207
488
|
|
|
208
489
|
// One indentation column. Lines are centered on the column (left/top 50%, a
|
|
@@ -297,6 +578,7 @@ $line-color: var(--tree-line-color, var(--border-color-light));
|
|
|
297
578
|
align-self: center;
|
|
298
579
|
flex-grow: 1;
|
|
299
580
|
overflow: hidden;
|
|
581
|
+
padding: var(--space-1);
|
|
300
582
|
text-overflow: ellipsis;
|
|
301
583
|
// Row height lives here (not in node padding) so the guide cells stretch
|
|
302
584
|
// the full row and the connector lines run gaplessly between rows.
|
|
@@ -304,5 +586,22 @@ $line-color: var(--tree-line-color, var(--border-color-light));
|
|
|
304
586
|
padding-inline-start: var(--space-1);
|
|
305
587
|
padding-inline-end: var(--space-2);
|
|
306
588
|
}
|
|
589
|
+
// In the label's place, as tall as its row; its text selects as the
|
|
590
|
+
// tree's does not.
|
|
591
|
+
&__input {
|
|
592
|
+
align-self: center;
|
|
593
|
+
flex-grow: 1;
|
|
594
|
+
min-width: 0;
|
|
595
|
+
margin: 0;
|
|
596
|
+
margin-inline-end: var(--space-2);
|
|
597
|
+
padding: var(--space-1);
|
|
598
|
+
border: 1px solid var(--border-color);
|
|
599
|
+
border-radius: 0;
|
|
600
|
+
background: var(--form-background);
|
|
601
|
+
color: var(--font-color);
|
|
602
|
+
font: inherit;
|
|
603
|
+
line-height: 18px;
|
|
604
|
+
user-select: text;
|
|
605
|
+
}
|
|
307
606
|
}
|
|
308
607
|
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Captures the event's pointer to the element handling it, so a drag keeps
|
|
2
|
+
// tracking the pointer outside that element without document-level listeners.
|
|
3
|
+
//
|
|
4
|
+
// setPointerCapture throws NotFoundError when the pointer is no longer active —
|
|
5
|
+
// released or cancelled between event dispatch and this call, a real race for
|
|
6
|
+
// touch/pen and under synthetic pointer streams. A gesture whose pointer is
|
|
7
|
+
// already gone has nothing to capture, so exactly that error is a no-op;
|
|
8
|
+
// anything else still throws.
|
|
9
|
+
export const capturePointer = (event: PointerEvent): void => {
|
|
10
|
+
try {
|
|
11
|
+
;(event.currentTarget as Element).setPointerCapture(event.pointerId)
|
|
12
|
+
} catch (e) {
|
|
13
|
+
if (!(e instanceof DOMException && e.name === 'NotFoundError')) throw e
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { showToast, type ToastHandle, type ToastOptions, type ToastVariant } from './toast/showToast'
|
|
2
|
-
export { messages, configureUiMessages, type UiMessages } from './messages'
|
|
2
|
+
export { messages, configureUiMessages, configurableMessages, type UiMessages } from './messages'
|
|
3
3
|
export { default as Loading } from './Loading.vue'
|
|
4
4
|
export { default as TabBar } from './TabBar.vue'
|
|
5
|
-
export type { TabDef } from './TabBar.vue'
|
|
5
|
+
export type { Pane, TabDef, TabPane } from './TabBar.vue'
|
|
6
|
+
export { default as TabPanels, type TabPanel } from './TabPanels.vue'
|
|
6
7
|
export { default as Splash } from './Splash.vue'
|
|
7
8
|
|
|
8
9
|
// Form controls
|
|
@@ -13,6 +14,7 @@ export { default as Checkbox } from './Checkbox.vue'
|
|
|
13
14
|
export { default as CurrencyInput } from './CurrencyInput.vue'
|
|
14
15
|
export { default as Field } from './Field.vue'
|
|
15
16
|
export { default as FieldTip } from './FieldTip'
|
|
17
|
+
export { default as InfoTip } from './InfoTip.vue'
|
|
16
18
|
export { validationScopeKey } from './validationScope'
|
|
17
19
|
|
|
18
20
|
// Form submission state machine
|
|
@@ -33,6 +35,8 @@ export type { SearchResultItem } from './AsyncSearchResults.vue'
|
|
|
33
35
|
export { default as Modal } from './Modal.vue'
|
|
34
36
|
export { default as ModalOnClick } from './ModalOnClick.vue'
|
|
35
37
|
export { default as MenuButton } from './MenuButton.vue'
|
|
38
|
+
export { default as ContextMenu } from './ContextMenu.vue'
|
|
39
|
+
export type { ContextMenuItem } from './ContextMenu.vue'
|
|
36
40
|
export { default as SelectBox } from './SelectBox.vue'
|
|
37
41
|
export type { SelectBoxOption } from './SelectBox.vue'
|
|
38
42
|
|
|
@@ -42,7 +46,9 @@ export type { GalleryItem } from './Gallery.vue'
|
|
|
42
46
|
export { default as ThumbnailGrid } from './ThumbnailGrid.vue'
|
|
43
47
|
export type { ThumbnailGridItem, ThumbnailGridLoader, ThumbnailGridLoaderParams } from './ThumbnailGrid.vue'
|
|
44
48
|
export { default as TreeView } from './TreeView.vue'
|
|
45
|
-
export type { TreeNode, TreeNodeId, TreeLoader } from './TreeView.vue'
|
|
49
|
+
export type { TreeNode, TreeNodeId, TreeLoader, TreeOccurrence, TreeMove } from './TreeView.vue'
|
|
50
|
+
export { default as InfoBlock } from './InfoBlock.vue'
|
|
51
|
+
export { default as SwitchRow } from './SwitchRow.vue'
|
|
46
52
|
export { default as Accordion } from './Accordion.vue'
|
|
47
53
|
export type { AccordionSection } from './Accordion.vue'
|
|
48
54
|
export { default as useSelection } from './useSelection'
|
|
@@ -56,9 +62,12 @@ export { copyTextToClipboard } from './copyTextToClipboard'
|
|
|
56
62
|
export { default as TheWrap } from './TheWrap.vue'
|
|
57
63
|
export { default as BottomNav } from './BottomNav.vue'
|
|
58
64
|
export type { BottomNavTab } from './BottomNav.vue'
|
|
65
|
+
export { default as SplitPane } from './SplitPane.vue'
|
|
59
66
|
|
|
60
67
|
// Gestures
|
|
61
68
|
export { TOUCH_PAN_SLOP_PX } from './touchSlop'
|
|
69
|
+
export { capturePointer } from './capturePointer'
|
|
70
|
+
export { default as useWidthDrag, type WidthDrag } from './useWidthDrag'
|
|
62
71
|
|
|
63
72
|
// Small DOM conveniences
|
|
64
73
|
export const scrollToTop = (): void => window.scroll({ top: 0 })
|
package/src/messages.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { reactive } from 'vue'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// A package's user-facing strings, overridable in one place: `messages`, read
|
|
4
|
+
// reactively, starts as `defaults`; `configure` replaces any of them. Apps with
|
|
5
|
+
// i18n call it at boot and again on each locale change.
|
|
6
|
+
export const configurableMessages = <M extends Record<string, string>>(defaults: M): { messages: M; configure: (overrides: Partial<M>) => void } => {
|
|
7
|
+
const messages = reactive({ ...defaults }) as M
|
|
8
|
+
return {
|
|
9
|
+
messages,
|
|
10
|
+
configure: overrides => {
|
|
11
|
+
Object.assign(messages, overrides)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Every user-facing string in this package.
|
|
5
17
|
const defaults = {
|
|
6
18
|
loading: 'Loading',
|
|
7
19
|
ok: 'OK',
|
|
@@ -25,6 +37,8 @@ const defaults = {
|
|
|
25
37
|
clickToCopy: 'Click to copy',
|
|
26
38
|
invalidEmail: 'Not a valid email address',
|
|
27
39
|
selectOption: 'Select an option',
|
|
40
|
+
// The accessible name of InfoTip's mark; what it explains is the tooltip.
|
|
41
|
+
aboutThis: 'About this',
|
|
28
42
|
errorOther: 'Oops.. Sorry, something went wrong',
|
|
29
43
|
errorNetwork: 'Looks like a network connectivity issue',
|
|
30
44
|
errorNotFound: 'The requested page not found'
|
|
@@ -32,8 +46,4 @@ const defaults = {
|
|
|
32
46
|
|
|
33
47
|
export type UiMessages = typeof defaults
|
|
34
48
|
|
|
35
|
-
export const messages:
|
|
36
|
-
|
|
37
|
-
export const configureUiMessages = (overrides: Partial<UiMessages>): void => {
|
|
38
|
-
Object.assign(messages, overrides)
|
|
39
|
-
}
|
|
49
|
+
export const { messages, configure: configureUiMessages } = configurableMessages(defaults)
|