Initial commit: Add project files
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import axios from 'axios'
|
||||
import { ApiProblem, type ProblemDetails } from './problem'
|
||||
|
||||
export const api = axios.create({ baseURL: '/api', timeout: 15_000 })
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const user = import.meta.env.VITE_DEV_AUTH_USER as string | undefined
|
||||
const role = import.meta.env.VITE_DEV_AUTH_ROLE as string | undefined
|
||||
if (import.meta.env.DEV && user && role) {
|
||||
config.headers['X-KArtSell-User'] = user
|
||||
config.headers['X-KArtSell-Role'] = role
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const data = error.response?.data as ProblemDetails | undefined
|
||||
if (data?.status) throw new ApiProblem(data)
|
||||
throw error
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface ProblemDetails {
|
||||
type?: string
|
||||
title: string
|
||||
status: number
|
||||
detail?: string
|
||||
traceId?: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export class ApiProblem extends Error {
|
||||
constructor(public readonly problem: ProblemDetails) {
|
||||
super(problem.title)
|
||||
}
|
||||
|
||||
get status(): number { return this.problem.status }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
allowed: boolean
|
||||
deniedMessage?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot v-if="props.allowed" />
|
||||
<p v-else role="alert">{{ props.deniedMessage ?? '이 기능을 사용할 권한이 없습니다.' }}</p>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface IdempotentCommand<T> {
|
||||
readonly idempotencyKey: string
|
||||
readonly request: T
|
||||
}
|
||||
|
||||
/**
|
||||
* Create once per user intent. Retries must reuse the returned envelope rather than call this again.
|
||||
*/
|
||||
export function createIdempotentCommand<T>(request: T): IdempotentCommand<T> {
|
||||
return Object.freeze({ idempotencyKey: crypto.randomUUID(), request })
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const versionSetSchema = z.object({
|
||||
datasetId: z.string().min(1).max(128),
|
||||
dataHash: z.string().min(1).max(128),
|
||||
modelVersion: z.string().min(1).max(128),
|
||||
configVersion: z.string().min(1).max(128),
|
||||
codeSha: z.string().min(1).max(128),
|
||||
contractVersion: z.string().min(1).max(64)
|
||||
})
|
||||
|
||||
export type VersionSet = z.infer<typeof versionSetSchema>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { StandardScreenState } from '../ui/contracts/screenContract'
|
||||
import PageLayout from '../ui/layouts/PageLayout.vue'
|
||||
import FormPageLayout from '../ui/layouts/FormPageLayout.vue'
|
||||
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
|
||||
withDefaults(defineProps<{ title: string; subtitle?: string; state?: StandardScreenState; dirty?: boolean; readonly?: boolean; asOf?: string; version?: string }>(), { state: 'READY', dirty: false, readonly: false })
|
||||
const emit = defineEmits<{ retry: []; submit: []; cancel: [] }>()
|
||||
</script>
|
||||
<template>
|
||||
<PageLayout :title="title" :subtitle="subtitle" :status="readonly ? 'READONLY' : state" :as-of="asOf" :version="version">
|
||||
<StandardScreenBoundary :state="readonly ? 'READONLY' : (dirty ? 'DIRTY' : state)" :stale-at="asOf" @retry="emit('retry')">
|
||||
<FormPageLayout @submit="emit('submit')">
|
||||
<slot />
|
||||
<template v-if="$slots.aside" #preview><slot name="aside" /></template>
|
||||
</FormPageLayout>
|
||||
</StandardScreenBoundary>
|
||||
<template #footer><slot name="actions" /></template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from '../ui/adapter/contracts'
|
||||
import type { StandardScreenState } from '../ui/contracts/screenContract'
|
||||
import PageLayout from '../ui/layouts/PageLayout.vue'
|
||||
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
|
||||
import KsDataGrid from '../ui/components/KsDataGrid.vue'
|
||||
import KsPaginator from '../ui/components/KsPaginator.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
state?: StandardScreenState
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
asOf?: string
|
||||
version?: string
|
||||
warning?: string
|
||||
}>(), { state: 'READY', rows: () => [] })
|
||||
const emit = defineEmits<{ retry: []; rowSelected: [row: unknown]; pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
</script>
|
||||
<template>
|
||||
<PageLayout :title="title" :subtitle="subtitle" :status="state" :as-of="asOf" :version="version">
|
||||
<template #actions><slot name="actions" /></template>
|
||||
<template #summary><slot name="summary" /></template>
|
||||
<template #filters><slot name="filters" /></template>
|
||||
<StandardScreenBoundary :state="state" :warning="warning" :stale-at="asOf" @retry="emit('retry')">
|
||||
<KsDataGrid :rows="rows" :columns="columns" @row-selected="emit('rowSelected', $event)" />
|
||||
<KsPaginator :page="page" :page-size="pageSize" :total="total" @page-change="emit('pageChange', $event)" />
|
||||
</StandardScreenBoundary>
|
||||
<template v-if="$slots.detail" #aside><slot name="detail" /></template>
|
||||
<template v-if="$slots.footer" #footer><slot name="footer" /></template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ZodType } from 'zod'
|
||||
import type { UiGridColumn, UiGridFilter, UiGridSort } from '../ui/adapter/contracts'
|
||||
|
||||
export type CrudPermission = 'read' | 'create' | 'update' | 'delete' | 'export' | 'review' | 'publish'
|
||||
export interface CrudListQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
sorts: UiGridSort[]
|
||||
filters: UiGridFilter[]
|
||||
}
|
||||
export interface CrudPageResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
asOf: string
|
||||
projectionVersion: string
|
||||
watermark?: string
|
||||
stale: boolean
|
||||
}
|
||||
export interface CrudMutationContext {
|
||||
idempotencyKey: string
|
||||
ifMatch?: string
|
||||
reason?: string
|
||||
correlationId?: string
|
||||
}
|
||||
export interface CrudResourceContract<TItem, TForm> {
|
||||
resourceCode: string
|
||||
routeBase: string
|
||||
queryKey: readonly string[]
|
||||
columns: UiGridColumn[]
|
||||
formSchema: ZodType<TForm>
|
||||
permissions: Partial<Record<CrudPermission, string>>
|
||||
defaultQuery: CrudListQuery
|
||||
parseListResponse(payload: unknown): CrudPageResult<TItem>
|
||||
parseDetailResponse(payload: unknown): TItem
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CrudListQuery } from './contracts'
|
||||
|
||||
const positiveInt = (value: string | null, fallback: number): number => {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery): CrudListQuery {
|
||||
const sorts = params.getAll('sort').flatMap(value => {
|
||||
const [field, direction] = value.split(':')
|
||||
return field && (direction === 'asc' || direction === 'desc') ? [{ field, direction }] : []
|
||||
})
|
||||
const filters = params.getAll('filter').flatMap(value => {
|
||||
const first = value.indexOf(':')
|
||||
const second = value.indexOf(':', first + 1)
|
||||
if (first <= 0 || second <= first) return []
|
||||
return [{ field: value.slice(0, first), operator: value.slice(first + 1, second), value: value.slice(second + 1) }]
|
||||
})
|
||||
return {
|
||||
page: positiveInt(params.get('page'), fallback.page),
|
||||
pageSize: positiveInt(params.get('pageSize'), fallback.pageSize),
|
||||
search: params.get('search')?.trim() || undefined,
|
||||
sorts: sorts.length ? sorts : fallback.sorts,
|
||||
filters: filters.length ? filters : fallback.filters
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeCrudQuery(query: CrudListQuery): URLSearchParams {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(query.page))
|
||||
params.set('pageSize', String(query.pageSize))
|
||||
if (query.search) params.set('search', query.search)
|
||||
for (const sort of query.sorts) params.append('sort', `${sort.field}:${sort.direction}`)
|
||||
for (const filter of query.filters) params.append('filter', `${filter.field}:${filter.operator}:${String(filter.value ?? '')}`)
|
||||
return params
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ZodType } from 'zod'
|
||||
import type { UiGridColumn } from '../ui/adapter/contracts'
|
||||
export type CrudConcurrencyMode = 'NONE' | 'ETAG_IF_MATCH'
|
||||
export type CrudIdempotencyMode = 'NONE' | 'IDEMPOTENCY_KEY'
|
||||
export interface CrudResourceDefinition<TQuery, TRow, TForm> {
|
||||
resourceId: string
|
||||
querySchemaVersion: string
|
||||
responseSchemaVersion: string
|
||||
querySchema: ZodType<TQuery>
|
||||
rowSchema: ZodType<TRow>
|
||||
formSchema: ZodType<TForm>
|
||||
columns: readonly UiGridColumn[]
|
||||
sensitiveFields: readonly string[]
|
||||
permissionPolicy: string
|
||||
concurrencyMode: CrudConcurrencyMode
|
||||
idempotencyMode: CrudIdempotencyMode
|
||||
}
|
||||
export function assertCrudResourceDefinition(definition: CrudResourceDefinition<unknown, unknown, unknown>): void {
|
||||
if (!definition.resourceId.trim()) throw new Error('resourceId is required')
|
||||
const fields = new Set(definition.columns.map(x => x.field))
|
||||
for (const sensitive of definition.sensitiveFields) if (!fields.has(sensitive)) throw new Error(`Sensitive field '${sensitive}' has no grid column contract`)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeCrudQuery, encodeCrudQuery } from '../queryCodec'
|
||||
const fallback = { page: 1, pageSize: 20, sorts: [], filters: [] }
|
||||
describe('CRUD URL codec', () => {
|
||||
it('round-trips paging, sort, filter and search without hidden Pinia state', () => {
|
||||
const source = { page: 3, pageSize: 50, search: '005930', sorts: [{ field: 'asOf', direction: 'desc' as const }], filters: [{ field: 'status', operator: 'eq', value: 'WARN' }] }
|
||||
expect(decodeCrudQuery(encodeCrudQuery(source), fallback)).toEqual(source)
|
||||
})
|
||||
it('fails closed to approved defaults for invalid paging', () => {
|
||||
expect(decodeCrudQuery(new URLSearchParams('page=0&pageSize=-1'), fallback)).toEqual({ ...fallback, search: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CrudListQuery } from './contracts'
|
||||
|
||||
export function useCrudListState(initial: CrudListQuery) {
|
||||
const query = ref<CrudListQuery>({ ...initial, sorts: [...initial.sorts], filters: [...initial.filters] })
|
||||
const selectedId = ref<string | number | null>(null)
|
||||
const dirty = ref(false)
|
||||
|
||||
function replace(next: CrudListQuery): void { query.value = { ...next, sorts: [...next.sorts], filters: [...next.filters] } }
|
||||
function setPage(page: number, pageSize = query.value.pageSize): void { query.value = { ...query.value, page, pageSize } }
|
||||
function setSearch(search?: string): void { query.value = { ...query.value, page: 1, search: search?.trim() || undefined } }
|
||||
function reset(): void { replace(initial); selectedId.value = null; dirty.value = false }
|
||||
|
||||
return {
|
||||
query,
|
||||
selectedId,
|
||||
dirty,
|
||||
offset: computed(() => (query.value.page - 1) * query.value.pageSize),
|
||||
replace,
|
||||
setPage,
|
||||
setSearch,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ref } from 'vue'
|
||||
import { createIdempotencyKey } from '../commands/idempotency'
|
||||
export interface OptimisticCommandRequest<T> { payload: T; etag?: string }
|
||||
export interface OptimisticCommandResponse<TResult> { data: TResult; etag?: string; correlationId?: string }
|
||||
export function useOptimisticCommand<TPayload, TResult>(execute: (request: OptimisticCommandRequest<TPayload>, headers: Record<string,string>) => Promise<OptimisticCommandResponse<TResult>>) {
|
||||
const pending = ref(false); const conflict = ref(false); const lastCorrelationId = ref<string>()
|
||||
async function run(request: OptimisticCommandRequest<TPayload>): Promise<OptimisticCommandResponse<TResult>> {
|
||||
if (pending.value) throw new Error('Command is already in progress')
|
||||
pending.value=true; conflict.value=false
|
||||
try {
|
||||
const headers: Record<string,string> = { 'Idempotency-Key': createIdempotencyKey() }
|
||||
if (request.etag) headers['If-Match']=request.etag
|
||||
const response=await execute(request,headers); lastCorrelationId.value=response.correlationId; return response
|
||||
} catch (error: unknown) {
|
||||
const status=(error as { response?: { status?: number } })?.response?.status
|
||||
if (status===409 || status===412) conflict.value=true
|
||||
throw error
|
||||
} finally { pending.value=false }
|
||||
}
|
||||
return { pending, conflict, lastCorrelationId, run }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function formatCurrency(value: number | null | undefined, currency: string, locale = 'ko-KR'): string {
|
||||
if (value == null || Number.isNaN(value)) return '—'
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 2 }).format(value)
|
||||
}
|
||||
export function formatPercent(value: number | null | undefined, digits = 2, locale = 'ko-KR'): string {
|
||||
if (value == null || Number.isNaN(value)) return '—'
|
||||
return new Intl.NumberFormat(locale, { style: 'percent', minimumFractionDigits: digits, maximumFractionDigits: digits }).format(value)
|
||||
}
|
||||
export function formatQuantity(value: number | null | undefined, digits = 4, locale = 'ko-KR'): string {
|
||||
if (value == null || Number.isNaN(value)) return '—'
|
||||
return new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value)
|
||||
}
|
||||
export function formatAsOf(value: string | Date | null | undefined, locale = 'ko-KR'): string {
|
||||
if (!value) return '—'
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Seoul' }).format(date)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatCurrency, formatPercent, formatQuantity } from '../financial'
|
||||
describe('financial formatters', () => {
|
||||
it('renders missing values as an explicit em dash', () => { expect(formatCurrency(null, 'KRW')).toBe('—'); expect(formatPercent(undefined)).toBe('—') })
|
||||
it('keeps percentage inputs in decimal-return units', () => { expect(formatPercent(0.125, 1)).toContain('12.5') })
|
||||
it('uses bounded quantity precision', () => { expect(formatQuantity(1.234567, 2)).toContain('1.23') })
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
asOf: string
|
||||
staleAfterMinutes: number
|
||||
}>()
|
||||
|
||||
const ageMinutes = computed(() => Math.max(0, (Date.now() - new Date(props.asOf).getTime()) / 60_000))
|
||||
const stale = computed(() => ageMinutes.value > props.staleAfterMinutes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :aria-label="stale ? '데이터 지연' : '데이터 최신'" :data-status="stale ? 'stale' : 'fresh'">
|
||||
{{ stale ? 'STALE' : 'FRESH' }} · {{ new Date(props.asOf).toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from './adapter/contracts'
|
||||
import { KsDataGrid } from './components'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
}>(), { loading: false, emptyMessage: '표시할 데이터가 없습니다.' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="데이터 표" :aria-busy="loading">
|
||||
<p v-if="loading">데이터를 불러오는 중입니다.</p>
|
||||
<p v-else-if="rows.length === 0">{{ emptyMessage }}</p>
|
||||
<KsDataGrid v-else :rows="rows" :columns="columns" height="30rem" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { VersionSet } from '../contracts/versionSet'
|
||||
|
||||
const props = defineProps<{
|
||||
value: VersionSet
|
||||
compact?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dl class="version-set" :data-compact="props.compact ? 'true' : 'false'">
|
||||
<dt>Dataset</dt><dd>{{ props.value.datasetId }}</dd>
|
||||
<dt>Data hash</dt><dd><code>{{ props.value.dataHash }}</code></dd>
|
||||
<dt>Model</dt><dd>{{ props.value.modelVersion }}</dd>
|
||||
<dt>Config</dt><dd>{{ props.value.configVersion }}</dd>
|
||||
<dt>Code</dt><dd><code>{{ props.value.codeSha }}</code></dd>
|
||||
<dt>Contract</dt><dd>{{ props.value.contractVersion }}</dd>
|
||||
</dl>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import KsButton from './components/KsButton.vue'
|
||||
import KsInlineMessage from './components/KsInlineMessage.vue'
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
processing?: boolean
|
||||
dirty?: boolean
|
||||
error?: Error | null
|
||||
empty?: boolean
|
||||
partial?: boolean
|
||||
staleAt?: string | null
|
||||
warning?: string | null
|
||||
unauthorized?: boolean
|
||||
forbidden?: boolean
|
||||
conflict?: boolean
|
||||
expired?: boolean
|
||||
readonly?: boolean
|
||||
correlationId?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
<template>
|
||||
<section :aria-busy="props.loading || props.processing">
|
||||
<KsInlineMessage v-if="props.loading" severity="info" message="불러오는 중입니다." />
|
||||
<KsInlineMessage v-else-if="props.unauthorized" severity="warning" title="로그인 필요" message="로그인 후 다시 시도하세요." />
|
||||
<KsInlineMessage v-else-if="props.forbidden" severity="danger" title="권한 없음" message="이 작업을 수행할 권한이 없습니다." />
|
||||
<KsInlineMessage v-else-if="props.conflict" severity="warning" title="변경 충돌" message="다른 사용자가 먼저 변경했습니다. 최신 버전을 확인하세요." />
|
||||
<KsInlineMessage v-else-if="props.expired" severity="warning" title="유효기간 만료" message="만료된 증거 또는 제안은 실행·공개할 수 없습니다." />
|
||||
<div v-else-if="props.error" role="alert" class="ks-state-error">
|
||||
<KsInlineMessage severity="danger" title="요청 실패" :message="props.error.message" />
|
||||
<KsButton label="같은 요청 다시 시도" severity="secondary" @click="emit('retry')" />
|
||||
<small v-if="correlationId">Correlation: {{ correlationId }}</small>
|
||||
</div>
|
||||
<KsInlineMessage v-else-if="props.empty" severity="info" message="표시할 데이터가 없습니다." />
|
||||
<template v-else>
|
||||
<KsInlineMessage v-if="props.partial" severity="warning" message="일부 데이터만 표시하고 있습니다. 완전성 경고를 확인하세요." />
|
||||
<KsInlineMessage v-if="props.warning" severity="warning" :message="props.warning" />
|
||||
<KsInlineMessage v-if="props.readonly" severity="info" message="읽기 전용 상태입니다." />
|
||||
<KsInlineMessage v-if="props.dirty" severity="warning" message="저장되지 않은 변경사항이 있습니다." />
|
||||
<KsInlineMessage v-if="props.processing" severity="info" message="처리 중입니다. 중복 제출하지 마세요." />
|
||||
<slot />
|
||||
</template>
|
||||
<small v-if="props.staleAt">데이터 기준시각: {{ props.staleAt }}</small>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.ks-state-error{display:grid;gap:var(--ks-space-2);justify-items:start}</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
currentVersion?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; reload: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog :open="props.open" aria-labelledby="version-conflict-title">
|
||||
<h2 id="version-conflict-title">버전 충돌</h2>
|
||||
<p>다른 사용자가 먼저 변경했습니다. 최신 데이터를 다시 불러온 뒤 재검토하세요.</p>
|
||||
<p v-if="props.currentVersion">현재 버전: {{ props.currentVersion }}</p>
|
||||
<button type="button" @click="emit('reload')">최신 버전 불러오기</button>
|
||||
<button type="button" @click="emit('close')">닫기</button>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
const props = defineProps<{ label: string; inputId?: string; required?: boolean; error?: string; help?: string }>()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-field-${generatedId}`)
|
||||
const messageId = computed(() => props.error || props.help ? `${resolvedId.value}-message` : undefined)
|
||||
</script>
|
||||
<template>
|
||||
<div class="ks-field-shell" :data-invalid="Boolean(error) || undefined">
|
||||
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
|
||||
<slot :input-id="resolvedId" :described-by="messageId" :invalid="Boolean(error)" />
|
||||
<small v-if="error || help" :id="messageId" :class="{ 'ks-danger-text': error }" :role="error ? 'alert' : undefined">{{ error ?? help }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-field-shell{display:grid;gap:var(--ks-space-1)}
|
||||
label{font-weight:650} small{color:var(--ks-color-neutral-600)}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiButtonType, UiSeverity } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
|
||||
withDefaults(defineProps<{ label?: string; severity?: UiSeverity; type?: UiButtonType; disabled?: boolean; loading?: boolean }>(), {
|
||||
severity: 'primary', type: 'button', disabled: false, loading: false
|
||||
})
|
||||
const emit = defineEmits<{ click: [event: MouseEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="adapter.components.Button" v-bind="$props" @activate="emit('click', $event)"><slot /></component>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: boolean; label: string; inputId?: string; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-check-${generatedId}`)
|
||||
</script>
|
||||
<template><label class="ks-check" :for="resolvedId"><component :is="adapter.components.Checkbox" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" /><span>{{ label }}</span></label></template>
|
||||
<style scoped>.ks-check { display: inline-flex; align-items: center; gap: var(--ks-space-2); cursor: pointer; }</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import KsButton from './KsButton.vue'
|
||||
export interface CommandBarAction { id: string; label: string; severity?: 'primary'|'secondary'|'success'|'info'|'warning'|'danger'; disabled?: boolean; busy?: boolean }
|
||||
defineProps<{ actions: readonly CommandBarAction[]; ariaLabel?: string }>()
|
||||
const emit = defineEmits<{ execute: [actionId: string] }>()
|
||||
</script>
|
||||
<template><nav class="ks-command-bar" :aria-label="ariaLabel ?? 'Page actions'"><KsButton v-for="action in actions" :key="action.id" :label="action.label" :severity="action.severity" :disabled="action.disabled" :loading="action.busy" @click="emit('execute', action.id)" /></nav></template>
|
||||
<style scoped>.ks-command-bar{display:flex;gap:var(--ks-space-2);flex-wrap:wrap;justify-content:flex-end}</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { VersionSet } from '../../contracts/versionSet'
|
||||
import EvidenceVersionSet from '../EvidenceVersionSet.vue'
|
||||
const props = defineProps<{
|
||||
title: string; asOf: string; datasetId: string; dataHash: string; modelVersion: string; configVersion: string;
|
||||
codeSha: string; contractVersion: string; projectionVersion?: string; watermark?: string;
|
||||
stale?: boolean; correlationId?: string
|
||||
}>()
|
||||
const versionSet = computed<VersionSet>(() => ({
|
||||
datasetId: props.datasetId,
|
||||
dataHash: props.dataHash,
|
||||
modelVersion: props.modelVersion,
|
||||
configVersion: props.configVersion,
|
||||
codeSha: props.codeSha,
|
||||
contractVersion: props.contractVersion
|
||||
}))
|
||||
</script>
|
||||
<template>
|
||||
<header class="ks-data-context" :data-stale="stale || undefined">
|
||||
<div><h1>{{ title }}</h1><p>As-of {{ asOf }} <strong v-if="stale">STALE</strong></p></div>
|
||||
<EvidenceVersionSet :value="versionSet" compact />
|
||||
<dl v-if="projectionVersion || watermark || correlationId">
|
||||
<template v-if="projectionVersion"><dt>Projection</dt><dd>{{ projectionVersion }}</dd></template>
|
||||
<template v-if="watermark"><dt>Watermark</dt><dd>{{ watermark }}</dd></template>
|
||||
<template v-if="correlationId"><dt>Correlation</dt><dd>{{ correlationId }}</dd></template>
|
||||
</dl>
|
||||
</header>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-data-context{display:grid;gap:var(--ks-space-3);padding:var(--ks-space-4);border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-md)}
|
||||
.ks-data-context[data-stale=true]{border-color:var(--ks-color-warning-500)} h1,p{margin:0} dl{display:flex;gap:var(--ks-space-3);margin:0;flex-wrap:wrap} dt{font-weight:700} dd{margin:0}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
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 adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.DataGrid" v-bind="$props" @row-selected="emit('rowSelected', $event)" /></template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: string | Date | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: Date; max?: Date }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-date-${generatedId}`)
|
||||
</script>
|
||||
<template><div class="ks-field"><label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label><component :is="adapter.components.DateField" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :min="min" :max="max" :aria-describedby="error || help ? `${resolvedId}-message` : undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /><small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small></div></template>
|
||||
<style scoped>.ks-field{display:grid;gap:var(--ks-space-1)}label{font-weight:650}small{color:var(--ks-color-neutral-600)}</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
defineProps<{ visible: boolean; title: string; modal?: boolean; closable?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.Dialog" v-bind="$props" @update:visible="emit('update:visible', $event)"><slot /><template #footer><slot name="footer" /></template></component></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSeverity } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
|
||||
const emit = defineEmits<{ dismiss: [] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.InlineMessage" v-bind="$props" @dismiss="emit('dismiss')" /></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSelectOption } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
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>] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.MultiSelect" v-bind="$props" @update:model-value="emit('update:modelValue', $event)" /></template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: number | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-number-${generatedId}`)
|
||||
</script>
|
||||
<template><div class="ks-field"><label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label><component :is="adapter.components.NumberField" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :min="min" :max="max" :min-fraction-digits="minFractionDigits" :max-fraction-digits="maxFractionDigits" :aria-describedby="error || help ? `${resolvedId}-message` : undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /><small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small></div></template>
|
||||
<style scoped>.ks-field{display:grid;gap:var(--ks-space-1)}label{font-weight:650}small{color:var(--ks-color-neutral-600)}</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
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 adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.Paginator" v-bind="$props" @page-change="emit('pageChange', $event)" /></template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import type { UiSelectOption } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: unknown; label: string; options: UiSelectOption[]; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-select-${generatedId}`)
|
||||
</script>
|
||||
<template>
|
||||
<div class="ks-field">
|
||||
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
|
||||
<component :is="adapter.components.Select" :input-id="resolvedId" :model-value="modelValue" :options="options" :disabled="disabled" :invalid="Boolean(error)" :placeholder="placeholder" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
|
||||
<small v-if="error || help" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.ks-field { display: grid; gap: var(--ks-space-1); } label { font-weight: 650; } small { color: var(--ks-color-neutral-600); }</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSeverity } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.StatusTag" v-bind="$props" /></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiTabItem } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.Tabs" v-bind="$props" @update:model-value="emit('update:modelValue', $event)"><template #default="slotProps"><slot :active-id="slotProps.activeId" /></template></component></template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; rows?: number; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-area-${generatedId}`)
|
||||
</script>
|
||||
<template>
|
||||
<div class="ks-field">
|
||||
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
|
||||
<component :is="adapter.components.TextArea" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :rows="rows" :placeholder="placeholder" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
|
||||
<small v-if="error || help" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.ks-field { display: grid; gap: var(--ks-space-1); } label { font-weight: 650; } small { color: var(--ks-color-neutral-600); }</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
|
||||
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-field-${generatedId}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ks-field">
|
||||
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
|
||||
<component
|
||||
:is="adapter.components.TextField"
|
||||
:input-id="resolvedId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="Boolean(error)"
|
||||
:placeholder="placeholder"
|
||||
:aria-describedby="error || help ? `${resolvedId}-message` : undefined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
<small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-field { display: grid; gap: var(--ks-space-1); }
|
||||
label { font-weight: 650; }
|
||||
small { color: var(--ks-color-neutral-600); }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
export { default as KsButton } from './KsButton.vue'
|
||||
export { default as KsTextField } from './KsTextField.vue'
|
||||
export { default as KsTextArea } from './KsTextArea.vue'
|
||||
export { default as KsSelect } from './KsSelect.vue'
|
||||
export { default as KsMultiSelect } from './KsMultiSelect.vue'
|
||||
export { default as KsCheckbox } from './KsCheckbox.vue'
|
||||
export { default as KsDateField } from './KsDateField.vue'
|
||||
export { default as KsNumberField } from './KsNumberField.vue'
|
||||
export { default as KsDialog } from './KsDialog.vue'
|
||||
export { default as KsStatusTag } from './KsStatusTag.vue'
|
||||
export { default as KsInlineMessage } from './KsInlineMessage.vue'
|
||||
export { default as KsPaginator } from './KsPaginator.vue'
|
||||
export { default as KsTabs } from './KsTabs.vue'
|
||||
export { default as KsDataGrid } from './KsDataGrid.vue'
|
||||
export { default as FieldShell } from './FieldShell.vue'
|
||||
export { default as KsDataContextHeader } from './KsDataContextHeader.vue'
|
||||
export { default as KsCommandBar } from './KsCommandBar.vue'
|
||||
@@ -0,0 +1,30 @@
|
||||
export type StandardScreenState =
|
||||
| 'READY' | 'LOADING' | 'EMPTY' | 'WARN' | 'ERROR'
|
||||
| 'UNAUTHORIZED' | 'FORBIDDEN' | 'PARTIAL' | 'DIRTY'
|
||||
| 'CONFLICT' | 'EXPIRED' | 'READONLY' | 'PROCESSING'
|
||||
|
||||
export interface ScreenEvidenceContext {
|
||||
asOf?: string
|
||||
version?: string
|
||||
datasetId?: string
|
||||
modelVersion?: string
|
||||
configVersion?: string
|
||||
codeSha?: string
|
||||
contractVersion?: string
|
||||
evidenceHash?: string
|
||||
projectionVersion?: string
|
||||
watermark?: string
|
||||
stale?: boolean
|
||||
correlationId?: string
|
||||
permission?: string
|
||||
capabilityStatus?: string
|
||||
}
|
||||
|
||||
export interface StandardScreenProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
state?: StandardScreenState
|
||||
evidence?: ScreenEvidenceContext
|
||||
warning?: string
|
||||
error?: Error | null
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
export type StandardUiState = 'LOADING' | 'EMPTY' | 'WARN' | 'ERROR' | 'EXPIRED' | 'UNAUTHORIZED' | 'READONLY' | 'DIRTY' | 'CONFLICT' | 'PROCESSING' | 'PARTIAL' | 'READY'
|
||||
withDefaults(defineProps<{ state: StandardUiState; title?: string; message?: string; traceId?: string; retryable?: boolean }>(), { state: 'READY', retryable: false })
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="state !== 'READY'" class="ks-state" :data-state="state" role="status" :aria-busy="state === 'LOADING' || state === 'PROCESSING'">
|
||||
<strong>{{ title ?? state }}</strong><p v-if="message">{{ message }}</p><small v-if="traceId">Trace: {{ traceId }}</small><button v-if="retryable" type="button" @click="$emit('retry')">다시 시도</button><slot />
|
||||
</div><slot v-else name="ready" />
|
||||
</template>
|
||||
<style scoped>.ks-state { padding: var(--ks-space-4); border: 1px solid var(--ks-color-neutral-300); border-left-width: .35rem; border-radius: var(--ks-radius-sm); background: #fff; }.ks-state[data-state='WARN'],.ks-state[data-state='PARTIAL']{border-left-color:var(--ks-color-warning)}.ks-state[data-state='ERROR'],.ks-state[data-state='CONFLICT']{border-left-color:var(--ks-color-danger)}.ks-state p{margin:var(--ks-space-1) 0}.ks-state small{display:block;color:var(--ks-color-neutral-600)}</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './components'
|
||||
export * from './layouts'
|
||||
export * from './screen-types'
|
||||
export { default as QueryStateBoundary } from './QueryStateBoundary.vue'
|
||||
export { default as EvidenceVersionSet } from './EvidenceVersionSet.vue'
|
||||
export { default as DataGridShell } from './DataGridShell.vue'
|
||||
export { default as VersionConflictDialog } from './VersionConflictDialog.vue'
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ productName?: string; environment?: string; automationStatus?: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="ks-shell">
|
||||
<a class="ks-skip" href="#ks-main">본문으로 건너뛰기</a>
|
||||
<header class="ks-shell__header">
|
||||
<div><strong>{{ productName ?? 'K-ArtSell Aegis' }}</strong><small>{{ environment ?? 'IMPLEMENTATION_TEMPLATE' }}</small></div>
|
||||
<div class="ks-shell__boundary" role="status">{{ automationStatus ?? '투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF' }}</div>
|
||||
<slot name="header-actions" />
|
||||
</header>
|
||||
<aside class="ks-shell__nav" aria-label="주요 메뉴"><slot name="navigation" /></aside>
|
||||
<main id="ks-main" class="ks-shell__main" tabindex="-1"><slot /></main>
|
||||
<footer class="ks-shell__footer"><slot name="footer">RESEARCH_CANDIDATE_NOT_PRODUCTION</slot></footer>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-shell { min-height: 100vh; display: grid; grid-template-columns: 16rem minmax(0, 1fr); grid-template-rows: auto 1fr auto; grid-template-areas: 'header header' 'nav main' 'footer footer'; }
|
||||
.ks-shell__header { grid-area: header; display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-4); padding: var(--ks-space-3) var(--ks-space-6); color: #fff; background: var(--ks-color-neutral-950); }
|
||||
.ks-shell__header > div:first-child { display: grid; } .ks-shell__header small { color: #cbd5e1; }
|
||||
.ks-shell__boundary { padding: var(--ks-space-2) var(--ks-space-3); border: 1px solid #fbbf24; border-radius: var(--ks-radius-sm); color: #fef3c7; }
|
||||
.ks-shell__nav { grid-area: nav; padding: var(--ks-space-4); border-right: 1px solid var(--ks-color-neutral-200); background: #fff; }
|
||||
.ks-shell__main { grid-area: main; min-width: 0; padding: var(--ks-space-6); }
|
||||
.ks-shell__footer { grid-area: footer; padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-neutral-200); background: #fff; color: var(--ks-color-neutral-600); font-size: var(--ks-font-caption); }
|
||||
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: #fff; } .ks-skip:focus { top: var(--ks-space-2); }
|
||||
@media (max-width: 900px) { .ks-shell { grid-template-columns: 1fr; grid-template-areas: 'header' 'nav' 'main' 'footer'; } .ks-shell__header { align-items: flex-start; flex-direction: column; } .ks-shell__nav { border-right: 0; border-bottom: 1px solid var(--ks-color-neutral-200); } }
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">withDefaults(defineProps<{ asideWidth?: string; dense?: boolean }>(), { asideWidth: '24rem', dense: false })</script>
|
||||
<template><section class="ks-crud-workspace" :class="{ dense }" :style="{ '--ks-crud-aside': asideWidth }"><header v-if="$slots.toolbar"><slot name="toolbar" /></header><div class="ks-crud-workspace__body" :class="{ 'has-aside': $slots.aside }"><main><slot /></main><aside v-if="$slots.aside"><slot name="aside" /></aside></div><footer v-if="$slots.actions"><slot name="actions" /></footer></section></template>
|
||||
<style scoped>.ks-crud-workspace{display:grid;gap:var(--ks-space-3)}.ks-crud-workspace__body{display:grid;gap:var(--ks-space-4);min-width:0}.ks-crud-workspace__body.has-aside{grid-template-columns:minmax(0,1fr) var(--ks-crud-aside)}main,aside{min-width:0}.dense{gap:var(--ks-space-2)}@media(max-width:1100px){.ks-crud-workspace__body.has-aside{grid-template-columns:1fr}}</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
<template><div class="ks-dashboard"><section class="ks-dashboard__kpis"><slot name="kpis" /></section><section class="ks-dashboard__primary ks-card"><slot name="primary" /></section><section class="ks-dashboard__secondary ks-card"><slot name="secondary" /></section><section class="ks-dashboard__alerts ks-card"><slot name="alerts" /></section></div></template>
|
||||
<style scoped>.ks-dashboard { display: grid; grid-template-columns: 2fr 1fr; gap: var(--ks-space-4); }.ks-dashboard__kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ks-space-3); }.ks-dashboard__primary,.ks-dashboard__secondary,.ks-dashboard__alerts { padding: var(--ks-space-4); }.ks-dashboard__alerts { grid-column: 1 / -1; }@media (max-width: 900px) { .ks-dashboard { grid-template-columns: 1fr; } }</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="ks-form-layout">
|
||||
<form class="ks-form-layout__form ks-card" @submit.prevent="$emit('submit')"><slot /></form>
|
||||
<aside v-if="$slots.preview" class="ks-form-layout__preview ks-card"><slot name="preview" /></aside>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">defineEmits<{ submit: [] }>()</script>
|
||||
<style scoped>.ks-form-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(18rem, 26rem); gap: var(--ks-space-4); align-items: start; } .ks-form-layout__form,.ks-form-layout__preview { padding: var(--ks-space-4); } @media (max-width: 950px) { .ks-form-layout { grid-template-columns: 1fr; } }</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">defineProps<{ title: string; runStatus?: string; watermark?: string; owner?: string }>()</script>
|
||||
<template><section class="ks-ops-console"><header><div><h1>{{ title }}</h1><p>상태 {{ runStatus ?? '-' }} · Watermark {{ watermark ?? '-' }} · Owner {{ owner ?? '-' }}</p></div><slot name="actions" /></header><div class="ks-ops-console__grid"><aside><slot name="runs" /></aside><main><slot /></main></div><footer v-if="$slots.footer"><slot name="footer" /></footer></section></template>
|
||||
<style scoped>.ks-ops-console{display:grid;gap:var(--ks-space-4)}header{display:flex;justify-content:space-between;gap:var(--ks-space-3)}h1{margin:0}.ks-ops-console__grid{display:grid;grid-template-columns:minmax(18rem,28rem) minmax(0,1fr);gap:var(--ks-space-4)}main,aside{min-width:0}@media(max-width:1000px){.ks-ops-console__grid{grid-template-columns:1fr}}</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string; version?: string; asideWidth?: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<section class="ks-page" :style="{ '--ks-aside-width': asideWidth ?? '22rem' }">
|
||||
<header class="ks-page__header">
|
||||
<div><h1>{{ title }}</h1><p v-if="subtitle">{{ subtitle }}</p><div class="ks-page__meta"><span v-if="status">상태: {{ status }}</span><span v-if="asOf">As-of: {{ asOf }}</span><span v-if="version">Version: {{ version }}</span></div></div>
|
||||
<div class="ks-page__actions"><slot name="actions" /></div>
|
||||
</header>
|
||||
<div v-if="$slots.summary" class="ks-page__summary"><slot name="summary" /></div>
|
||||
<div v-if="$slots.filters" class="ks-page__filters ks-card"><slot name="filters" /></div>
|
||||
<div class="ks-page__workspace" :class="{ 'has-aside': $slots.aside }">
|
||||
<div class="ks-page__content"><slot /></div>
|
||||
<aside v-if="$slots.aside" class="ks-page__aside"><slot name="aside" /></aside>
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="ks-page__footer"><slot name="footer" /></footer>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-page { max-width: var(--ks-content-max); margin: 0 auto; display: grid; gap: var(--ks-space-4); }
|
||||
.ks-page__header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--ks-space-4); }
|
||||
h1 { margin: 0; font-size: var(--ks-font-page); line-height: var(--ks-line-page); } p { margin: var(--ks-space-1) 0 0; color: var(--ks-color-neutral-600); }
|
||||
.ks-page__meta { display: flex; gap: var(--ks-space-3); flex-wrap: wrap; margin-top: var(--ks-space-2); color: var(--ks-color-neutral-600); font-size: var(--ks-font-caption); }
|
||||
.ks-page__summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ks-space-3); }
|
||||
.ks-page__filters { padding: var(--ks-space-3); }
|
||||
.ks-page__workspace { min-width: 0; display: grid; gap: var(--ks-space-4); }
|
||||
.ks-page__workspace.has-aside { grid-template-columns: minmax(0, 1fr) var(--ks-aside-width); }
|
||||
.ks-page__content, .ks-page__aside { min-width: 0; }
|
||||
.ks-page__footer { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: flex-end; gap: var(--ks-space-2); padding: var(--ks-space-3); border: 1px solid var(--ks-color-neutral-200); background: rgb(255 255 255 / 96%); }
|
||||
@media (max-width: 1100px) { .ks-page__workspace.has-aside { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="ks-review-workbench">
|
||||
<section class="ks-review-workbench__queue ks-card" aria-label="검토 대기열"><slot name="queue" /></section>
|
||||
<section class="ks-review-workbench__detail ks-card" aria-label="결정 상세"><slot name="detail" /></section>
|
||||
<aside class="ks-review-workbench__decision ks-card" aria-label="검토 결정"><slot name="decision" /></aside>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-review-workbench { display: grid; grid-template-columns: minmax(18rem, 28rem) minmax(24rem, 1fr) minmax(18rem, 24rem); gap: var(--ks-space-3); align-items: start; }
|
||||
.ks-review-workbench > * { min-height: 30rem; padding: var(--ks-space-4); }
|
||||
@media (max-width: 1200px) { .ks-review-workbench { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as AppShellLayout } from './AppShellLayout.vue'
|
||||
export { default as PageLayout } from './PageLayout.vue'
|
||||
export { default as DashboardLayout } from './DashboardLayout.vue'
|
||||
export { default as FormPageLayout } from './FormPageLayout.vue'
|
||||
export { default as ReviewWorkbenchLayout } from './ReviewWorkbenchLayout.vue'
|
||||
export { default as CrudWorkspaceLayout } from './CrudWorkspaceLayout.vue'
|
||||
export { default as OperationsConsoleLayout } from './OperationsConsoleLayout.vue'
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { App } from 'vue'
|
||||
import type { UiAdapterId } from '../adapter/contracts'
|
||||
|
||||
export interface UiProvider {
|
||||
readonly id: UiAdapterId
|
||||
install(app: App): void
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { UiProvider } from './UiProvider'
|
||||
export { resolveUiProvider } from './resolveUiProvider'
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UiProvider } from './UiProvider'
|
||||
import { nativeUiProvider } from '../adapter/native/installNativeAdapter'
|
||||
import { primeVueUiProvider } from '../adapter/primevue/installPrimeVueAdapter'
|
||||
|
||||
export type UiProviderName = 'primevue' | 'native'
|
||||
|
||||
export function resolveUiProvider(name: string | undefined): UiProvider {
|
||||
const normalized = (name ?? 'primevue').trim().toLowerCase()
|
||||
if (normalized === 'primevue') return primeVueUiProvider
|
||||
if (normalized === 'native') return nativeUiProvider
|
||||
throw new Error(`Unsupported VITE_UI_ADAPTER '${name}'. Allowed values: primevue, native.`)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; defineProps<{ title: string; status?: string; asOf?: string }>()</script>
|
||||
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><template #summary><slot name="run-summary" /></template><section class="ks-stack"><div class="ks-card ks-section"><slot name="timeline" /></div><div class="ks-card ks-section"><slot name="records" /></div><div class="ks-card ks-section"><slot name="reprocess" /></div></section><template #aside><slot name="runbook" /></template></PageLayout></template>
|
||||
<style scoped>.ks-section { padding: var(--ks-space-4); }</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string; version?: string }>()</script>
|
||||
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><template #summary><slot name="summary" /></template><slot /><template #aside><slot name="side-panel" /></template><template #footer><slot name="footer" /></template></PageLayout></template>
|
||||
@@ -0,0 +1,2 @@
|
||||
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; import FormPageLayout from '../layouts/FormPageLayout.vue'; defineProps<{ title: string; subtitle?: string; status?: string; version?: string }>(); defineEmits<{ submit: [] }>()</script>
|
||||
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><FormPageLayout @submit="$emit('submit')"><slot /><template #preview><slot name="preview" /></template></FormPageLayout><template #footer><slot name="footer" /></template></PageLayout></template>
|
||||
@@ -0,0 +1,2 @@
|
||||
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string }>()</script>
|
||||
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><template #summary><slot name="summary" /></template><template #filters><slot name="filters" /></template><slot /><template #aside><slot name="detail" /></template><template #footer><slot name="footer" /></template></PageLayout></template>
|
||||
@@ -0,0 +1,2 @@
|
||||
<script setup lang="ts">import PageLayout from '../layouts/PageLayout.vue'; import ReviewWorkbenchLayout from '../layouts/ReviewWorkbenchLayout.vue'; defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string }>()</script>
|
||||
<template><PageLayout v-bind="$props"><template #actions><slot name="actions" /></template><ReviewWorkbenchLayout><template #queue><slot name="queue" /></template><template #detail><slot name="detail" /></template><template #decision><slot name="decision" /></template></ReviewWorkbenchLayout></PageLayout></template>
|
||||
@@ -0,0 +1,30 @@
|
||||
export type ScreenTemplateId = 'T01' | 'T02' | 'T03' | 'T04' | 'T05' | 'T06' | 'T07' | 'T08' | 'T09' | 'T10'
|
||||
|
||||
export interface ScreenTemplateDefinition {
|
||||
id: ScreenTemplateId
|
||||
name: string
|
||||
component: string
|
||||
intendedUse: string
|
||||
mandatoryStates: readonly string[]
|
||||
mandatoryEvidence: readonly string[]
|
||||
antiPatterns: readonly string[]
|
||||
}
|
||||
|
||||
export const screenTemplateCatalogue: readonly ScreenTemplateDefinition[] = Object.freeze([
|
||||
{ id: 'T01', name: '검색·목록형 CRUD', component: 'SearchListCrudPage', intendedUse: '상품·고객·권한·데이터 Run·추천 검토함', mandatoryStates: ['LOADING','EMPTY','WARN','ERROR','UNAUTHORIZED','PARTIAL'], mandatoryEvidence: ['filter-url','permission','export-auth'], antiPatterns: ['client-side-all-data','unbounded-export'] },
|
||||
{ id: 'T02', name: '상세 조회형', component: 'DetailReadPage', intendedUse: '투자제안·상품·포트폴리오·백테스트 결과', mandatoryStates: ['LOADING','WARN','EXPIRED','READONLY','UNAUTHORIZED'], mandatoryEvidence: ['as-of','version-set','audit'], antiPatterns: ['mutable-evidence','hidden-version'] },
|
||||
{ id: 'T03', name: '등록·편집 Form', component: 'EditFormPage', intendedUse: '고객·IPS·비용표·권한·설정', mandatoryStates: ['DIRTY','CONFLICT','PROCESSING','ERROR'], mandatoryEvidence: ['zod','if-match','idempotency-key'], antiPatterns: ['silent-overwrite','pinia-form-cache'] },
|
||||
{ id: 'T04', name: 'Master-Detail', component: 'MasterDetailCrudPage', intendedUse: '고객-IPS·추천-항목·Watch-Stage·대사 Run-Break', mandatoryStates: ['LOADING','EMPTY','DIRTY','PARTIAL','CONFLICT'], mandatoryEvidence: ['route-selection','unsaved-guard','version'], antiPatterns: ['selection-only-local','detail-n-plus-one'] },
|
||||
{ id: 'T05', name: '검토·승인 Workbench', component: 'ApprovalWorkbenchPage', intendedUse: '추천·모델·정정·대사 maker-checker', mandatoryStates: ['WARN','EXPIRED','CONFLICT','PROCESSING','READONLY'], mandatoryEvidence: ['maker-checker','reason','warning-ack'], antiPatterns: ['self-approval','approval-without-evidence'] },
|
||||
{ id: 'T06', name: '단계 Wizard', component: 'StepWizardPage', intendedUse: '고객 온보딩·IPS·리밸런싱·Backfill', mandatoryStates: ['DIRTY','ERROR','PROCESSING','READONLY'], mandatoryEvidence: ['resume','branch','impact-revalidation'], antiPatterns: ['single-huge-form','skip-validation'] },
|
||||
{ id: 'T07', name: 'Dashboard·Scorecard', component: 'ScorecardDashboardPage', intendedUse: '고객 대시보드·일평가·운영 SLO', mandatoryStates: ['LOADING','EMPTY','WARN','PARTIAL'], mandatoryEvidence: ['metric-definition','sample-size','table-alternative'], antiPatterns: ['chart-only','metric-definition-hidden'] },
|
||||
{ id: 'T08', name: 'Batch·데이터 운영', component: 'BatchOperationsPageV2', intendedUse: '수집·Feature·추천·평가·Backfill', mandatoryStates: ['PROCESSING','WARN','ERROR','PARTIAL','READONLY'], mandatoryEvidence: ['job-run','watermark','replay-scope'], antiPatterns: ['blind-retry','overwrite-reprocess'] },
|
||||
{ id: 'T09', name: '대사·예외 처리', component: 'ReconciliationExceptionPage', intendedUse: 'KIS/원장 대사·데이터 격리·DQ 예외', mandatoryStates: ['WARN','CONFLICT','PROCESSING','READONLY'], mandatoryEvidence: ['before-after','correction','audit'], antiPatterns: ['direct-db-fix','delete-break'] },
|
||||
{ id: 'T10', name: '버전 비교·거버넌스', component: 'VersionGovernancePage', intendedUse: '모델·정책·설정·데이터 공급원 승격', mandatoryStates: ['WARN','EXPIRED','READONLY','CONFLICT'], mandatoryEvidence: ['same-dataset-cost','gate-pack','rollback'], antiPatterns: ['auto-promotion','different-cohort-comparison'] }
|
||||
])
|
||||
|
||||
export function getScreenTemplate(id: ScreenTemplateId): ScreenTemplateDefinition {
|
||||
const template = screenTemplateCatalogue.find(x => x.id === id)
|
||||
if (!template) throw new Error(`Unknown screen template: ${id}`)
|
||||
return template
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as CrudListPage } from './CrudListPage.vue'
|
||||
export { default as CrudDetailPage } from './CrudDetailPage.vue'
|
||||
export { default as CrudFormPage } from './CrudFormPage.vue'
|
||||
export { default as CrudReviewPage } from './CrudReviewPage.vue'
|
||||
export { default as BatchOperationsPage } from './BatchOperationsPage.vue'
|
||||
export * from './v2'
|
||||
export * from './catalogue'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getScreenTemplate, screenTemplateCatalogue } from '../catalogue'
|
||||
|
||||
describe('screen template catalogue', () => {
|
||||
it('defines all ten screen contracts without duplicate IDs or components', () => {
|
||||
expect(screenTemplateCatalogue).toHaveLength(10)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.id)).size).toBe(10)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.component)).size).toBe(10)
|
||||
})
|
||||
|
||||
it('requires evidence and anti-pattern declarations for every template', () => {
|
||||
for (const template of screenTemplateCatalogue) {
|
||||
expect(template.mandatoryEvidence.length).toBeGreaterThan(0)
|
||||
expect(template.mandatoryStates.length).toBeGreaterThan(0)
|
||||
expect(template.antiPatterns.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a template by stable ID', () => {
|
||||
expect(getScreenTemplate('T05').component).toBe('ApprovalWorkbenchPage')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import ReviewWorkbenchLayout from '../../layouts/ReviewWorkbenchLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
|
||||
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><ReviewWorkbenchLayout><template #queue><slot name="queue"/></template><template #detail><slot name="detail"/></template><template #decision><slot name="decision"/></template></ReviewWorkbenchLayout></StandardScreenBoundary></PageLayout></template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
|
||||
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="actions"/></template><template #summary><slot name="runSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><section class="ks-stack"><div class="ks-card ks-section"><slot name="timeline"/></div><div class="ks-card ks-section"><slot name="records"/></div><div class="ks-card ks-section"><slot name="reprocess"/></div></section></StandardScreenBoundary><template #aside><slot name="runbook"/></template></PageLayout></template>
|
||||
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user