Files
moeka-project/packages/ui/src/components/form/select/select.vue
T

242 lines
7.5 KiB
Vue

<script setup lang="ts" generic="T extends AcceptableValue">
import type { AcceptableValue } from 'reka-ui'
import {
SelectArrow,
SelectContent,
SelectGroup,
SelectIcon,
SelectLabel,
SelectPortal,
SelectRoot,
SelectSeparator,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
import { computed } from 'vue'
import SelectOption from './select-option.vue'
interface SelectOptionItem<T extends AcceptableValue> {
label: string
value: T
description?: string
disabled?: boolean
icon?: string
}
interface SelectOptionGroupItem<T extends AcceptableValue> {
groupLabel?: string
children?: SelectOptionItem<T>[]
}
const props = withDefaults(defineProps<{
options: SelectOptionItem<T>[] | SelectOptionGroupItem<T>[]
placeholder?: string
disabled?: boolean
by?: string | ((a: T, b: T) => boolean)
contentMinWidth?: string | number
contentWidth?: string | number
variant?: 'blurry' | 'default'
}>(), {
placeholder: 'Select an option',
disabled: false,
by: undefined,
contentMinWidth: 160,
contentWidth: undefined,
variant: 'default',
})
const modelValue = defineModel<T>({ required: false })
const normalizedOptions = computed<SelectOptionGroupItem<T>[]>(() => {
if (!props.options.length) {
return []
}
const [firstOption] = props.options
if ('value' in firstOption) {
return [
{
groupLabel: '',
children: props.options as SelectOptionItem<T>[],
},
]
}
return props.options as SelectOptionGroupItem<T>[]
})
const flattenedOptions = computed<SelectOptionItem<T>[]>(() =>
normalizedOptions.value.flatMap(group => group.children ?? []),
)
const selectedOption = computed<SelectOptionItem<T> | undefined>(() =>
flattenedOptions.value.find(option => isSelectedOption(option.value, modelValue.value)),
)
function isSelectedOption(a: T, b: T | undefined): boolean {
if (b == null) {
return false
}
if (typeof props.by === 'function') {
return props.by(a, b)
}
if (typeof props.by === 'string') {
return (a as Record<string, unknown> | null)?.[props.by] === (b as Record<string, unknown> | null)?.[props.by]
}
return a === b
}
function toCssSize(value?: string | number): string | undefined {
if (value == null) {
return undefined
}
return typeof value === 'number' ? `${value}px` : value
}
</script>
<template>
<SelectRoot
v-model="modelValue"
:by="props.by"
:disabled="props.disabled"
>
<SelectTrigger
:class="[
'group',
'w-full inline-flex items-center justify-between rounded-xl border px-3 leading-none h-fit gap-[5px] outline-none',
'text-sm text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-400 dark:data-[placeholder]:text-neutral-500',
props.variant === 'default' ? 'bg-white dark:bg-neutral-900 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700' : '',
props.variant === 'blurry' ? 'bg-neutral-50/70 dark:bg-neutral-800/70 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700' : '',
props.variant === 'blurry' ? 'backdrop-blur-md' : '',
'border-2 border-solid focus:border-primary-300 dark:focus:border-primary-400/50',
props.variant === 'default' ? 'border-neutral-200 dark:border-neutral-800' : '',
props.variant === 'blurry' ? 'border-neutral-100/60 dark:border-neutral-800/30' : '',
'shadow-sm focus:shadow-[0_0_0_2px] focus:shadow-black/10 dark:focus:shadow-black/30',
'transition-colors duration-200 ease-in-out',
props.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
]"
>
<div :class="['min-w-0 flex-1 text-left']">
<slot
v-if="$slots.value"
name="value"
v-bind="{ option: selectedOption, value: modelValue, placeholder: props.placeholder }"
>
<span
:class="[
'block truncate',
selectedOption
? 'text-neutral-700 dark:text-neutral-200'
: 'text-neutral-400 dark:text-neutral-500',
]"
>
{{ selectedOption?.label ?? props.placeholder }}
</span>
</slot>
<SelectValue
v-else
v-model="modelValue"
:placeholder="props.placeholder"
/>
</div>
<SelectIcon as-child>
<div
i-solar:alt-arrow-down-linear
:class="[
'h-4 w-4 shrink-0',
'text-neutral-700 dark:text-neutral-200',
'transition-transform duration-200 ease-in-out',
'group-data-[state=open]:rotate-180',
]"
/>
</SelectIcon>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
side="bottom"
align="start"
:side-offset="4"
:avoid-collisions="true"
:class="[
// NOTICE: DialogContent/DialogOverlay use z-[9999], and DrawerContent uses z-[1000].
// SelectContent must render above these layers so that dropdowns inside
// Dialog/Drawer are not hidden behind the overlay or dismissed unexpectedly.
// Read more at: https://github.com/moeru-ai/airi/issues/1136
'z-[10010]',
'overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
'bg-white dark:bg-neutral-900',
'border-neutral-200 dark:border-neutral-800 border-solid border-2',
]"
:style="{
width: toCssSize(props.contentWidth) ?? 'var(--reka-select-trigger-width)',
minWidth: toCssSize(props.contentMinWidth),
}"
>
<SelectViewport
:class="[
'p-[2px]',
'max-h-50dvh',
'overflow-y-auto',
]"
>
<template
v-for="(group, groupIndex) in normalizedOptions"
:key="group.groupLabel || `group-${groupIndex}`"
>
<SelectGroup :class="['overflow-x-hidden']">
<SelectSeparator
v-if="groupIndex !== 0"
:class="['m-[5px]', 'h-[1px]', 'bg-neutral-200 dark:bg-neutral-800']"
/>
<SelectLabel
v-if="group.groupLabel"
:class="[
'px-[25px] text-xs leading-[25px]',
'text-neutral-500 dark:text-neutral-400',
'transition-colors duration-200 ease-in-out',
]"
>
{{ group.groupLabel }}
</SelectLabel>
<SelectOption
v-for="(option, optionIndex) in group.children || []"
:key="`${group.groupLabel || groupIndex}-${option.label}-${optionIndex}`"
:option="option"
>
<template
v-if="$slots.option"
#default="{ option: slotOption }"
>
<slot
name="option"
v-bind="{ option: slotOption }"
/>
</template>
</SelectOption>
</SelectGroup>
</template>
</SelectViewport>
<SelectArrow
:class="[
'fill-white dark:fill-neutral-900',
'stroke-neutral-200 dark:stroke-neutral-800',
]"
/>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>