File size: 3,499 Bytes
4bcd925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
<template>
  <div ref="root" class="relative w-full">
    <button
      type="button"
      class="flex w-full items-center justify-between gap-2 rounded-full border border-input bg-background px-4 py-2 text-sm
             text-foreground transition-colors hover:border-primary"
      @click="toggle"
    >
      <span class="truncate">{{ currentLabel }}</span>
      <svg aria-hidden="true" viewBox="0 0 20 20" class="h-4 w-4" fill="currentColor">
        <path d="M5 7l5 6 5-6H5z" />
      </svg>
    </button>
    <div
      v-if="open"
      class="absolute right-0 z-30 w-full space-y-1 rounded-2xl border border-border bg-card p-2 shadow-lg"
      :class="menuPositionClass"
    >
      <button
        v-for="option in normalizedOptions"
        :key="option.value"
        type="button"
        class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors
               hover:bg-accent"
        :class="isSelected(option.value) ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
        @click="select(option.value)"
      >
        <span>{{ option.label }}</span>
        <span v-if="isSelected(option.value)" class="text-xs">OK</span>
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

type Option = { label: string; value: string }

const props = defineProps<{
  modelValue: string | string[]
  options: Array<string | Option>
  multiple?: boolean
  placeholder?: string
  placement?: 'up' | 'down'
}>()

const emit = defineEmits<{
  (e: 'update:modelValue', value: string | string[]): void
}>()

const open = ref(false)
const root = ref<HTMLElement | null>(null)

const normalizedOptions = computed<Option[]>(() =>
  props.options.map(option =>
    typeof option === 'string' ? { label: option, value: option } : option
  )
)

const currentLabel = computed(() => {
  if (props.multiple) {
    const values = Array.isArray(props.modelValue) ? props.modelValue : []
    if (!values.length) return props.placeholder || '请选择'
    if (values.length === 1) {
      const match = normalizedOptions.value.find(option => option.value === values[0])
      return match?.label || values[0]
    }
    return `已选 ${values.length} 项`
  }

  const match = normalizedOptions.value.find(option => option.value === props.modelValue)
  return match?.label || String(props.modelValue ?? '')
})

const menuPositionClass = computed(() =>
  props.placement === 'up' ? 'bottom-full mb-2' : 'mt-2'
)

const toggle = () => {
  open.value = !open.value
}

const isSelected = (value: string) => {
  if (props.multiple) {
    return Array.isArray(props.modelValue) && props.modelValue.includes(value)
  }
  return props.modelValue === value
}

const select = (value: string) => {
  if (props.multiple) {
    const current = Array.isArray(props.modelValue) ? props.modelValue : []
    const exists = current.includes(value)
    const next = exists ? current.filter(item => item !== value) : [...current, value]
    emit('update:modelValue', next)
    return
  }

  emit('update:modelValue', value)
  open.value = false
}

const handleClickOutside = (event: MouseEvent) => {
  if (!root.value) return
  if (root.value.contains(event.target as Node)) return
  open.value = false
}

onMounted(() => {
  document.addEventListener('click', handleClickOutside)
})

onBeforeUnmount(() => {
  document.removeEventListener('click', handleClickOutside)
})
</script>