Initial commit: Add project files
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { UiAdapter, UiAdapterCapability } from './contracts'
|
||||
|
||||
export interface UiAdapterCompatibilityIssue {
|
||||
code: 'CONTRACT_VERSION' | 'MISSING_CAPABILITY' | 'MISSING_COMPONENT' | 'PRODUCTION_INELIGIBLE'
|
||||
severity: 'ERROR' | 'WARNING'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface UiAdapterCompatibilityReport {
|
||||
adapterId: string
|
||||
contractVersion: string
|
||||
compatible: boolean
|
||||
issues: readonly UiAdapterCompatibilityIssue[]
|
||||
}
|
||||
|
||||
const componentByCapability: Readonly<Record<UiAdapterCapability, keyof UiAdapter['components']>> = {
|
||||
'button': 'Button', 'text-field': 'TextField', 'text-area': 'TextArea', 'select': 'Select',
|
||||
'multi-select': 'MultiSelect', 'checkbox': 'Checkbox', 'date-field': 'DateField',
|
||||
'number-field': 'NumberField', 'dialog': 'Dialog', 'status-tag': 'StatusTag',
|
||||
'inline-message': 'InlineMessage', 'paginator': 'Paginator', 'tabs': 'Tabs', 'data-grid': 'DataGrid'
|
||||
}
|
||||
|
||||
export function evaluateUiAdapterCompatibility(
|
||||
adapter: UiAdapter,
|
||||
requiredCapabilities: readonly UiAdapterCapability[],
|
||||
requireProductionEligible = false
|
||||
): UiAdapterCompatibilityReport {
|
||||
const issues: UiAdapterCompatibilityIssue[] = []
|
||||
if (adapter.descriptor.contractVersion !== '4.0') {
|
||||
issues.push({ code: 'CONTRACT_VERSION', severity: 'ERROR', detail: `Expected 4.0, got ${adapter.descriptor.contractVersion}` })
|
||||
}
|
||||
for (const capability of requiredCapabilities) {
|
||||
if (!adapter.descriptor.capabilities.has(capability)) {
|
||||
issues.push({ code: 'MISSING_CAPABILITY', severity: 'ERROR', detail: capability })
|
||||
continue
|
||||
}
|
||||
const component = adapter.components[componentByCapability[capability]] as Component | undefined
|
||||
if (!component) issues.push({ code: 'MISSING_COMPONENT', severity: 'ERROR', detail: capability })
|
||||
}
|
||||
if (requireProductionEligible && !adapter.descriptor.productionEligible) {
|
||||
issues.push({ code: 'PRODUCTION_INELIGIBLE', severity: 'ERROR', detail: adapter.descriptor.id })
|
||||
}
|
||||
return { adapterId: adapter.descriptor.id, contractVersion: adapter.descriptor.contractVersion, compatible: !issues.some(x => x.severity === 'ERROR'), issues }
|
||||
}
|
||||
|
||||
export function assertUiAdapterCompatibility(report: UiAdapterCompatibilityReport): void {
|
||||
if (!report.compatible) throw new Error(`UI adapter ${report.adapterId} is incompatible: ${report.issues.map(x => `${x.code}:${x.detail}`).join(', ')}`)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Component, InjectionKey } from 'vue'
|
||||
|
||||
export type UiSeverity = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
|
||||
export type UiButtonType = 'button' | 'submit' | 'reset'
|
||||
export type UiAdapterId = 'primevue-aggrid' | 'native-accessible'
|
||||
export type UiAdapterContractVersion = '4.0'
|
||||
export type UiAdapterCapability =
|
||||
| 'button'
|
||||
| 'text-field'
|
||||
| 'text-area'
|
||||
| 'select'
|
||||
| 'multi-select'
|
||||
| 'checkbox'
|
||||
| 'date-field'
|
||||
| 'number-field'
|
||||
| 'dialog'
|
||||
| 'status-tag'
|
||||
| 'inline-message'
|
||||
| 'paginator'
|
||||
| 'tabs'
|
||||
| 'data-grid'
|
||||
|
||||
export interface UiSelectOption {
|
||||
label: string
|
||||
value: string | number | boolean | null
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface UiTabItem {
|
||||
id: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
badge?: string | number
|
||||
}
|
||||
|
||||
export type UiGridSortDirection = 'asc' | 'desc'
|
||||
export interface UiGridSort { field: string; direction: UiGridSortDirection }
|
||||
export interface UiGridFilter { field: string; operator: string; value: unknown }
|
||||
export interface UiGridQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
sorts: UiGridSort[]
|
||||
filters: UiGridFilter[]
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface UiGridColumn {
|
||||
field: string
|
||||
header: string
|
||||
width?: number
|
||||
minWidth?: number
|
||||
sortable?: boolean
|
||||
filterable?: boolean
|
||||
sensitive?: boolean
|
||||
formatter?: (value: unknown, row: unknown) => string
|
||||
}
|
||||
|
||||
export interface UiAdapterDescriptor {
|
||||
readonly id: UiAdapterId
|
||||
readonly version: string
|
||||
readonly contractVersion: UiAdapterContractVersion
|
||||
readonly vendor: string
|
||||
readonly capabilities: ReadonlySet<UiAdapterCapability>
|
||||
readonly productionEligible: boolean
|
||||
readonly accessibilityBaseline: 'WCAG_2_2_AA_TARGET'
|
||||
}
|
||||
|
||||
export interface UiAdapter {
|
||||
readonly descriptor: UiAdapterDescriptor
|
||||
readonly components: {
|
||||
Button: Component
|
||||
TextField: Component
|
||||
TextArea: Component
|
||||
Select: Component
|
||||
MultiSelect: Component
|
||||
Checkbox: Component
|
||||
DateField: Component
|
||||
NumberField: Component
|
||||
Dialog: Component
|
||||
StatusTag: Component
|
||||
InlineMessage: Component
|
||||
Paginator: Component
|
||||
Tabs: Component
|
||||
DataGrid: Component
|
||||
}
|
||||
}
|
||||
|
||||
export const requiredUiAdapterCapabilities: readonly UiAdapterCapability[] = Object.freeze([
|
||||
'button', 'text-field', 'text-area', 'select', 'multi-select', 'checkbox', 'date-field',
|
||||
'number-field', 'dialog', 'status-tag', 'inline-message', 'paginator', 'tabs', 'data-grid'
|
||||
])
|
||||
|
||||
export function assertUiAdapterContract(adapter: UiAdapter): void {
|
||||
if (adapter.descriptor.contractVersion !== '4.0') {
|
||||
throw new Error(`Unsupported UI adapter contract: ${adapter.descriptor.contractVersion}`)
|
||||
}
|
||||
const missing = requiredUiAdapterCapabilities.filter(x => !adapter.descriptor.capabilities.has(x))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`UI adapter ${adapter.descriptor.id} is missing capabilities: ${missing.join(', ')}`)
|
||||
}
|
||||
const componentNames = [
|
||||
'Button', 'TextField', 'TextArea', 'Select', 'MultiSelect', 'Checkbox', 'DateField',
|
||||
'NumberField', 'Dialog', 'StatusTag', 'InlineMessage', 'Paginator', 'Tabs', 'DataGrid'
|
||||
] as const
|
||||
for (const name of componentNames) {
|
||||
if (!adapter.components[name]) throw new Error(`UI adapter ${adapter.descriptor.id} has no component for ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const uiAdapterKey: InjectionKey<UiAdapter> = Symbol('KArtSellUiAdapterV4')
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiButtonType, UiSeverity } from '../contracts'
|
||||
withDefaults(defineProps<{ label?: string; severity?: UiSeverity; type?: UiButtonType; disabled?: boolean; loading?: boolean }>(), { severity: 'primary', type: 'button', disabled: false, loading: false })
|
||||
const emit = defineEmits<{ activate: [event: MouseEvent] }>()
|
||||
</script>
|
||||
<template><button class="ks-native-button" :class="`is-${severity}`" :type="type" :disabled="disabled || loading" @click="emit('activate', $event)"><span v-if="loading" aria-hidden="true">…</span><slot>{{ label }}</slot></button></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: boolean; inputId?: string; disabled?: boolean; invalid?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template><input :id="inputId" class="ks-native-checkbox" type="checkbox" :checked="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" @change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from '../contracts'
|
||||
withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none' }>(), { loading: false, height: '32rem', rowSelection: 'single' })
|
||||
const emit = defineEmits<{ 'row-selected': [row: unknown] }>()
|
||||
function value(row: unknown, field: string): unknown { return typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[field] : undefined }
|
||||
</script>
|
||||
<template><div class="ks-native-grid" :style="{ maxHeight: height }" :aria-busy="loading"><p v-if="loading" role="status">불러오는 중입니다.</p><table><thead><tr><th v-for="column in columns" :key="column.field" scope="col" :style="{ width: column.width ? `${column.width}px` : undefined, minWidth: column.minWidth ? `${column.minWidth}px` : undefined }">{{ column.header }}</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="index" tabindex="0" @click="emit('row-selected', row)" @keydown.enter="emit('row-selected', row)"><td v-for="column in columns" :key="column.field">{{ column.formatter ? column.formatter(value(row, column.field), row) : value(row, column.field) }}</td></tr><tr v-if="!loading && rows.length === 0"><td :colspan="columns.length">조회 결과가 없습니다.</td></tr></tbody></table></div></template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: string | Date | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: Date; max?: Date }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
|
||||
function toDateValue(value: string | Date | null): string { if (!value) return ''; if (value instanceof Date) return value.toISOString().slice(0, 10); return value.slice(0, 10) }
|
||||
function boundary(value?: Date): string | undefined { return value?.toISOString().slice(0, 10) }
|
||||
</script>
|
||||
<template><input :id="inputId" class="ks-native-input" type="date" :value="toDateValue(modelValue)" :disabled="disabled" :aria-invalid="invalid || undefined" :min="boundary(min)" :max="boundary(max)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
const props = defineProps<{ visible: boolean; title?: string; modal?: boolean; closeOnEscape?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
|
||||
const element = ref<HTMLDialogElement | null>(null)
|
||||
watch(() => props.visible, async visible => { await nextTick(); const dialog = element.value; if (!dialog) return; if (visible && !dialog.open) props.modal === false ? dialog.show() : dialog.showModal(); if (!visible && dialog.open) dialog.close() }, { immediate: true })
|
||||
function close(): void { emit('update:visible', false) }
|
||||
</script>
|
||||
<template><dialog ref="element" class="ks-native-dialog" @close="close" @cancel="close"><header><h2>{{ title }}</h2><button type="button" aria-label="닫기" @click="close">×</button></header><section><slot /></section><footer><slot name="footer" /></footer></dialog></template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSeverity } from '../contracts'
|
||||
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
|
||||
const emit = defineEmits<{ dismiss: [] }>()
|
||||
</script>
|
||||
<template><div class="ks-inline-message" :data-severity="severity" :role="severity === 'danger' ? 'alert' : 'status'"><strong v-if="title">{{ title }}</strong><span>{{ message }}</span><button v-if="dismissible" type="button" aria-label="메시지 닫기" @click="emit('dismiss')">×</button></div></template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSelectOption } from '../contracts'
|
||||
const props = withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
|
||||
function update(event: Event): void {
|
||||
const selected = Array.from((event.target as HTMLSelectElement).selectedOptions).map(x => {
|
||||
const option = props.options[Number(x.value)]
|
||||
return option?.value ?? null
|
||||
})
|
||||
emit('update:modelValue', selected)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<label class="ks-field"><span v-if="label">{{ label }}<b v-if="required" aria-hidden="true"> *</b></span>
|
||||
<select multiple :disabled="disabled" :required="required" @change="update">
|
||||
<option v-for="(option, index) in options" :key="`${index}:${option.label}`" :value="index" :disabled="option.disabled" :selected="modelValue.includes(option.value)">{{ option.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: number | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
function parse(raw: string): number | null { if (raw.trim() === '') return null; const value = Number(raw); return Number.isFinite(value) ? value : null }
|
||||
</script>
|
||||
<template><input :id="inputId" class="ks-native-input" type="number" :value="modelValue ?? ''" :disabled="disabled" :aria-invalid="invalid || undefined" :min="min" :max="max" :step="maxFractionDigits ? 1 / 10 ** maxFractionDigits : 1" @input="emit('update:modelValue', parse(($event.target as HTMLInputElement).value))" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
|
||||
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
const pageCount = () => Math.max(1, Math.ceil(props.total / props.pageSize))
|
||||
function move(page: number): void { emit('pageChange', { page: Math.min(Math.max(1, page), pageCount()), pageSize: props.pageSize }) }
|
||||
function size(event: Event): void { emit('pageChange', { page: 1, pageSize: Number((event.target as HTMLSelectElement).value) }) }
|
||||
</script>
|
||||
<template><nav class="ks-paginator" aria-label="목록 페이지"><button type="button" :disabled="disabled || page <= 1" @click="move(page - 1)">이전</button><span>{{ page }} / {{ pageCount() }} · 총 {{ total }}건</span><button type="button" :disabled="disabled || page >= pageCount()" @click="move(page + 1)">다음</button><label>페이지 크기 <select :value="pageSize" :disabled="disabled" @change="size"><option v-for="item in pageSizes" :key="item" :value="item">{{ item }}</option></select></label></nav></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSelectOption } from '../contracts'
|
||||
const props = defineProps<{ modelValue: unknown; inputId?: string; options: UiSelectOption[]; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
|
||||
function encode(value: UiSelectOption['value']): string { return JSON.stringify(value) }
|
||||
function decode(raw: string): unknown { const option = props.options.find(x => encode(x.value) === raw); return option?.value ?? null }
|
||||
</script>
|
||||
<template><select :id="inputId" class="ks-native-input" :value="encode(modelValue as UiSelectOption['value'])" :disabled="disabled" :aria-invalid="invalid || undefined" @change="emit('update:modelValue', decode(($event.target as HTMLSelectElement).value))" @blur="emit('blur', $event)"><option v-if="placeholder" value="" disabled>{{ placeholder }}</option><option v-for="option in options" :key="encode(option.value)" :value="encode(option.value)" :disabled="option.disabled">{{ option.label }}</option></select></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSeverity } from '../contracts'
|
||||
withDefaults(defineProps<{ value: string; severity?: UiSeverity }>(), { severity: 'info' })
|
||||
</script>
|
||||
<template><span class="ks-native-tag" :class="`is-${severity}`">{{ value }}</span></template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiTabItem } from '../contracts'
|
||||
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
<template><div><div class="ks-tabs" role="tablist" :aria-label="ariaLabel"><button v-for="item in items" :key="item.id" type="button" role="tab" :aria-selected="modelValue === item.id" :disabled="item.disabled" @click="emit('update:modelValue', item.id)">{{ item.label }}<small v-if="item.badge"> {{ item.badge }}</small></button></div><div role="tabpanel"><slot :active-id="modelValue" /></div></div></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; rows?: number; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template><textarea :id="inputId" class="ks-native-input" :value="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" :rows="rows ?? 4" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template><input :id="inputId" class="ks-native-input" type="text" :value="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { UiAdapter, UiAdapterCapability } from '../contracts'
|
||||
import Button from './NativeButtonAdapter.vue'
|
||||
import TextField from './NativeTextFieldAdapter.vue'
|
||||
import TextArea from './NativeTextAreaAdapter.vue'
|
||||
import Select from './NativeSelectAdapter.vue'
|
||||
import MultiSelect from './NativeMultiSelectAdapter.vue'
|
||||
import Checkbox from './NativeCheckboxAdapter.vue'
|
||||
import DateField from './NativeDateFieldAdapter.vue'
|
||||
import NumberField from './NativeNumberFieldAdapter.vue'
|
||||
import Dialog from './NativeDialogAdapter.vue'
|
||||
import StatusTag from './NativeStatusTagAdapter.vue'
|
||||
import InlineMessage from './NativeInlineMessageAdapter.vue'
|
||||
import Paginator from './NativePaginatorAdapter.vue'
|
||||
import Tabs from './NativeTabsAdapter.vue'
|
||||
import DataGrid from './NativeDataGridAdapter.vue'
|
||||
|
||||
const capabilities: ReadonlySet<UiAdapterCapability> = new Set([
|
||||
'button','text-field','text-area','select','multi-select','checkbox','date-field','number-field',
|
||||
'dialog','status-tag','inline-message','paginator','tabs','data-grid'
|
||||
])
|
||||
|
||||
export const nativeUiAdapter: UiAdapter = Object.freeze({
|
||||
descriptor: Object.freeze({ id: 'native-accessible', version: '2.0.0', contractVersion: '4.0', vendor: 'HTML platform primitives', capabilities, productionEligible: false, accessibilityBaseline: 'WCAG_2_2_AA_TARGET' }),
|
||||
components: Object.freeze({ Button, TextField, TextArea, Select, MultiSelect, Checkbox, DateField, NumberField, Dialog, StatusTag, InlineMessage, Paginator, Tabs, DataGrid })
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { App } from 'vue'
|
||||
import type { UiProvider } from '../../provider/UiProvider'
|
||||
import { installUiAdapter } from '../useUiAdapter'
|
||||
import { nativeUiAdapter } from './index'
|
||||
import './native.css'
|
||||
|
||||
export const nativeUiProvider: UiProvider = {
|
||||
id: 'native-accessible',
|
||||
install(app: App): void { installUiAdapter(app, nativeUiAdapter) }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.ks-native-button,.ks-native-input,.ks-native-dialog{font:inherit}.ks-native-button{min-height:2.5rem;padding:.5rem .9rem;border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-sm);background:#fff;cursor:pointer}.ks-native-button.is-primary{background:var(--ks-color-primary-700);border-color:var(--ks-color-primary-700);color:#fff}.ks-native-button:disabled{opacity:.55;cursor:not-allowed}.ks-native-input{width:100%;min-height:2.5rem;padding:.45rem .65rem;border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-sm);background:#fff}.ks-native-input[aria-invalid=true]{border-color:var(--ks-color-danger-600)}.ks-native-checkbox{width:1.15rem;height:1.15rem}.ks-native-dialog{width:min(42rem,calc(100vw - 2rem));border:0;border-radius:var(--ks-radius-md);box-shadow:0 1rem 3rem rgb(15 23 42 / 25%)}.ks-native-dialog::backdrop{background:rgb(15 23 42 / 55%)}.ks-native-dialog header{display:flex;justify-content:space-between;align-items:center}.ks-native-dialog footer{display:flex;justify-content:flex-end;gap:var(--ks-space-2)}.ks-native-tag{display:inline-flex;padding:.2rem .55rem;border-radius:999px;background:var(--ks-color-neutral-100)}.ks-native-tag.is-warning{background:#fef3c7}.ks-native-tag.is-danger{background:#fee2e2}.ks-native-tag.is-success{background:#dcfce7}.ks-native-grid{overflow:auto;border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm)}.ks-native-grid table{width:100%;border-collapse:collapse}.ks-native-grid th,.ks-native-grid td{padding:.65rem;border-bottom:1px solid var(--ks-color-neutral-200);text-align:left}.ks-native-grid tbody tr:focus{outline:2px solid var(--ks-color-primary-700);outline-offset:-2px}
|
||||
.ks-inline-message{display:flex;gap:.5rem;align-items:flex-start;padding:.75rem;border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm);background:#fff}.ks-inline-message[data-severity="danger"]{border-color:#b91c1c}.ks-inline-message[data-severity="warning"]{border-color:#b45309}.ks-paginator,.ks-tabs{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.ks-tabs [aria-selected="true"]{font-weight:700;border-bottom:2px solid currentColor}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import {
|
||||
AllCommunityModule,
|
||||
ModuleRegistry,
|
||||
themeQuartz,
|
||||
type ColDef,
|
||||
type RowClickedEvent
|
||||
} from 'ag-grid-community'
|
||||
import type { UiGridColumn } from '../contracts'
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule])
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
loading?: boolean
|
||||
height?: string
|
||||
rowSelection?: 'single' | 'multiple' | 'none'
|
||||
}>(), { loading: false, height: '32rem', rowSelection: 'single' })
|
||||
|
||||
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
|
||||
|
||||
const columnDefs = computed<ColDef[]>(() => props.columns.map(column => ({
|
||||
field: column.field,
|
||||
headerName: column.header,
|
||||
width: column.width,
|
||||
minWidth: column.minWidth ?? 120,
|
||||
sortable: column.sortable ?? true,
|
||||
filter: column.filterable ?? true,
|
||||
valueFormatter: column.formatter
|
||||
? params => column.formatter?.(params.value, params.data) ?? ''
|
||||
: undefined
|
||||
})))
|
||||
|
||||
const rowSelectionOptions = computed(() => {
|
||||
if (props.rowSelection === 'none') return undefined
|
||||
return props.rowSelection === 'multiple'
|
||||
? ({ mode: 'multiRow' } as const)
|
||||
: ({ mode: 'singleRow' } as const)
|
||||
})
|
||||
|
||||
function onRowClicked(event: RowClickedEvent): void {
|
||||
if (event.data) emit('rowSelected', event.data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ks-grid" :style="{ height }" :aria-busy="loading">
|
||||
<AgGridVue
|
||||
style="height: 100%; width: 100%"
|
||||
:theme="themeQuartz"
|
||||
:row-data="rows"
|
||||
:column-defs="columnDefs"
|
||||
:row-selection="rowSelectionOptions"
|
||||
:loading="loading"
|
||||
@row-clicked="onRowClicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-grid {
|
||||
min-height: 12rem;
|
||||
border: 1px solid var(--ks-color-neutral-200);
|
||||
border-radius: var(--ks-radius-md);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import type { UiButtonType, UiSeverity } from '../contracts'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
label?: string
|
||||
severity?: UiSeverity
|
||||
type?: UiButtonType
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>(), { severity: 'primary', type: 'button', disabled: false, loading: false })
|
||||
|
||||
defineEmits<{ activate: [event: MouseEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
class="ks-button"
|
||||
:label="label"
|
||||
:severity="severity"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
@click="$emit('activate', $event)"
|
||||
>
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-button { min-height: var(--ks-control-height); padding: 0 var(--ks-space-4); border: 0; border-radius: var(--ks-radius-sm); background: var(--ks-color-action); color: #fff; font-weight: 650; cursor: pointer; }
|
||||
.ks-button:hover:not(:disabled) { background: var(--ks-color-action-hover); }
|
||||
.ks-button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
|
||||
defineProps<{ modelValue: boolean; inputId?: string; disabled?: boolean }>()
|
||||
defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Checkbox
|
||||
class="ks-checkbox"
|
||||
:input-id="inputId"
|
||||
:model-value="modelValue"
|
||||
binary
|
||||
:disabled="disabled"
|
||||
@update:model-value="$emit('update:modelValue', Boolean($event))"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
defineProps<{ modelValue: string | Date | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: Date; max?: Date }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template>
|
||||
<DatePicker
|
||||
:input-id="inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:min-date="min"
|
||||
:max-date="max"
|
||||
date-format="yy-mm-dd"
|
||||
show-icon
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
defineProps<{ visible: boolean; title: string; modal?: boolean; closable?: boolean }>()
|
||||
defineEmits<{ 'update:visible': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
class="ks-dialog"
|
||||
:visible="visible"
|
||||
:header="title"
|
||||
:modal="modal ?? true"
|
||||
:closable="closable ?? true"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
>
|
||||
<slot />
|
||||
<template #footer><slot name="footer" /></template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import type { UiSeverity } from '../contracts'
|
||||
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
|
||||
const emit = defineEmits<{ dismiss: [] }>()
|
||||
const map = { primary: 'info', secondary: 'secondary', success: 'success', info: 'info', warning: 'warn', danger: 'error' } as const
|
||||
</script>
|
||||
<template><Message :severity="map[severity]" :closable="dismissible" @close="emit('dismiss')"><strong v-if="title">{{ title }} </strong>{{ message }}</Message></template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import type { UiSelectOption } from '../contracts'
|
||||
withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
|
||||
</script>
|
||||
<template><label class="ks-field"><span v-if="label">{{ label }}<b v-if="required" aria-hidden="true"> *</b></span><MultiSelect :model-value="modelValue" :options="options" option-label="label" option-value="value" option-disabled="disabled" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" /></label></template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
defineProps<{ modelValue: number | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template>
|
||||
<InputNumber
|
||||
:input-id="inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:min-fraction-digits="minFractionDigits"
|
||||
:max-fraction-digits="maxFractionDigits"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Paginator from 'primevue/paginator'
|
||||
withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
|
||||
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
</script>
|
||||
<template><Paginator :first="(page - 1) * pageSize" :rows="pageSize" :total-records="total" :rows-per-page-options="pageSizes" :disabled="disabled" @page="emit('pageChange', { page: $event.page + 1, pageSize: $event.rows })" /></template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import type { UiSelectOption } from '../contracts'
|
||||
|
||||
defineProps<{ modelValue: unknown; inputId?: string; options: UiSelectOption[]; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
|
||||
defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select
|
||||
class="ks-select"
|
||||
:input-id="inputId"
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
option-disabled="disabled"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@blur="$emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-select { width: 100%; min-height: var(--ks-control-height); border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); background: #fff; }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
import type { UiSeverity } from '../contracts'
|
||||
|
||||
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag class="ks-status-tag" :severity="severity ?? 'info'">
|
||||
<span v-if="iconLabel" aria-hidden="true">{{ iconLabel }}</span>
|
||||
<span>{{ value }}</span>
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-status-tag { display: inline-flex; align-items: center; gap: var(--ks-space-1); border-radius: 999px; padding: var(--ks-space-1) var(--ks-space-2); border: 1px solid currentColor; font-size: var(--ks-font-caption); line-height: var(--ks-line-caption); }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiTabItem } from '../contracts'
|
||||
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
<template><div><div class="ks-tabs" role="tablist" :aria-label="ariaLabel"><button v-for="item in items" :key="item.id" type="button" role="tab" :aria-selected="modelValue === item.id" :disabled="item.disabled" @click="emit('update:modelValue', item.id)">{{ item.label }}<small v-if="item.badge"> {{ item.badge }}</small></button></div><div role="tabpanel"><slot :active-id="modelValue" /></div></div></template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import Textarea from 'primevue/textarea'
|
||||
|
||||
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; rows?: number; placeholder?: string }>()
|
||||
defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Textarea
|
||||
class="ks-textarea"
|
||||
:id="inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:rows="rows ?? 4"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="$emit('update:modelValue', String($event ?? ''))"
|
||||
@blur="$emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-textarea { width: 100%; border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); padding: var(--ks-space-3); background: #fff; color: var(--ks-color-neutral-950); resize: vertical; }
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
|
||||
defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InputText
|
||||
class="ks-input"
|
||||
:id="inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="$emit('update:modelValue', String($event ?? ''))"
|
||||
@blur="$emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-input { width: 100%; min-height: var(--ks-control-height); border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); padding: 0 var(--ks-space-3); background: #fff; color: var(--ks-color-neutral-950); }
|
||||
.ks-input[aria-invalid='true'] { border-color: var(--ks-color-danger); }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
/* PrimeVue v4 is intentionally installed in unstyled mode. Vendor DOM classes stay in this adapter boundary. */
|
||||
.p-dialog-mask { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: var(--ks-space-4); background: rgb(15 23 42 / 48%); }
|
||||
.p-dialog.ks-dialog { width: min(42rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); background: #fff; box-shadow: var(--ks-shadow-lg); }
|
||||
.p-dialog.ks-dialog .p-dialog-header, .p-dialog.ks-dialog .p-dialog-content, .p-dialog.ks-dialog .p-dialog-footer { padding: var(--ks-space-4); }
|
||||
.p-dialog.ks-dialog .p-dialog-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--ks-color-neutral-200); font-weight: 700; }
|
||||
.p-dialog.ks-dialog .p-dialog-footer { display: flex; justify-content: flex-end; gap: var(--ks-space-2); border-top: 1px solid var(--ks-color-neutral-200); }
|
||||
.p-select-overlay { z-index: 1100; min-width: 12rem; overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-sm); background: #fff; box-shadow: var(--ks-shadow-md); }
|
||||
.p-select-list { margin: 0; padding: var(--ks-space-1); list-style: none; }
|
||||
.p-select-option { padding: var(--ks-space-2) var(--ks-space-3); border-radius: var(--ks-radius-sm); cursor: pointer; }
|
||||
.p-select-option.p-focus, .p-select-option:hover { background: var(--ks-color-neutral-100); }
|
||||
.p-checkbox.ks-checkbox { display: inline-grid; width: 1.25rem; height: 1.25rem; place-items: center; border: 1px solid var(--ks-color-neutral-400); border-radius: .25rem; background: #fff; }
|
||||
.p-checkbox.ks-checkbox.p-checked { border-color: var(--ks-color-action); background: var(--ks-color-action); color: #fff; }
|
||||
.ks-tabs{display:flex;gap:.5rem;flex-wrap:wrap}.ks-tabs [aria-selected="true"]{font-weight:700;border-bottom:2px solid currentColor}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { UiAdapter, UiAdapterCapability } from '../contracts'
|
||||
import Button from './PrimeButtonAdapter.vue'
|
||||
import TextField from './PrimeTextFieldAdapter.vue'
|
||||
import TextArea from './PrimeTextAreaAdapter.vue'
|
||||
import Select from './PrimeSelectAdapter.vue'
|
||||
import MultiSelect from './PrimeMultiSelectAdapter.vue'
|
||||
import Checkbox from './PrimeCheckboxAdapter.vue'
|
||||
import DateField from './PrimeDateFieldAdapter.vue'
|
||||
import NumberField from './PrimeNumberFieldAdapter.vue'
|
||||
import Dialog from './PrimeDialogAdapter.vue'
|
||||
import StatusTag from './PrimeStatusTagAdapter.vue'
|
||||
import InlineMessage from './PrimeInlineMessageAdapter.vue'
|
||||
import Paginator from './PrimePaginatorAdapter.vue'
|
||||
import Tabs from './PrimeTabsAdapter.vue'
|
||||
import DataGrid from './AgGridAdapter.vue'
|
||||
|
||||
const capabilities: ReadonlySet<UiAdapterCapability> = new Set([
|
||||
'button','text-field','text-area','select','multi-select','checkbox','date-field','number-field',
|
||||
'dialog','status-tag','inline-message','paginator','tabs','data-grid'
|
||||
])
|
||||
|
||||
export const primeVueUiAdapter: UiAdapter = Object.freeze({
|
||||
descriptor: Object.freeze({ id: 'primevue-aggrid', version: '4.x+34.x', contractVersion: '4.0', vendor: 'PrimeVue + AG Grid Community', capabilities, productionEligible: true, accessibilityBaseline: 'WCAG_2_2_AA_TARGET' }),
|
||||
components: Object.freeze({ Button, TextField, TextArea, Select, MultiSelect, Checkbox, DateField, NumberField, Dialog, StatusTag, InlineMessage, Paginator, Tabs, DataGrid })
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { App } from 'vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import type { UiProvider } from '../../provider/UiProvider'
|
||||
import { installUiAdapter } from '../useUiAdapter'
|
||||
import { primeVueUiAdapter } from './index'
|
||||
import './adapter.css'
|
||||
|
||||
export const primeVueUiProvider: UiProvider = {
|
||||
id: 'primevue-aggrid',
|
||||
install(app: App): void {
|
||||
app.use(PrimeVue, { unstyled: true })
|
||||
installUiAdapter(app, primeVueUiAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
export function installPrimeVueAdapter(app: App): void {
|
||||
primeVueUiProvider.install(app)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertUiAdapterContract, requiredUiAdapterCapabilities } from '../contracts'
|
||||
import { nativeUiAdapter } from '../native'
|
||||
import { primeVueUiAdapter } from '../primevue'
|
||||
|
||||
describe.each([nativeUiAdapter, primeVueUiAdapter])('UI adapter $descriptor.id', adapter => {
|
||||
it('implements the complete v4 normalized contract', () => {
|
||||
expect(() => assertUiAdapterContract(adapter)).not.toThrow()
|
||||
expect(adapter.descriptor.contractVersion).toBe('4.0')
|
||||
expect(adapter.descriptor.capabilities.size).toBe(requiredUiAdapterCapabilities.length)
|
||||
expect(adapter.descriptor.accessibilityBaseline).toBe('WCAG_2_2_AA_TARGET')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { App } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
import type { UiAdapter } from './contracts'
|
||||
import { assertUiAdapterContract, uiAdapterKey } from './contracts'
|
||||
|
||||
export function installUiAdapter(app: App, adapter: UiAdapter): void {
|
||||
assertUiAdapterContract(adapter)
|
||||
app.provide(uiAdapterKey, adapter)
|
||||
}
|
||||
|
||||
export function useUiAdapter(): UiAdapter {
|
||||
const adapter = inject(uiAdapterKey)
|
||||
if (!adapter) {
|
||||
throw new Error('UI adapter is not installed. Install a validated provider during app bootstrap.')
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
Reference in New Issue
Block a user