Initial commit: Add project files
ci / backend (push) Failing after 12s
ci / frontend (push) Failing after 19s
ci / static (push) Failing after 45s

This commit is contained in:
2026-08-02 05:15:36 +09:00
commit dcd1322d41
636 changed files with 122352 additions and 0 deletions
@@ -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(', ')}`)
}
+110
View File
@@ -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
}