207 lines
8.9 KiB
Vue
207 lines
8.9 KiB
Vue
<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>
|