@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,468 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface SelectBoxOption<T extends string | number = string | number> {
|
|
3
|
+
value: T
|
|
4
|
+
label: string
|
|
5
|
+
disabled?: boolean
|
|
6
|
+
// Tree depth for hierarchical vocabularies: indents the option in the MENU
|
|
7
|
+
// only — the closed control always shows the plain label.
|
|
8
|
+
depth?: number
|
|
9
|
+
}
|
|
10
|
+
</script>
|
|
11
|
+
<script setup lang="ts" generic="T extends string | number">
|
|
12
|
+
import { computed, ref, watch, nextTick, onUnmounted } from 'vue'
|
|
13
|
+
import { hasFinePointer } from '@greendrake/util'
|
|
14
|
+
import { messages } from './messages'
|
|
15
|
+
|
|
16
|
+
// Single-select dropdown: string|number values, optional clear button,
|
|
17
|
+
// optional type-to-filter, full keyboard support (arrows/Enter/Escape).
|
|
18
|
+
// Themable via the --vs-* CSS custom properties (the same contract app skins
|
|
19
|
+
// already target).
|
|
20
|
+
const props = withDefaults(
|
|
21
|
+
defineProps<{
|
|
22
|
+
options: SelectBoxOption<T>[]
|
|
23
|
+
placeholder?: string
|
|
24
|
+
// Accessible name for the combobox; defaults to the placeholder.
|
|
25
|
+
ariaLabel?: string
|
|
26
|
+
clearable?: boolean
|
|
27
|
+
// Show a text input in the control while the menu is open, filtering
|
|
28
|
+
// the options by label (and stringified value) as the user types.
|
|
29
|
+
searchable?: boolean
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
// Render option labels via v-html instead of text — explicitly named
|
|
32
|
+
// because it is an HTML injection surface; enable only for labels
|
|
33
|
+
// built from trusted/escaped content.
|
|
34
|
+
htmlLabel?: boolean
|
|
35
|
+
}>(),
|
|
36
|
+
{
|
|
37
|
+
clearable: false,
|
|
38
|
+
searchable: false,
|
|
39
|
+
disabled: false,
|
|
40
|
+
htmlLabel: false
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// null ⇔ no selection (shows the placeholder).
|
|
45
|
+
const model = defineModel<T | null>({ default: null })
|
|
46
|
+
|
|
47
|
+
// Fired on every menu selection, including re-picking the already-selected
|
|
48
|
+
// option (which produces no update:modelValue). Hosts that commit an action on
|
|
49
|
+
// pick (e.g. the location modal's instant-apply) listen to this, not the model.
|
|
50
|
+
const emit = defineEmits<{ pick: [value: T] }>()
|
|
51
|
+
|
|
52
|
+
const root = ref<HTMLElement | null>(null)
|
|
53
|
+
const menu = ref<HTMLElement | null>(null)
|
|
54
|
+
const searchInput = ref<HTMLInputElement | null>(null)
|
|
55
|
+
const menuOpen = ref(false)
|
|
56
|
+
const search = ref('')
|
|
57
|
+
const focusedIndex = ref(-1)
|
|
58
|
+
// Inline placement overrides (flip/clamp) computed per open — see placeMenu.
|
|
59
|
+
const menuStyle = ref<Record<string, string> | null>(null)
|
|
60
|
+
|
|
61
|
+
const selectedOption = computed(() => props.options.find(o => o.value === model.value))
|
|
62
|
+
|
|
63
|
+
// Matching is done on strings on both sides, so numeric values can never blow
|
|
64
|
+
// up a .toLowerCase() call.
|
|
65
|
+
const filteredOptions = computed(() => {
|
|
66
|
+
if (!props.searchable || !search.value) {
|
|
67
|
+
return props.options
|
|
68
|
+
}
|
|
69
|
+
const needle = search.value.toLowerCase()
|
|
70
|
+
return props.options.filter(o => o.label.toLowerCase().includes(needle) || String(o.value).toLowerCase().includes(needle))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const firstSelectableIndex = (): number => filteredOptions.value.findIndex(o => !o.disabled)
|
|
74
|
+
|
|
75
|
+
// How far the menu clears the control: it overlaps the shared border by a pixel,
|
|
76
|
+
// so the two read as one box. Both anchor directions use it.
|
|
77
|
+
const MENU_OVERLAP = 1
|
|
78
|
+
const MARGIN = 8
|
|
79
|
+
|
|
80
|
+
// Where the menu would sit, anchored to the control in viewport coordinates.
|
|
81
|
+
// The menu is positioned against the viewport rather than the control (see the
|
|
82
|
+
// `position: fixed` note in the style block), so every open sets its box here —
|
|
83
|
+
// CSS cannot follow a control it is not a descendant of.
|
|
84
|
+
const anchorStyle = (rootRect: DOMRect, up: boolean): Record<string, string> => ({
|
|
85
|
+
left: `${rootRect.left}px`,
|
|
86
|
+
width: `${rootRect.width}px`,
|
|
87
|
+
// Fixed offsets resolve against the LAYOUT viewport, so innerHeight is the
|
|
88
|
+
// right bottom edge here — not the visual viewport the floor is measured in.
|
|
89
|
+
...(up ? { bottom: `${window.innerHeight - rootRect.top - MENU_OVERLAP}px` } : { top: `${rootRect.bottom - MENU_OVERLAP}px` })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// The menu's height with nothing clamping it, read from the scroll extent rather
|
|
93
|
+
// than by dropping the clamp and measuring again: re-measuring would paint the
|
|
94
|
+
// unclamped menu for a frame, which on a scroll-driven re-place is a flicker
|
|
95
|
+
// every frame.
|
|
96
|
+
const naturalHeight = (m: HTMLElement): number => m.scrollHeight + (m.offsetHeight - m.clientHeight)
|
|
97
|
+
|
|
98
|
+
// The menu defaults to opening downward, but fixed chrome (e.g. a bottom nav)
|
|
99
|
+
// can paint over it, hiding the very options scroll-to-selected parks at the
|
|
100
|
+
// bottom edge. Probe the rendered menu's bottom edge with a hit-test: anything
|
|
101
|
+
// on top of it that isn't ours marks the usable floor; clamp the menu's height
|
|
102
|
+
// to the space above that floor, or flip upward when the space above the
|
|
103
|
+
// control is more generous. Self-contained — no app-side configuration.
|
|
104
|
+
//
|
|
105
|
+
// Anchored first and probed after: the hit-test only means anything once the
|
|
106
|
+
// menu is where it will actually be.
|
|
107
|
+
const placeMenu = async (): Promise<void> => {
|
|
108
|
+
const m = menu.value
|
|
109
|
+
const r = root.value
|
|
110
|
+
if (!m || !r) return
|
|
111
|
+
menuStyle.value = anchorStyle(r.getBoundingClientRect(), false)
|
|
112
|
+
await nextTick()
|
|
113
|
+
const height = naturalHeight(m)
|
|
114
|
+
const rootRect = r.getBoundingClientRect()
|
|
115
|
+
// The floor is the bottom of what is actually on screen, which the on-screen
|
|
116
|
+
// keyboard moves up: it shrinks the VISUAL viewport while the layout one —
|
|
117
|
+
// and so window.innerHeight — is unchanged, and it is not in the DOM either,
|
|
118
|
+
// so the hit-test below cannot see it. offsetTop covers the browser scrolling
|
|
119
|
+
// the visual viewport to hold a focused field clear of the keyboard.
|
|
120
|
+
const vv = window.visualViewport
|
|
121
|
+
const viewportBottom = vv ? vv.offsetTop + vv.height : window.innerHeight
|
|
122
|
+
const menuRect = m.getBoundingClientRect()
|
|
123
|
+
let floor = Math.min(viewportBottom, menuRect.bottom + MARGIN)
|
|
124
|
+
const probe = document.elementFromPoint(Math.round(menuRect.x + menuRect.width / 2), Math.round(Math.min(menuRect.bottom, viewportBottom) - 4))
|
|
125
|
+
if (probe && probe !== m && !m.contains(probe)) {
|
|
126
|
+
floor = Math.min(floor, probe.getBoundingClientRect().top)
|
|
127
|
+
}
|
|
128
|
+
const below = floor - rootRect.bottom - MARGIN
|
|
129
|
+
const above = rootRect.top - MARGIN
|
|
130
|
+
if (height > below && above > below) {
|
|
131
|
+
menuStyle.value = { ...anchorStyle(rootRect, true), maxHeight: `${Math.min(height, above)}px` }
|
|
132
|
+
} else if (height > below) {
|
|
133
|
+
menuStyle.value = { ...anchorStyle(rootRect, false), maxHeight: `${Math.max(0, below)}px` }
|
|
134
|
+
}
|
|
135
|
+
// Re-anchor the focused option inside the resized/flipped scroller.
|
|
136
|
+
nextTick(() => m.querySelector('.SelectBox__option--focused')?.scrollIntoView({ block: 'nearest' }))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const open = () => {
|
|
140
|
+
if (props.disabled || menuOpen.value) return
|
|
141
|
+
// Freeze the control's rendered width while open: the searchable state
|
|
142
|
+
// swaps the value span for an <input>, whose intrinsic width differs from
|
|
143
|
+
// the label's — without the pin the whole control jumps as the menu
|
|
144
|
+
// toggles.
|
|
145
|
+
if (root.value) {
|
|
146
|
+
root.value.style.width = `${root.value.getBoundingClientRect().width}px`
|
|
147
|
+
}
|
|
148
|
+
menuOpen.value = true
|
|
149
|
+
const selectedIdx = filteredOptions.value.findIndex(o => o.value === model.value && !o.disabled)
|
|
150
|
+
focusedIndex.value = selectedIdx === -1 ? firstSelectableIndex() : selectedIdx
|
|
151
|
+
nextTick(() => {
|
|
152
|
+
placeMenu()
|
|
153
|
+
// Pointer devices only: there, landing in the filter is a free head
|
|
154
|
+
// start. On touch it summons the on-screen keyboard over the very
|
|
155
|
+
// options the menu just opened to show — a cost paid by every user
|
|
156
|
+
// picking from a short list with no intention of typing. The input is
|
|
157
|
+
// still rendered there and still filters: its @click.stop keeps a tap
|
|
158
|
+
// on it from closing the menu, so it focuses natively and the keyboard
|
|
159
|
+
// arrives only for someone who asked to type.
|
|
160
|
+
if (props.searchable && hasFinePointer()) searchInput.value?.focus()
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const close = () => {
|
|
165
|
+
menuOpen.value = false
|
|
166
|
+
search.value = ''
|
|
167
|
+
menuStyle.value = null
|
|
168
|
+
if (root.value) {
|
|
169
|
+
root.value.style.width = ''
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const onControlClick = () => {
|
|
174
|
+
if (props.disabled) return
|
|
175
|
+
if (menuOpen.value) {
|
|
176
|
+
close()
|
|
177
|
+
} else {
|
|
178
|
+
open()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const select = (option: SelectBoxOption<T>) => {
|
|
183
|
+
if (option.disabled) return
|
|
184
|
+
model.value = option.value
|
|
185
|
+
emit('pick', option.value)
|
|
186
|
+
close()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const clearSelection = () => {
|
|
190
|
+
model.value = null
|
|
191
|
+
close()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const moveFocus = (dir: 1 | -1) => {
|
|
195
|
+
const opts = filteredOptions.value
|
|
196
|
+
if (!opts.length) return
|
|
197
|
+
let i = focusedIndex.value
|
|
198
|
+
for (let step = 0; step < opts.length; step++) {
|
|
199
|
+
i = (i + dir + opts.length) % opts.length
|
|
200
|
+
if (!opts[i].disabled) {
|
|
201
|
+
focusedIndex.value = i
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Escape must close only this dropdown, not any modal hosting it. The modal
|
|
208
|
+
// stack listens for keyup on document, so consuming the keydown alone is not
|
|
209
|
+
// enough — the matching keyup is swallowed too.
|
|
210
|
+
let swallowEscapeUp = false
|
|
211
|
+
|
|
212
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
213
|
+
switch (e.key) {
|
|
214
|
+
case 'ArrowDown':
|
|
215
|
+
case 'ArrowUp':
|
|
216
|
+
e.preventDefault()
|
|
217
|
+
if (menuOpen.value) {
|
|
218
|
+
moveFocus(e.key === 'ArrowDown' ? 1 : -1)
|
|
219
|
+
} else {
|
|
220
|
+
open()
|
|
221
|
+
}
|
|
222
|
+
break
|
|
223
|
+
case 'Enter':
|
|
224
|
+
e.preventDefault()
|
|
225
|
+
if (menuOpen.value) {
|
|
226
|
+
const option = filteredOptions.value[focusedIndex.value]
|
|
227
|
+
if (option) {
|
|
228
|
+
select(option)
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
open()
|
|
232
|
+
}
|
|
233
|
+
break
|
|
234
|
+
case 'Escape':
|
|
235
|
+
if (menuOpen.value) {
|
|
236
|
+
e.stopPropagation()
|
|
237
|
+
swallowEscapeUp = true
|
|
238
|
+
close()
|
|
239
|
+
}
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
245
|
+
if (e.key === 'Escape' && swallowEscapeUp) {
|
|
246
|
+
e.stopPropagation()
|
|
247
|
+
swallowEscapeUp = false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Keep the focused option visible while stepping through a scrolled menu.
|
|
252
|
+
watch(focusedIndex, () => {
|
|
253
|
+
nextTick(() => menu.value?.querySelector('.SelectBox__option--focused')?.scrollIntoView({ block: 'nearest' }))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Typing changes the option list — restart focus from its top.
|
|
257
|
+
watch(search, () => {
|
|
258
|
+
focusedIndex.value = firstSelectableIndex()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const onDocumentPointerDown = (e: PointerEvent) => {
|
|
262
|
+
if (!root.value?.contains(e.target as Node)) {
|
|
263
|
+
close()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The visible area can change under an open menu — the on-screen keyboard
|
|
268
|
+
// arriving or leaving, a pinch-zoom, the browser scrolling the visual viewport
|
|
269
|
+
// to clear a focused field — and each of those moves the floor placeMenu
|
|
270
|
+
// measured against. Coalesced to one re-place per frame: a viewport scroll
|
|
271
|
+
// fires far more often than the screen updates, and each pass reads layout.
|
|
272
|
+
let frame = 0
|
|
273
|
+
const onViewportChange = (): void => {
|
|
274
|
+
if (frame) return
|
|
275
|
+
frame = requestAnimationFrame(() => {
|
|
276
|
+
frame = 0
|
|
277
|
+
void placeMenu()
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const watchViewport = (on: boolean): void => {
|
|
282
|
+
const vv = window.visualViewport
|
|
283
|
+
if (on) {
|
|
284
|
+
document.addEventListener('pointerdown', onDocumentPointerDown)
|
|
285
|
+
// Capture, so a scroll in ANY ancestor container is heard: a
|
|
286
|
+
// viewport-positioned menu does not travel with the control on its own,
|
|
287
|
+
// and scroll events from a nested scroller do not bubble to the window.
|
|
288
|
+
document.addEventListener('scroll', onViewportChange, true)
|
|
289
|
+
vv?.addEventListener('resize', onViewportChange)
|
|
290
|
+
vv?.addEventListener('scroll', onViewportChange)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
|
294
|
+
document.removeEventListener('scroll', onViewportChange, true)
|
|
295
|
+
vv?.removeEventListener('resize', onViewportChange)
|
|
296
|
+
vv?.removeEventListener('scroll', onViewportChange)
|
|
297
|
+
if (frame) {
|
|
298
|
+
cancelAnimationFrame(frame)
|
|
299
|
+
frame = 0
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
watch(menuOpen, watchViewport)
|
|
304
|
+
onUnmounted(() => watchViewport(false))
|
|
305
|
+
|
|
306
|
+
// Programmatic open, for hosts that summon the menu from outside the control
|
|
307
|
+
// (e.g. the location modal dropping the list when its option row is chosen).
|
|
308
|
+
defineExpose({ open })
|
|
309
|
+
</script>
|
|
310
|
+
<template>
|
|
311
|
+
<div ref="root" class="SelectBox" :class="{ 'SelectBox--open': menuOpen, 'SelectBox--disabled': disabled }" @keydown="onKeyDown" @keyup="onKeyUp">
|
|
312
|
+
<div class="SelectBox__control" role="combobox" :aria-label="ariaLabel ?? placeholder" :aria-expanded="menuOpen" :aria-disabled="disabled || undefined" :tabindex="disabled ? undefined : 0" @click="onControlClick">
|
|
313
|
+
<!-- The committed value must stay evident while open: echo it as the
|
|
314
|
+
filter input's placeholder until the user types. -->
|
|
315
|
+
<input v-if="searchable && menuOpen" ref="searchInput" v-model="search" class="SelectBox__search" type="text" :placeholder="selectedOption?.label ?? placeholder" @click.stop />
|
|
316
|
+
<template v-else>
|
|
317
|
+
<span v-if="selectedOption && htmlLabel" class="SelectBox__value" v-html="selectedOption.label" />
|
|
318
|
+
<span v-else-if="selectedOption" class="SelectBox__value">{{ selectedOption.label }}</span>
|
|
319
|
+
<span v-else class="SelectBox__placeholder">{{ placeholder ?? messages.selectOption }}</span>
|
|
320
|
+
</template>
|
|
321
|
+
<button v-if="clearable && selectedOption && !disabled" type="button" class="SelectBox__clear" :aria-label="messages.clear" @click.stop="clearSelection">×</button>
|
|
322
|
+
<span class="SelectBox__indicator" aria-hidden="true" />
|
|
323
|
+
</div>
|
|
324
|
+
<div v-if="menuOpen" ref="menu" class="SelectBox__menu" role="listbox" :style="menuStyle">
|
|
325
|
+
<div v-for="(option, i) in filteredOptions" :key="option.value" class="SelectBox__option" :class="{ 'SelectBox__option--focused': i === focusedIndex, 'SelectBox__option--selected': option.value === model, 'SelectBox__option--disabled': option.disabled }" role="option" :aria-selected="option.value === model" @click="select(option)" @pointerenter="!option.disabled && (focusedIndex = i)">
|
|
326
|
+
<span v-if="option.depth" class="SelectBox__depth" :style="{ width: `${option.depth * 14}px` }" aria-hidden="true" />
|
|
327
|
+
<span v-if="htmlLabel" v-html="option.label" />
|
|
328
|
+
<template v-else>{{ option.label }}</template>
|
|
329
|
+
</div>
|
|
330
|
+
<div v-if="!filteredOptions.length" class="SelectBox__no-options">{{ messages.nothingFound }}</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
</template>
|
|
334
|
+
<style lang="scss">
|
|
335
|
+
@use '@greendrake/scss-kit' as *;
|
|
336
|
+
|
|
337
|
+
.SelectBox {
|
|
338
|
+
position: relative;
|
|
339
|
+
// Never force a flex ancestor wider than its container: without this the
|
|
340
|
+
// control's min-content width (a long, non-wrapping selected value) would
|
|
341
|
+
// push the box past its container instead of ellipsising.
|
|
342
|
+
min-width: 0;
|
|
343
|
+
&__control {
|
|
344
|
+
display: flex;
|
|
345
|
+
align-items: center;
|
|
346
|
+
gap: 4px;
|
|
347
|
+
padding: var(--vs-padding, 4px 8px);
|
|
348
|
+
// Match a text input's height from a single source: consumers point
|
|
349
|
+
// --vs-height (and --vs-padding/--vs-border) at the same values their
|
|
350
|
+
// inputs use. An explicit height (rather than a line-height-derived one)
|
|
351
|
+
// keeps the control identical across engines — Firefox's line-height
|
|
352
|
+
// clamp on <input> would otherwise size the searchable input taller than
|
|
353
|
+
// the closed value. --vs-line-height is inherited by the value/search
|
|
354
|
+
// descendants so their text line matches too.
|
|
355
|
+
box-sizing: border-box;
|
|
356
|
+
height: var(--vs-height);
|
|
357
|
+
line-height: var(--vs-line-height, normal);
|
|
358
|
+
border: var(--vs-border, 1px solid #e4e4e7);
|
|
359
|
+
border-radius: var(--vs-border-radius, 4px);
|
|
360
|
+
background-color: var(--vs-input-bg, #fff);
|
|
361
|
+
color: var(--vs-text-color, inherit);
|
|
362
|
+
cursor: default;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
&__value,
|
|
366
|
+
&__placeholder {
|
|
367
|
+
flex-grow: 1;
|
|
368
|
+
// min-width:0 lets the flex item shrink below its content so the
|
|
369
|
+
// ellipsis actually engages (flex items default to min-width:auto).
|
|
370
|
+
min-width: 0;
|
|
371
|
+
overflow: hidden;
|
|
372
|
+
white-space: nowrap;
|
|
373
|
+
text-overflow: ellipsis;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
&__placeholder {
|
|
377
|
+
// Muted by colour rather than by transparency: a placeholder is text a
|
|
378
|
+
// reader has to read, and 0.6 of the control's own colour lands below
|
|
379
|
+
// the contrast bar on every surface this control sits on. The app's
|
|
380
|
+
// muted token is a colour its theme has already had to defend.
|
|
381
|
+
color: var(--font-color-muted, var(--vs-text-color, inherit));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Scoped under __control (0,2,0) so app-level input skins — e.g. a global
|
|
385
|
+
// `input:focus { background: … }` at (0,1,1) — can never paint a foreign
|
|
386
|
+
// surface over the open control; the focus state re-declares for (0,2,1).
|
|
387
|
+
&__control &__search {
|
|
388
|
+
flex-grow: 1;
|
|
389
|
+
min-width: 0;
|
|
390
|
+
border: none;
|
|
391
|
+
padding: 0;
|
|
392
|
+
background: transparent;
|
|
393
|
+
color: inherit;
|
|
394
|
+
font: inherit;
|
|
395
|
+
&:focus {
|
|
396
|
+
outline: none;
|
|
397
|
+
background: transparent;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
&__clear {
|
|
402
|
+
@include control-clear;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
&__indicator {
|
|
406
|
+
@include control-chevron;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
&--open &__indicator {
|
|
410
|
+
transform: rotate(225deg);
|
|
411
|
+
margin-bottom: -3px;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
&--disabled &__control {
|
|
415
|
+
opacity: 0.5;
|
|
416
|
+
pointer-events: none;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
&__menu {
|
|
420
|
+
// Against the viewport, not the control: an ancestor with a scroll
|
|
421
|
+
// container (a bottom-sheet modal, a scrollable panel) clips an
|
|
422
|
+
// absolutely-positioned descendant, and a dropdown is exactly the thing
|
|
423
|
+
// that has to reach past its container's edge. placeMenu supplies the
|
|
424
|
+
// box, and re-supplies it whenever anything scrolls under it.
|
|
425
|
+
position: fixed;
|
|
426
|
+
z-index: z('raised');
|
|
427
|
+
max-height: var(--vs-menu-height, 200px);
|
|
428
|
+
overflow-y: auto;
|
|
429
|
+
border: var(--vs-border, 1px solid #e4e4e7);
|
|
430
|
+
border-radius: var(--vs-border-radius, 4px);
|
|
431
|
+
background-color: var(--vs-menu-bg, #fff);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
&__option {
|
|
435
|
+
// Flex + min-height let a themed menu match its options to the
|
|
436
|
+
// control's height exactly (zero the vertical padding and the row
|
|
437
|
+
// centres within --vs-height); unthemed menus keep the padded default.
|
|
438
|
+
display: flex;
|
|
439
|
+
align-items: center;
|
|
440
|
+
min-height: var(--vs-height, auto);
|
|
441
|
+
padding: var(--vs-option-padding, 8px 12px);
|
|
442
|
+
color: var(--vs-option-text-color, var(--vs-text-color, inherit));
|
|
443
|
+
cursor: default;
|
|
444
|
+
|
|
445
|
+
&--focused {
|
|
446
|
+
background-color: var(--vs-option-hover-color, #dbeafe);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
&--selected {
|
|
450
|
+
background-color: var(--vs-option-selected-color, #93c5fd);
|
|
451
|
+
color: var(--vs-option-selected-text-color, inherit);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
&--disabled {
|
|
455
|
+
opacity: 0.5;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
&__depth {
|
|
460
|
+
flex-shrink: 0;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
&__no-options {
|
|
464
|
+
padding: var(--vs-option-padding, 8px 12px);
|
|
465
|
+
opacity: 0.6;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
</style>
|
package/src/Splash.vue
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Loading from './Loading.vue'
|
|
3
|
+
</script>
|
|
4
|
+
<template>
|
|
5
|
+
<div class="Splash">
|
|
6
|
+
<div class="Splash__content">
|
|
7
|
+
<div class="Splash__logo">
|
|
8
|
+
<slot />
|
|
9
|
+
</div>
|
|
10
|
+
<Loading />
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
14
|
+
<style lang="scss">
|
|
15
|
+
@use '@greendrake/scss-kit' as *;
|
|
16
|
+
|
|
17
|
+
.Splash {
|
|
18
|
+
pointer-events: none;
|
|
19
|
+
position: absolute;
|
|
20
|
+
inset: 0;
|
|
21
|
+
z-index: z('raised');
|
|
22
|
+
display: flex;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
align-items: center;
|
|
25
|
+
|
|
26
|
+
&__content {
|
|
27
|
+
text-align: center;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&__logo {
|
|
31
|
+
font-size: 48px;
|
|
32
|
+
line-height: 48px;
|
|
33
|
+
font-weight: 700;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
</style>
|
package/src/TabBar.vue
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface TabDef {
|
|
3
|
+
id: string
|
|
4
|
+
label: string
|
|
5
|
+
closable?: boolean
|
|
6
|
+
}
|
|
7
|
+
</script>
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import { computed } from 'vue'
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
tabs: TabDef[]
|
|
14
|
+
modelValue: string | null
|
|
15
|
+
closable?: boolean
|
|
16
|
+
}>(),
|
|
17
|
+
{
|
|
18
|
+
closable: false
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
defineEmits<{
|
|
23
|
+
'update:modelValue': [id: string]
|
|
24
|
+
close: [id: string]
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const activeIndex = computed(() => props.tabs.findIndex(t => t.id === props.modelValue))
|
|
28
|
+
</script>
|
|
29
|
+
<template>
|
|
30
|
+
<div class="TabBar" :style="{ '--TabBar-active-index': activeIndex }">
|
|
31
|
+
<div v-for="(tab, index) in tabs" :key="tab.id" class="TabBar__tab" :class="{ 'TabBar__tab--active': tab.id === modelValue }" :style="{ '--TabBar-tab-index': index }" @click="$emit('update:modelValue', tab.id)">
|
|
32
|
+
<span class="TabBar__tab-label">{{ tab.label }}</span>
|
|
33
|
+
<button v-if="tab.closable ?? closable" class="TabBar__tab-close" @click.stop="$emit('close', tab.id)">×</button>
|
|
34
|
+
</div>
|
|
35
|
+
<div v-if="$slots.default" class="TabBar__panel">
|
|
36
|
+
<slot />
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
<style lang="scss">
|
|
41
|
+
@use '@greendrake/scss-kit' as *;
|
|
42
|
+
|
|
43
|
+
.TabBar {
|
|
44
|
+
display: flex;
|
|
45
|
+
|
|
46
|
+
&__tab {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 6px;
|
|
50
|
+
padding: 6px 12px;
|
|
51
|
+
cursor: default;
|
|
52
|
+
|
|
53
|
+
&:hover {
|
|
54
|
+
background-color: var(--form-background-hover);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
&__panel {
|
|
59
|
+
padding: var(--space-3) var(--space-2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
&__tab-label {
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
&__tab-close {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: center;
|
|
70
|
+
width: 16px;
|
|
71
|
+
height: 16px;
|
|
72
|
+
padding: 0;
|
|
73
|
+
border: none;
|
|
74
|
+
background: transparent;
|
|
75
|
+
font-size: 14px;
|
|
76
|
+
line-height: 1;
|
|
77
|
+
cursor: default;
|
|
78
|
+
opacity: 0.5;
|
|
79
|
+
border-radius: 3px;
|
|
80
|
+
|
|
81
|
+
&:hover {
|
|
82
|
+
opacity: 1;
|
|
83
|
+
background-color: var(--border-color-light);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@include respond-above('narrow') {
|
|
88
|
+
flex-wrap: wrap;
|
|
89
|
+
border-bottom: 1px solid var(--border-color-light);
|
|
90
|
+
|
|
91
|
+
// When a panel slot is present, the underline moves to the panel's
|
|
92
|
+
// top edge so it sits between the tab row and the panel instead of
|
|
93
|
+
// under everything.
|
|
94
|
+
&:has(&__panel) {
|
|
95
|
+
border-bottom: none;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&__tab {
|
|
99
|
+
border-bottom: 2px solid transparent;
|
|
100
|
+
|
|
101
|
+
&--active {
|
|
102
|
+
border-bottom-color: var(--accent-color);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
&__panel {
|
|
107
|
+
flex-basis: 100%;
|
|
108
|
+
border-top: 1px solid var(--border-color-light);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@include respond-below('narrow') {
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
|
|
115
|
+
&__tab {
|
|
116
|
+
padding: 12px 14px;
|
|
117
|
+
border-inline-start: 3px solid transparent;
|
|
118
|
+
border-block-start: 1px solid var(--border-color-light);
|
|
119
|
+
order: calc(var(--TabBar-tab-index) * 2);
|
|
120
|
+
|
|
121
|
+
&:not(:has(~ .TabBar__tab)) {
|
|
122
|
+
border-block-end: 1px solid var(--border-color-light);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Expand/collapse caret — an accordion idiom. The right-chevron used
|
|
126
|
+
// before reads as "drill into a subpage", but the section expands
|
|
127
|
+
// inline. The caret points down when collapsed and flips up on the
|
|
128
|
+
// open section (so the expanded row keeps a visible affordance).
|
|
129
|
+
&::after {
|
|
130
|
+
content: '\25BE';
|
|
131
|
+
margin-inline-start: auto;
|
|
132
|
+
line-height: 1;
|
|
133
|
+
opacity: 0.5;
|
|
134
|
+
transition: transform 0.15s;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
&--active {
|
|
138
|
+
border-inline-start-color: var(--accent-color);
|
|
139
|
+
background-color: var(--form-background-hover);
|
|
140
|
+
|
|
141
|
+
&::after {
|
|
142
|
+
transform: rotate(180deg);
|
|
143
|
+
opacity: 0.9;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
&__panel {
|
|
149
|
+
order: calc(var(--TabBar-active-index) * 2 + 1);
|
|
150
|
+
// Tie the expanded content to its header: continue the active tab's
|
|
151
|
+
// accent edge and tint so the open section reads as one unit rather
|
|
152
|
+
// than free-floating content below the list.
|
|
153
|
+
border-inline-start: 3px solid var(--accent-color);
|
|
154
|
+
background-color: color-mix(in srgb, var(--form-background-hover) 40%, transparent);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
</style>
|