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

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