@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,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">&times;</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)">&times;</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>