@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -0
  3. package/package.json +52 -0
  4. package/src/Accordion.vue +84 -0
  5. package/src/AsyncAction.vue +66 -0
  6. package/src/AsyncSearch.vue +262 -0
  7. package/src/AsyncSearchResults.vue +74 -0
  8. package/src/BottomNav.vue +125 -0
  9. package/src/Checkbox.vue +112 -0
  10. package/src/CircularProgress.vue +57 -0
  11. package/src/Copy.vue +51 -0
  12. package/src/CurrencyInput.vue +143 -0
  13. package/src/Email.vue +17 -0
  14. package/src/Field.vue +54 -0
  15. package/src/FieldTip.scss +76 -0
  16. package/src/FieldTip.ts +57 -0
  17. package/src/FixAndroidTyping.ts +13 -0
  18. package/src/Form.ts +124 -0
  19. package/src/Form.vue +78 -0
  20. package/src/Gallery.vue +174 -0
  21. package/src/Loading.vue +32 -0
  22. package/src/MenuButton.vue +95 -0
  23. package/src/Modal.vue +365 -0
  24. package/src/ModalOnClick.vue +73 -0
  25. package/src/OptionSwitch.vue +110 -0
  26. package/src/Password.vue +85 -0
  27. package/src/RowSelect.ts +211 -0
  28. package/src/SearchField.vue +124 -0
  29. package/src/SegmentedControl.vue +85 -0
  30. package/src/SelectBox.vue +468 -0
  31. package/src/Splash.vue +36 -0
  32. package/src/TabBar.vue +158 -0
  33. package/src/TextField.vue +116 -0
  34. package/src/TheWrap.vue +30 -0
  35. package/src/ThumbnailGrid.vue +166 -0
  36. package/src/TreeView.vue +308 -0
  37. package/src/_shared.scss +22 -0
  38. package/src/copyTextToClipboard.ts +3 -0
  39. package/src/downsizeImage.ts +78 -0
  40. package/src/inModal.ts +8 -0
  41. package/src/main.ts +78 -0
  42. package/src/messages.ts +39 -0
  43. package/src/modalStack.ts +181 -0
  44. package/src/styles.d.ts +6 -0
  45. package/src/toast/ToastShell.vue +70 -0
  46. package/src/toast/showToast.ts +81 -0
  47. package/src/touchSlop.ts +6 -0
  48. package/src/useSelection.ts +99 -0
  49. 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>
@@ -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>
@@ -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>
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ // Requires a secure context (https/localhost), which every deployment target
2
+ // provides. Rejects when the document is not focused or permission is denied.
3
+ export const copyTextToClipboard = (text: string): Promise<void> => navigator.clipboard.writeText(text)