This commit is contained in:
2025-05-24 01:49:48 +09:00
commit 62abbcf4eb
2376 changed files with 325522 additions and 0 deletions

View File

@@ -0,0 +1,494 @@
<script lang="ts">
import type { SlotsType, InjectionKey, PropType } from 'vue'
import type { VFlexTableColumn } from '../base/VFlexTable.vue'
import VFlexTableSortColumn from '../base/VFlexTableSortColumn.vue'
export type VFlexTableWrapperDataResolver<T = any> = (parameters: {
searchTerm: string
start: number
limit: number
sort?: string
controller?: AbortController
}) => T[] | Promise<T[]>
export type VFlexTableWrapperSortFunction<T = any> = (parameters: {
key: string
column: Partial<VFlexTableWrapperColumn>
order: 'asc' | 'desc'
a: T
b: T
}) => number
export type VFlexTableWrapperFilterFunction<T = any> = (parameters: {
searchTerm: string
value: any
row: T
column: Partial<VFlexTableWrapperColumn>
index: number
}) => boolean
export interface VFlexTableWrapperColumn extends VFlexTableColumn {
searchable?: boolean
sortable?: boolean
sort?: VFlexTableWrapperSortFunction
filter?: VFlexTableWrapperFilterFunction
}
export interface VFlexTableWrapperInjection {
data?: any[] | undefined
columns?: Record<string, Partial<VFlexTableWrapperColumn>>
loading?: boolean
searchInput?: string
searchTerm?: string
start?: number
limit?: number
sort?: string
page?: number
total?: number
totalPages?: number
fetchData: (controller?: AbortController) => Promise<void>
}
export const flewTableWrapperSymbol: InjectionKey<VFlexTableWrapperInjection> = Symbol()
const defaultFormatter = (value: any) => value
const defaultSortFunction: VFlexTableWrapperSortFunction = ({ key, order, a, b }) => {
const aValue = a[key]
const bValue = b[key]
if (typeof aValue === 'string') {
if (order === 'asc') {
return aValue.localeCompare(bValue)
}
else {
return bValue.localeCompare(aValue)
}
}
if (aValue > bValue) {
return order === 'asc' ? 1 : -1
}
if (aValue < bValue) {
return order === 'asc' ? -1 : 1
}
return 0
}
export default defineComponent({
props: {
data: {
type: [Array, Function] as PropType<any[] | VFlexTableWrapperDataResolver>,
default: undefined,
},
columns: {
type: Object as PropType<Record<string, string | Partial<VFlexTableWrapperColumn>>>,
default: undefined,
},
sort: {
type: String,
default: undefined,
},
searchTerm: {
type: String,
default: undefined,
},
limit: {
type: Number,
default: undefined,
},
page: {
type: Number,
default: undefined,
},
total: {
type: Number,
default: undefined,
},
debounceSearch: {
type: Number,
default: 300,
},
},
slots: Object as SlotsType<{
default: VFlexTableWrapperInjection
}>,
emits: ['update:sort', 'update:page', 'update:limit', 'update:searchTerm'],
setup(props, context) {
const rawData = ref<any[]>()
const loading = ref(false)
const defaultSort = ref('')
const sort = computed({
get: () => props.sort ?? defaultSort.value,
set(value) {
if (props.sort === undefined) {
defaultSort.value = value
}
else {
context.emit('update:sort', value)
}
},
})
const defaultSearchInput = ref('')
const searchInput = computed({
get: () => props.searchTerm ?? defaultSearchInput.value,
set(value) {
if (props.searchTerm === undefined) {
defaultSearchInput.value = value
}
else {
context.emit('update:searchTerm', value)
}
},
})
const defaultPage = ref(1)
const page = computed({
get: () => props.page ?? defaultPage.value,
set(value) {
if (props.page === undefined) {
defaultPage.value = value
}
else {
context.emit('update:page', value)
}
},
})
const defaultLimit = ref(10)
const limit = computed({
get: () => Math.max(1, props.limit ?? defaultLimit.value),
set(value) {
if (props.limit === undefined) {
defaultLimit.value = value
}
else {
context.emit('update:limit', value)
}
},
})
const columns = computed(() => {
const columnProps = props.columns
if (!columnProps) return columnProps
const wrapperColumns: Record<string, Partial<VFlexTableWrapperColumn>> = {}
Object.keys(columnProps).reduce((acc, key) => {
const value = columnProps[key]
if (typeof value === 'string') {
acc[key] = {
format: defaultFormatter,
label: value,
key,
}
}
else if (typeof value === 'object') {
acc[key] = {
format: defaultFormatter,
label: key,
key,
...value,
}
if (value.sortable === true) {
if (value.renderHeader) {
acc[key].renderHeader = () => {
return h(
VFlexTableSortColumn,
{
'id': key,
'noRouter': true,
'modelValue': sort.value,
'onUpdate:modelValue': value => (sort.value = value),
},
{
default: value.renderHeader,
},
)
}
}
else {
acc[key].renderHeader = () => {
return h(VFlexTableSortColumn, {
'id': key,
'label': value.label ?? key,
'noRouter': true,
'modelValue': sort.value,
'onUpdate:modelValue': value => (sort.value = value),
})
}
}
}
if (value.searchable === true && !value.sort) {
acc[key].sort = defaultSortFunction
}
}
return acc
}, wrapperColumns)
return wrapperColumns
})
const filteredData = computed(() => {
let data = rawData.value
if (!data) return data
if (typeof props.data === 'function') return data
// filter data
if (searchTerm.value) {
const searchableColumns = columns.value
? (Object.values(columns.value).filter((column) => {
if (!column || typeof column === 'string') return false
return column.searchable === true
}) as Partial<VFlexTableWrapperColumn>[])
: []
if (searchableColumns.length) {
const _searchRe = new RegExp(searchTerm.value, 'i')
data = data.filter((row, index) => {
return searchableColumns.some((column) => {
if (!column.key) return false
const value = row[column.key]
if (column.filter) {
return column.filter({
searchTerm: searchTerm.value,
value,
row,
column,
index,
})
}
if (typeof value === 'string') return value.match(_searchRe)
return false
})
})
}
}
return data
})
const sortedData = computed(() => {
let data = filteredData.value
if (!data) return data
if (typeof props.data === 'function') return data
// sort data
if (sort.value && sort.value.includes(':')) {
const [sortField, sortOrder] = sort.value.split(':') as [string, 'desc' | 'asc']
const sortingColumn = columns.value
? (Object.values(columns.value).find((column) => {
if (!column || typeof column === 'string') return false
return column.sortable === true && column.key === sortField
}) as Partial<VFlexTableWrapperColumn>)
: null
if (sortingColumn) {
const sorted = [...data]
sorted.sort((a, b) => {
if (!sortingColumn.key) return 0
if (!sortingColumn.sort) return 0
return sortingColumn.sort({
order: sortOrder,
column: sortingColumn,
key: sortingColumn.key,
a,
b,
})
})
data = sorted
}
}
return data
})
const data = computed(() => {
if (typeof props.data === 'function') return rawData.value
if (!rawData.value) return rawData.value
let data = sortedData.value
// paginate data
return data?.slice(start.value, start.value + limit.value)
})
const searchTerm = useDebounce(searchInput, props.debounceSearch)
const total = computed(() => props.total ?? sortedData.value?.length ?? 0)
const start = computed(() => (page.value - 1) * limit.value)
const totalPages = computed(() =>
total.value ? Math.ceil(total.value / limit.value) : 0,
)
async function fetchData(controller?: AbortController) {
if (typeof props.data === 'function') {
loading.value = true
try {
rawData.value = await props.data({
searchTerm: searchTerm.value,
start: start.value,
limit: limit.value,
sort: sort.value,
controller,
})
}
finally {
loading.value = false
}
}
}
watch([searchTerm, limit], () => {
if (page.value !== 1) {
page.value = 1
}
})
watchEffect(async (onInvalidate) => {
let controller: AbortController
if (typeof props.data === 'function') {
controller = new AbortController()
await fetchData(controller)
}
else {
rawData.value = props.data
}
onInvalidate(() => {
controller?.abort()
})
})
const wrapperState = reactive({
data,
columns,
loading,
searchInput,
searchTerm,
start,
page,
limit,
sort,
total,
totalPages,
fetchData,
}) as VFlexTableWrapperInjection
provide(flewTableWrapperSymbol, wrapperState)
context.expose(wrapperState)
return () => {
const slotContent = context.slots.default?.(wrapperState)
return h('div', { class: 'flex-table-wrapper' }, slotContent)
}
},
})
</script>
<style lang="scss">
/* 테이블 헤더 & 로우 공통 스타일 */
.flex-table-header,
.flex-table-row {
display: flex;
width: 100%;
border-bottom: 1px solid #e0e0e0;
}
/* 개별 셀 스타일 */
.flex-table-item {
flex: 1;
padding: 12px 15px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 컬럼별 너비 설정 */
.paymentColumn1 { flex: 0 0 15%; } /* 결재번호 */
.paymentColumn2 { flex: 0 0 30%; } /* 제목 */
.paymentColumn3 { flex: 0 0 15%; } /* 작성자 */
.paymentColumn4 { flex: 0 0 20%; } /* 등록일 */
.paymentColumn5 { flex: 0 0 20%; } /* 구분 */
.flex-table-wrapper {
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
border-radius: 8px;
padding: 20px;
.flex-table {
.flex-table-item {
margin-bottom: 0;
border-radius: 0;
border-inline-start: none;
border-inline-end: none;
border-top: none;
&:last-child {
margin-bottom: 6px;
border-bottom: none;
}
&:focus-visible {
border-radius: 4px;
outline-offset: var(--accessibility-focus-outline-offset);
outline-width: var(--accessibility-focus-outline-width);
outline-style: var(--accessibility-focus-outline-style);
outline-color: var(--accessibility-focus-outline-color);
}
}
}
}
/* ==========================================================================
6. Flex Table advanced wrapper Dark mode
========================================================================== */
.is-dark {
.flex-table-wrapper {
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
/* ==========================================================================
9. Media Queries
========================================================================== */
@media (width <= 767px) {
.flex-table-wrapper {
.flex-table {
.flex-table-header {
.is-checkbox {
display: none;
}
}
.flex-table-item {
padding-inline-start: 0;
padding-inline-end: 0;
.is-checkbox {
display: none;
}
}
}
}
}
</style>