refactor: Link component

This commit is contained in:
Jannat Patel
2026-02-16 19:48:58 +05:30
parent 73b20653f0
commit c1608f8cc4
2 changed files with 150 additions and 195 deletions

View File

@@ -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>

View File

@@ -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)