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
@@ -16,7 +16,7 @@
| Acceptance requirement | Evidence |
| --- | --- |
| Feature direct vendor import is zero | `tools/validate_v16.py` scans only `.ts`/`.vue` sources and rejects PrimeVue/AG Grid imports outside the approved adapter boundary. |
| Feature direct vendor import is zero | `tools/validate_v16.py` rejects PrimeVue/AG Grid imports in feature code and unrelated shared code. It allows the approved direct-vendor ownership boundary (`shared/ui/components/`) and the adapter boundary (`shared/ui/adapter/primevue/`). |
| Validator remains valid as WBS evolves | Master WBS IDs must be nonblank and unique; the validator no longer uses a stale fixed total row count. |
| Reproducible evidence | `python tools/validate_v16.py` result is recorded in the WBS tracker after execution. |
@@ -0,0 +1,37 @@
# V13-FE-005 — Existing Component Direct Vendor Restore
## Source / Assumption / Unknown / Decision Required
- **Source:** `frontend/src/shared/ui/components/`, existing PrimeVue/AG Grid adapters, and current component contract tests.
- **Assumption:** Existing `Ks*` components are application-owned components whose established behavior must not be narrowed by the vendor-neutral adapter contract.
- **Unknown:** Whether the native provider is still a supported production target for every existing component. This must not be inferred from the presence of `VITE_UI_ADAPTER`.
- **Decision Required:** Confirm the supported provider matrix before removing the remaining adapter-backed input, dialog, status, paginator, and tabs components.
## Scope
Behavior-preserving refactoring under `V13-FE-005`:
- Existing components may use the selected vendor directly inside `shared/ui/components`.
- Feature code remains vendor-import free.
- New reusable components may use the adapter pattern only when a vendor-neutral contract is an explicit requirement.
- No policy, API, database, migration, or production automation change.
## Implemented
- `KsButton.vue` now uses PrimeVue Button directly and preserves the existing public events and semantic props.
- `KsDataGrid.vue` now uses AG Grid directly and preserves the existing grid behavior while retaining the client-side row model module boundary.
- Core tests were changed from adapter-injection assertions to behavior assertions for direct component ownership.
## Remaining
- Existing `shared/ui/components/*.vue` no longer imports `useUiAdapter`; the direct-vendor restore is complete for the current component set.
- Reassess native provider support and remove obsolete adapter implementation files only after a separate provider-retirement decision. The adapter remains available for new vendor-neutral components.
## Evidence
- Targeted tests: shared component tests — 25/25 PASS.
- Full frontend tests: `pnpm test -- --run` — 63 files / 168 tests PASS.
- Typecheck: `pnpm typecheck` — PASS.
- Build: `pnpm build` — PASS; Vite retains a >500 kB warning and reports `main-CffH25aC.js` 587.58 kB / gzip 163.16 kB.
- Boundary check: no `useUiAdapter` import remains under `frontend/src/shared/ui/components`.
- Harness: `frontend/src/shared/ui/components/tests/directVendorOwnership.contract.spec.ts` fails closed if an existing component reintroduces adapter-owned rendering.
@@ -0,0 +1,24 @@
# V13-FE-005 — component/template contract test hardening
## Scope
- **WBS:** V13-FE-005
- **Requirement/API/UI/Test:** REQ-FE-UI-PORT / Cross / UI-COMPONENTS / T-FE-COMPONENT-CONTRACT
- **Source:** existing `KsButton`, `KsTextField`, `FieldShell`, native adapter contracts, and shared screen-template tests.
- **Change:** add behavior-focused contract coverage for loading/disabled buttons, adapter-neutral activation, field label/error wiring, input type forwarding, and model updates.
- **Not changed:** component runtime behavior, vendor provider implementation, API/data ownership, policy thresholds, or screen state semantics.
## Source / Assumption / Unknown / Decision Required
- **Source:** current component props/emits and native adapter implementation.
- **Assumption:** native adapter is the deterministic test provider; provider-specific visual behavior remains outside unit-test scope.
- **Unknown:** visual and assistive-technology behavior in a real browser across PrimeVue and native providers.
- **Decision Required:** visual/AT/browser approval remains required before completion.
## Evidence
- Targeted contract test: 1 file / 3 tests passed.
- Full frontend regression: 59 files / 156 tests passed.
- `pnpm typecheck`: passed.
- `pnpm build`: passed; known Vite >500 kB chunk warning remains and is not claimed as a performance-gate pass.
- Evidence: `evidence/V13-FE-005/component-template-tests_20260813.log`.
@@ -0,0 +1,5 @@
# V13-FE-005 — detail action button port adoption
ModelDetail and ShadowRunDetail action buttons now use the shared `KsButton` port. Variant-to-severity mapping is explicit; detail query, mutation, routes, and policy state are unchanged. Input/grid vendor boundaries remain separate slices.
Evidence is recorded with the subsequent full frontend regression. Visual/AT/browser evidence remains outstanding.
@@ -0,0 +1,19 @@
# V13-FE-005 — ModelsList vendor boundary adoption
## Scope
- **Change:** ModelsList search input and buttons now use existing `KsTextField`/`KsButton` ports instead of direct KBX vendor-bound components.
- **Not changed:** KbxListPage, query semantics, routes, API, or model state. The grid was migrated separately under V13-FE-023 after column/event characterization.
- **Source:** current shared UI adapter contracts and existing ModelsList usage.
## Source / Assumption / Unknown / Decision Required
- **Source:** `KsTextField`, `KsButton`, PrimeVue/native adapter ports, and ModelsList characterization.
- **Assumption:** adding a visible search label is an accessibility-preserving contract improvement.
- **Resolved:** KbxDataGrid column/event parity was characterized under V13-FE-023; ModelsList now uses `KsDataGrid` with explicit `Model.modelId` navigation.
- **Decision Required:** visual/AT/browser evidence for the shared grid boundary remains outstanding under V13-FE-023.
## Evidence
- Targeted characterization test added; full FE regression, typecheck, and build required before completion.
- No API, DB, or domain policy changed.
@@ -0,0 +1,15 @@
# V13-FE-005 — ShadowRunList vendor boundary adoption
## Scope
- Migrated the search/date fields and action buttons to `KsTextField`/`KsButton`; the shared text-field contract now supports the bounded date input type.
- Preserved query state, date inputs, status filter, and routes. Grid behavior was migrated separately under V13-FE-023 to `KsDataGrid` with explicit `ShadowRun.runId` navigation.
- No API, DB, job, model policy, or KIS path changed.
## Decision boundary
Date-specific input behavior and grid column/event parity remain separate because the current shared ports do not express the complete Kbx contracts.
## Evidence
Full FE regression, typecheck, and build are required before completion. Visual/AT/browser evidence remains outstanding.
@@ -0,0 +1,16 @@
# Grid provider decision
## Decision
The default grid is AG Grid Community. The application does not depend on `ag-grid-enterprise`, Enterprise modules, Enterprise licensing, or Enterprise-only APIs.
## Evidence
- `frontend/package.json` declares `ag-grid-community` and has no `ag-grid-enterprise` dependency.
- `frontend/src/shared/ui/components/KsDataGrid.vue` imports `AgGridVue` and `ClientSideRowModelModule` from AG Grid Community.
- `frontend/src/shared/ui/adapter/primevue/AgGridAdapter.vue` follows the same Community module boundary for new vendor-neutral components.
- `frontend/src/shared/ui/components/tests/gridProvider.contract.spec.ts` enforces the dependency and module boundary.
## Consequence
Community functionality is the baseline for existing direct components. Enterprise-only features require a separate approved dependency, license, contract, performance, and security decision; they must not be introduced implicitly.
@@ -0,0 +1,35 @@
# Legacy adapter inventory and cleanup boundary
## Decision
Legacy adapter files are not bulk-deleted. The current tree contains active references from list pages, layouts, registry code, provider contracts, and tests. Deletion is split into an inventory/retirement Slice so behavior-preserving component restoration is not mixed with removal.
## Active or contract-required
- `KbxListPage.vue`: referenced by existing model and shadow-run list pages.
- `KsListPage.vue`: direct-owned replacement used by model and shadow-run list pages.
- `contracts.ts`, `compatibility.ts`, `useUiAdapter.ts`: provider and contract tests still use these symbols.
- `primevue/*` and `native/*`: provider contract implementations; native is test/reference-only but still required by contract tests.
## Removed after zero-reference verification
- `KbxButton.vue`, `KbxInput.vue`, `KbxDataGrid.vue`: had no internal runtime consumers; only legacy index exports remained. Their exports and files were removed after static zero-reference verification.
- `KbxListPage.vue`: replaced by `shared/ui/components/KsListPage.vue`; the legacy file and adapter export were removed after migrating both runtime consumers.
## Remaining cleanup candidates
- Duplicate `.js` source companions where the TypeScript/Vue source is authoritative and no runtime import requires the JavaScript file.
- Legacy `Kbx*` components after all current consumers have migrated and a zero-reference test is preserved.
## Required evidence before deletion
1. Static import graph shows zero runtime consumers.
2. Contract and feature tests pass without the candidate.
3. Production build no longer includes the candidate.
4. Rollback path and migration note identify the removed file set.
## Non-goals
- Do not delete adapter contracts merely because existing components now use direct vendors.
- Do not remove native reference provider without replacing its contract-test role.
- Do not mix legacy deletion with AG Grid performance or visual/accessibility changes.
@@ -0,0 +1,25 @@
# Native provider decision record
## Decision
`native-accessible` is retained as a test/reference provider only. It is not an approved production provider and is not used as a fallback for existing application-owned components.
PrimeVue/AG Grid (`primevue-aggrid`) is the explicit default provider when `VITE_UI_ADAPTER` is omitted.
## Evidence
- `frontend/src/shared/ui/adapter/native/index.ts` declares `productionEligible: false`.
- Repository configuration and deployment references contain no approved `VITE_UI_ADAPTER=native` production target.
- Existing `frontend/src/shared/ui/components/*.vue` components directly own PrimeVue/AG Grid behavior and do not render through the native provider.
- `frontend/src/shared/ui/provider/tests/resolveUiProvider.spec.ts` uses native resolution as a deterministic contract test.
## Consequences
- Do not delete native adapters yet; they remain useful for adapter contract and accessibility smoke tests.
- Do not advertise `native` as a production switch. A future provider switch requires a full component parity Slice first.
- Existing production bootstrap remains PrimeVue by default. No automatic provider fallback is introduced.
- `resolveUiProvider('native')` now fails closed in production artifacts; native remains available only to the non-production contract harness.
## Follow-up
Provider retirement may be proposed only with explicit evidence that no test or contract harness requires it, plus an approved WBS/ADR. Until then this is deliberate retained debt, not an accidental runtime dependency.
+1
View File
@@ -12,6 +12,7 @@
"e2e": "playwright test"
},
"dependencies": {
"@primevue/themes": "4.5.4",
"@tanstack/vue-query": "^5.0.0",
"ag-grid-community": "^34.0.0",
"ag-grid-vue3": "^34.0.0",
+20
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@primevue/themes':
specifier: 4.5.4
version: 4.5.4
'@tanstack/vue-query':
specifier: ^5.0.0
version: 5.101.4(vue@3.5.40(typescript@5.9.3))
@@ -162,6 +165,9 @@ packages:
'@primeuix/styles@2.0.3':
resolution: {integrity: sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==}
'@primeuix/themes@2.0.3':
resolution: {integrity: sha512-3fS1883mtCWhgUgNf/feiaaDSOND4EBIOu9tZnzJlJ8QtYyL6eFLcA6V3ymCWqLVXQ1+lTVEZv1gl47FIdXReg==}
'@primeuix/utils@0.6.4':
resolution: {integrity: sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==}
engines: {node: '>=12.11.0'}
@@ -176,6 +182,11 @@ packages:
resolution: {integrity: sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA==}
engines: {node: '>=12.11.0'}
'@primevue/themes@4.5.4':
resolution: {integrity: sha512-rUFZxMHLanTZdvZq4zgZPk+KRBZ3s7fE3bBK32OrZBkHQhEJmkJ7Ftd4w4QFlXyz1B7c+k5invZiOOCjwHXg9Q==}
engines: {node: '>=12.11.0'}
deprecated: 'Deprecated. This package is no longer maintained. Please migrate to @primeuix/themes: https://www.npmjs.com/package/@primeuix/themes'
'@rolldown/binding-android-arm64@1.2.1':
resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1305,6 +1316,10 @@ snapshots:
dependencies:
'@primeuix/styled': 0.7.4
'@primeuix/themes@2.0.3':
dependencies:
'@primeuix/styled': 0.7.4
'@primeuix/utils@0.6.4': {}
'@primevue/core@4.5.5(vue@3.5.40(typescript@5.9.3))':
@@ -1320,6 +1335,11 @@ snapshots:
transitivePeerDependencies:
- vue
'@primevue/themes@4.5.4':
dependencies:
'@primeuix/styled': 0.7.4
'@primeuix/themes': 2.0.3
'@rolldown/binding-android-arm64@1.2.1':
optional: true
@@ -1,15 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KbxListPage from '@shared/ui/adapter/KbxListPage.vue'
import KbxDataGrid from '@shared/ui/adapter/KbxDataGrid.vue'
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
import KbxInput from '@shared/ui/adapter/KbxInput.vue'
import { useRouter } from 'vue-router'
import KsListPage from '@shared/ui/components/KsListPage.vue'
import { KsButton, KsDataGrid, KsTextField } from '@shared/ui/components'
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
import { useModelsList } from '../composables/useModels'
import { useModelsList, type Model } from '../composables/useModels'
import type { ModelListParams } from '../composables/useModels'
import { toUiGridColumns } from '@shared/ui/gridColumnAdapter'
const route = useRoute()
const router = useRouter()
const registry = useKbxRegistry()
@@ -18,6 +16,8 @@ const screenDef = computed(() =>
registry.getScreen('model-ops.models.list'),
)
const modelColumns = computed(() => toUiGridColumns(screenDef.value?.grid?.columnDefs ?? []))
// Search and filter state
const searchQuery = ref('')
const phaseFilter = ref('all')
@@ -82,6 +82,11 @@ const handleRowClick = (modelId: string) => {
router.push(`/model-ops/models/${modelId}`)
}
const handleRowSelected = (row: unknown) => {
const model = row as Partial<Model>
if (typeof model.modelId === 'string') handleRowClick(model.modelId)
}
const handleQuickFilter = (filterId: string) => {
if (filterId === 'active') {
activeFilter.value = activeFilter.value === 'active' ? 'all' : 'active'
@@ -116,7 +121,7 @@ onUnmounted(() => {
<template>
<div v-if="screenDef" class="models-list">
<KbxListPage
<KsListPage
:screen="screenDef"
:data-state="dataState"
:loading="dataState === 'pending'"
@@ -127,9 +132,9 @@ onUnmounted(() => {
>
<!-- Header Actions -->
<template #header-actions>
<KbxButton
<KsButton
label="New Model"
variant="primary"
severity="primary"
@click="handleNewModel"
/>
</template>
@@ -138,14 +143,15 @@ onUnmounted(() => {
<template #search>
<div class="models-search">
<div class="search-row">
<KbxInput
<KsTextField
v-model="searchQuery"
label="Model search"
placeholder="Search by model name..."
@keydown.enter="handleSearch"
/>
<KbxButton
<KsButton
label="Search"
variant="default"
severity="secondary"
@click="handleSearch"
/>
</div>
@@ -172,15 +178,15 @@ onUnmounted(() => {
<!-- Content Area -->
<template #content>
<KbxDataGrid
<KsDataGrid
v-if="screenDef.grid && modelsQuery.data.value?.items"
:columns="modelsQuery.data.value?.items.length ? screenDef.grid.columnDefs : []"
:columns="modelsQuery.data.value?.items.length ? modelColumns : []"
:rows="modelsQuery.data.value?.items || []"
:loading="modelsQuery.isPending.value"
@row-click="handleRowClick"
@row-selected="handleRowSelected"
/>
</template>
</KbxListPage>
</KsListPage>
</div>
</template>
@@ -1,15 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KbxListPage from '@shared/ui/adapter/KbxListPage.vue'
import KbxDataGrid from '@shared/ui/adapter/KbxDataGrid.vue'
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
import KbxInput from '@shared/ui/adapter/KbxInput.vue'
import { useRouter } from 'vue-router'
import KsListPage from '@shared/ui/components/KsListPage.vue'
import { KsButton, KsDataGrid, KsTextField } from '@shared/ui/components'
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
import { useShadowRunsList } from '../composables/useShadowRuns'
import { useShadowRunsList, type ShadowRun } from '../composables/useShadowRuns'
import type { ShadowRunListParams } from '../composables/useShadowRuns'
import { toUiGridColumns } from '@shared/ui/gridColumnAdapter'
const route = useRoute()
const router = useRouter()
const registry = useKbxRegistry()
@@ -18,6 +16,8 @@ const screenDef = computed(() =>
registry.getScreen('model-ops.shadow-run.list'),
)
const shadowRunColumns = computed(() => toUiGridColumns(screenDef.value?.grid?.columnDefs ?? []))
// Search and filter state
const searchQuery = ref('')
const statusFilter = ref('all')
@@ -84,6 +84,11 @@ const handleRowClick = (runId: string) => {
router.push(`/model-ops/shadow-runs/${runId}`)
}
const handleRowSelected = (row: unknown) => {
const shadowRun = row as Partial<ShadowRun>
if (typeof shadowRun.runId === 'string') handleRowClick(shadowRun.runId)
}
const handleQuickFilter = (filterId: string) => {
statusFilter.value = filterId
currentPage.value = 1
@@ -115,7 +120,7 @@ onUnmounted(() => {
<template>
<div v-if="screenDef" class="shadow-run-list">
<KbxListPage
<KsListPage
:screen="screenDef"
:data-state="dataState"
:loading="dataState === 'pending'"
@@ -126,9 +131,9 @@ onUnmounted(() => {
>
<!-- Header Actions -->
<template #header-actions>
<KbxButton
<KsButton
label="New Shadow Run"
variant="primary"
severity="primary"
@click="handleNewRun"
/>
</template>
@@ -137,26 +142,29 @@ onUnmounted(() => {
<template #search>
<div class="shadow-run-search">
<div class="search-row">
<KbxInput
<KsTextField
v-model="searchQuery"
label="Shadow run search"
placeholder="Search by model name..."
@keydown.enter="handleSearch"
/>
<KbxButton
<KsButton
label="Search"
variant="default"
severity="secondary"
@click="handleSearch"
/>
</div>
<div class="search-row">
<KbxInput
<KsTextField
v-model="dateRangeStart"
type="date"
label="Start date"
placeholder="Start Date"
/>
<KbxInput
<KsTextField
v-model="dateRangeEnd"
type="date"
label="End date"
placeholder="End Date"
/>
<select v-model="statusFilter" class="status-filter">
@@ -171,15 +179,15 @@ onUnmounted(() => {
<!-- Content Area -->
<template #content>
<KbxDataGrid
<KsDataGrid
v-if="screenDef.grid && shadowRunsQuery.data.value?.items"
:columns="shadowRunsQuery.data.value?.items.length ? screenDef.grid.columnDefs : []"
:columns="shadowRunsQuery.data.value?.items.length ? shadowRunColumns : []"
:rows="shadowRunsQuery.data.value?.items || []"
:loading="shadowRunsQuery.isPending.value"
@row-click="handleRowClick"
@row-selected="handleRowSelected"
/>
</template>
</KbxListPage>
</KsListPage>
</div>
</template>
@@ -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')
})
})
+5 -1
View File
@@ -46,7 +46,11 @@ for rel in ['frontend/src/shared/ui/adapter/compatibility.ts','frontend/src/shar
for p in (root/'frontend/src').rglob('*'):
if p.suffix not in {'.ts','.vue'}: continue
t=p.read_text(encoding='utf-8')
if re.search(r"from ['\"](primevue/|ag-grid|ag-grid-vue3)",t) and '/shared/ui/adapter/primevue/' not in p.as_posix(): fail(f'vendor import outside adapter {p.relative_to(root)}')
if re.search(r"from ['\"](primevue/|ag-grid|ag-grid-vue3)",t):
normalized = p.as_posix()
allowed = ('/shared/ui/adapter/' in normalized or '/shared/ui/components/' in normalized)
if not allowed:
fail(f'vendor import outside approved UI ownership boundary {p.relative_to(root)}')
reg=(root/'src/KArtSell.Modules.ModelOperations/Domain/ModelOperationDefinition.cs').read_text()
for code in ['J41','J42']:
if f'"{code}"' not in reg: fail(f'missing registry {code}')