wip: select regency paginated

todo: search reactive

wip: paginated regency

todo: search bind

wip gess
This commit is contained in:
Khafid Prayoga
2025-10-13 13:56:41 +07:00
parent c8620e4ed2
commit bc286f16c8
4 changed files with 386 additions and 79 deletions
+9 -1
View File
@@ -10,6 +10,7 @@ import RadioDisability from './_common/radio-disability.vue'
import RadioGender from './_common/radio-gender.vue'
import RadioNationality from './_common/radio-nationality.vue'
import RadioNewborn from './_common/radio-newborn.vue'
import SelectBirthPlace from '~/components/app/person/_common/select-birth-place.vue'
import SelectDisability from './_common/select-disability.vue'
import SelectDob from './_common/select-dob.vue'
import SelectEducation from './_common/select-education.vue'
@@ -66,12 +67,19 @@ defineExpose({
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3">
<InputBase
<!-- <InputBase
field-name="birthPlace"
label="Tempat Lahir"
placeholder="Malang"
:errors="errors"
is-required
/> -->
<SelectBirthPlace
field-name="birthPlace"
label="Tempat Lahir"
placeholder="Pilih tempat lahir"
:errors="errors"
is-required
/>
<SelectDob
label="Tanggal Lahir"
@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import ComboboxPaginated from '~/components/pub/my-ui/combobox/combobox-paginated.vue'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { cn } from '~/lib/utils'
const props = defineProps<{
fieldName?: string
isDisabled?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
isRequired?: boolean
}>()
const {
fieldName = 'birthPlace',
placeholder = 'Pilih tempat lahir',
errors,
class: containerClass,
fieldGroupClass,
} = props
// Gunakan composable untuk mengelola data regencies
const {
fetchRegencies,
regencyOptions,
isLoading,
error,
paginationMeta,
nextPage,
prevPage,
setSearch,
} = useRegencies({ enablePagination: true, pageSize: 10,enableSearch:true })
// Computed untuk menentukan placeholder berdasarkan state
const dynamicPlaceholder = computed(() => {
if (isLoading.value) return 'Memuat data tempat lahir...'
if (error.value) return 'Gagal memuat data'
return placeholder
})
// Computed untuk menentukan apakah field disabled
const isFieldDisabled = computed(() => {
return props.isDisabled || isLoading.value || !!error.value
})
onMounted(() => {
fetchRegencies()
})
function onSearchChange(query: string) {
setSearch(query)
}
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label :label-for="fieldName" :is-required="isRequired">
Tempat Lahir
</Label>
<Field :id="fieldName" :errors="errors" :class="cn('select-field-wrapper')">
<FormField v-slot="{ componentField }" :name="fieldName">
<FormItem>
<FormControl>
<ComboboxPaginated :id="fieldName" v-bind="componentField" :items="regencyOptions"
:placeholder="dynamicPlaceholder" :is-disabled="isFieldDisabled" search-placeholder="Cari tempat lahir..."
empty-message="Tempat lahir tidak ditemukan"
:page="paginationMeta.page"
:total-page="paginationMeta.totalPage"
:has-next="paginationMeta.hasNext"
:has-prev="paginationMeta.hasPrev"
:is-loading="isLoading"
@update:searchText="onSearchChange"
@next="nextPage()"
@prev="prevPage()"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</template>
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
import { type Item } from './index'
const props = defineProps<{
id?: string
modelValue?: string
items: Item[]
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
class?: string
isDisabled?: boolean
page?: number
totalPage?: number
hasNext?: boolean
hasPrev?: boolean
isLoading?: boolean
searchText?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:searchText': [value: string]
'page-change': [page: number]
next: []
prev: []
}>()
const open = ref(false)
const internalSearchText = ref(props.searchText || '')
// Keep internal search text synced with props
watch(
() => props.searchText,
(val) => {
if (val !== internalSearchText.value) internalSearchText.value = val || ''
},
)
const selectedItem = computed(() => props.items.find((item) => item.value === props.modelValue))
const displayText = computed(() => {
if (selectedItem.value?.label) return selectedItem.value.label
return props.placeholder || 'Pilih item'
})
const searchableItems = computed(() => {
const itemsWithSearch = props.items.map((item) => ({
...item,
searchValue: `${item.code || ''} ${item.label}`.trim(),
isSelected: item.value === props.modelValue,
}))
return itemsWithSearch.sort((a, b) => {
const aPriority = a.priority ?? 0
const bPriority = b.priority ?? 0
if (aPriority !== bPriority) return bPriority - aPriority
if (a.isSelected && !b.isSelected) return -1
if (!a.isSelected && b.isSelected) return 1
return a.label.localeCompare(b.label)
})
})
function onSelect(item: Item) {
emit('update:modelValue', item.value)
open.value = false
}
function onSearchInput(value: string) {
console.log('[ComboboxPaginated] emit update:searchText', value)
internalSearchText.value = value
emit('update:searchText', value)
}
function handlePrev() {
if (props.hasPrev) emit('prev')
}
function handleNext() {
if (props.hasNext) emit('next')
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
:id="props.id"
:disabled="props.isDisabled"
variant="outline"
role="combobox"
:aria-expanded="open"
:aria-controls="`${props.id}-list`"
:aria-describedby="`${props.id}-search`"
:class="
cn(
'h-8 w-full justify-between rounded-md border px-3 font-normal focus:outline-none focus:ring-1 focus:ring-black dark:!border-slate-400 dark:focus:ring-white md:text-xs 2xl:h-9 2xl:text-sm',
{
'cursor-not-allowed border-gray-300 bg-gray-100 text-gray-500 opacity-50': props.isDisabled,
'border-gray-400 bg-white text-black hover:bg-gray-50 dark:border-gray-600 dark:bg-slate-950 dark:text-white dark:hover:bg-gray-700':
!props.isDisabled,
'text-gray-400 dark:text-gray-500': !props.modelValue && !props.isDisabled,
},
props.class,
)
"
>
{{ displayText }}
<Icon
name="i-lucide-chevrons-up-down"
:class="
cn('ml-2 h-4 w-4 shrink-0', {
'opacity-30': props.isDisabled,
'text-gray-500 opacity-50 dark:text-gray-300': !props.isDisabled,
})
"
/>
</Button>
</PopoverTrigger>
<PopoverContent
class="w-[var(--radix-popover-trigger-width)] border border-gray-200 bg-white p-0 dark:border-gray-700 dark:bg-gray-800"
>
<Command class="bg-white dark:bg-gray-800">
<CommandInput
:id="`${props.id}-search`"
class="h-9 border-0 border-b border-gray-200 bg-white text-black focus:ring-0 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
:placeholder="searchPlaceholder || 'Cari...'"
v-model="internalSearchText"
@input="onSearchInput(($event.target as HTMLInputElement).value)"
:aria-label="`Cari ${displayText}`"
/>
<CommandEmpty class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ emptyMessage || 'Item tidak ditemukan.' }}
</CommandEmpty>
<CommandList
:id="`${props.id}-list`"
role="listbox"
class="max-h-60 overflow-auto"
>
<CommandGroup>
<CommandItem
v-for="item in searchableItems"
:key="item.value"
:value="item.value"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 md:text-xs xl:text-sm',
'text-black focus:outline-none dark:text-white',
'hover:bg-primary hover:text-white focus:bg-primary focus:text-white',
'data-[highlighted]:bg-primary data-[highlighted]:text-white',
)
"
@select="onSelect(item)"
>
<div class="flex w-full items-center justify-between">
<span>{{ item.label }}</span>
<div class="flex items-center gap-2">
<span
v-if="item.code"
class="text-xs text-muted-foreground"
>
{{ item.code }}
</span>
<Icon
name="i-lucide-check"
:class="cn('h-4 w-4', props.modelValue === item.value ? 'opacity-100' : 'opacity-0')"
/>
</div>
</div>
</CommandItem>
</CommandGroup>
</CommandList>
<div class="flex items-center justify-between border-t border-gray-200 p-2 text-xs dark:border-gray-700">
<div class="flex items-center gap-2">
<Icon
v-if="props.isLoading"
name="i-lucide-loader-2"
class="h-3 w-3 animate-spin"
/>
<span v-else>{{ props.page || 1 }} / {{ props.totalPage || 1 }}</span>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
:disabled="!props.hasPrev || props.isDisabled"
@click="handlePrev"
>
Prev
</Button>
<Button
size="sm"
variant="ghost"
:disabled="!props.hasNext || props.isDisabled"
@click="handleNext"
>
Next
</Button>
</div>
</div>
</Command>
</PopoverContent>
</Popover>
</template>