112 lines
3.5 KiB
Vue
112 lines
3.5 KiB
Vue
<template>
|
|
<div>
|
|
<button
|
|
ref="buttonRef"
|
|
type="button"
|
|
:class="buttonClass"
|
|
aria-haspopup="dialog"
|
|
:aria-expanded="open"
|
|
@click="open = true"
|
|
>
|
|
<span :class="selected ? 'text-ink' : 'text-muted'">{{ selected?.label || placeholder }}</span>
|
|
</button>
|
|
|
|
<Teleport to="body">
|
|
<Transition
|
|
enter-active-class="transition duration-200 ease-out"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition duration-150 ease-in"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div v-if="open" class="fixed inset-0 z-40 bg-ink/35" @click="open = false" />
|
|
</Transition>
|
|
|
|
<Transition
|
|
enter-active-class="transition duration-200 ease-out"
|
|
enter-from-class="translate-y-full"
|
|
enter-to-class="translate-y-0"
|
|
leave-active-class="transition duration-150 ease-in"
|
|
leave-from-class="translate-y-0"
|
|
leave-to-class="translate-y-full"
|
|
>
|
|
<section
|
|
v-if="open"
|
|
class="fixed inset-x-0 bottom-0 z-50 mx-auto max-h-[78vh] max-w-lg overflow-hidden rounded-t-2xl bg-white shadow-2xl"
|
|
role="dialog"
|
|
:aria-label="title"
|
|
>
|
|
<div class="sticky top-0 border-b border-line bg-white px-4 pb-3 pt-2">
|
|
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-base font-bold">{{ title }}</h2>
|
|
<button type="button" class="rounded-md px-2 py-1 text-sm font-semibold text-muted" @click="open = false">닫기</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-h-[60vh] overflow-y-auto px-2 py-2">
|
|
<button
|
|
v-for="option in options"
|
|
:key="option.value"
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-md px-3 py-3 text-left active:bg-paper"
|
|
@click="select(option.value)"
|
|
>
|
|
<span>
|
|
<span class="block text-sm font-semibold text-ink">{{ option.label }}</span>
|
|
<span v-if="option.description" class="mt-1 block text-xs text-muted">{{ option.description }}</span>
|
|
</span>
|
|
<span
|
|
class="ml-3 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-sm"
|
|
:class="option.value === modelValue ? 'border-brand-700 bg-brand-700 text-white' : 'border-line text-transparent'"
|
|
>
|
|
✓
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</Transition>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
interface Option {
|
|
value: string
|
|
label: string
|
|
description?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
modelValue: string
|
|
options: Option[]
|
|
title: string
|
|
placeholder?: string
|
|
buttonClass?: string
|
|
}>(), {
|
|
placeholder: '선택',
|
|
buttonClass: 'field flex min-h-[50px] items-center justify-between text-left'
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string]
|
|
}>()
|
|
|
|
const open = ref(false)
|
|
const buttonRef = ref<HTMLButtonElement | null>(null)
|
|
const selected = computed(() => props.options.find(option => option.value === props.modelValue))
|
|
|
|
function select(value: string) {
|
|
emit('update:modelValue', value)
|
|
open.value = false
|
|
}
|
|
|
|
function focus() {
|
|
buttonRef.value?.focus()
|
|
open.value = true
|
|
}
|
|
|
|
defineExpose({ focus })
|
|
</script>
|