@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,73 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import Modal from './Modal.vue'
4
+
5
+ // Trigger-plus-modal composite: the default slot is the trigger element;
6
+ // clicking it opens the built-in Modal around the `content` slot. The modal
7
+ // chrome props mirror Modal's and are forwarded as-is — undefined falls
8
+ // through to Modal's own defaults; `modalClass` is a distinct prop because
9
+ // class/$attrs on this component land on the trigger element.
10
+ //
11
+ // `ok` is re-emitted WITHOUT auto-closing: async submitters keep the modal
12
+ // open on failure and close programmatically (exposed onCancel) on success.
13
+ // `cancel` auto-closes first, then re-emits for callers that reset form state
14
+ // on dismissal.
15
+ withDefaults(
16
+ defineProps<{
17
+ // Forwarded to Modal: titled header with explicit close.
18
+ title?: string
19
+ tag?: string
20
+ // Trigger-element attributes, declared (not $attrs) so strictTemplates
21
+ // accepts them at call sites.
22
+ id?: string
23
+ type?: string
24
+ modalClass?: string
25
+ okLabel?: string
26
+ cancelLabel?: string
27
+ okDisabled?: boolean
28
+ hideOk?: boolean
29
+ closable?: boolean
30
+ expandable?: boolean
31
+ buttons?: boolean
32
+ }>(),
33
+ {
34
+ tag: 'a',
35
+ // Vue casts absent boolean props to false unless a default is
36
+ // declared; explicit `undefined` defaults keep absent props undefined
37
+ // so Modal's own defaults (notably `buttons: true`) stay in effect.
38
+ okDisabled: undefined,
39
+ hideOk: undefined,
40
+ closable: undefined,
41
+ expandable: undefined,
42
+ buttons: undefined
43
+ }
44
+ )
45
+ const emit = defineEmits<{ open: []; ok: []; cancel: []; click: [event: MouseEvent] }>()
46
+ const modalOpen = ref(false)
47
+ const onClick = (event: MouseEvent) => {
48
+ // Re-emitted so callers can react to the trigger interaction itself
49
+ // (e.g. stop propagation inside a clickable row).
50
+ emit('click', event)
51
+ modalOpen.value = true
52
+ emit('open')
53
+ }
54
+ const onCancel = () => {
55
+ modalOpen.value = false
56
+ }
57
+ const onModalCancel = () => {
58
+ onCancel()
59
+ emit('cancel')
60
+ }
61
+ defineExpose({
62
+ open: onClick,
63
+ onCancel
64
+ })
65
+ </script>
66
+ <template>
67
+ <Modal v-if="modalOpen" :class="modalClass" :title="title" :ok-label="okLabel" :cancel-label="cancelLabel" :ok-disabled="okDisabled" :hide-ok="hideOk" :closable="closable" :expandable="expandable" :buttons="buttons" @ok="emit('ok')" @cancel="onModalCancel">
68
+ <slot name="content" />
69
+ </Modal>
70
+ <component :is="tag" :id="id" :type="type" href="#" v-bind="$attrs" @click.prevent="onClick">
71
+ <slot />
72
+ </component>
73
+ </template>
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ export type OptionValue = string | number | boolean
3
+ // Array form: a bare value renders String(value) as its title; a [value, title]
4
+ // tuple sets the title explicitly. Map form: value → title.
5
+ export type OptionsArray<T extends OptionValue = OptionValue> = Array<T | [T, string]>
6
+ export type OptionsMap<T extends OptionValue = OptionValue> = Map<T, string>
7
+ </script>
8
+ <script setup lang="ts" generic="T extends string | number | boolean">
9
+ import { computed, ref, onUnmounted } from 'vue'
10
+ import Form, { type FormState } from './Form'
11
+
12
+ // Radio-style switch between a fixed set of values: clicking an option runs
13
+ // saveHandler through the Form state machine (with optional confirmation) and
14
+ // commits the model on success. Each option can carry extra markup via a slot
15
+ // named after its value.
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ label?: string
19
+ options: OptionsArray<T> | OptionsMap<T>
20
+ requireConfirmation?: boolean
21
+ disabledOptions?: T[]
22
+ saveHandler?: (value: T) => Promise<void> | void
23
+ }>(),
24
+ {
25
+ disabledOptions: () => []
26
+ }
27
+ )
28
+ const model = defineModel<T>()
29
+
30
+ // Re-emitted from the root so hosts can react to the interaction itself
31
+ // (e.g. dismissing a popper that would cover the confirm step).
32
+ const emit = defineEmits<{ click: [event: MouseEvent] }>()
33
+
34
+ const optionsMap = computed(() => {
35
+ if (!Array.isArray(props.options)) {
36
+ return props.options
37
+ }
38
+ const map = new Map<T, string>()
39
+ for (const el of props.options) {
40
+ if (Array.isArray(el)) {
41
+ map.set(el[0], el[1])
42
+ } else {
43
+ map.set(el, String(el))
44
+ }
45
+ }
46
+ return map
47
+ })
48
+ if (model.value !== undefined && !optionsMap.value.has(model.value)) {
49
+ throw new Error(`OptionSwitch value is not among the options: ${model.value}`)
50
+ }
51
+ const values = computed(() => Array.from(optionsMap.value.keys()))
52
+ const titles = computed(() => Array.from(optionsMap.value.values()))
53
+ const value2index = computed(() => new Map(values.value.map((v, i) => [v, i])))
54
+ const currentValueIndex = computed(() => (model.value === undefined ? undefined : value2index.value.get(model.value)))
55
+ const candidateValue = ref<T | null>(null)
56
+ const candidateValueIndex = computed(() => (candidateValue.value !== null ? value2index.value.get(candidateValue.value) : undefined))
57
+ const form = new Form({
58
+ alwaysValid: true,
59
+ alwaysDirty: true,
60
+ requireConfirmation: props.requireConfirmation,
61
+ handler: async () => {
62
+ // action() is only ever reached via onLabelClick, which sets the candidate.
63
+ const value = candidateValue.value!
64
+ await props.saveHandler?.(value)
65
+ model.value = value
66
+ },
67
+ successTimeout: false
68
+ })
69
+ onUnmounted(() => form.destroy())
70
+ const disabledSet = computed(() => new Set(props.disabledOptions))
71
+ const onLabelClick = async (vi: number) => {
72
+ if (disabledSet.value.has(values.value[vi])) {
73
+ return
74
+ }
75
+ candidateValue.value = values.value[vi]
76
+ await form.action()
77
+ }
78
+ const stateIs = (s: FormState) => computed(() => form.state.value === s)
79
+ const isInProgress = stateIs('inprogress')
80
+ const isError = stateIs('error')
81
+ const isConfirm = stateIs('confirm')
82
+ </script>
83
+ <template>
84
+ <div class="OptionSwitch__Wrap">
85
+ <label v-if="label">{{ label }}</label>
86
+ <div :class="{ OptionSwitch: true, saving: isInProgress, error: isError }" @click="e => emit('click', e)">
87
+ <div v-for="(title, i) in titles" :key="String(values[i])" :data-index="i" :class="{ confirm: isConfirm && candidateValueIndex === i, current: currentValueIndex === i, disabled: disabledSet.has(values[i]) }" @click="onLabelClick(i)">
88
+ <div v-if="title">{{ title }}</div>
89
+ <slot :name="String(values[i])" />
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </template>
94
+ <style lang="scss">
95
+ .OptionSwitch {
96
+ position: relative;
97
+ > div {
98
+ display: flex;
99
+ align-items: start;
100
+ // Let the label and slotted content (e.g. a SelectBox) shrink below
101
+ // their content width instead of forcing the row past its container.
102
+ // Flex items default to min-width:auto; a wide, non-wrapping slot value
103
+ // would otherwise overflow. The radio glyph keeps its fixed width via
104
+ // its own min-width.
105
+ > * {
106
+ min-width: 0;
107
+ }
108
+ }
109
+ }
110
+ </style>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, onMounted, nextTick } from 'vue'
3
+ import { hasFinePointer } from '@greendrake/util'
4
+ import FixAndroidTyping from './FixAndroidTyping'
5
+
6
+ const showPassword = ref(false)
7
+ const plainInput = ref<HTMLInputElement | null>(null)
8
+ const passwordInput = ref<HTMLInputElement | null>(null)
9
+ const model = defineModel<string>({ default: '' })
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ autofocus?: boolean
13
+ minLength?: number
14
+ // Lands on the wrapper — tests and FieldTip anchor `#<id>`, inner
15
+ // inputs via `#<id> input`.
16
+ id?: string
17
+ }>(),
18
+ {
19
+ minLength: 1
20
+ }
21
+ )
22
+ const currentInput = computed(() => (showPassword.value ? plainInput : passwordInput))
23
+ const onInput = () => FixAndroidTyping(currentInput.value, model)
24
+ const onSwitchVisibility = () => {
25
+ // Preserve the caret position
26
+ const caretPos = currentInput.value.value?.selectionStart ?? 0
27
+ showPassword.value = !showPassword.value
28
+ nextTick(() => {
29
+ const input = currentInput.value.value
30
+ if (input) {
31
+ input.focus()
32
+ input.setSelectionRange(caretPos, caretPos)
33
+ }
34
+ })
35
+ }
36
+ // Pointer devices only — see TextField for why touch is excluded.
37
+ if (props.autofocus && hasFinePointer()) {
38
+ onMounted(() => {
39
+ currentInput.value.value?.focus()
40
+ })
41
+ }
42
+ </script>
43
+ <template>
44
+ <div :id="id" class="Password" :class="{ PasswordVisible: showPassword }">
45
+ <input v-if="showPassword" ref="plainInput" v-model="model" type="text" :minlength="minLength" required spellcheck="false" @input="onInput" />
46
+ <input v-else ref="passwordInput" v-model="model" type="password" :minlength="minLength" required @input="onInput" />
47
+ <div @click.prevent="onSwitchVisibility"></div>
48
+ </div>
49
+ </template>
50
+ <style lang="scss">
51
+ @use '@greendrake/scss-kit' as *;
52
+ .Password {
53
+ display: flex;
54
+ border: solid 1px var(--font-color);
55
+ border-radius: 2px;
56
+ input {
57
+ border: 0;
58
+ flex-grow: 1;
59
+ &:focus {
60
+ outline: none;
61
+ }
62
+ line-height: var(--line-height-normal);
63
+ @include monospace;
64
+ }
65
+ > div {
66
+ background-color: green;
67
+ mask-size: 70%;
68
+ opacity: 0.8;
69
+ align-self: stretch;
70
+ mask-position: 2px center;
71
+ cursor: default;
72
+ width: 30px;
73
+ &:hover {
74
+ opacity: 1;
75
+ }
76
+ @include icon-base;
77
+ @include i('eye');
78
+ }
79
+ &.PasswordVisible {
80
+ > div {
81
+ @include i('eye-off');
82
+ }
83
+ }
84
+ }
85
+ </style>
@@ -0,0 +1,211 @@
1
+ import { reactive, ref, onUnmounted, type Ref, type ComputedRef } from 'vue'
2
+ import useSelection from './useSelection'
3
+
4
+ const INTERACTIVE = 'a,button,input,select,textarea,[role="button"]'
5
+ const DRAG_THRESHOLD = 5
6
+
7
+ // Pointer-driven selection for a <table> host: row click/ctrl/shift selection
8
+ // (via useSelection), rubber-band drag selection over empty space, and — when
9
+ // onReorder is provided — drag-to-reorder of rows. The host binds containerRef
10
+ // to the table's scroll container and wires onPointerDown/onKeyDown.
11
+ export default function RowSelect<Id extends string | number>(items: Ref<Record<string, unknown>[]> | ComputedRef<Record<string, unknown>[]>, idField: string, onReorder?: (orderedIds: Id[]) => void) {
12
+ const sel = useSelection<Id>(items, idField)
13
+ const { selected, getId } = sel
14
+ const containerRef = ref<HTMLElement | null>(null)
15
+ const band = reactive({
16
+ active: false,
17
+ left: 0,
18
+ top: 0,
19
+ width: 0,
20
+ height: 0
21
+ })
22
+ const reorder = reactive({
23
+ active: false,
24
+ fromIndex: -1,
25
+ toIndex: -1
26
+ })
27
+
28
+ let startX = 0
29
+ let startY = 0
30
+ let dragStarted = false
31
+ let preClickSnapshot: Set<Id> | null = null
32
+ let cleanupDrag: (() => void) | null = null
33
+
34
+ function getIntersectingIds(top: number, bottom: number): Id[] {
35
+ const tbody = containerRef.value?.querySelector('tbody')
36
+ if (!tbody) return []
37
+ const rows = tbody.children
38
+ const ids: Id[] = []
39
+ for (let i = 0; i < rows.length && i < items.value.length; i++) {
40
+ const r = rows[i].getBoundingClientRect()
41
+ if (r.bottom > top && r.top < bottom) ids.push(getId(i))
42
+ }
43
+ return ids
44
+ }
45
+
46
+ function hitIndex(target: HTMLElement): number {
47
+ const tbody = containerRef.value?.querySelector('tbody')
48
+ if (!tbody) return -1
49
+ const tr = target.closest('tr')
50
+ if (!tr || tr.parentElement !== tbody) return -1
51
+ const index = Array.prototype.indexOf.call(tbody.children, tr)
52
+ return index >= 0 && index < items.value.length ? index : -1
53
+ }
54
+
55
+ function computeDropIndex(clientY: number): number {
56
+ const tbody = containerRef.value!.querySelector('tbody')!
57
+ const rows = tbody.children
58
+ for (let i = 0; i < rows.length && i < items.value.length; i++) {
59
+ const r = rows[i].getBoundingClientRect()
60
+ if (clientY < (r.top + r.bottom) / 2) return i
61
+ }
62
+ return items.value.length
63
+ }
64
+
65
+ function armBandDrag(): void {
66
+ const onMove = (me: PointerEvent) => {
67
+ if (!dragStarted && Math.abs(me.clientX - startX) < DRAG_THRESHOLD && Math.abs(me.clientY - startY) < DRAG_THRESHOLD) return
68
+
69
+ if (!dragStarted) {
70
+ dragStarted = true
71
+ selected.clear()
72
+ }
73
+
74
+ const cr = containerRef.value!.getBoundingClientRect()
75
+ band.left = Math.min(startX, me.clientX) - cr.left
76
+ band.top = Math.min(startY, me.clientY) - cr.top
77
+ band.width = Math.abs(me.clientX - startX)
78
+ band.height = Math.abs(me.clientY - startY)
79
+ band.active = true
80
+
81
+ const bandTop = Math.min(startY, me.clientY)
82
+ const bandBottom = Math.max(startY, me.clientY)
83
+ selected.clear()
84
+ for (const rid of getIntersectingIds(bandTop, bandBottom)) selected.add(rid)
85
+ }
86
+
87
+ const onUp = () => {
88
+ document.removeEventListener('pointermove', onMove)
89
+ document.removeEventListener('pointerup', onUp)
90
+ cleanupDrag = null
91
+ band.active = false
92
+ dragStarted = false
93
+ }
94
+
95
+ document.addEventListener('pointermove', onMove)
96
+ document.addEventListener('pointerup', onUp)
97
+ cleanupDrag = onUp
98
+ }
99
+
100
+ function armReorderDrag(fromIndex: number): void {
101
+ const onMove = (me: PointerEvent) => {
102
+ if (!dragStarted && Math.abs(me.clientX - startX) < DRAG_THRESHOLD && Math.abs(me.clientY - startY) < DRAG_THRESHOLD) return
103
+
104
+ if (!dragStarted) {
105
+ dragStarted = true
106
+ // Restore selection state from before the tentative click
107
+ if (preClickSnapshot) {
108
+ selected.clear()
109
+ for (const id of preClickSnapshot) selected.add(id)
110
+ preClickSnapshot = null
111
+ }
112
+ reorder.fromIndex = fromIndex
113
+ reorder.active = true
114
+ }
115
+
116
+ reorder.toIndex = computeDropIndex(me.clientY)
117
+ }
118
+
119
+ const onUp = () => {
120
+ document.removeEventListener('pointermove', onMove)
121
+ document.removeEventListener('pointerup', onUp)
122
+ cleanupDrag = null
123
+
124
+ if (reorder.active) {
125
+ const from = reorder.fromIndex
126
+ let to = reorder.toIndex
127
+ reorder.active = false
128
+ reorder.fromIndex = -1
129
+ reorder.toIndex = -1
130
+
131
+ if (to > from) to--
132
+ if (from !== to) {
133
+ const ids = items.value.map(item => item[idField] as Id)
134
+ const [moved] = ids.splice(from, 1)
135
+ ids.splice(to, 0, moved)
136
+ onReorder!(ids)
137
+ }
138
+ }
139
+
140
+ dragStarted = false
141
+ }
142
+
143
+ document.addEventListener('pointermove', onMove)
144
+ document.addEventListener('pointerup', onUp)
145
+ cleanupDrag = onUp
146
+ }
147
+
148
+ function onPointerDown(e: PointerEvent): void {
149
+ const target = e.target as HTMLElement
150
+ if (target.closest(INTERACTIVE)) return
151
+
152
+ e.preventDefault()
153
+ containerRef.value?.focus()
154
+
155
+ const rowIndex = hitIndex(target)
156
+ const id = rowIndex >= 0 ? getId(rowIndex) : null
157
+
158
+ if (id !== null && e.shiftKey) {
159
+ sel.rangeTo(id)
160
+ return
161
+ }
162
+
163
+ preClickSnapshot = new Set(selected)
164
+
165
+ if (id !== null) {
166
+ if (e.ctrlKey || e.metaKey) {
167
+ sel.toggle(id)
168
+ } else {
169
+ sel.replace(id)
170
+ }
171
+ }
172
+
173
+ startX = e.clientX
174
+ startY = e.clientY
175
+ dragStarted = false
176
+
177
+ if (id !== null && onReorder) {
178
+ armReorderDrag(rowIndex)
179
+ } else {
180
+ armBandDrag()
181
+ }
182
+ }
183
+
184
+ function onKeyDown(e: KeyboardEvent): void {
185
+ if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
186
+ e.preventDefault()
187
+ sel.selectAll()
188
+ return
189
+ }
190
+
191
+ const isUp = e.key === 'ArrowUp'
192
+ const isDown = e.key === 'ArrowDown'
193
+ if (!isUp && !isDown) return
194
+
195
+ e.preventDefault()
196
+ sel.moveCursor(isUp ? 'up' : 'down', e.shiftKey)
197
+ }
198
+
199
+ onUnmounted(() => {
200
+ if (cleanupDrag) cleanupDrag()
201
+ })
202
+
203
+ return {
204
+ selected,
205
+ containerRef,
206
+ band,
207
+ reorder,
208
+ onPointerDown,
209
+ onKeyDown
210
+ }
211
+ }
@@ -0,0 +1,124 @@
1
+ <script setup lang="ts">
2
+ // Attrs are placed by hand (see the template): class/style on the root, the
3
+ // rest — placeholder, role, aria-* — on the inner input. That split is what Vue
4
+ // does by default and what this option suspends; forwarding the whole $attrs
5
+ // put a host's class on the <input>, where a rule written against the field
6
+ // silently never matched (the busy affordance `.SearchField.spinner::before`,
7
+ // driven by AsyncSearch, is exactly that case).
8
+ defineOptions({ inheritAttrs: false })
9
+ import { ref, watch } from 'vue'
10
+ import { Debouncer } from '@greendrake/util'
11
+ import TextField from './TextField.vue'
12
+ import { messages } from './messages'
13
+
14
+ const model = defineModel<string>({ default: '' })
15
+ const internalModel = ref(model.value)
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ placeholder?: string
19
+ autofocus?: boolean
20
+ // true ⇒ default delay; a number ⇒ fixed delay (ms); a function ⇒ delay
21
+ // decided per keystroke from the prospective value (e.g. a swift debounce
22
+ // when the consumer knows the change resolves locally, a longer one when it
23
+ // triggers a remote fetch).
24
+ debounce?: boolean | number | ((value: string) => number)
25
+ }>(),
26
+ {
27
+ debounce: undefined
28
+ }
29
+ )
30
+ // One Debouncer instance for the field's lifetime so each keystroke invalidates the
31
+ // previous pending fire; the delay is resolved per fire (see watch below) to support
32
+ // the function form. ms 0 here is just a placeholder — fire() always gets an explicit delay.
33
+ const debouncer = props.debounce ? new Debouncer(0) : undefined
34
+ const resolveDelay = (value: string): number => {
35
+ const d = props.debounce
36
+ if (typeof d === 'function') return d(value)
37
+ if (d === true) return 600
38
+ return d || 0
39
+ }
40
+ const clear = () => {
41
+ model.value = ''
42
+ }
43
+ // Commit the current (un-debounced) value immediately and drop any pending
44
+ // debounced update, so an explicit "search now" (Enter) acts on exactly what's
45
+ // typed right now. Returns whether the value actually changed — letting the caller
46
+ // distinguish a real update (the model watcher will react) from a no-op re-trigger.
47
+ const flush = (): boolean => {
48
+ debouncer?.cancel()
49
+ if (model.value === internalModel.value) return false
50
+ model.value = internalModel.value
51
+ return true
52
+ }
53
+ defineExpose({ flush })
54
+ watch(model, v => {
55
+ if (v !== internalModel.value) {
56
+ internalModel.value = v
57
+ }
58
+ })
59
+ watch(internalModel, async v => {
60
+ if (debouncer) {
61
+ const proceed = await debouncer.fire(resolveDelay(v))
62
+ if (!proceed()) {
63
+ return
64
+ }
65
+ }
66
+ model.value = v
67
+ })
68
+ </script>
69
+ <template>
70
+ <div class="SearchField" :class="$attrs.class" :style="$attrs.style" @keyup.esc="clear">
71
+ <!-- Escape handled on the root (keyup bubbles from the inner input). A
72
+ comment ABOVE this div would render the component as a fragment in
73
+ dev builds and break $el consumers (AsyncSearch anchors its popper
74
+ on it) — the attr-splitting rationale is in the script instead. -->
75
+ <TextField v-model="internalModel" :placeholder="placeholder" :autofocus="autofocus" v-bind="{ ...$attrs, class: undefined, style: undefined }" />
76
+ <button v-if="internalModel" type="button" class="clear" :aria-label="messages.clear" @click="clear" />
77
+ </div>
78
+ </template>
79
+ <style scoped lang="scss">
80
+ .SearchField {
81
+ display: flex;
82
+ position: relative;
83
+ input {
84
+ padding-inline: 30px;
85
+ flex-grow: 1;
86
+ width: 100%;
87
+ }
88
+ .clear {
89
+ position: absolute;
90
+ inset-inline-end: 4px;
91
+ top: 50%;
92
+ transform: translateY(-50%);
93
+ width: 24px;
94
+ height: 24px;
95
+ padding: 0;
96
+ border: none;
97
+ background-color: var(--font-color);
98
+ background-image: none;
99
+ opacity: 0.5;
100
+ cursor: default;
101
+ mask-position: center center;
102
+ mask-repeat: no-repeat;
103
+ mask-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5L15 15M15 5L5 15' stroke='currentColor' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
104
+ &:hover {
105
+ opacity: 0.8;
106
+ }
107
+ }
108
+ &::after {
109
+ content: '';
110
+ background-color: var(--font-color);
111
+ width: 32px;
112
+ height: 70%;
113
+ display: block;
114
+ position: absolute;
115
+ left: 0;
116
+ top: 50%;
117
+ transform: translateY(-50%);
118
+ opacity: 0.5;
119
+ mask-position: center center;
120
+ mask-repeat: no-repeat;
121
+ mask-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.6923 8.92308C12.6923 11.0012 11.0012 12.6923 8.92308 12.6923C6.84495 12.6923 5.15385 11.0012 5.15385 8.92308C5.15385 6.84495 6.84495 5.15385 8.92308 5.15385C11.0012 5.15385 12.6923 6.84495 12.6923 8.92308ZM17 15.9231C17 15.637 16.8822 15.3594 16.6887 15.1659L13.8029 12.28C14.4844 11.2957 14.8462 10.1178 14.8462 8.92308C14.8462 5.65024 12.1959 3 8.92308 3C5.65024 3 3 5.65024 3 8.92308C3 12.1959 5.65024 14.8462 8.92308 14.8462C10.1178 14.8462 11.2957 14.4844 12.28 13.8029L15.1659 16.6803C15.3594 16.8822 15.637 17 15.9231 17C16.512 17 17 16.512 17 15.9231Z' fill='currentColor'/%3E%3C/svg%3E%0A");
122
+ }
123
+ }
124
+ </style>
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ export interface SegmentedOption {
3
+ value: string
4
+ label: string
5
+ }
6
+ </script>
7
+ <script setup lang="ts">
8
+ import { randomString } from '@greendrake/util'
9
+
10
+ // A segmented control backed by real radio inputs: native semantics (arrow-key
11
+ // group navigation, form participation) with pill presentation. Each input is
12
+ // stretched invisibly across its whole segment, so the accessible target IS
13
+ // the segment — no sub-24px radio dots (WCAG 2.5.8).
14
+ // `ariaLabel` names the group for assistive tech (the bare Available/Wanted
15
+ // segments give no context out of the visual layout); when set the container
16
+ // becomes an explicit radiogroup.
17
+ defineProps<{ options: SegmentedOption[]; ariaLabel?: string }>()
18
+ const model = defineModel<string>({ required: true })
19
+ const group = randomString()
20
+ </script>
21
+ <template>
22
+ <div class="SegmentedControl" :role="ariaLabel ? 'radiogroup' : undefined" :aria-label="ariaLabel">
23
+ <label v-for="opt in options" :key="opt.value" class="SegmentedControl__segment" :class="{ 'SegmentedControl__segment--active': model === opt.value }">
24
+ <input v-model="model" type="radio" :name="group" :value="opt.value" />
25
+ <span>{{ opt.label }}</span>
26
+ </label>
27
+ </div>
28
+ </template>
29
+ <style lang="scss">
30
+ .SegmentedControl {
31
+ display: inline-flex;
32
+ border: 1px solid var(--border-color-light);
33
+ border-radius: 999px;
34
+ overflow: hidden;
35
+ background: var(--form-background);
36
+ &__segment {
37
+ position: relative;
38
+ // Segments share the track equally, so the control reads symmetric
39
+ // whether it sizes to content or is stretched by its container.
40
+ flex: 1 1 0;
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: center;
44
+ min-height: 32px;
45
+ padding: 0 var(--space-3);
46
+ cursor: default;
47
+ white-space: nowrap;
48
+ input {
49
+ // Invisible but real: covers the segment so IT is the hit target,
50
+ // and keyboard focus stays on a native radio.
51
+ position: absolute;
52
+ inset: 0;
53
+ width: 100%;
54
+ height: 100%;
55
+ margin: 0;
56
+ opacity: 0;
57
+ cursor: default;
58
+ }
59
+ &:not(:last-child) {
60
+ border-inline-end: 1px solid var(--border-color-light);
61
+ }
62
+ // End caps carry the track's pill radius so focus rings (inset
63
+ // box-shadows follow border-radius) hug the curve instead of being
64
+ // clipped into a broken C by the track's overflow:hidden.
65
+ &:first-child {
66
+ border-radius: 999px 0 0 999px;
67
+ }
68
+ &:last-child {
69
+ border-radius: 0 999px 999px 0;
70
+ }
71
+ &--active {
72
+ background: var(--accent-color);
73
+ color: var(--accent-contrast);
74
+ }
75
+ // Two-tone inset ring keeps the app's green focus duty without going
76
+ // invisible on the green active fill: the outer accent ring reads on
77
+ // neutral segments, the inner contrast ring reads on the accent fill.
78
+ &:has(input:focus-visible) {
79
+ box-shadow:
80
+ inset 0 0 0 2px var(--accent-color, currentColor),
81
+ inset 0 0 0 4px var(--accent-contrast, transparent);
82
+ }
83
+ }
84
+ }
85
+ </style>