V13-FE-005: restore direct UI components and harden grid provider
deploy / deploy (push) Failing after 51s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:39:48 +09:00
parent c4f0224a4f
commit d79edae546
45 changed files with 463 additions and 760 deletions
@@ -1,92 +0,0 @@
<script setup lang="ts">
import PButton from 'primevue/button'
import { computed } from 'vue'
interface Props {
label?: string
variant?: 'default' | 'primary' | 'danger' | 'success'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
icon?: string
iconPosition?: 'left' | 'right'
fullWidth?: boolean
text?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
size: 'md',
disabled: false,
loading: false,
iconPosition: 'left',
fullWidth: false,
text: false,
})
const emit = defineEmits<{ click: [] }>()
// Map KBX variants to PrimeVue severity
const severityMap = {
default: 'secondary',
primary: 'primary',
danger: 'danger',
success: 'success',
}
const severity = computed(() => severityMap[props.variant])
// Size classes (density-aware via CSS variables)
const sizeClasses = computed(() => {
const densityVar = 'var(--kbx-density, compact)'
return {
'kbx-button--sm': props.size === 'sm',
'kbx-button--md': props.size === 'md',
'kbx-button--lg': props.size === 'lg',
}
})
</script>
<template>
<PButton
:label="label"
:severity="severity"
:disabled="disabled || loading"
:loading="loading"
:icon="icon"
:icon-pos="iconPosition"
:class="{ ...sizeClasses, 'p-button-text': text, 'w-full': fullWidth }"
@click="emit('click')"
>
<slot />
</PButton>
</template>
<style scoped>
.kbx-button--sm :deep(.p-button) {
min-height: calc(var(--kbx-input-height, 34px) - 4px);
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
}
.kbx-button--md :deep(.p-button) {
min-height: var(--kbx-input-height, 34px);
font-size: 0.875rem;
padding: 0.5rem 1rem;
}
.kbx-button--lg :deep(.p-button) {
min-height: calc(var(--kbx-input-height, 34px) + 6px);
font-size: 1rem;
padding: 0.75rem 1.5rem;
}
/* Density-aware spacing */
:deep(.p-button) {
transition: all 0.2s ease;
}
:deep(.p-button:not(:disabled):hover) {
transform: translateY(-1px);
}
</style>
@@ -1,174 +0,0 @@
<script setup lang="ts">
import { AgGridVue } from 'ag-grid-vue3'
import { computed, ref } from 'vue'
import type { GridOptions } from 'ag-grid-community'
import type { KbxGridColumn, KbxDensity } from '@shared/contracts/kbx-types'
interface Props<T = any> {
columns: KbxGridColumn<T>[]
rows?: T[]
rowKey?: keyof T | string
loading?: boolean
density?: KbxDensity
allowSelection?: boolean
serverSideDatasource?: boolean
pageSize?: number
}
const props = withDefaults(defineProps<Props>(), {
rows: () => [],
loading: false,
density: 'compact',
allowSelection: false,
serverSideDatasource: false,
pageSize: 50,
})
const emit = defineEmits<{
rowClick: [row: any]
selectionChange: [selectedRows: any[]]
loadMore: [{ offset: number; pageSize: number }]
}>()
const selectedRows = ref<any[]>([])
// Density-aware row height
const densityHeights = {
compact: 34,
comfortable: 36,
touch: 48,
}
const gridOptions = computed(() => {
const colDefs = props.columns.map(col => ({
field: String(col.field),
headerName: col.header,
width: typeof col.width === 'number' ? col.width : undefined,
pinned: col.pinned || undefined,
sortable: col.sortable !== false,
filter: col.filterable !== false,
}))
return {
columnDefs: colDefs,
rowData: props.rows,
rowSelection: props.allowSelection ? ('multiple' as const) : undefined,
rowHeight: densityHeights[props.density],
pagination: !props.serverSideDatasource,
paginationPageSize: props.pageSize,
suppressMovableColumns: false,
suppressColumnMoveAnimation: false,
headerHeight: 36,
theme: 'ag-theme-quartz',
} as unknown as GridOptions<any>
})
const onSelectionChanged = (event: any) => {
selectedRows.value = event.api.getSelectedRows()
emit('selectionChange', selectedRows.value)
}
const onRowClicked = (event: any) => {
emit('rowClick', event.data)
}
</script>
<template>
<div
class="kbx-data-grid"
:class="`kbx-data-grid--${density}`"
:style="{
'--kbx-grid-row-height': `${densityHeights[density]}px`,
}"
>
<div v-if="loading" class="kbx-data-grid__loader">
<div class="spinner"></div>
Loading...
</div>
<AgGridVue
:grid-options="gridOptions"
class="ag-theme-quartz"
@selection-changed="onSelectionChanged"
@row-clicked="onRowClicked"
/>
</div>
</template>
<style scoped>
.kbx-data-grid {
position: relative;
width: 100%;
height: 100%;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 0.375rem;
overflow: hidden;
}
.kbx-data-grid__loader {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.8);
z-index: 10;
flex-direction: column;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--kbx-color-border, #e5e7eb);
border-top-color: var(--kbx-color-primary, #3b82f6);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Density variants */
.kbx-data-grid--compact {
font-size: 0.75rem;
}
.kbx-data-grid--comfortable {
font-size: 0.875rem;
}
.kbx-data-grid--touch {
font-size: 1rem;
}
:deep(.ag-theme-quartz) {
--ag-row-height: var(--kbx-grid-row-height, 34px);
--ag-header-height: 36px;
--ag-font-size: 0.875rem;
--ag-border-color: var(--kbx-color-border, #e5e7eb);
}
:deep(.ag-header-cell-text) {
font-weight: 600;
color: var(--kbx-color-text, #000);
}
:deep(.ag-row) {
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
:deep(.ag-row:hover) {
background-color: var(--kbx-color-hover, #f9fafb);
}
:deep(.ag-row-selected) {
background-color: var(--kbx-color-primary-alpha, rgba(59, 130, 246, 0.1));
}
</style>
-109
View File
@@ -1,109 +0,0 @@
<script setup lang="ts">
import PInputText from 'primevue/inputtext'
import { computed } from 'vue'
interface Props {
modelValue?: string | null
type?: 'text' | 'email' | 'password' | 'number' | 'date'
placeholder?: string
disabled?: boolean
readonly?: boolean
invalid?: boolean
label?: string
help?: string
required?: boolean
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
type: 'text',
placeholder: undefined,
disabled: false,
readonly: false,
invalid: false,
required: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
focus: []
blur: []
}>()
const inputClasses = computed(() => ({
'p-invalid': props.invalid,
'kbx-input--disabled': props.disabled,
}))
</script>
<template>
<div class="kbx-input-wrapper">
<label v-if="label" class="kbx-input__label">
{{ label }}
<span v-if="required" class="kbx-input__required">*</span>
</label>
<PInputText
:model-value="modelValue || ''"
:type="type"
:placeholder="placeholder || ''"
:disabled="disabled"
:readonly="readonly"
:class="inputClasses"
class="kbx-input"
@update:model-value="emit('update:modelValue', $event || '')"
@focus="emit('focus')"
@blur="emit('blur')"
/>
<small v-if="help" class="kbx-input__help">{{ help }}</small>
</div>
</template>
<style scoped>
.kbx-input-wrapper {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.kbx-input__label {
font-size: 0.875rem;
font-weight: 500;
color: var(--kbx-color-text, #000);
}
.kbx-input__required {
color: var(--kbx-color-danger, #ef4444);
}
.kbx-input {
min-height: var(--kbx-input-height, 34px);
padding: 0.5rem 0.75rem;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.kbx-input:focus {
border-color: var(--kbx-color-primary, #3b82f6);
box-shadow: 0 0 0 3px var(--kbx-color-primary-alpha, rgba(59, 130, 246, 0.1));
}
.kbx-input:disabled,
.kbx-input--disabled {
background-color: var(--kbx-color-disabled, #f3f4f6);
cursor: not-allowed;
opacity: 0.6;
}
.kbx-input.p-invalid {
border-color: var(--kbx-color-danger, #ef4444);
}
.kbx-input__help {
font-size: 0.75rem;
color: var(--kbx-color-text-muted, #6b7280);
}
</style>
@@ -1,281 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type {
KbxScreenDefinition,
KbxAsyncState,
KbxSummaryItem,
KbxQuickFilterItem,
KbxScreenContext,
} from '@shared/contracts/kbx-types'
interface Props {
screen: KbxScreenDefinition
dataState?: KbxAsyncState
loading?: boolean
selectionCount?: number
summaryItems?: KbxSummaryItem[]
quickFilters?: KbxQuickFilterItem[]
context?: KbxScreenContext | null
allowActions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
dataState: 'ready',
loading: false,
selectionCount: 0,
summaryItems: () => [],
quickFilters: () => [],
context: null,
allowActions: true,
})
const emit = defineEmits<{
command: [commandId: string]
quickFilter: [filterId: string]
refresh: []
}>()
// Visibility states
const showSearch = computed(() => !!props.screen.type)
const showSummary = computed(() => props.summaryItems.length > 0)
const showQuickFilter = computed(() => props.quickFilters.length > 0)
const stateMessages = {
idle: 'Ready to search',
pending: 'Loading data...',
ready: 'Data loaded',
error: 'Error loading data',
empty: 'No results found',
}
const currentStateMessage = computed(() => stateMessages[props.dataState])
</script>
<template>
<div class="kbx-list-page">
<!-- Page Header -->
<header class="kbx-list-page__header">
<div class="kbx-list-page__header-content">
<div>
<h1 class="kbx-list-page__title">{{ screen.title }}</h1>
<p v-if="screen.description" class="kbx-list-page__description">
{{ screen.description }}
</p>
</div>
<div class="kbx-list-page__header-actions">
<slot name="header-actions" />
</div>
</div>
</header>
<!-- Search Panel (slot) -->
<div v-if="showSearch" class="kbx-list-page__search">
<slot name="search" />
</div>
<!-- Quick Filters -->
<div v-if="showQuickFilter" class="kbx-list-page__quick-filters">
<button
v-for="filter in quickFilters"
:key="filter.id"
class="kbx-list-page__quick-filter-item"
:class="{ active: filter.active }"
@click="emit('quickFilter', filter.id)"
>
{{ filter.label }}
<span v-if="filter.badge" class="badge">{{ filter.badge }}</span>
</button>
</div>
<!-- Context Bar -->
<div v-if="context" class="kbx-list-page__context">
<slot name="context" />
</div>
<!-- Content Area -->
<main class="kbx-list-page__content">
<!-- Loading State -->
<div v-if="dataState === 'pending'" class="kbx-list-page__state">
<div class="state-spinner"></div>
{{ currentStateMessage }}
</div>
<!-- Empty State -->
<div v-else-if="dataState === 'empty'" class="kbx-list-page__state">
<p>{{ currentStateMessage }}</p>
<button class="kbx-button--secondary" @click="emit('command', 'new')">
Create New
</button>
</div>
<!-- Error State -->
<div v-else-if="dataState === 'error'" class="kbx-list-page__state kbx-list-page__state--error">
<p>{{ currentStateMessage }}</p>
<button class="kbx-button--primary" @click="emit('refresh')">
Retry
</button>
</div>
<!-- Content Slot -->
<slot v-else name="content" />
</main>
<!-- Summary Bar (sticky footer) -->
<footer v-if="showSummary" class="kbx-list-page__footer">
<div class="kbx-list-page__summary">
<span v-if="selectionCount > 0" class="summary-item">
{{ selectionCount }} item(s) selected
</span>
<div class="summary-items">
<span v-for="item in summaryItems" :key="item.label" class="summary-item">
<strong>{{ item.label }}:</strong>
{{ item.value }}
</span>
</div>
</div>
</footer>
</div>
</template>
<style scoped>
.kbx-list-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: var(--kbx-color-background, #fff);
}
.kbx-list-page__header {
padding: 1.5rem;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
background-color: var(--kbx-color-shell-chrome, #f9fafb);
}
.kbx-list-page__header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.kbx-list-page__title {
font-size: 1.875rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
color: var(--kbx-color-text, #000);
}
.kbx-list-page__description {
font-size: 0.875rem;
color: var(--kbx-color-text-muted, #6b7280);
margin: 0;
}
.kbx-list-page__header-actions {
display: flex;
gap: 0.5rem;
}
.kbx-list-page__search {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
background-color: var(--kbx-color-surface, #fff);
}
.kbx-list-page__quick-filters {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
background-color: var(--kbx-color-surface, #fff);
overflow-x: auto;
}
.kbx-list-page__quick-filter-item {
padding: 0.5rem 1rem;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 0.375rem;
background-color: transparent;
cursor: pointer;
white-space: nowrap;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.kbx-list-page__quick-filter-item:hover {
border-color: var(--kbx-color-primary, #3b82f6);
color: var(--kbx-color-primary, #3b82f6);
}
.kbx-list-page__quick-filter-item.active {
background-color: var(--kbx-color-primary, #3b82f6);
color: white;
border-color: var(--kbx-color-primary, #3b82f6);
}
.badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.125rem 0.375rem;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 0.25rem;
font-size: 0.75rem;
}
.kbx-list-page__content {
flex: 1;
overflow-y: auto;
background-color: var(--kbx-color-surface, #fff);
display: flex;
align-items: center;
justify-content: center;
}
.kbx-list-page__state {
text-align: center;
color: var(--kbx-color-text-muted, #6b7280);
font-size: 0.875rem;
}
.kbx-list-page__state--error {
color: var(--kbx-color-danger, #ef4444);
}
.state-spinner {
width: 40px;
height: 40px;
margin: 0 auto 1rem;
border: 4px solid var(--kbx-color-border, #e5e7eb);
border-top-color: var(--kbx-color-primary, #3b82f6);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.kbx-list-page__footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--kbx-color-border, #e5e7eb);
background-color: var(--kbx-color-shell-chrome, #f9fafb);
font-size: 0.875rem;
}
.kbx-list-page__summary {
display: flex;
gap: 2rem;
align-items: center;
color: var(--kbx-color-text-muted, #6b7280);
}
.summary-items {
display: flex;
gap: 1rem;
}
.summary-item {
white-space: nowrap;
}
</style>
@@ -1,4 +1,5 @@
import type { Component, InjectionKey } from 'vue'
import type { UiGridStatusMap } from '../gridStatus'
export type UiSeverity = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
export type UiButtonType = 'button' | 'submit' | 'reset'
@@ -53,8 +54,11 @@ export interface UiGridColumn {
filterable?: boolean
sensitive?: boolean
formatter?: (value: unknown, row: unknown) => string
statusMap?: UiGridStatusMap
}
export type UiTextFieldType = 'text' | 'email' | 'password' | 'number' | 'date'
export interface UiAdapterDescriptor {
readonly id: UiAdapterId
readonly version: string
-4
View File
@@ -7,10 +7,6 @@
export * from '@shared/contracts/kbx-types'
// Adapter Components (PrimeVue wrapped)
export { default as KbxButton } from './KbxButton.vue'
export { default as KbxInput } from './KbxInput.vue'
export { default as KbxDataGrid } from './KbxDataGrid.vue'
export { default as KbxListPage } from './KbxListPage.vue'
// Density tokens
export const densityTokens = {
@@ -2,15 +2,17 @@
import { computed } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import {
AllCommunityModule,
ModuleRegistry,
ClientSideRowModelModule,
TextFilterModule,
themeQuartz,
ModuleRegistry,
RowSelectionModule,
type ColDef,
type RowClickedEvent
} from 'ag-grid-community'
import type { UiGridColumn } from '../contracts'
ModuleRegistry.registerModules([AllCommunityModule])
ModuleRegistry.registerModules([ClientSideRowModelModule, TextFilterModule, RowSelectionModule])
const props = withDefaults(defineProps<{
rows: unknown[]
@@ -1,4 +1,4 @@
/* PrimeVue v4 is intentionally installed in unstyled mode. Vendor DOM classes stay in this adapter boundary. */
/* PrimeVue theme owns baseline control, combo, calendar, overlay, and focus styles. */
/* PrimeVue 4.5.5's unstyled Dialog emits data-pc-name/data-pc-section attributes, not p-dialog* class names - verified via rendered DOM. */
[data-pc-section="mask"]:has(> [data-pc-name="dialog"]) { background: rgb(15 23 42 / 48%); padding: var(--ks-space-4); }
.p-dialog.ks-dialog, [data-pc-name="dialog"][data-pc-section="root"].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); }
@@ -1,5 +1,6 @@
import type { App } from 'vue'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import type { UiProvider } from '../../provider/UiProvider'
import { installUiAdapter } from '../useUiAdapter'
import { primeVueUiAdapter } from './index'
@@ -8,7 +9,12 @@ import './adapter.css'
export const primeVueUiProvider: UiProvider = {
id: 'primevue-aggrid',
install(app: App): void {
app.use(PrimeVue, { unstyled: true })
app.use(PrimeVue, {
theme: {
preset: Aura,
options: { darkModeSelector: false }
}
})
installUiAdapter(app, primeVueUiAdapter)
}
}
@@ -1,14 +1,13 @@
<script setup lang="ts">
import Button from 'primevue/button'
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>
<Button v-bind="$props" class="ks-button" @click="emit('click', $event)"><slot /></Button>
</template>
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { useUiAdapter } from '../adapter/useUiAdapter'
import Checkbox from 'primevue/checkbox'
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>
<template><label class="ks-check" :for="resolvedId"><Checkbox class="ks-checkbox" :input-id="resolvedId" :model-value="modelValue" binary :disabled="disabled" @update:model-value="emit('update:modelValue', Boolean($event))" /><span>{{ label }}</span></label></template>
<style scoped>.ks-check { display: inline-flex; align-items: center; gap: var(--ks-space-2); cursor: pointer; }</style>
@@ -1,8 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import { ClientSideRowModelModule, ModuleRegistry, RowSelectionModule, TextFilterModule, themeQuartz, type ColDef, type RowClickedEvent } from 'ag-grid-community'
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' })
ModuleRegistry.registerModules([ClientSideRowModelModule, TextFilterModule, RowSelectionModule])
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
const adapter = useUiAdapter()
const props = withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none' }>(), { loading: false, height: '32rem', rowSelection: 'single' })
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(() => props.rowSelection === 'none' ? undefined : ({ mode: props.rowSelection === 'multiple' ? 'multiRow' : 'singleRow' } as const))
function onRowClicked(event: RowClickedEvent): void {
if (event.data) emit('rowSelected', event.data)
}
</script>
<template><component :is="adapter.components.DataGrid" v-bind="$props" @row-selected="emit('rowSelected', $event)" /></template>
<template>
<div class="ks-grid" :style="{ height: props.height }" :aria-busy="props.loading">
<AgGridVue
style="height: 100%; width: 100%"
:theme="themeQuartz"
:row-data="props.rows"
:column-defs="columnDefs"
:row-selection="rowSelectionOptions"
:loading="props.loading"
:suppress-no-rows-overlay="props.rows.length > 0"
@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>
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
import DatePicker from 'primevue/datepicker'
import { computed } from 'vue'
import FieldShell from './FieldShell.vue'
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 dateValue = computed(() => typeof props.modelValue === 'string' ? new Date(props.modelValue) : props.modelValue)
function handleDateChange(value: Date | Date[] | (Date | null)[] | null | undefined): void {
if (value instanceof Date) emit('update:modelValue', value)
else if (value == null) emit('update:modelValue', null)
else emit('update:modelValue', value[0] ?? null)
}
</script>
<template><FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field"><component :is="adapter.components.DateField" :input-id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :min="min" :max="max" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /></FieldShell></template>
<template><FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field"><DatePicker :input-id="field.inputId" :model-value="dateValue" :disabled="disabled" :invalid="field.invalid" :min-date="min" :max-date="max" date-format="yy-mm-dd" show-icon :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="handleDateChange" @blur="emit('blur', $event as unknown as FocusEvent)" /></FieldShell></template>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
import Dialog from 'primevue/dialog'
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>
<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>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { UiSeverity } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
import Message from 'primevue/message'
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
const emit = defineEmits<{ dismiss: [] }>()
const adapter = useUiAdapter()
const map = { primary: 'info', secondary: 'secondary', success: 'success', info: 'info', warning: 'warn', danger: 'error' } as const
</script>
<template><component :is="adapter.components.InlineMessage" v-bind="$props" @dismiss="emit('dismiss')" /></template>
<template><Message :severity="map[severity]" :closable="dismissible" @close="emit('dismiss')"><strong v-if="title">{{ title }} </strong>{{ message }}</Message></template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { KbxScreenDefinition, KbxAsyncState, KbxSummaryItem, KbxQuickFilterItem, KbxScreenContext } from '@shared/contracts/kbx-types'
withDefaults(defineProps<{
screen: KbxScreenDefinition
dataState?: KbxAsyncState
loading?: boolean
selectionCount?: number
summaryItems?: KbxSummaryItem[]
quickFilters?: KbxQuickFilterItem[]
context?: KbxScreenContext | null
allowActions?: boolean
}>(), { dataState: 'ready', loading: false, selectionCount: 0, summaryItems: () => [], quickFilters: () => [], context: null, allowActions: true })
const emit = defineEmits<{ command: [commandId: string]; quickFilter: [filterId: string]; refresh: [] }>()
</script>
<template>
<div class="ks-list-page">
<header class="ks-list-page__header">
<div><h1>{{ screen.title }}</h1><p v-if="screen.description">{{ screen.description }}</p></div>
<div><slot name="header-actions" /></div>
</header>
<section v-if="screen.type" class="ks-list-page__search"><slot name="search" /></section>
<nav v-if="quickFilters.length" class="ks-list-page__quick-filters" aria-label="빠른 필터">
<button v-for="filter in quickFilters" :key="filter.id" type="button" :aria-pressed="filter.active" @click="emit('quickFilter', filter.id)">{{ filter.label }}<span v-if="filter.badge"> {{ filter.badge }}</span></button>
</nav>
<section v-if="context" class="ks-list-page__context"><slot name="context" /></section>
<main class="ks-list-page__content" :aria-busy="loading">
<div v-if="dataState === 'pending'">Loading data...</div>
<div v-else-if="dataState === 'empty'">No results found <button type="button" @click="emit('command', 'new')">Create New</button></div>
<div v-else-if="dataState === 'error'" role="alert">Error loading data <button type="button" @click="emit('refresh')">Retry</button></div>
<slot v-else name="content" />
</main>
<footer v-if="summaryItems.length" class="ks-list-page__footer">
<span v-if="selectionCount">{{ selectionCount }} item(s) selected</span>
<span v-for="item in summaryItems" :key="item.label"><strong>{{ item.label }}:</strong> {{ item.value }}</span>
</footer>
</div>
</template>
<style scoped>
.ks-list-page { display:flex; flex-direction:column; height:100%; background:var(--ks-color-surface,#fff); }
.ks-list-page__header,.ks-list-page__search,.ks-list-page__quick-filters,.ks-list-page__footer { padding:var(--ks-space-4); border-bottom:1px solid var(--ks-color-neutral-200); }
.ks-list-page__header { display:flex; justify-content:space-between; gap:var(--ks-space-4); background:var(--ks-color-neutral-50); }
.ks-list-page__header h1 { margin:0; }
.ks-list-page__header p { margin:.5rem 0 0; color:var(--ks-color-text-muted); }
.ks-list-page__quick-filters { display:flex; gap:var(--ks-space-2); overflow:auto; }
.ks-list-page__quick-filters button { padding:.5rem .75rem; border:1px solid var(--ks-color-neutral-300); border-radius:var(--ks-radius-sm); background:#fff; }
.ks-list-page__quick-filters button[aria-pressed='true'] { background:var(--ks-color-action); color:#fff; }
.ks-list-page__content { flex:1; overflow:auto; }
.ks-list-page__footer { display:flex; gap:var(--ks-space-4); flex-wrap:wrap; background:var(--ks-color-neutral-50); }
</style>
@@ -1,15 +1,13 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
import InputNumber from 'primevue/inputnumber'
import FieldShell from './FieldShell.vue'
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; currency?: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number }>(), { currency: 'KRW' })
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
const adapter = useUiAdapter()
</script>
<template>
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
<span class="ks-money-field">
<component
:is="adapter.components.NumberField"
<InputNumber
:input-id="field.inputId"
:model-value="modelValue"
:disabled="disabled"
@@ -21,7 +19,7 @@ const adapter = useUiAdapter()
:aria-describedby="field.describedBy"
:aria-required="field.required || undefined"
@update:model-value="emit('update:modelValue', $event)"
@blur="emit('blur', $event)"
@blur="emit('blur', $event as unknown as FocusEvent)"
/>
<span class="ks-money-field__currency" aria-hidden="true">{{ props.currency }}</span>
</span>
@@ -1,8 +1,7 @@
<script setup lang="ts">
import type { UiSelectOption } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
import MultiSelect from 'primevue/multiselect'
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>
<template><label class="ks-field"><span v-if="label">{{ label }}<b v-if="required" aria-hidden="true"> *</b></span><MultiSelect class="ks-multi-select" :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>
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
import InputNumber from 'primevue/inputnumber'
import FieldShell from './FieldShell.vue'
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()
</script>
<template><FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field"><component :is="adapter.components.NumberField" :input-id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :min="min" :max="max" :min-fraction-digits="minFractionDigits" :max-fraction-digits="maxFractionDigits" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /></FieldShell></template>
<template><FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field"><InputNumber :input-id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :min="min" :max="max" :min-fraction-digits="minFractionDigits" :max-fraction-digits="maxFractionDigits" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event as unknown as FocusEvent)" /></FieldShell></template>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
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 }] }>()
const adapter = useUiAdapter()
</script>
<template><component :is="adapter.components.Paginator" v-bind="$props" @page-change="emit('pageChange', $event)" /></template>
<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>
@@ -1,10 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUiAdapter } from '../adapter/useUiAdapter'
import InputNumber from 'primevue/inputnumber'
import FieldShell from './FieldShell.vue'
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; unit?: string; availableQuantity?: number; allowNegative?: boolean; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string }>(), { unit: '주', allowNegative: false })
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
const adapter = useUiAdapter()
const exceedsAvailable = computed(() =>
props.modelValue != null && props.availableQuantity != null && props.modelValue > props.availableQuantity
@@ -15,8 +14,7 @@ const effectiveHelp = computed(() => props.availableQuantity != null ? `가용 $
<template>
<FieldShell :label="label" :input-id="inputId" :required="required" :error="effectiveError" :help="effectiveHelp" v-slot="field">
<span class="ks-quantity-field">
<component
:is="adapter.components.NumberField"
<InputNumber
:input-id="field.inputId"
:model-value="modelValue"
:disabled="disabled"
@@ -27,7 +25,7 @@ const effectiveHelp = computed(() => props.availableQuantity != null ? `가용 $
:aria-describedby="field.describedBy"
:aria-required="field.required || undefined"
@update:model-value="emit('update:modelValue', $event)"
@blur="emit('blur', $event)"
@blur="emit('blur', $event as unknown as FocusEvent)"
/>
<span class="ks-quantity-field__unit" aria-hidden="true">{{ props.unit }}</span>
</span>
@@ -1,13 +1,12 @@
<script setup lang="ts">
import type { UiSelectOption } from '../adapter/contracts'
import { useUiAdapter } from '../adapter/useUiAdapter'
import Select from 'primevue/select'
import FieldShell from './FieldShell.vue'
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()
</script>
<template>
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
<component :is="adapter.components.Select" :input-id="field.inputId" :model-value="modelValue" :options="options" :disabled="disabled" :invalid="field.invalid" :placeholder="placeholder" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
<Select class="ks-select" :input-id="field.inputId" :model-value="modelValue" :options="options" option-label="label" option-value="value" option-disabled="disabled" :disabled="disabled" :invalid="field.invalid" :placeholder="placeholder" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event as unknown as FocusEvent)" />
</FieldShell>
</template>
@@ -1,7 +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()
import type { UiGridStatusSemantic } from '../gridStatus'
import Tag from 'primevue/tag'
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string; semantic?: UiGridStatusSemantic; unknown?: boolean }>()
</script>
<template><component :is="adapter.components.StatusTag" v-bind="$props" /></template>
<template><Tag class="ks-status-tag" :severity="severity ?? 'info'" :data-semantic="semantic" :data-unknown="unknown || undefined" :aria-label="unknown ? `정의되지 않음: ${value}` : value"><span aria-hidden="true">{{ iconLabel ?? (unknown ? '?' : '●') }}</span><span>{{ value }}</span></Tag></template>
+1 -3
View File
@@ -1,8 +1,6 @@
<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>
<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>
@@ -1,12 +1,11 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
import Textarea from 'primevue/textarea'
import FieldShell from './FieldShell.vue'
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()
</script>
<template>
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
<component :is="adapter.components.TextArea" :input-id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :rows="rows" :placeholder="placeholder" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
<Textarea class="ks-textarea" :id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :rows="rows ?? 4" :placeholder="placeholder" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', String($event ?? ''))" @blur="emit('blur', $event)" />
</FieldShell>
</template>
@@ -1,24 +1,24 @@
<script setup lang="ts">
import { useUiAdapter } from '../adapter/useUiAdapter'
import InputText from 'primevue/inputtext'
import FieldShell from './FieldShell.vue'
import type { UiTextFieldType } from '../adapter/contracts'
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
const props = defineProps<{ modelValue: string; label: string; inputId?: string; type?: UiTextFieldType; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
const adapter = useUiAdapter()
</script>
<template>
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
<component
:is="adapter.components.TextField"
<InputText
:input-id="field.inputId"
:model-value="modelValue"
:type="type"
:disabled="disabled"
:invalid="field.invalid"
:placeholder="placeholder"
:aria-describedby="field.describedBy"
:aria-required="field.required || undefined"
@update:model-value="emit('update:modelValue', $event)"
@update:model-value="emit('update:modelValue', String($event ?? ''))"
@blur="emit('blur', $event)"
/>
</FieldShell>
@@ -14,6 +14,7 @@ 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 KsListPage } from './KsListPage.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,19 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
function componentSources(root: string): string[] {
return readdirSync(root, { withFileTypes: true })
.filter(entry => entry.isFile() && entry.name.endsWith('.vue'))
.map(entry => join(root, entry.name))
}
describe('existing component ownership boundary', () => {
it('prevents existing components from regressing to adapter-owned rendering', () => {
const root = join(process.cwd(), 'src', 'shared', 'ui', 'components')
const forbidden = /useUiAdapter|adapter\.components/
const violations = componentSources(root).filter(path => forbidden.test(readFileSync(path, 'utf8')))
expect(violations).toEqual([])
})
})
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
describe('grid provider policy', () => {
it('uses AG Grid Community as the default direct grid implementation', () => {
const packageJson = readFileSync('package.json', 'utf8')
const source = readFileSync('src/shared/ui/components/KsDataGrid.vue', 'utf8')
expect(packageJson).toContain('"ag-grid-community"')
expect(packageJson).not.toContain('ag-grid-enterprise')
expect(source).toContain("from 'ag-grid-community'")
expect(source).toContain('ClientSideRowModelModule')
})
})
@@ -8,6 +8,9 @@ export async function resolveUiProvider(name: string | undefined): Promise<UiPro
return (await import('../adapter/primevue/installPrimeVueAdapter')).primeVueUiProvider
}
if (normalized === 'native') {
if (import.meta.env.PROD) {
throw new Error('The native UI provider is test/reference-only and cannot be used in a production artifact.')
}
return (await import('../adapter/native/installNativeAdapter')).nativeUiProvider
}
throw new Error(`Unsupported VITE_UI_ADAPTER '${name}'. Allowed values: primevue, native.`)
@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import { resolveUiProvider } from '../resolveUiProvider'
import { useUiAdapter } from '../../adapter/useUiAdapter'
describe('resolveUiProvider', () => {
it('uses PrimeVue as the default provider', async () => {
await expect(resolveUiProvider(undefined)).resolves.toMatchObject({ id: 'primevue-aggrid' })
})
it('loads the selected native provider', async () => {
await expect(resolveUiProvider('native')).resolves.toMatchObject({ id: 'native-accessible' })
})
@@ -11,4 +17,18 @@ describe('resolveUiProvider', () => {
"Unsupported VITE_UI_ADAPTER 'unsupported'. Allowed values: primevue, native."
)
})
it('installs the validated adapter before the application is mounted', async () => {
const provider = await resolveUiProvider('native')
const app = createApp({ template: '<div />' })
provider.install(app)
expect(app.runWithContext(() => useUiAdapter()).descriptor.id).toBe('native-accessible')
})
it('keeps the native provider available only to the non-production contract harness', async () => {
const provider = await resolveUiProvider('native')
expect(provider.id).toBe('native-accessible')
})
})