@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/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 VisibleEntry = {
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
- guides: Guide[]
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. Selection is
32
- // mouse-driven (click = single, shift = range, ctrl/cmd = toggle); there are no
33
- // checkboxes. The selected node ids are exposed as a v-model array.
34
- import { ref, computed, reactive, watch, onMounted } from 'vue'
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
- // Depth-first flatten of every currently-visible node. This ordered list is both
61
- // what the template renders and the index space useSelection operates on (range
62
- // selection walks contiguous entries here).
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 entry also carries the `guides` it needs to draw the connector lines: one
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 node's own
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
- const visibleNodes = computed(() => {
70
- const out: VisibleEntry[] = []
71
- const walk = (nodes: TreeNode[], depth: number, ancestorVerticals: boolean[]) => {
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 isLast = idx === nodes.length - 1
75
- const guides: Guide[] = ancestorVerticals.map(v => (v ? 'through' : 'blank'))
76
- if (depth > 0) guides.push(isLast ? 'elbow-last' : 'elbow')
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
- const children = childrenCache.get(id)
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, depth + 1, depth === 0 ? [] : [...ancestorVerticals, !isLast])
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, 0, [])
176
+ walk(roots.value, null, [])
91
177
  return out
92
178
  })
93
179
 
94
- const selection = useSelection<TreeNodeId>(visibleNodes, 'id')
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 toggleExpand(entry: VisibleEntry) {
118
- if (!entry.expandable) return
119
- if (expanded.has(entry.id)) {
120
- expanded.delete(entry.id)
121
- return
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 onRowClick(entry: VisibleEntry, e: MouseEvent) {
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
- <li
152
- v-for="entry in visibleNodes"
153
- :key="entry.id"
154
- class="TreeView__node"
155
- :class="{
156
- 'TreeView__node--selected': selection.selected.has(entry.id),
157
- 'TreeView__node--leaf': !entry.expandable
158
- }"
159
- @click="onRowClick(entry, $event)"
160
- >
161
- <span v-for="(guide, i) in entry.guides" :key="i" class="TreeView__guide" :class="`TreeView__guide--${guide}`" />
162
- <button v-if="entry.expandable" type="button" class="TreeView__toggle" :class="{ spinner: childLoading.has(entry.id) }" @click.stop="toggleExpand(entry)">
163
- <svg v-if="!childLoading.has(entry.id)" class="TreeView__icon" viewBox="0 0 16 16" aria-hidden="true">
164
- <line x1="4" y1="8" x2="12" y2="8" />
165
- <line v-if="!expanded.has(entry.id)" x1="8" y1="4" x2="8" y2="12" />
166
- </svg>
167
- </button>
168
- <span v-else class="TreeView__spacer" />
169
- <span class="TreeView__label">{{ entry.node[labelField] }}</span>
170
- </li>
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
- // Every user-facing string in this package, overridable in one place. Apps
4
- // with i18n call configureUiMessages at boot (and again on locale change).
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: UiMessages = reactive({ ...defaults })
36
-
37
- export const configureUiMessages = (overrides: Partial<UiMessages>): void => {
38
- Object.assign(messages, overrides)
39
- }
49
+ export const { messages, configure: configureUiMessages } = configurableMessages(defaults)