91 lines
3.1 KiB
Vue
91 lines
3.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import type { Unit } from '@/utils/types'
|
|
|
|
export type UnitChoice =
|
|
| { kind: 'known'; id: number; name: string }
|
|
| { kind: 'free'; name: string }
|
|
|
|
const model = defineModel<UnitChoice | undefined>()
|
|
const props = defineProps<{ units: Unit[]; id?: string; invalid?: boolean }>()
|
|
|
|
const FREE = '__free__'
|
|
|
|
const selected = ref<string>(
|
|
model.value?.kind === 'known' ? String(model.value.id) : model.value ? FREE : '',
|
|
)
|
|
const freeName = ref(model.value?.kind === 'free' ? model.value.name : '')
|
|
|
|
const onlyFree = computed(() => props.units.length === 0)
|
|
const showFreeInput = computed(() => onlyFree.value || selected.value === FREE)
|
|
|
|
/** The choice the two controls above currently stand for */
|
|
const local = computed<UnitChoice | undefined>(() => {
|
|
if (showFreeInput.value) {
|
|
const name = freeName.value.trim()
|
|
// An empty box is an unfinished choice, not another association: the field
|
|
// reads as unfilled, while the box itself stays open to type in.
|
|
return name ? { kind: 'free', name } : undefined
|
|
}
|
|
const unit = props.units.find((u) => String(u.id) === selected.value)
|
|
return unit ? { kind: 'known', id: unit.id, name: unit.name } : undefined
|
|
})
|
|
|
|
function sameChoice(left: UnitChoice | undefined, right: UnitChoice | undefined) {
|
|
if (!left || !right) return left === right
|
|
if (left.kind === 'known') return right.kind === 'known' && left.id === right.id
|
|
return right.kind === 'free' && left.name === right.name
|
|
}
|
|
|
|
watch(local, (choice) => {
|
|
model.value = choice
|
|
})
|
|
|
|
/**
|
|
* Follows the model when it says something other than what the controls say —
|
|
* a form being reset, mostly.
|
|
*
|
|
* Comparing the two, rather than watching for any change, is what keeps an
|
|
* emptied text box open: clearing it does report "nothing chosen", and that
|
|
* report must not be read back as somebody else cancelling the choice.
|
|
*/
|
|
watch(model, (choice) => {
|
|
if (sameChoice(choice, local.value)) return
|
|
selected.value = choice?.kind === 'known' ? String(choice.id) : choice ? FREE : ''
|
|
freeName.value = choice?.kind === 'free' ? choice.name : ''
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="grid gap-2">
|
|
<Select v-if="!onlyFree" v-model="selected">
|
|
<SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full">
|
|
<SelectValue :placeholder="$t('reservation.association-placeholder')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="unit in units" :key="unit.id" :value="String(unit.id)">
|
|
{{ unit.name }}
|
|
</SelectItem>
|
|
<SelectItem :value="FREE">{{ $t('reservation.association-other') }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Input
|
|
v-if="showFreeInput"
|
|
:id="onlyFree ? id : undefined"
|
|
v-model="freeName"
|
|
:aria-label="$t('reservation.association')"
|
|
:placeholder="$t('reservation.association-placeholder')"
|
|
:aria-invalid="invalid || undefined"
|
|
/>
|
|
</div>
|
|
</template>
|