@greendrake/ui 0.1.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/LICENSE +21 -0
- package/README.md +327 -0
- package/package.json +52 -0
- package/src/Accordion.vue +84 -0
- package/src/AsyncAction.vue +66 -0
- package/src/AsyncSearch.vue +262 -0
- package/src/AsyncSearchResults.vue +74 -0
- package/src/BottomNav.vue +125 -0
- package/src/Checkbox.vue +112 -0
- package/src/CircularProgress.vue +57 -0
- package/src/Copy.vue +51 -0
- package/src/CurrencyInput.vue +143 -0
- package/src/Email.vue +17 -0
- package/src/Field.vue +54 -0
- package/src/FieldTip.scss +76 -0
- package/src/FieldTip.ts +57 -0
- package/src/FixAndroidTyping.ts +13 -0
- package/src/Form.ts +124 -0
- package/src/Form.vue +78 -0
- package/src/Gallery.vue +174 -0
- package/src/Loading.vue +32 -0
- package/src/MenuButton.vue +95 -0
- package/src/Modal.vue +365 -0
- package/src/ModalOnClick.vue +73 -0
- package/src/OptionSwitch.vue +110 -0
- package/src/Password.vue +85 -0
- package/src/RowSelect.ts +211 -0
- package/src/SearchField.vue +124 -0
- package/src/SegmentedControl.vue +85 -0
- package/src/SelectBox.vue +468 -0
- package/src/Splash.vue +36 -0
- package/src/TabBar.vue +158 -0
- package/src/TextField.vue +116 -0
- package/src/TheWrap.vue +30 -0
- package/src/ThumbnailGrid.vue +166 -0
- package/src/TreeView.vue +308 -0
- package/src/_shared.scss +22 -0
- package/src/copyTextToClipboard.ts +3 -0
- package/src/downsizeImage.ts +78 -0
- package/src/inModal.ts +8 -0
- package/src/main.ts +78 -0
- package/src/messages.ts +39 -0
- package/src/modalStack.ts +181 -0
- package/src/styles.d.ts +6 -0
- package/src/toast/ToastShell.vue +70 -0
- package/src/toast/showToast.ts +81 -0
- package/src/touchSlop.ts +6 -0
- package/src/useSelection.ts +99 -0
- package/src/validationScope.ts +6 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch, onMounted, inject, type Component } from 'vue'
|
|
3
|
+
import { hasFinePointer } from '@greendrake/util'
|
|
4
|
+
import FieldTip from './FieldTip'
|
|
5
|
+
import FixAndroidTyping from './FixAndroidTyping'
|
|
6
|
+
import { validationScopeKey } from './validationScope'
|
|
7
|
+
|
|
8
|
+
const input = ref<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
|
9
|
+
const error = ref<string | false>(false)
|
|
10
|
+
// Blank input yields '' — the model is always a string, never undefined.
|
|
11
|
+
// When a validator is set, the model likewise falls back to '' while the
|
|
12
|
+
// typed value is invalid.
|
|
13
|
+
const model = defineModel<string>({ default: '' })
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
autofocus?: boolean
|
|
17
|
+
// Lands on the input so focusIn(id) / <label for> can target it.
|
|
18
|
+
id?: string
|
|
19
|
+
type?: string
|
|
20
|
+
placeholder?: string
|
|
21
|
+
// Accessible name when no visible label is associated; defaults to the
|
|
22
|
+
// placeholder so a placeholder-only field always names itself.
|
|
23
|
+
ariaLabel?: string
|
|
24
|
+
validator?: (v: string) => true | string
|
|
25
|
+
validateOnBlur?: boolean
|
|
26
|
+
// The rich-text editor component rendered when type === 'rich'. It must
|
|
27
|
+
// speak the same string v-model contract as this field. Supplied by the
|
|
28
|
+
// consumer (see @greendrake/ui-rich) so tiptap is bundled only where
|
|
29
|
+
// rich editing is actually used. Init-time contract: read once below.
|
|
30
|
+
richEditor?: Component
|
|
31
|
+
}>(),
|
|
32
|
+
{
|
|
33
|
+
type: 'text',
|
|
34
|
+
validateOnBlur: true
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
if (props.type === 'rich' && !props.richEditor) {
|
|
38
|
+
throw new Error('TextField: type="rich" requires the richEditor prop')
|
|
39
|
+
}
|
|
40
|
+
const richEditor = props.richEditor
|
|
41
|
+
const inputValue = ref(model.value)
|
|
42
|
+
const isInputHere = computed(() => input.value && input.value.isConnected)
|
|
43
|
+
const validationScope = inject(validationScopeKey, null)
|
|
44
|
+
const onLeave = (e: FocusEvent) => {
|
|
45
|
+
const validator = props.validator
|
|
46
|
+
if (!validator || !props.validateOnBlur) return
|
|
47
|
+
// When a form provides a validation scope, suppress validation if focus
|
|
48
|
+
// is leaving that scope entirely (clicked outside the form, or onto
|
|
49
|
+
// unfocusable content). Validate only when focus stays within the form.
|
|
50
|
+
const scopeEl = validationScope?.value
|
|
51
|
+
if (scopeEl) {
|
|
52
|
+
const next = e.relatedTarget as Node | null
|
|
53
|
+
if (!next || !scopeEl.contains(next)) return
|
|
54
|
+
}
|
|
55
|
+
// this event may be triggered by destroying the housing component, in which case
|
|
56
|
+
// there is no need to validate/set tips.
|
|
57
|
+
// Verify that the element is still part of DOM after a small delay:
|
|
58
|
+
const valueAtBlur = inputValue.value
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
// Skip validation if the value changed since blur (e.g. form reset due
|
|
61
|
+
// to navigation) or the field is empty (an untouched/emptied field
|
|
62
|
+
// should not nag on its way out).
|
|
63
|
+
if (isInputHere.value && inputValue.value === valueAtBlur && inputValue.value !== '') {
|
|
64
|
+
const validationResult = validator(inputValue.value)
|
|
65
|
+
if (true !== validationResult) {
|
|
66
|
+
error.value = validationResult
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}, 200)
|
|
70
|
+
}
|
|
71
|
+
let ignoreModelChange = false
|
|
72
|
+
watch(inputValue, () => {
|
|
73
|
+
if (error.value !== false) {
|
|
74
|
+
error.value = false
|
|
75
|
+
}
|
|
76
|
+
const modelValue = !props.validator || props.validator(inputValue.value) === true ? inputValue.value : ''
|
|
77
|
+
if (modelValue !== model.value) {
|
|
78
|
+
ignoreModelChange = true
|
|
79
|
+
model.value = modelValue
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
watch(model, m => {
|
|
83
|
+
if (ignoreModelChange) {
|
|
84
|
+
ignoreModelChange = false
|
|
85
|
+
} else if (inputValue.value !== m) {
|
|
86
|
+
inputValue.value = m
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
FieldTip(error, input)
|
|
90
|
+
// Pointer devices only: there, landing in the field is a free head start and the
|
|
91
|
+
// whole surface stays visible. On touch it would summon the on-screen keyboard
|
|
92
|
+
// over the content the user came to read, before they asked to type.
|
|
93
|
+
if (props.autofocus && hasFinePointer()) {
|
|
94
|
+
onMounted(() => {
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
// Only claim focus when nothing else has it. Otherwise a delayed
|
|
97
|
+
// autofocus can race against a modal that opened after this field
|
|
98
|
+
// mounted and steal focus mid-interaction (e.g. between Playwright's
|
|
99
|
+
// .fill focus step and its keyboard.insertText step).
|
|
100
|
+
if (isInputHere.value && input.value && document.activeElement === document.body) {
|
|
101
|
+
input.value.focus()
|
|
102
|
+
}
|
|
103
|
+
}, 300)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
const onInput = () => FixAndroidTyping(input, inputValue)
|
|
107
|
+
const focus = () => {
|
|
108
|
+
if (isInputHere.value && input.value) input.value.focus()
|
|
109
|
+
}
|
|
110
|
+
defineExpose({ focus })
|
|
111
|
+
</script>
|
|
112
|
+
<template>
|
|
113
|
+
<textarea v-if="type === 'textarea'" :id="id" ref="input" v-model="inputValue" :placeholder="placeholder" :aria-label="ariaLabel ?? placeholder" spellcheck="false" @blur="onLeave" @input="onInput" />
|
|
114
|
+
<component :is="richEditor!" v-else-if="type === 'rich'" v-model="inputValue" :aria-label="ariaLabel" />
|
|
115
|
+
<input v-else :id="id" ref="input" v-model="inputValue" :type="type" :placeholder="placeholder" :aria-label="ariaLabel ?? placeholder" spellcheck="false" @blur="onLeave" @input="onInput" />
|
|
116
|
+
</template>
|
package/src/TheWrap.vue
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="TheWrap">
|
|
3
|
+
<div class="TheWrap__content">
|
|
4
|
+
<slot name="top" />
|
|
5
|
+
<slot />
|
|
6
|
+
</div>
|
|
7
|
+
<slot name="bottom" />
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
10
|
+
<style lang="scss" scoped>
|
|
11
|
+
// Page layout shell: `top` slot + routed content stretch, the `bottom` slot
|
|
12
|
+
// (footer) is pushed to the viewport bottom on short pages. When only a single
|
|
13
|
+
// child renders (e.g. a splash), it is centered vertically instead.
|
|
14
|
+
// CSS contract: --max-content-width (optional) caps the shell's width; without
|
|
15
|
+
// it the shell spans its container.
|
|
16
|
+
.TheWrap {
|
|
17
|
+
width: 100%;
|
|
18
|
+
max-width: var(--max-content-width, none);
|
|
19
|
+
min-height: 100%;
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
justify-content: space-between;
|
|
23
|
+
&__content {
|
|
24
|
+
flex: 1 0 auto;
|
|
25
|
+
}
|
|
26
|
+
&:has(> :only-child) {
|
|
27
|
+
justify-content: center;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
</style>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
export type ThumbnailGridItem = Record<string, unknown>
|
|
5
|
+
|
|
6
|
+
export interface ThumbnailGridLoaderParams {
|
|
7
|
+
offset: number
|
|
8
|
+
limit: number
|
|
9
|
+
[key: string]: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ThumbnailGridLoader = (params: ThumbnailGridLoaderParams, loading: Ref<boolean>) => Promise<{ data: ThumbnailGridItem[]; total: number }>
|
|
13
|
+
</script>
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
// Paged, multi-select thumbnail grid. Backed by the same loader contract as
|
|
16
|
+
// the data table ((params, loading) => { data, total }) so a host can feed
|
|
17
|
+
// both views from one closure. Selection is a v-model array of id values. Reloads
|
|
18
|
+
// whenever `filters` changes (host puts its filter signature there).
|
|
19
|
+
import { ref, watch, onMounted } from 'vue'
|
|
20
|
+
import { messages } from './messages'
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(
|
|
23
|
+
defineProps<{
|
|
24
|
+
loader: ThumbnailGridLoader
|
|
25
|
+
filters?: Record<string, unknown>
|
|
26
|
+
thumbnailField?: string
|
|
27
|
+
titleField?: string
|
|
28
|
+
idField?: string
|
|
29
|
+
pageSize?: number
|
|
30
|
+
}>(),
|
|
31
|
+
{
|
|
32
|
+
filters: () => ({}),
|
|
33
|
+
thumbnailField: 'thumbnail',
|
|
34
|
+
titleField: 'title',
|
|
35
|
+
idField: 'node_id',
|
|
36
|
+
pageSize: 24
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const selected = defineModel<Array<string | number>>('selected', { default: () => [] })
|
|
41
|
+
|
|
42
|
+
const items = ref<ThumbnailGridItem[]>([])
|
|
43
|
+
const total = ref(0)
|
|
44
|
+
const offset = ref(0)
|
|
45
|
+
const loading = ref(false)
|
|
46
|
+
|
|
47
|
+
// The item fields are addressed by configurable names, so their types are the
|
|
48
|
+
// grid's contract rather than the item type's: id is a string/number key,
|
|
49
|
+
// thumbnail a URL string, title display text.
|
|
50
|
+
const idOf = (item: ThumbnailGridItem) => item[props.idField] as string | number
|
|
51
|
+
const thumbnailOf = (item: ThumbnailGridItem) => item[props.thumbnailField] as string
|
|
52
|
+
const titleOf = (item: ThumbnailGridItem) => item[props.titleField] as string
|
|
53
|
+
|
|
54
|
+
async function load() {
|
|
55
|
+
const res = await props.loader(
|
|
56
|
+
{
|
|
57
|
+
offset: offset.value,
|
|
58
|
+
limit: props.pageSize,
|
|
59
|
+
...props.filters
|
|
60
|
+
},
|
|
61
|
+
loading
|
|
62
|
+
)
|
|
63
|
+
items.value = res.data
|
|
64
|
+
total.value = res.total
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function refresh() {
|
|
68
|
+
offset.value = 0
|
|
69
|
+
load()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
watch(
|
|
73
|
+
() => props.filters,
|
|
74
|
+
() => {
|
|
75
|
+
offset.value = 0
|
|
76
|
+
load()
|
|
77
|
+
},
|
|
78
|
+
{ deep: true }
|
|
79
|
+
)
|
|
80
|
+
watch(offset, load)
|
|
81
|
+
onMounted(load)
|
|
82
|
+
|
|
83
|
+
function toggle(id: string | number) {
|
|
84
|
+
selected.value = selected.value.includes(id) ? selected.value.filter(x => x !== id) : [...selected.value, id]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function end() {
|
|
88
|
+
return Math.min(offset.value + props.pageSize, total.value)
|
|
89
|
+
}
|
|
90
|
+
function prev() {
|
|
91
|
+
if (offset.value > 0) offset.value = Math.max(0, offset.value - props.pageSize)
|
|
92
|
+
}
|
|
93
|
+
function next() {
|
|
94
|
+
if (end() < total.value) offset.value += props.pageSize
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
defineExpose({ refresh })
|
|
98
|
+
</script>
|
|
99
|
+
<template>
|
|
100
|
+
<div class="ThumbnailGrid" :class="{ spinner: loading }">
|
|
101
|
+
<div class="ThumbnailGrid__tiles">
|
|
102
|
+
<div v-for="item in items" :key="idOf(item)" class="ThumbnailGrid__tile" :class="{ selected: selected.includes(idOf(item)) }" @click="toggle(idOf(item))">
|
|
103
|
+
<img class="ThumbnailGrid__img" :src="thumbnailOf(item)" :alt="titleOf(item)" />
|
|
104
|
+
<div class="ThumbnailGrid__title">{{ titleOf(item) }}</div>
|
|
105
|
+
</div>
|
|
106
|
+
<div v-if="!items.length" class="ThumbnailGrid__empty">{{ messages.nothingFound }}</div>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="ThumbnailGrid__pager">
|
|
109
|
+
<button type="button" :disabled="offset === 0" @click="prev">‹</button>
|
|
110
|
+
<span class="ThumbnailGrid__range">{{ total ? offset + 1 : 0 }}–{{ end() }} / {{ total }}</span>
|
|
111
|
+
<button type="button" :disabled="end() >= total" @click="next">›</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
115
|
+
<style lang="scss">
|
|
116
|
+
.ThumbnailGrid {
|
|
117
|
+
display: flex;
|
|
118
|
+
flex-direction: column;
|
|
119
|
+
min-height: 0;
|
|
120
|
+
&__tiles {
|
|
121
|
+
flex-grow: 1;
|
|
122
|
+
overflow-y: auto;
|
|
123
|
+
display: grid;
|
|
124
|
+
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
125
|
+
gap: var(--space-2);
|
|
126
|
+
padding: var(--space-2);
|
|
127
|
+
align-content: start;
|
|
128
|
+
}
|
|
129
|
+
&__tile {
|
|
130
|
+
border: 1px solid var(--border-color-light);
|
|
131
|
+
border-radius: var(--border-radius);
|
|
132
|
+
overflow: hidden;
|
|
133
|
+
cursor: default;
|
|
134
|
+
&.selected {
|
|
135
|
+
border-color: var(--border-color-hover);
|
|
136
|
+
outline: 2px solid var(--border-color-hover);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
&__img {
|
|
140
|
+
display: block;
|
|
141
|
+
width: 100%;
|
|
142
|
+
aspect-ratio: 1;
|
|
143
|
+
object-fit: cover;
|
|
144
|
+
background: var(--form-background);
|
|
145
|
+
}
|
|
146
|
+
&__title {
|
|
147
|
+
padding: var(--space-1);
|
|
148
|
+
font-size: var(--font-xs);
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
overflow: hidden;
|
|
151
|
+
text-overflow: ellipsis;
|
|
152
|
+
}
|
|
153
|
+
&__empty {
|
|
154
|
+
padding: var(--space-4);
|
|
155
|
+
opacity: 0.6;
|
|
156
|
+
}
|
|
157
|
+
&__pager {
|
|
158
|
+
display: flex;
|
|
159
|
+
align-items: center;
|
|
160
|
+
justify-content: center;
|
|
161
|
+
gap: var(--space-2);
|
|
162
|
+
padding: var(--space-1);
|
|
163
|
+
border-top: 1px solid var(--border-color-light);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
</style>
|
package/src/TreeView.vue
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Node shape (whatever the loader returns) must carry at least:
|
|
3
|
+
// - [idField] (default 'key') — unique string/number id
|
|
4
|
+
// - [labelField] (default 'title') — display text
|
|
5
|
+
// - expandable — whether it has children to load
|
|
6
|
+
export interface TreeNode {
|
|
7
|
+
expandable: boolean
|
|
8
|
+
[key: string]: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type TreeNodeId = string | number
|
|
12
|
+
|
|
13
|
+
// loader(parentId | null) => Promise<TreeNode[]> (null → root level)
|
|
14
|
+
export type TreeLoader = (parentId: TreeNodeId | null) => Promise<TreeNode[]>
|
|
15
|
+
|
|
16
|
+
type Guide = 'through' | 'blank' | 'elbow' | 'elbow-last'
|
|
17
|
+
|
|
18
|
+
type VisibleEntry = {
|
|
19
|
+
id: TreeNodeId
|
|
20
|
+
node: TreeNode
|
|
21
|
+
depth: number
|
|
22
|
+
expandable: boolean
|
|
23
|
+
guides: Guide[]
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
// Generic lazy tree. Children are loaded on demand via `loader` and cached per
|
|
28
|
+
// node; expanding/collapsing only re-walks the cache. The whole tree is rendered
|
|
29
|
+
// as one flat list of the currently-visible nodes (each carrying its depth), so
|
|
30
|
+
// 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'
|
|
35
|
+
import useSelection from './useSelection'
|
|
36
|
+
|
|
37
|
+
const props = withDefaults(
|
|
38
|
+
defineProps<{
|
|
39
|
+
loader: TreeLoader
|
|
40
|
+
idField?: string
|
|
41
|
+
labelField?: string
|
|
42
|
+
multiSelect?: boolean
|
|
43
|
+
}>(),
|
|
44
|
+
{
|
|
45
|
+
idField: 'key',
|
|
46
|
+
labelField: 'title',
|
|
47
|
+
multiSelect: true
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const selected = defineModel<TreeNodeId[]>('selected', { default: () => [] })
|
|
52
|
+
|
|
53
|
+
const roots = ref<TreeNode[]>([])
|
|
54
|
+
const loading = ref(false)
|
|
55
|
+
// id -> child node[]; presence means "already loaded".
|
|
56
|
+
const childrenCache = reactive(new Map<TreeNodeId, TreeNode[]>())
|
|
57
|
+
const expanded = reactive(new Set<TreeNodeId>())
|
|
58
|
+
const childLoading = reactive(new Set<TreeNodeId>())
|
|
59
|
+
|
|
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).
|
|
63
|
+
//
|
|
64
|
+
// Each entry also carries the `guides` it needs to draw the connector lines: one
|
|
65
|
+
// cell per indentation column. `ancestorVerticals[i]` (threaded down the walk)
|
|
66
|
+
// 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
|
|
68
|
+
// (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[]) => {
|
|
72
|
+
nodes.forEach((node, idx) => {
|
|
73
|
+
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({
|
|
78
|
+
id,
|
|
79
|
+
node,
|
|
80
|
+
depth,
|
|
81
|
+
expandable: node.expandable,
|
|
82
|
+
guides
|
|
83
|
+
})
|
|
84
|
+
const children = childrenCache.get(id)
|
|
85
|
+
if (expanded.has(id) && children) {
|
|
86
|
+
walk(children, depth + 1, depth === 0 ? [] : [...ancestorVerticals, !isLast])
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
walk(roots.value, 0, [])
|
|
91
|
+
return out
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const selection = useSelection<TreeNodeId>(visibleNodes, 'id')
|
|
95
|
+
|
|
96
|
+
// Selection is owned by useSelection's Set; the v-model array is a mirror. Seed
|
|
97
|
+
// the Set from any initial model value, then keep both in sync. Each watcher
|
|
98
|
+
// no-ops when the two already agree, so the round-trip terminates.
|
|
99
|
+
for (const id of selected.value) selection.selected.add(id)
|
|
100
|
+
|
|
101
|
+
watch(
|
|
102
|
+
() => [...selection.selected],
|
|
103
|
+
arr => {
|
|
104
|
+
if (arr.length !== selected.value.length || arr.some(id => !selected.value.includes(id))) {
|
|
105
|
+
selected.value = arr
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
watch(selected, val => {
|
|
111
|
+
const set = selection.selected
|
|
112
|
+
if (set.size === val.length && val.every(id => set.has(id))) return
|
|
113
|
+
set.clear()
|
|
114
|
+
for (const id of val) set.add(id)
|
|
115
|
+
})
|
|
116
|
+
|
|
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
|
+
}
|
|
130
|
+
}
|
|
131
|
+
expanded.add(entry.id)
|
|
132
|
+
}
|
|
133
|
+
|
|
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 () => {
|
|
141
|
+
loading.value = true
|
|
142
|
+
try {
|
|
143
|
+
roots.value = await props.loader(null)
|
|
144
|
+
} finally {
|
|
145
|
+
loading.value = false
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
</script>
|
|
149
|
+
<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>
|
|
171
|
+
</ul>
|
|
172
|
+
</template>
|
|
173
|
+
<style lang="scss">
|
|
174
|
+
// One column width, shared by guide cells, the toggle box, and the leaf spacer so
|
|
175
|
+
// connectors line up under their parent's box exactly.
|
|
176
|
+
$indent: 16px;
|
|
177
|
+
$line-color: var(--tree-line-color, var(--border-color-light));
|
|
178
|
+
|
|
179
|
+
.TreeView {
|
|
180
|
+
list-style: none;
|
|
181
|
+
margin: 0;
|
|
182
|
+
padding: 0;
|
|
183
|
+
user-select: none;
|
|
184
|
+
|
|
185
|
+
&.spinner {
|
|
186
|
+
min-height: 40px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
&__node {
|
|
190
|
+
display: flex;
|
|
191
|
+
align-items: stretch;
|
|
192
|
+
// Reset the site's global list-item margin; the rows must abut so the
|
|
193
|
+
// connector lines run continuously from one to the next.
|
|
194
|
+
margin: 0;
|
|
195
|
+
cursor: default;
|
|
196
|
+
white-space: nowrap;
|
|
197
|
+
|
|
198
|
+
&:hover {
|
|
199
|
+
background-color: var(--form-background-hover);
|
|
200
|
+
}
|
|
201
|
+
&--selected,
|
|
202
|
+
&--selected:hover {
|
|
203
|
+
// Neutral gray band (classic tree look); themeable via the shared token.
|
|
204
|
+
background-color: var(--row-select-color, rgba(127, 127, 127, 0.32));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// One indentation column. Lines are centered on the column (left/top 50%, a
|
|
209
|
+
// 1px line pulled back half its width) so they sit exactly on the box centre
|
|
210
|
+
// and the box's glyph strokes: a vertical (::before) and, for elbows, a
|
|
211
|
+
// horizontal stub (::after) reaching the node's box.
|
|
212
|
+
&__guide {
|
|
213
|
+
position: relative;
|
|
214
|
+
flex-shrink: 0;
|
|
215
|
+
width: $indent;
|
|
216
|
+
|
|
217
|
+
&--through::before,
|
|
218
|
+
&--elbow::before,
|
|
219
|
+
&--elbow-last::before {
|
|
220
|
+
content: '';
|
|
221
|
+
position: absolute;
|
|
222
|
+
top: 0;
|
|
223
|
+
left: 50%;
|
|
224
|
+
width: 1px;
|
|
225
|
+
margin-left: -0.5px;
|
|
226
|
+
background: $line-color;
|
|
227
|
+
}
|
|
228
|
+
// Sibling line passing through, and the ├ elbow: full height.
|
|
229
|
+
&--through::before,
|
|
230
|
+
&--elbow::before {
|
|
231
|
+
bottom: 0;
|
|
232
|
+
}
|
|
233
|
+
// The └ elbow (last child): vertical stops at the connector.
|
|
234
|
+
&--elbow-last::before {
|
|
235
|
+
height: 50%;
|
|
236
|
+
}
|
|
237
|
+
&--elbow::after,
|
|
238
|
+
&--elbow-last::after {
|
|
239
|
+
content: '';
|
|
240
|
+
position: absolute;
|
|
241
|
+
top: 50%;
|
|
242
|
+
margin-top: -0.5px;
|
|
243
|
+
left: 50%;
|
|
244
|
+
right: 0;
|
|
245
|
+
height: 1px;
|
|
246
|
+
background: $line-color;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A leaf has no box, just an aligning spacer in the box slot. Extend the
|
|
251
|
+
// elbow's horizontal across that slot so the line reaches the label rather
|
|
252
|
+
// than stopping short where a box would have been.
|
|
253
|
+
&__node--leaf &__guide--elbow::after,
|
|
254
|
+
&__node--leaf &__guide--elbow-last::after {
|
|
255
|
+
right: -$indent;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// +/- box: a sharp square centered on the node's vertical spine. box-sizing is
|
|
259
|
+
// border-box (global), so width/height include the 1px border → a true 16×16
|
|
260
|
+
// square; padding:0 overrides the app's global button padding.
|
|
261
|
+
&__toggle {
|
|
262
|
+
align-self: center;
|
|
263
|
+
flex-shrink: 0;
|
|
264
|
+
width: $indent;
|
|
265
|
+
height: $indent;
|
|
266
|
+
padding: 0;
|
|
267
|
+
display: inline-flex;
|
|
268
|
+
align-items: center;
|
|
269
|
+
justify-content: center;
|
|
270
|
+
border: 1px solid var(--border-color-light);
|
|
271
|
+
background: var(--form-background);
|
|
272
|
+
color: var(--font-color);
|
|
273
|
+
cursor: default;
|
|
274
|
+
|
|
275
|
+
&.spinner::before {
|
|
276
|
+
width: 12px;
|
|
277
|
+
height: 12px;
|
|
278
|
+
margin-top: -6px;
|
|
279
|
+
margin-left: -6px;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Plus/minus as centered strokes: the '+' vertical sits on the box centre
|
|
283
|
+
// (= the connector spine), its horizontal on the row centre.
|
|
284
|
+
&__icon {
|
|
285
|
+
display: block;
|
|
286
|
+
width: 100%;
|
|
287
|
+
height: 100%;
|
|
288
|
+
stroke: currentColor;
|
|
289
|
+
stroke-width: 1.5;
|
|
290
|
+
}
|
|
291
|
+
// Keeps leaf labels aligned with their expandable siblings'.
|
|
292
|
+
&__spacer {
|
|
293
|
+
flex-shrink: 0;
|
|
294
|
+
width: $indent;
|
|
295
|
+
}
|
|
296
|
+
&__label {
|
|
297
|
+
align-self: center;
|
|
298
|
+
flex-grow: 1;
|
|
299
|
+
overflow: hidden;
|
|
300
|
+
text-overflow: ellipsis;
|
|
301
|
+
// Row height lives here (not in node padding) so the guide cells stretch
|
|
302
|
+
// the full row and the connector lines run gaplessly between rows.
|
|
303
|
+
line-height: 20px;
|
|
304
|
+
padding-inline-start: var(--space-1);
|
|
305
|
+
padding-inline-end: var(--space-2);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
</style>
|
package/src/_shared.scss
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Package-internal SCSS shared between components (app themes never import this).
|
|
2
|
+
|
|
3
|
+
// Repositions the theme's base `.spinner::before` pseudo-element as a small
|
|
4
|
+
// indicator vertically centered at the element's right edge.
|
|
5
|
+
// top/bottom both 0 + auto block-margin centers a fixed-height absolutely
|
|
6
|
+
// positioned element vertically without depending on the container's height.
|
|
7
|
+
// Can't use `transform: translateY(-50%)` here — the base spinner rule
|
|
8
|
+
// animates `transform: rotate` and the two would clash.
|
|
9
|
+
@mixin spinner-inline-right($right) {
|
|
10
|
+
position: absolute;
|
|
11
|
+
width: 22px;
|
|
12
|
+
height: 22px;
|
|
13
|
+
top: 0;
|
|
14
|
+
bottom: 0;
|
|
15
|
+
// Reset the base spinner's centring margins: block:auto centres vertically
|
|
16
|
+
// against top/bottom:0; inline:0 clears the base's negative margin-left so
|
|
17
|
+
// right-edge anchoring lands where $right places it.
|
|
18
|
+
margin-block: auto;
|
|
19
|
+
margin-inline: 0;
|
|
20
|
+
right: $right;
|
|
21
|
+
left: unset;
|
|
22
|
+
}
|