refactor: Link component
This commit is contained in:
@@ -1,142 +1,110 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Label -->
|
||||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
||||||
{{ __(label) }}
|
{{ __(label) }}
|
||||||
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
||||||
</div>
|
</div>
|
||||||
<Combobox
|
|
||||||
v-model="selectedValue"
|
<Combobox v-model="selectedValue" nullable by="value">
|
||||||
nullable
|
<div class="relative w-full">
|
||||||
v-slot="{ open: isComboboxOpen }"
|
<ComboboxButton
|
||||||
>
|
class="flex w-full items-center justify-between focus:outline-none"
|
||||||
<Popover class="w-full" v-model:show="showOptions">
|
:class="inputClasses"
|
||||||
<template #target="{ open: openPopover, togglePopover }">
|
:disabled="attrs.readonly || disabled"
|
||||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
>
|
||||||
<div class="w-full">
|
<div class="flex items-center w-[90%] truncate">
|
||||||
<button
|
<slot name="prefix" />
|
||||||
class="flex w-full items-center justify-between focus:outline-none"
|
<span
|
||||||
:class="inputClasses"
|
v-if="selectedValue"
|
||||||
@click="
|
class="block truncate text-base leading-5"
|
||||||
() => {
|
|
||||||
showOptions = !showOptions
|
|
||||||
togglePopover()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
:disabled="attrs.readonly"
|
|
||||||
>
|
|
||||||
<div class="flex items-center w-[90%]">
|
|
||||||
<slot name="prefix" />
|
|
||||||
<span
|
|
||||||
class="block truncate text-base leading-5"
|
|
||||||
v-if="selectedValue"
|
|
||||||
>
|
|
||||||
{{ displayValue(selectedValue) }}
|
|
||||||
</span>
|
|
||||||
<span class="text-base leading-5 text-ink-gray-4" v-else>
|
|
||||||
{{ placeholder || '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</template>
|
|
||||||
<template #body="{ isOpen }">
|
|
||||||
<div v-show="isOpen" class="">
|
|
||||||
<div
|
|
||||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
|
||||||
>
|
>
|
||||||
<div class="relative px-1.5 pt-0.5">
|
{{ displayValue(selectedValue) }}
|
||||||
<ComboboxInput
|
</span>
|
||||||
ref="search"
|
<span v-else class="text-base leading-5 text-ink-gray-4">
|
||||||
class="form-input w-full"
|
{{ placeholder || '' }}
|
||||||
type="text"
|
</span>
|
||||||
@change="
|
</div>
|
||||||
(e) => {
|
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||||
query = e.target.value
|
</ComboboxButton>
|
||||||
}
|
|
||||||
"
|
<!-- Dropdown -->
|
||||||
:value="query"
|
<ComboboxOptions
|
||||||
autocomplete="off"
|
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-white py-1 text-base border-2 shadow-lg"
|
||||||
placeholder="Search"
|
>
|
||||||
/>
|
<!-- Search -->
|
||||||
<button
|
<div class="relative px-1.5 pt-1">
|
||||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
<ComboboxInput
|
||||||
@click="selectedValue = null"
|
ref="search"
|
||||||
>
|
class="form-input w-full"
|
||||||
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
type="text"
|
||||||
</button>
|
@change="(e) => (query = e.target.value)"
|
||||||
</div>
|
autocomplete="off"
|
||||||
<ComboboxOptions
|
placeholder="Search"
|
||||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
/>
|
||||||
static
|
|
||||||
|
<!-- Clear -->
|
||||||
|
<button
|
||||||
|
v-if="selectedValue"
|
||||||
|
class="absolute right-1.5 top-1 inline-flex h-7 w-7 items-center justify-center"
|
||||||
|
@mousedown.prevent="clearValue"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options -->
|
||||||
|
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
|
||||||
|
<template v-for="group in groups" :key="group.key">
|
||||||
|
<div
|
||||||
|
v-if="group.group && !group.hideLabel"
|
||||||
|
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||||
>
|
>
|
||||||
<div
|
{{ group.group }}
|
||||||
class="mt-1.5"
|
|
||||||
v-for="group in groups"
|
|
||||||
:key="group.key"
|
|
||||||
v-show="group.items.length > 0"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="group.group && !group.hideLabel"
|
|
||||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
|
||||||
>
|
|
||||||
{{ group.group }}
|
|
||||||
</div>
|
|
||||||
<ComboboxOption
|
|
||||||
as="template"
|
|
||||||
v-for="option in group.items"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option"
|
|
||||||
v-slot="{ active, selected }"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
:class="[
|
|
||||||
'flex items-center rounded px-2.5 py-2 text-base',
|
|
||||||
{ 'bg-surface-gray-2': active },
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<slot
|
|
||||||
name="item-prefix"
|
|
||||||
v-bind="{ active, selected, option }"
|
|
||||||
/>
|
|
||||||
<slot
|
|
||||||
name="item-label"
|
|
||||||
v-bind="{ active, selected, option }"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1 p-1">
|
|
||||||
<div class="text-base font-medium text-ink-gray-8">
|
|
||||||
{{
|
|
||||||
option.value == option.label
|
|
||||||
? option.description
|
|
||||||
: option.label
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-ink-gray-5">
|
|
||||||
{{ option.value }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</li>
|
|
||||||
</ComboboxOption>
|
|
||||||
</div>
|
|
||||||
<li
|
|
||||||
v-if="groups.length == 0"
|
|
||||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
|
||||||
>
|
|
||||||
No results found
|
|
||||||
</li>
|
|
||||||
</ComboboxOptions>
|
|
||||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
|
||||||
<slot
|
|
||||||
name="footer"
|
|
||||||
v-bind="{ value: search?.el._value, close }"
|
|
||||||
></slot>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ComboboxOption
|
||||||
|
v-for="option in group.items"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option"
|
||||||
|
v-slot="{ active }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
|
||||||
|
{ 'bg-surface-gray-2': active },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1 p-1">
|
||||||
|
<div class="text-base font-medium text-ink-gray-8">
|
||||||
|
{{
|
||||||
|
option.value === option.label
|
||||||
|
? option.description
|
||||||
|
: option.label
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-ink-gray-5">
|
||||||
|
{{ option.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ComboboxOption>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="groups.length === 0"
|
||||||
|
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||||
|
>
|
||||||
|
{{ __('No results found') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</Popover>
|
<!-- Footer -->
|
||||||
|
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||||
|
<slot name="footer" :value="query" />
|
||||||
|
</div>
|
||||||
|
</ComboboxOptions>
|
||||||
|
</div>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -147,15 +115,16 @@ import {
|
|||||||
ComboboxInput,
|
ComboboxInput,
|
||||||
ComboboxOptions,
|
ComboboxOptions,
|
||||||
ComboboxOption,
|
ComboboxOption,
|
||||||
|
ComboboxButton,
|
||||||
} from '@headlessui/vue'
|
} from '@headlessui/vue'
|
||||||
import { Popover } from 'frappe-ui'
|
import { ref, computed, useAttrs, useSlots, watch } from 'vue'
|
||||||
import { ChevronDown, X } from 'lucide-vue-next'
|
import { ChevronDown, X } from 'lucide-vue-next'
|
||||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: [String, Object],
|
||||||
default: '',
|
default: null,
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -186,107 +155,95 @@ const props = defineProps({
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
|
||||||
|
|
||||||
const query = ref('')
|
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||||
const showOptions = ref(false)
|
|
||||||
const search = ref(null)
|
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
const selectedValue = ref(props.modelValue)
|
||||||
|
const query = ref('')
|
||||||
|
|
||||||
const valuePropPassed = computed(() => 'value' in attrs)
|
const valuePropPassed = computed(() => 'value' in attrs)
|
||||||
|
|
||||||
const selectedValue = computed({
|
watch(selectedValue, (val) => {
|
||||||
get() {
|
if (!val?.value) return
|
||||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
query.value = ''
|
||||||
},
|
console.log('Selected value changed:', val)
|
||||||
set(val) {
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||||
query.value = ''
|
|
||||||
if (val) {
|
|
||||||
showOptions.value = false
|
|
||||||
}
|
|
||||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function close() {
|
function clearValue() {
|
||||||
showOptions.value = false
|
emit('update:modelValue', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = computed(() => {
|
const groups = computed(() => {
|
||||||
if (!props.options || props.options.length == 0) return []
|
if (!props.options?.length) return []
|
||||||
|
|
||||||
let groups = props.options[0]?.group
|
const normalized = props.options[0]?.group
|
||||||
? props.options
|
? props.options
|
||||||
: [{ group: '', items: props.options }]
|
: [{ group: '', items: props.options }]
|
||||||
|
return normalized
|
||||||
return groups
|
.map((group, i) => ({
|
||||||
.map((group, i) => {
|
key: i,
|
||||||
return {
|
group: group.group,
|
||||||
key: i,
|
hideLabel: group.hideLabel || false,
|
||||||
group: group.group,
|
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||||
hideLabel: group.hideLabel || false,
|
}))
|
||||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((group) => group.items.length > 0)
|
.filter((group) => group.items.length > 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
function filterOptions(options) {
|
function filterOptions(options) {
|
||||||
if (!query.value) {
|
if (!query.value) return options
|
||||||
return options
|
const q = query.value.toLowerCase()
|
||||||
}
|
return options.filter((option) =>
|
||||||
return options.filter((option) => {
|
[option.label, option.value]
|
||||||
let searchTexts = [option.label, option.value]
|
.filter(Boolean)
|
||||||
return searchTexts.some((text) =>
|
.some((text) => text.toString().toLowerCase().includes(q))
|
||||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
)
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayValue(option) {
|
function displayValue(option) {
|
||||||
|
if (!option) return ''
|
||||||
|
|
||||||
if (typeof option === 'string') {
|
if (typeof option === 'string') {
|
||||||
let allOptions = groups.value.flatMap((group) => group.items)
|
const flat = groups.value.flatMap((g) => g.items)
|
||||||
let selectedOption = allOptions.find((o) => o.value === option)
|
const match = flat.find((o) => o.value === option)
|
||||||
return selectedOption?.label || option
|
return match?.label || option
|
||||||
}
|
}
|
||||||
return option?.label
|
|
||||||
|
return option.label
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(query, (q) => {
|
watchDebounced(
|
||||||
emit('update:query', q)
|
query,
|
||||||
})
|
(val) => {
|
||||||
|
emit('update:query', val)
|
||||||
|
},
|
||||||
|
{ debounce: 300 }
|
||||||
|
)
|
||||||
|
|
||||||
watch(showOptions, (val) => {
|
const textColor = computed(() =>
|
||||||
if (val) {
|
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||||
nextTick(() => {
|
)
|
||||||
search.value.el.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const textColor = computed(() => {
|
|
||||||
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputClasses = computed(() => {
|
const inputClasses = computed(() => {
|
||||||
let sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'text-base rounded h-7',
|
sm: 'text-base rounded h-7',
|
||||||
md: 'text-base rounded h-8',
|
md: 'text-base rounded h-8',
|
||||||
lg: 'text-lg rounded-md h-10',
|
lg: 'text-lg rounded-md h-10',
|
||||||
xl: 'text-xl rounded-md h-10',
|
xl: 'text-xl rounded-md h-10',
|
||||||
}[props.size]
|
}[props.size]
|
||||||
|
|
||||||
let paddingClasses = {
|
const paddingClasses = {
|
||||||
sm: 'py-1.5 px-2',
|
sm: 'py-1.5 px-2',
|
||||||
md: 'py-1.5 px-2.5',
|
md: 'py-1.5 px-2.5',
|
||||||
lg: 'py-1.5 px-3',
|
lg: 'py-1.5 px-3',
|
||||||
xl: 'py-1.5 px-3',
|
xl: 'py-1.5 px-3',
|
||||||
}[props.size]
|
}[props.size]
|
||||||
|
|
||||||
let variant = props.disabled ? 'disabled' : props.variant
|
const variant = props.disabled ? 'disabled' : props.variant
|
||||||
let variantClasses = {
|
|
||||||
|
const variantClasses = {
|
||||||
subtle:
|
subtle:
|
||||||
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||||
outline:
|
outline:
|
||||||
@@ -307,6 +264,4 @@ const inputClasses = computed(() => {
|
|||||||
'transition-colors w-full',
|
'transition-colors w-full',
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ query })
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
:size="attrs.size || 'sm'"
|
:size="attrs.size || 'sm'"
|
||||||
:variant="attrs.variant"
|
:variant="attrs.variant"
|
||||||
:placeholder="attrs.placeholder"
|
:placeholder="attrs.placeholder"
|
||||||
:filterable="false"
|
|
||||||
:readonly="attrs.readonly"
|
:readonly="attrs.readonly"
|
||||||
>
|
>
|
||||||
<template #target="{ open, togglePopover }">
|
<template #target="{ open, togglePopover }">
|
||||||
@@ -95,6 +94,7 @@ const valuePropPassed = computed(() => 'value' in attrs)
|
|||||||
const value = computed({
|
const value = computed({
|
||||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
|
console.log('Setting value to:', val)
|
||||||
return (
|
return (
|
||||||
val?.value &&
|
val?.value &&
|
||||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||||
|
|||||||
Reference in New Issue
Block a user