feat: adopt vendor-neutral form components (V13-FE-005)

This commit is contained in:
2026-08-09 01:02:34 +09:00
parent eee15039e2
commit e5da826329
8 changed files with 131 additions and 0 deletions
@@ -0,0 +1,16 @@
<script setup lang="ts">
withDefaults(defineProps<{ columns?: 1 | 2; ariaLabel?: string }>(), {
columns: 2,
ariaLabel: '입력 항목'
})
</script>
<template>
<div class="ks-form-grid" :data-columns="columns" role="group" :aria-label="ariaLabel"><slot /></div>
</template>
<style scoped>
.ks-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ks-space-4); align-items: start; }
.ks-form-grid[data-columns='1'] { grid-template-columns: minmax(0, 1fr); }
@media (max-width: 56rem) { .ks-form-grid { grid-template-columns: minmax(0, 1fr); } }
</style>
@@ -0,0 +1,23 @@
<script setup lang="ts">
withDefaults(defineProps<{ title: string; description?: string; labelledBy?: string }>(), { description: '' })
</script>
<template>
<section class="ks-form-section" :aria-labelledby="labelledBy">
<header class="ks-form-section__header">
<h2 :id="labelledBy">{{ title }}</h2>
<p v-if="description">{{ description }}</p>
<div v-if="$slots.actions" class="ks-form-section__actions"><slot name="actions" /></div>
</header>
<div class="ks-form-section__content"><slot /></div>
</section>
</template>
<style scoped>
.ks-form-section { display: flex; flex-direction: column; gap: var(--ks-space-3); margin-bottom: var(--ks-space-6); }
.ks-form-section__header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--ks-space-2); padding-bottom: var(--ks-space-2); border-bottom: 1px solid var(--ks-color-neutral-200); }
h2 { margin: 0; font-size: var(--ks-font-section); line-height: var(--ks-line-section); }
p { grid-column: 1; margin: 0; color: var(--ks-color-neutral-600); font-size: var(--ks-font-caption); }
.ks-form-section__actions { grid-column: 2; grid-row: 1 / -1; display: flex; align-items: center; gap: var(--ks-space-2); }
.ks-form-section__content { min-width: 0; }
</style>
@@ -0,0 +1,10 @@
<script setup lang="ts">
withDefaults(defineProps<{ span?: 'cell' | 'full' }>(), { span: 'cell' })
</script>
<template><div class="ks-form-span" :data-span="span"><slot /></div></template>
<style scoped>
.ks-form-span { min-width: 0; }
.ks-form-span[data-span='full'] { grid-column: 1 / -1; }
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
export interface ValidationSummaryError { readonly field?: string; readonly message: string }
defineProps<{ errors: readonly ValidationSummaryError[]; title?: string }>()
</script>
<template>
<section v-if="errors.length" class="ks-validation-summary" role="alert" aria-live="assertive">
<strong>{{ title ?? `저장할 수 없습니다. ${errors.length}개 항목을 확인하세요.` }}</strong>
<ul><li v-for="(error, index) in errors" :key="`${error.field ?? 'form'}-${index}`">{{ error.message }}</li></ul>
</section>
</template>
<style scoped>
.ks-validation-summary { border: 1px solid var(--ks-color-danger); background: color-mix(in srgb, var(--ks-color-danger) 8%, var(--ks-color-surface)); padding: var(--ks-space-2) var(--ks-space-3); border-radius: var(--ks-radius-sm); color: var(--ks-color-neutral-950); }
strong { color: var(--ks-color-danger); } ul { margin: var(--ks-space-2) 0 0 var(--ks-space-6); padding: 0; }
</style>
@@ -15,3 +15,7 @@ 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'
export { default as KsFormGrid } from './KsFormGrid.vue'
export { default as KsFormSection } from './KsFormSection.vue'
export { default as KsFormSpan } from './KsFormSpan.vue'
export { default as KsValidationSummary } from './KsValidationSummary.vue'
@@ -0,0 +1,31 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import KsFormGrid from '../KsFormGrid.vue'
import KsFormSection from '../KsFormSection.vue'
import KsFormSpan from '../KsFormSpan.vue'
import KsValidationSummary from '../KsValidationSummary.vue'
describe('KBX-derived vendor-neutral form components', () => {
it('keeps grid semantics and collapses its layout by tokenized CSS', () => {
const wrapper = mount(KsFormGrid, { props: { columns: 2, ariaLabel: '고객 정보' }, slots: { default: '<input aria-label="이름">' } })
expect(wrapper.attributes('role')).toBe('group')
expect(wrapper.attributes('aria-label')).toBe('고객 정보')
expect(wrapper.attributes('data-columns')).toBe('2')
})
it('allows a field to span the full form row', () => {
expect(mount(KsFormSpan, { props: { span: 'full' } }).attributes('data-span')).toBe('full')
})
it('provides an explicit heading and optional actions without a provider dependency', () => {
const wrapper = mount(KsFormSection, { props: { title: '주문 조건', labelledBy: 'order-condition' }, slots: { actions: '<button>초기화</button>' } })
expect(wrapper.find('h2').attributes('id')).toBe('order-condition')
expect(wrapper.get('button').text()).toBe('초기화')
})
it('announces every supplied validation error', () => {
const wrapper = mount(KsValidationSummary, { props: { errors: [{ field: 'amount', message: '금액을 입력하세요.' }, { field: 'date', message: '일자를 확인하세요.' }] } })
expect(wrapper.attributes('role')).toBe('alert')
expect(wrapper.findAll('li')).toHaveLength(2)
})
})