Select.vue 10.8 KB
<script setup>
/**
 * 下拉选择。自绘 trigger + listbox 浮层,不用原生 `<select>`(它的面板由操作系统绘制,没法跟主题一致)。
 *
 * 浮层 teleport 到 body:卡片有 `overflow: hidden`,不 teleport 会被剪掉。
 *
 * @typedef {'sm'|'md'|'lg'} SelectSize
 * @typedef {{ value: string|number, label: string }} SelectOption
 */
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'

const props = defineProps({
  modelValue: { type: [String, Number], default: '' },
  /** @type {import('vue').PropType<SelectOption[]>} */
  options: { type: Array, default: () => [] },
  /** 未选中时 trigger 上显示的文案。 */
  placeholder: { type: String, default: '' },
  ariaLabel: { type: String, default: '' },
  disabled: { type: Boolean, default: false },
  invalid: { type: Boolean, default: false },
  /** @type {import('vue').PropType<SelectSize>} */
  size: { type: String, default: 'md' },
  /** 撑满父容器。默认按内容宽度。 */
  block: { type: Boolean, default: false },
  /** 窄屏下将选项改为从底部弹出的选择面板。 */
  mobileSheet: { type: Boolean, default: false },
})

const emit = defineEmits(['update:modelValue'])

const open = ref(false)
const triggerEl = ref(null)
const panelEl = ref(null)
const activeIndex = ref(-1)
const panelStyle = ref({})

const selected = computed(() => props.options.find((o) => o.value === props.modelValue) ?? null)
const triggerLabel = computed(() => selected.value?.label ?? props.placeholder)

function place() {
  const rect = triggerEl.value.getBoundingClientRect()
  const below = window.innerHeight - rect.bottom
  const flip = below < 240 && rect.top > below
  panelStyle.value = {
    left: `${rect.left}px`,
    minWidth: `${rect.width}px`,
    ...(flip ? { bottom: `${window.innerHeight - rect.top + 4}px` } : { top: `${rect.bottom + 4}px` }),
  }
}

async function toggle() {
  if (props.disabled) return
  open.value = !open.value
  if (!open.value) return
  activeIndex.value = props.options.findIndex((o) => o.value === props.modelValue)
  place()
  await nextTick()
  panelEl.value?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
}

function choose(option) {
  emit('update:modelValue', option.value)
  open.value = false
  triggerEl.value?.focus()
}

function onKeydown(event) {
  if (props.disabled) return
  if (!open.value) {
    if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
      event.preventDefault()
      toggle()
    }
    return
  }
  if (event.key === 'Escape') {
    event.preventDefault()
    open.value = false
    triggerEl.value?.focus()
  } else if (event.key === 'ArrowDown') {
    event.preventDefault()
    activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1)
  } else if (event.key === 'ArrowUp') {
    event.preventDefault()
    activeIndex.value = Math.max(activeIndex.value - 1, 0)
  } else if (event.key === 'Enter' || event.key === ' ') {
    event.preventDefault()
    const option = props.options[activeIndex.value]
    if (option) choose(option)
  }
}

function onPointerDown(event) {
  if (triggerEl.value?.contains(event.target) || panelEl.value?.contains(event.target)) return
  open.value = false
}

watch(open, (isOpen) => {
  const method = isOpen ? 'addEventListener' : 'removeEventListener'
  document[method]('pointerdown', onPointerDown, true)
  window[method]('resize', place)
  window[method]('scroll', place, true)
})

onBeforeUnmount(() => {
  document.removeEventListener('pointerdown', onPointerDown, true)
  window.removeEventListener('resize', place)
  window.removeEventListener('scroll', place, true)
})
</script>

<template>
  <button
    ref="triggerEl"
    type="button"
    class="drama-select"
    :class="[`drama-select--${size}`, { 'is-invalid': invalid, 'is-open': open, 'is-block': block, 'is-placeholder': !selected }]"
    :disabled="disabled"
    :aria-label="ariaLabel || undefined"
    :aria-expanded="open"
    :aria-invalid="invalid ? 'true' : 'false'"
    aria-haspopup="listbox"
    @click="toggle"
    @keydown="onKeydown"
  >
    <span class="drama-select__label">{{ triggerLabel }}</span>
    <svg
      class="drama-select__chevron"
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      aria-hidden="true"
    >
      <path d="m6 9 6 6 6-6" />
    </svg>
  </button>

  <Teleport to="body">
    <button
      v-if="open && mobileSheet"
      class="drama-select__sheet-dismiss"
      type="button"
      aria-label="Close"
      @click="open = false"
    ></button>
    <div
      v-if="open"
      ref="panelEl"
      class="drama-select__panel"
      :class="[`drama-select__panel--${size}`, { 'is-mobile-sheet': mobileSheet }]"
      :style="panelStyle"
      role="listbox"
    >
      <button
        v-for="(option, index) in options"
        :key="option.value"
        type="button"
        class="drama-select__option"
        role="option"
        :aria-selected="option.value === modelValue"
        :data-active="index === activeIndex"
        @click="choose(option)"
        @mousemove="activeIndex = index"
      >
        <span class="drama-select__option-label">{{ option.label }}</span>
        <svg
          v-if="option.value === modelValue"
          width="14"
          height="14"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <path d="M20 6 9 17l-5-5" />
        </svg>
      </button>
    </div>
  </Teleport>
