Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/packages/kbx-ui/src/components/KbxLookupDialog.vue
T

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>