feat: adopt vendor-neutral form components (V13-FE-005)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user