</template>

<style scoped>
.drama-select {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--drama-space-sm);
  border: 1px solid var(--drama-color-border);
  border-radius: var(--drama-radius-sm);
  background-color: var(--drama-color-surface);
  color: var(--drama-color-text);
  font-family: var(--drama-font-family-body);
  line-height: var(--drama-line-height-tight);
  cursor: pointer;
  outline: none;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.drama-select.is-block {
  display: flex;
  width: 100%;
}

.drama-select:hover:not(:disabled):not(.is-open) {
  border-color: var(--drama-color-border-strong);
}

.drama-select:focus-visible,
.drama-select.is-open {
  border-color: var(--drama-color-primary);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--drama-color-primary) 18%, transparent);
}

.drama-select:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.drama-select.is-invalid {
  border-color: var(--drama-color-danger);
}

.drama-select.is-placeholder .drama-select__label {
  color: var(--drama-color-text-muted);
}

.drama-select__label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.drama-select__chevron {
  flex-shrink: 0;
  color: var(--drama-color-text-muted);
  stroke-width: 1.75;
  transition: transform 0.15s ease;
}

/* 箭头是提示,不是内容:32px 高的控件里塞一个 16px、粗描边的箭头,它比值还抢眼。 */
.drama-select--sm .drama-select__chevron {
  width: 14px;
  height: 14px;
}

.drama-select.is-open .drama-select__chevron {
  transform: rotate(180deg);
}

/* 高度取 control token,和同尺寸的 Input / SegmentedControl / Button 等高。 */
.drama-select--sm {
  height: var(--drama-control-height-sm);
  padding: 0 var(--drama-space-md);
  font-size: var(--drama-font-size-sm);
}

.drama-select--md {
  height: var(--drama-control-height-md);
  padding: 0 var(--drama-space-md);
  font-size: var(--drama-font-size-md);
}

.drama-select--lg {
  height: var(--drama-control-height-lg);
  padding: 0 var(--drama-space-lg);
  font-size: var(--drama-font-size-lg);
}

.drama-select__panel {
  position: fixed;
  z-index: 1000;
  max-height: 280px;
  overflow-y: auto;
  padding: var(--drama-space-xs);
  background-color: var(--drama-color-surface);
  border: 1px solid var(--drama-color-border);
  border-radius: var(--drama-radius-md);
  box-shadow: var(--drama-shadow-md);
}

.drama-select__sheet-dismiss {
  display: none;
}

/* 面板的字号和行高跟着 trigger 走。写死一个尺寸,sm 的 trigger 下面就会掉出一张字更大、行更高的
   列表 —— 同一个控件的两半对不上。 */
.drama-select__panel--sm {
  font-size: var(--drama-font-size-sm);
  --drama-select-option-height: var(--drama-control-height-sm);
}

.drama-select__panel--md {
  font-size: var(--drama-font-size-md);
  --drama-select-option-height: var(--drama-control-height-md);
}

.drama-select__panel--lg {
  font-size: var(--drama-font-size-lg);
  --drama-select-option-height: var(--drama-control-height-lg);
}

.drama-select__option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--drama-space-md);
  width: 100%;
  height: var(--drama-select-option-height);
  padding: 0 var(--drama-space-md);
  border: 0;
  border-radius: var(--drama-radius-sm);
  background: transparent;
  color: var(--drama-color-text);
  font-family: var(--drama-font-family-body);
  font-size: inherit;
  line-height: var(--drama-line-height-tight);
  text-align: left;
  cursor: pointer;
}

/* 键盘/指针停在哪一行 —— 一层很淡的底色,不是一条压过去的大色块。 */
.drama-select__option[data-active='true'] {
  background-color: color-mix(in srgb, var(--drama-color-primary) 8%, transparent);
}

/* 选中项靠主色文字 + 右侧对勾表明,不再叠一层填充:填充是「光标在这行」,对勾才是「选的是这个」。 */
.drama-select__option[aria-selected='true'] {
  color: var(--drama-color-primary);
  font-weight: var(--drama-font-weight-medium);
}

.drama-select__option-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

@media (max-width: 680px) {
  .drama-select__sheet-dismiss {
    position: fixed;
    z-index: 1001;
    inset: 0;
    display: block;
    padding: 0;
    border: 0;
    background: var(--drama-color-overlay);
  }

  .drama-select__panel.is-mobile-sheet {
    position: fixed;
    z-index: 1002;
    top: auto !important;
    right: 0;
    bottom: 0 !important;
    left: 0 !important;
    width: 100%;
    min-width: 0 !important;
    max-height: min(70dvh, 520px);
    padding: 18px 12px calc(12px + env(safe-area-inset-bottom));
    border-right: 0;
    border-bottom: 0;
    border-left: 0;
    border-radius: 18px 18px 0 0;
    box-shadow: var(--drama-shadow-lg);
  }

  .drama-select__panel.is-mobile-sheet::before {
    position: absolute;
    top: 7px;
    left: 50%;
    width: 34px;
    height: 3px;
    border-radius: var(--drama-radius-full);
    background: var(--drama-color-border-strong);
    content: '';
    transform: translateX(-50%);
  }

  .drama-select__panel.is-mobile-sheet .drama-select__option {
    height: 46px;
    padding-inline: 14px;
    border-radius: 8px;
    font-size: 13px;
  }
}
</style>