V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue'
|
||||
import type { KbxLookupColumnDefinition, KbxLookupItem } from '@kbx/contracts'
|
||||
import { kbxLookupRegistryKey } from '../lookup/registry'
|
||||
import KbxButton from './KbxButton.vue'
|
||||
import KbxDialog from './KbxDialog.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
entity: string
|
||||
title: string
|
||||
initialQuery?: string
|
||||
columns?: KbxLookupColumnDefinition[]
|
||||
pageSize?: number
|
||||
}>(), { initialQuery: '', pageSize: 30 })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [boolean]
|
||||
select: [KbxLookupItem<string>]
|
||||
}>()
|
||||
|
||||
const registry = inject(kbxLookupRegistryKey, {})
|
||||
const query = ref('')
|
||||
const items = ref<KbxLookupItem<string>[]>([])
|
||||
const selectedIndex = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const lastSearchedQuery = ref('')
|
||||
const page = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
let requestSequence = 0
|
||||
|
||||
const defaultColumns: KbxLookupColumnDefinition[] = [
|
||||
{ key:'code', label:'코드', source:'code' },
|
||||
{ key:'displayName', label:'명칭', source:'displayName' },
|
||||
{ key:'status', label:'상태', source:'status', align:'center' },
|
||||
]
|
||||
const columns = computed(() => props.columns?.length ? props.columns : defaultColumns)
|
||||
const safePageSize = computed(() => Math.max(10, Math.min(100, props.pageSize)))
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(totalCount.value / safePageSize.value)))
|
||||
const hasPrevious = computed(() => page.value > 1 && !loading.value)
|
||||
const hasNext = computed(() => page.value < pageCount.value && !loading.value)
|
||||
|
||||
watch(() => [props.visible, props.entity] as const, async ([visible]) => {
|
||||
requestSequence += 1
|
||||
if (!visible) return
|
||||
query.value = props.initialQuery.slice(0, 120)
|
||||
page.value = 1
|
||||
items.value = []
|
||||
totalCount.value = 0
|
||||
selectedIndex.value = 0
|
||||
error.value = ''
|
||||
await search(1)
|
||||
})
|
||||
|
||||
function valueOf(item: KbxLookupItem<string>, column: KbxLookupColumnDefinition) {
|
||||
switch (column.source) {
|
||||
case 'code': return item.code
|
||||
case 'displayName': return item.displayName
|
||||
case 'secondaryText': return item.secondaryText ?? ''
|
||||
case 'status': return item.status ?? ''
|
||||
default: return item.metadata?.[column.source.slice('metadata.'.length)] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
async function search(nextPage = 1) {
|
||||
const provider = registry[props.entity]
|
||||
if (!provider) {
|
||||
items.value = []
|
||||
totalCount.value = 0
|
||||
error.value = '조회 공급자가 구성되지 않았습니다. 관리자에게 문의하세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const sequence = ++requestSequence
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await provider.search({
|
||||
query: query.value.trim().slice(0, 120),
|
||||
page: nextPage,
|
||||
pageSize: safePageSize.value,
|
||||
})
|
||||
if (sequence !== requestSequence) return
|
||||
items.value = result.items
|
||||
totalCount.value = Math.max(0, result.totalCount)
|
||||
page.value = nextPage
|
||||
selectedIndex.value = 0
|
||||
lastSearchedQuery.value = query.value
|
||||
await nextTick()
|
||||
scrollSelectedIntoView()
|
||||
} catch {
|
||||
if (sequence !== requestSequence) return
|
||||
items.value = []
|
||||
totalCount.value = 0
|
||||
error.value = '조회하지 못했습니다. 네트워크 상태를 확인한 후 다시 조회하세요.'
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onQueryEnter() {
|
||||
if (!loading.value && !error.value && lastSearchedQuery.value === query.value && items.value[selectedIndex.value]) {
|
||||
select(items.value[selectedIndex.value])
|
||||
return
|
||||
}
|
||||
await search(1)
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
if (!items.value.length || loading.value) return
|
||||
selectedIndex.value = Math.max(0, Math.min(items.value.length - 1, selectedIndex.value + delta))
|
||||
nextTick(scrollSelectedIntoView)
|
||||
}
|
||||
|
||||
function scrollSelectedIntoView() {
|
||||
root.value?.querySelector<HTMLElement>('tr[aria-selected="true"]')?.scrollIntoView({ block:'nearest' })
|
||||
}
|
||||
|
||||
function select(item: KbxLookupItem<string>) {
|
||||
emit('select', item)
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxDialog :open="visible" :title="`${title} 검색`" size="lg" @update:open="emit('update:visible', $event)">
|
||||
<div
|
||||
ref="root"
|
||||
class="kbx-lookup-dialog"
|
||||
data-kbx-component="lookup-dialog"
|
||||
:aria-busy="loading || undefined"
|
||||
@keydown.down.prevent="move(1)"
|
||||
@keydown.up.prevent="move(-1)"
|
||||
@keydown.esc.stop="emit('update:visible', false)"
|
||||
>
|
||||
<div class="kbx-lookup-dialog__search">
|
||||
<label class="sr-only" for="kbx-lookup-dialog-query">검색어</label>
|
||||
<input
|
||||
id="kbx-lookup-dialog-query"
|
||||
v-model="query"
|
||||
class="kbx-lookup-dialog__input"
|
||||
maxlength="120"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
placeholder="코드 또는 명칭"
|
||||
@input="error=''"
|
||||
@keydown.enter.stop.prevent="onQueryEnter"
|
||||
>
|
||||
<KbxButton label="조회" variant="secondary" :loading="loading" @click="search(1)" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="kbx-lookup-dialog__state" role="status">조회 중...</div>
|
||||
<div v-else-if="error" class="kbx-lookup-dialog__state is-error" role="alert">
|
||||
<strong>조회할 수 없습니다.</strong><span>{{ error }}</span><KbxButton label="다시 조회" variant="secondary" @click="search(page)" />
|
||||
</div>
|
||||
<div v-else-if="!items.length" class="kbx-lookup-dialog__state">
|
||||
<strong>조회된 항목이 없습니다.</strong><span>검색어를 변경해 다시 조회하세요.</span>
|
||||
</div>
|
||||
<div v-else class="kbx-lookup-dialog__results">
|
||||
<table class="kbx-lookup-table">
|
||||
<thead><tr><th v-for="column in columns" :key="column.key" :style="{ width: column.width ? `${column.width}px` : undefined, textAlign: column.align }">{{ column.label }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in items"
|
||||
:key="String(item.id)"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:class="{ selected: index === selectedIndex }"
|
||||
@click="selectedIndex = index"
|
||||
@dblclick="select(item)"
|
||||
>
|
||||
<td v-for="column in columns" :key="column.key" :style="{ textAlign: column.align }">{{ valueOf(item, column) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="kbx-lookup-dialog__footer">
|
||||
<span aria-live="polite">{{ totalCount.toLocaleString() }}건 · {{ page }} / {{ pageCount }} 페이지 · ↑↓ 이동 · Enter 선택 · Esc 닫기</span>
|
||||
<div class="kbx-lookup-dialog__footer-actions">
|
||||
<KbxButton label="이전" variant="secondary" :disabled="!hasPrevious" @click="search(page - 1)" />
|
||||
<KbxButton label="다음" variant="secondary" :disabled="!hasNext" @click="search(page + 1)" />
|
||||
<KbxButton label="선택" variant="primary" :disabled="!items[selectedIndex]" @click="items[selectedIndex] && select(items[selectedIndex])" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</KbxDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kbx-lookup-dialog{display:grid;gap:var(--kbx-space-3)}
|
||||
.kbx-lookup-dialog__search{display:flex;gap:var(--kbx-space-2)}
|
||||
.kbx-lookup-dialog__input{flex:1;height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2);font:inherit;color:var(--kbx-color-text);background:var(--kbx-color-surface)}
|
||||
.kbx-lookup-dialog__results{max-height:60vh;overflow:auto;border:var(--kbx-border-width) solid var(--kbx-color-border)}
|
||||
.kbx-lookup-dialog__state{min-height:var(--kbx-data-state-min-height);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kbx-space-2);padding:var(--kbx-space-4);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);text-align:center;color:var(--kbx-color-text-muted)}
|
||||
.kbx-lookup-dialog__state strong{color:var(--kbx-color-text)}
|
||||
.kbx-lookup-dialog__state.is-error{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}
|
||||
.kbx-lookup-table{width:100%;border-collapse:collapse;font-size:var(--kbx-font-sm)}
|
||||
.kbx-lookup-table th,.kbx-lookup-table td{border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);padding:var(--kbx-space-2);text-align:left;white-space:nowrap}
|
||||
.kbx-lookup-table th{position:sticky;top:0;background:var(--kbx-color-surface-muted);font-weight:600;z-index:1}
|
||||
.kbx-lookup-table tr.selected{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:calc(var(--kbx-border-width) * -2);background:var(--kbx-color-info-surface)}
|
||||
.kbx-lookup-dialog__footer{display:flex;justify-content:space-between;align-items:center;gap:var(--kbx-space-3);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}
|
||||
.kbx-lookup-dialog__footer-actions{display:flex;gap:var(--kbx-space-2)}
|
||||
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||
</style>
|
||||
Reference in New Issue
Block a user