chore: remove kbx-foundation-v36 reference (superseded by v4 implementation)
Removed entire kbx-foundation-v36 directory as it's been replaced by the new KBX Foundation v4 patterns implemented in this session: - Registry-driven screen definitions - Density-aware UI adapter components - Feature module templates (ShadowRun, Models) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Composable: useKbxRegistry
|
||||
* Access screen registry, permissions, and density from components
|
||||
*/
|
||||
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import type {
|
||||
KbxScreenDefinition,
|
||||
KbxPermissionDefinition,
|
||||
KbxDensity,
|
||||
} from '@shared/contracts/kbx-types'
|
||||
|
||||
// Reactive state
|
||||
const currentDensity = ref<KbxDensity>('compact')
|
||||
const userPermissions = ref<Set<string>>(new Set())
|
||||
|
||||
export function useKbxRegistry() {
|
||||
// Get injected registries
|
||||
const screenRegistry = inject<Map<string, KbxScreenDefinition>>(
|
||||
'kbx-screens',
|
||||
new Map(),
|
||||
)
|
||||
|
||||
const permissionRegistry = inject<Map<string, KbxPermissionDefinition>>(
|
||||
'kbx-permissions',
|
||||
new Map(),
|
||||
)
|
||||
|
||||
// Screen methods
|
||||
const getScreen = (screenId: string) => screenRegistry.get(screenId)
|
||||
|
||||
const getAllScreens = () => Array.from(screenRegistry.values())
|
||||
|
||||
const getScreenByModule = (module: string) =>
|
||||
getAllScreens().filter(s => s.module === module)
|
||||
|
||||
// Permission methods
|
||||
const hasPermission = (permissionId: string) => {
|
||||
return userPermissions.value.has(permissionId)
|
||||
}
|
||||
|
||||
const hasAllPermissions = (permissionIds: string[]) => {
|
||||
return permissionIds.every(id => userPermissions.value.has(id))
|
||||
}
|
||||
|
||||
const hasAnyPermission = (permissionIds: string[]) => {
|
||||
return permissionIds.some(id => userPermissions.value.has(id))
|
||||
}
|
||||
|
||||
const canAccessScreen = (screenId: string) => {
|
||||
const screen = getScreen(screenId)
|
||||
if (!screen) return false
|
||||
return hasAllPermissions(screen.permissions)
|
||||
}
|
||||
|
||||
// Density methods
|
||||
const setDensity = (density: KbxDensity) => {
|
||||
currentDensity.value = density
|
||||
document.documentElement.style.setProperty('--kbx-density', density)
|
||||
|
||||
const tokens = {
|
||||
compact: {
|
||||
inputHeight: '34px',
|
||||
gridRowHeight: '34px',
|
||||
touchTarget: '44px',
|
||||
fontSize: '12px',
|
||||
controlHeight: '34px',
|
||||
},
|
||||
comfortable: {
|
||||
inputHeight: '36px',
|
||||
gridRowHeight: '36px',
|
||||
touchTarget: '48px',
|
||||
fontSize: '14px',
|
||||
controlHeight: '36px',
|
||||
},
|
||||
touch: {
|
||||
inputHeight: '48px',
|
||||
gridRowHeight: '48px',
|
||||
touchTarget: '52px',
|
||||
fontSize: '16px',
|
||||
controlHeight: '48px',
|
||||
},
|
||||
}
|
||||
|
||||
Object.entries(tokens[density]).forEach(([key, value]) => {
|
||||
document.documentElement.style.setProperty(`--kbx-${key}`, value)
|
||||
})
|
||||
}
|
||||
|
||||
const getDensity = computed(() => currentDensity.value)
|
||||
|
||||
// Update user permissions
|
||||
const setPermissions = (permissions: string[]) => {
|
||||
userPermissions.value.clear()
|
||||
permissions.forEach(p => userPermissions.value.add(p))
|
||||
}
|
||||
|
||||
return {
|
||||
// Screen access
|
||||
getScreen,
|
||||
getAllScreens,
|
||||
getScreenByModule,
|
||||
canAccessScreen,
|
||||
|
||||
// Permission access
|
||||
hasPermission,
|
||||
hasAllPermissions,
|
||||
hasAnyPermission,
|
||||
setPermissions,
|
||||
|
||||
// Density
|
||||
setDensity,
|
||||
getDensity,
|
||||
|
||||
// Registries
|
||||
screenRegistry,
|
||||
permissionRegistry,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* KBX Foundation v4 Core Types
|
||||
* Single source of truth for screen definitions, permissions, and UI contracts
|
||||
*/
|
||||
|
||||
// Screen Definition (Registry Entry)
|
||||
export interface KbxScreenDefinition {
|
||||
screenId: string // e.g., "model-ops.shadow-run.list"
|
||||
title: string // e.g., "Shadow Run Validation"
|
||||
module: 'ModelOps' | 'SignalEngine' | 'Admin'
|
||||
type: 'list' | 'detail' | 'form' | 'dashboard'
|
||||
path: string // Vue Router path
|
||||
component: () => Promise<any> // Lazy-loaded component
|
||||
permissions: string[] // Required permissions (e.g., ['model.read'])
|
||||
help?: KbxHelpDefinition
|
||||
grid?: KbxGridDefinition
|
||||
shortcuts?: KbxShortcut[]
|
||||
telemetry?: { enabled: boolean }
|
||||
}
|
||||
|
||||
// Grid Column Definition
|
||||
export interface KbxGridColumn<T = any> {
|
||||
field: keyof T
|
||||
header: string
|
||||
type?: 'text' | 'number' | 'date' | 'status' | 'link' | 'money' | 'quantity'
|
||||
width?: number | string
|
||||
pinned?: 'left' | 'right'
|
||||
sortable?: boolean
|
||||
filterable?: boolean
|
||||
formatter?: (value: any, row: T) => string
|
||||
}
|
||||
|
||||
// Grid Configuration
|
||||
export interface KbxGridDefinition {
|
||||
columnDefs: KbxGridColumn[]
|
||||
rowHeight?: number | 'auto'
|
||||
pageSize?: number
|
||||
serverSideDatasource?: boolean
|
||||
theme?: string
|
||||
}
|
||||
|
||||
// Search Field Definition
|
||||
export interface KbxSearchField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'date' | 'date-range' | 'select' | 'multi-select'
|
||||
options?: Array<{ value: string | number; label: string }>
|
||||
range?: { from: string; to: string } // for date-range
|
||||
placeholder?: string
|
||||
width?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
// Help Definition
|
||||
export interface KbxHelpDefinition {
|
||||
title: string
|
||||
sections: KbxHelpSection[]
|
||||
relatedScreens?: string[]
|
||||
externalUrl?: string
|
||||
}
|
||||
|
||||
export interface KbxHelpSection {
|
||||
title: string
|
||||
content: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// Permission Definition
|
||||
export interface KbxPermissionDefinition {
|
||||
permissionId: string // e.g., 'model.create'
|
||||
label: string
|
||||
description?: string
|
||||
screens: string[] // Which screens require this
|
||||
}
|
||||
|
||||
// Command Definition (Actions)
|
||||
export interface KbxCommand {
|
||||
id: string
|
||||
label: string
|
||||
group?: string // 'query' | 'edit' | 'workflow' | 'output'
|
||||
permission?: string
|
||||
requiresSelection?: boolean
|
||||
minSelection?: number
|
||||
variant?: 'default' | 'primary' | 'danger'
|
||||
shortcut?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// Keyboard Shortcut
|
||||
export interface KbxShortcut {
|
||||
key: string // 'F3', 'Ctrl+S', etc.
|
||||
label: string
|
||||
action: string
|
||||
}
|
||||
|
||||
// Data State (Loading, Error, Empty)
|
||||
export type KbxAsyncState = 'idle' | 'pending' | 'ready' | 'error' | 'empty'
|
||||
|
||||
// Grid Summary Item
|
||||
export interface KbxSummaryItem {
|
||||
label: string
|
||||
value: string | number
|
||||
format?: 'number' | 'money' | 'quantity' | 'percentage'
|
||||
}
|
||||
|
||||
// Quick Filter
|
||||
export interface KbxQuickFilterItem {
|
||||
id: string
|
||||
label: string
|
||||
badge?: string | number
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
// Screen Context (Breadcrumb, Parent Info)
|
||||
export interface KbxScreenContext {
|
||||
parentScreenId?: string
|
||||
breadcrumb?: string
|
||||
contextData?: Record<string, any>
|
||||
}
|
||||
|
||||
// Problem/Error Display
|
||||
export interface KbxProblem {
|
||||
code: string
|
||||
message: string
|
||||
details?: string
|
||||
recoveryActions?: string[]
|
||||
retryable?: boolean
|
||||
}
|
||||
|
||||
// Theme Configuration
|
||||
export interface KbxThemeConfig {
|
||||
primary: string
|
||||
secondary: string
|
||||
danger: string
|
||||
success: string
|
||||
warning: string
|
||||
info: string
|
||||
}
|
||||
|
||||
// Density Token (UI Sizing)
|
||||
export type KbxDensity = 'compact' | 'comfortable' | 'touch'
|
||||
export interface KbxDensityTokens {
|
||||
inputHeight: number
|
||||
gridRowHeight: number
|
||||
touchTarget: number
|
||||
fontSize: number
|
||||
controlHeight: number
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<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>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import { computed, ref } from 'vue'
|
||||
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(() => ({
|
||||
columnDefs: props.columns.map(col => ({
|
||||
field: col.field,
|
||||
headerName: col.header,
|
||||
width: col.width || 'auto',
|
||||
pinned: col.pinned,
|
||||
sortable: col.sortable !== false,
|
||||
filter: col.filterable !== false,
|
||||
type: col.type,
|
||||
})),
|
||||
rowData: props.rows,
|
||||
rowSelection: props.allowSelection ? 'multiple' : undefined,
|
||||
rowHeight: densityHeights[props.density],
|
||||
pagination: !props.serverSideDatasource,
|
||||
paginationPageSize: props.pageSize,
|
||||
suppressMovableColumns: false,
|
||||
suppressColumnMoveAnimation: false,
|
||||
headerHeight: 36,
|
||||
theme: 'ag-theme-quartz',
|
||||
}))
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import PInputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number
|
||||
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: '',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
invalid: false,
|
||||
required: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
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>
|
||||
@@ -0,0 +1,281 @@
|
||||
<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>
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* KBX UI Adapter - Export all wrapped components
|
||||
* Single boundary: PrimeVue/AG Grid usage restricted to this module
|
||||
*/
|
||||
|
||||
// Contracts & Types
|
||||
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 = {
|
||||
compact: {
|
||||
inputHeight: 34,
|
||||
gridRowHeight: 34,
|
||||
touchTarget: 44,
|
||||
fontSize: 12,
|
||||
},
|
||||
comfortable: {
|
||||
inputHeight: 36,
|
||||
gridRowHeight: 36,
|
||||
touchTarget: 48,
|
||||
fontSize: 14,
|
||||
},
|
||||
touch: {
|
||||
inputHeight: 48,
|
||||
gridRowHeight: 48,
|
||||
touchTarget: 52,
|
||||
fontSize: 16,
|
||||
},
|
||||
}
|
||||
|
||||
// Theme configuration
|
||||
export const defaultTheme = {
|
||||
primary: '#3b82f6',
|
||||
secondary: '#6b7280',
|
||||
danger: '#ef4444',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
info: '#06b6d4',
|
||||
}
|
||||
Reference in New Issue
Block a user