@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,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>
|
package/src/Password.vue
ADDED
|
@@ -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>
|
package/src/RowSelect.ts
ADDED
|
@@ -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>
|