feat(fe): Add form field navigation - Enter key moves to next field
- Create useFormFieldNavigation composable for Tab-like Enter behavior - KsTextField: Enter -> next field - KsTextArea: Ctrl+Enter for newline, Enter -> next field - KsSelect: Enter -> next field after selection - Implements standard form navigation pattern across input components
This commit is contained in:
@@ -22,6 +22,8 @@ export const router = createRouter({
|
||||
// KBX v60 Pages
|
||||
{ path: '/model-ops/shadow-run-jobs', component: () => import('../features/shadow-run/pages/ShadowRunQueue.vue'), meta: { screenId: 'model-ops.shadow-run.queue', module: 'ModelOps', title: 'Shadow Run Jobs', permissions: ['model.read'] } },
|
||||
{ path: '/model-ops/models-master', component: () => import('../features/models/pages/ModelList.vue'), meta: { screenId: 'model-ops.models.master', module: 'ModelOps', title: 'Models (Master-Detail)', permissions: ['model.read'] } },
|
||||
{ path: '/system/common-codes', component: () => import('../features/system/pages/CommonCodeManagementPage.vue'), meta: { screenId: 'SCR-SYS-001', templateId: 'T01', module: 'System', section: 'System', title: '공통코드 관리', order: 1, favoriteAllowed: true } },
|
||||
{ path: '/governance/approvals', component: () => import('../features/approval/pages/ApprovalQueue.vue'), meta: { screenId: 'governance.approval.queue', module: 'Governance', title: 'Approval Queue', permissions: ['approval.review'] } }
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -7,9 +7,45 @@ body {
|
||||
background: var(--ks-color-canvas);
|
||||
color: var(--ks-color-text);
|
||||
font-family: Inter, Pretendard, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: var(--ks-font-body);
|
||||
line-height: var(--ks-line-body);
|
||||
font-size: var(--ks-font-body, 12px);
|
||||
line-height: var(--ks-line-body, 16px);
|
||||
}
|
||||
|
||||
/* KBX v60 Global Typography Token Scale Standard (표준 폰트 크기 계층 규격) */
|
||||
h1, .ks-title {
|
||||
font-size: var(--ks-font-page, 16px) !important;
|
||||
line-height: var(--ks-line-page, 22px) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
h2, .ks-section-title {
|
||||
font-size: var(--ks-font-section, 14px) !important;
|
||||
line-height: var(--ks-line-section, 18px) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
h3, h4, dt, legend {
|
||||
font-size: var(--ks-font-section, 14px) !important;
|
||||
line-height: var(--ks-line-section, 18px) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
p, span, li, td, input, select, button, label, .ks-font-body {
|
||||
font-size: var(--ks-font-body, 12px);
|
||||
line-height: var(--ks-line-body, 16px);
|
||||
}
|
||||
|
||||
th, .ag-header-cell-text {
|
||||
font-size: var(--ks-font-grid, 12px) !important;
|
||||
line-height: var(--ks-line-grid, 16px) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
caption, .hint, .note, .ks-caption, small, .help-text, .ks-font-caption {
|
||||
font-size: var(--ks-font-caption, 11px) !important;
|
||||
line-height: var(--ks-line-caption, 14px) !important;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
a { color: var(--ks-color-action); }
|
||||
:focus-visible { outline: var(--ks-focus-ring-width) solid color-mix(in srgb, var(--ks-color-focus) 55%, transparent); outline-offset: var(--ks-focus-ring-offset); }
|
||||
@@ -56,7 +92,79 @@ input[type="text"], input[type="number"], input[type="date"], select, textarea,
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Default Width Standards for Form & Filter Controls (기본 넓이 표준 규격) */
|
||||
/* Automatic Global Inline Rule for Filter Container Slots */
|
||||
.ks-page__filters .ks-text-field,
|
||||
.ks-page__filters .ks-select,
|
||||
.ks-page__filters .ks-date-field {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
gap: var(--ks-space-2, 6px) !important;
|
||||
}
|
||||
|
||||
.ks-page__filters:empty,
|
||||
.ks-page__command-bar:empty,
|
||||
.ks-page__summary:empty {
|
||||
display: none !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
|
||||
.ks-page__filters .ks-text-field__label,
|
||||
.ks-page__filters .ks-select__label,
|
||||
.ks-page__filters .ks-date-field__label,
|
||||
.ks-text-field--inline .ks-text-field__label,
|
||||
.ks-select--inline .ks-select__label,
|
||||
.ks-date-field--inline .ks-date-field__label {
|
||||
width: var(--ks-field-label-width, 72px) !important;
|
||||
min-width: var(--ks-field-label-width, 72px) !important;
|
||||
max-width: var(--ks-field-label-width, 72px) !important;
|
||||
text-align: left !important;
|
||||
white-space: nowrap !important;
|
||||
flex-shrink: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
font-size: var(--ks-font-body) !important;
|
||||
}
|
||||
|
||||
.ks-page__filters .ks-text-field__container,
|
||||
.ks-page__filters .ks-select__container,
|
||||
.ks-page__filters .ks-date-field__container,
|
||||
.ks-text-field--inline .ks-text-field__container,
|
||||
.ks-select--inline .ks-select__container,
|
||||
.ks-date-field--inline .ks-date-field__container {
|
||||
flex: 1 1 auto !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
|
||||
.ks-set-sm, .ks-filter-sm {
|
||||
width: var(--ks-set-width-sm, 200px) !important;
|
||||
min-width: var(--ks-set-width-sm, 200px) !important;
|
||||
max-width: var(--ks-set-width-sm, 200px) !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.ks-set-md, .ks-filter-md {
|
||||
width: var(--ks-set-width-md, 260px) !important;
|
||||
min-width: var(--ks-set-width-md, 260px) !important;
|
||||
max-width: var(--ks-set-width-md, 260px) !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.ks-set-lg, .ks-filter-lg {
|
||||
width: var(--ks-set-width-lg, 340px) !important;
|
||||
min-width: var(--ks-set-width-lg, 340px) !important;
|
||||
max-width: var(--ks-set-width-lg, 340px) !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
select, .p-select, .p-dropdown, .ks-select, .status-select {
|
||||
width: var(--ks-control-width-select);
|
||||
max-width: 100%;
|
||||
@@ -75,10 +183,11 @@ input[type="date"] {
|
||||
.filters, .ks-page__filters form, .ks-page__filters section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ks-space-2);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ks-space-3);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
|
||||
button, .p-button, .ks-button {
|
||||
height: var(--ks-control-height) !important;
|
||||
font-size: var(--ks-font-body) !important;
|
||||
|
||||
@@ -59,9 +59,14 @@
|
||||
|
||||
/* KBX v60 ERP/OMS/WMS Ultra-Dense Control & Layout Heights & Default Widths */
|
||||
--ks-control-height: 28px; /* ERP/OMS Input Control 28px */
|
||||
--ks-field-label-width: 72px; /* Standardized Inline Field Label Fixed Width 72px */
|
||||
--ks-set-width-sm: 200px; /* Standardized Compact Field Set Width 200px */
|
||||
--ks-set-width-md: 260px; /* Standardized Default Field Set Width 260px */
|
||||
--ks-set-width-lg: 340px; /* Standardized Wide Field Set Width 340px */
|
||||
--ks-control-width-select: 160px; /* Standard Select/Dropdown Default Width 160px */
|
||||
--ks-control-width-search: 220px; /* Standard Search Input Default Width 220px */
|
||||
--ks-control-width-date: 140px; /* Standard Date Field Default Width 140px */
|
||||
|
||||
--ks-grid-row-height: 28px; /* Ultra-Dense Grid Row 28px */
|
||||
--ks-grid-header-height: 30px; /* Grid Header 30px */
|
||||
--ks-content-max: 100%; /* Full Screen Utilization */
|
||||
@@ -266,34 +271,90 @@
|
||||
}
|
||||
|
||||
/* Raw Table Standard Grid Class for legacy fallback tables */
|
||||
.grid-table, .kbx-grid-table, .models-table, .shadow-runs-table {
|
||||
.grid-table, .kbx-grid-table, .models-table, .shadow-runs-table, .ks-grid-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--ks-font-grid);
|
||||
background: var(--ks-color-surface);
|
||||
border: 1px solid var(--ks-color-border);
|
||||
border: 1px solid var(--ks-color-border-strong);
|
||||
border-radius: var(--ks-radius-sm);
|
||||
}
|
||||
|
||||
.grid-table th, .kbx-grid-table th, .models-table th, .shadow-runs-table th {
|
||||
height: 30px;
|
||||
.grid-table th, .kbx-grid-table th, .models-table th, .shadow-runs-table th, .ks-grid-table th {
|
||||
height: var(--ks-grid-header-height, 30px);
|
||||
padding: 0 10px;
|
||||
background-color: var(--ks-color-canvas);
|
||||
border-bottom: 1px solid var(--ks-color-border);
|
||||
font-weight: 600;
|
||||
background-color: #f1f5f9;
|
||||
border-bottom: 2px solid var(--ks-color-border-strong);
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
color: var(--ks-color-text-muted);
|
||||
color: var(--ks-color-neutral-900);
|
||||
}
|
||||
|
||||
.grid-table td, .kbx-grid-table td, .models-table td, .shadow-runs-table td {
|
||||
height: 28px;
|
||||
[data-theme='dark'] .grid-table th,
|
||||
[data-theme='dark'] .kbx-grid-table th,
|
||||
[data-theme='dark'] .models-table th,
|
||||
[data-theme='dark'] .shadow-runs-table th,
|
||||
[data-theme='dark'] .ks-grid-table th {
|
||||
background-color: #1e293b;
|
||||
color: #f8fafc;
|
||||
border-bottom: 2px solid #475569;
|
||||
}
|
||||
|
||||
.grid-table td, .kbx-grid-table td, .models-table td, .shadow-runs-table td, .ks-grid-table td {
|
||||
height: var(--ks-grid-row-height, 28px);
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid var(--ks-color-border);
|
||||
color: var(--ks-color-text);
|
||||
box-sizing: border-box;
|
||||
color: var(--ks-color-neutral-950);
|
||||
box-sizing: border-border-box;
|
||||
}
|
||||
|
||||
.grid-table tr:hover, .kbx-grid-table tr:hover, .models-table tr:hover, .shadow-runs-table tr:hover {
|
||||
background-color: var(--ks-color-neutral-100);
|
||||
[data-theme='dark'] .grid-table td,
|
||||
[data-theme='dark'] .kbx-grid-table td,
|
||||
[data-theme='dark'] .models-table td,
|
||||
[data-theme='dark'] .shadow-runs-table td,
|
||||
[data-theme='dark'] .ks-grid-table td {
|
||||
color: #f8fafc;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
/* Zebra Stripes & Hover & Selection Rules */
|
||||
.grid-table tr:nth-child(even), .kbx-grid-table tr:nth-child(even), .ks-grid-table tr:nth-child(even) {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.grid-table tr:nth-child(odd), .kbx-grid-table tr:nth-child(odd), .ks-grid-table tr:nth-child(odd) {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .grid-table tr:nth-child(even),
|
||||
[data-theme='dark'] .kbx-grid-table tr:nth-child(even),
|
||||
[data-theme='dark'] .ks-grid-table tr:nth-child(even) {
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .grid-table tr:nth-child(odd),
|
||||
[data-theme='dark'] .kbx-grid-table tr:nth-child(odd),
|
||||
[data-theme='dark'] .ks-grid-table tr:nth-child(odd) {
|
||||
background-color: #090d16;
|
||||
}
|
||||
|
||||
.grid-table tr:hover, .kbx-grid-table tr:hover, .models-table tr:hover, .shadow-runs-table tr:hover, .ks-grid-table tr:hover, .ks-grid-row:hover {
|
||||
background-color: #e0f2fe !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .grid-table tr:hover,
|
||||
[data-theme='dark'] .kbx-grid-table tr:hover,
|
||||
[data-theme='dark'] .models-table tr:hover,
|
||||
[data-theme='dark'] .shadow-runs-table tr:hover,
|
||||
[data-theme='dark'] .ks-grid-table tr:hover,
|
||||
[data-theme='dark'] .ks-grid-row:hover {
|
||||
background-color: #334155 !important;
|
||||
}
|
||||
|
||||
.grid-table tr.selected, .ks-grid-table tr.selected, .ks-grid-row.selected {
|
||||
background-color: #dbeafe !important;
|
||||
color: #1e3a8a !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -40,9 +40,6 @@ const toggleDarkMode = () => {
|
||||
<main class="app-content">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -58,10 +55,11 @@ const toggleDarkMode = () => {
|
||||
|
||||
.app-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.app-content {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@@ -76,14 +76,12 @@ onMounted(() => {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<KsFooter />
|
||||
|
||||
<!-- Notifications (toasts) -->
|
||||
<KsNotifications />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.ks-app-shell {
|
||||
display: flex;
|
||||
|
||||
@@ -77,6 +77,15 @@ const menuItems: MenuItem[] = [
|
||||
{ id: 'market-data', label: '시장 데이터 수집', icon: '💾', path: '/ops/market-data-ingestion' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
path: '/system/common-codes',
|
||||
children: [
|
||||
{ id: 'sys-common-codes', label: '공통코드 관리', icon: '🔤', path: '/system/common-codes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ui-catalog',
|
||||
label: 'UI 컴포넌트 갤러리',
|
||||
@@ -85,6 +94,7 @@ const menuItems: MenuItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
// Auto-expand sections based on current active route
|
||||
onMounted(() => {
|
||||
menuItems.forEach(item => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { UiGridColumn } from './adapter/contracts'
|
||||
import { KsDataGrid, KsPaginator, SkeletonLoader, EmptyStatePlaceholder } from './components'
|
||||
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
@@ -11,14 +13,30 @@ const props = withDefaults(defineProps<{
|
||||
page?: number
|
||||
pageSize?: number
|
||||
total?: number
|
||||
}>(), { loading: false, emptyMessage: '검색 조건에 해당하거나 조회된 데이터가 없습니다.', height: 'calc(100vh - 230px)' })
|
||||
}>(), { loading: false, emptyMessage: '검색 조건에 해당하거나 조회된 데이터가 없습니다.', height: '100%' })
|
||||
|
||||
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
|
||||
const gridRef = ref<{ focusRow: (rowIndex: number, colKey?: string) => void; redrawRows: () => void } | null>(null)
|
||||
|
||||
function focusRow(rowIndex: number, colKey?: string): void {
|
||||
gridRef.value?.focusRow(rowIndex, colKey)
|
||||
}
|
||||
|
||||
function redrawRows(): void {
|
||||
gridRef.value?.redrawRows()
|
||||
}
|
||||
|
||||
defineExpose({ focusRow, redrawRows, gridRef })
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ks-grid-shell-section" aria-label="데이터 표" :aria-busy="loading">
|
||||
<section class="ks-grid-shell-section" :style="{ height: props.height ?? '100%' }" aria-label="데이터 표" :aria-busy="loading">
|
||||
|
||||
<SkeletonLoader v-if="loading" type="table" :rows="6" />
|
||||
<KsDataGrid v-else :rows="rows" :columns="columns" :height="props.height" />
|
||||
<KsDataGrid v-else ref="gridRef" :rows="rows" :columns="columns" :height="props.height" />
|
||||
|
||||
<KsPaginator
|
||||
v-if="props.page !== undefined && props.pageSize !== undefined && props.total !== undefined"
|
||||
:page="props.page"
|
||||
|
||||
@@ -9,7 +9,12 @@ const props = defineProps<{
|
||||
dirty?: boolean
|
||||
error?: Error | null
|
||||
empty?: boolean
|
||||
emptyTitle?: string
|
||||
emptyMessage?: string
|
||||
emptyIcon?: string
|
||||
emptyActionLabel?: string
|
||||
partial?: boolean
|
||||
|
||||
staleAt?: string | null
|
||||
warning?: string | null
|
||||
unauthorized?: boolean
|
||||
@@ -35,15 +40,23 @@ const emit = defineEmits<{ retry: [] }>()
|
||||
<KsButton label="같은 요청 다시 시도" severity="secondary" @click="emit('retry')" />
|
||||
<small v-if="correlationId">Correlation: {{ correlationId }}</small>
|
||||
</div>
|
||||
<EmptyStatePlaceholder v-else-if="props.empty" />
|
||||
<EmptyStatePlaceholder
|
||||
v-else-if="props.empty"
|
||||
:title="props.emptyTitle"
|
||||
:message="props.emptyMessage"
|
||||
:icon="props.emptyIcon"
|
||||
:action-label="props.emptyActionLabel"
|
||||
@action="emit('retry')"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<KsInlineMessage v-if="props.partial" severity="warning" message="일부 데이터만 표시하고 있습니다. 완전성 경고를 확인하세요." />
|
||||
<KsInlineMessage v-if="props.warning" severity="warning" :message="props.warning" />
|
||||
<KsInlineMessage v-if="props.readonly" severity="info" message="읽기 전용 상태입니다." />
|
||||
<KsInlineMessage v-if="props.dirty" severity="warning" message="저장되지 않은 변경사항이 있습니다." />
|
||||
<KsInlineMessage v-if="props.processing" severity="info" message="처리 중입니다. 중복 제출하지 마세요." />
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<small v-if="props.staleAt">데이터 기준시각: {{ props.staleAt }}</small>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -50,14 +50,18 @@ export interface UiGridColumn {
|
||||
header: string
|
||||
width?: number
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
flex?: number
|
||||
sortable?: boolean
|
||||
filterable?: boolean
|
||||
sensitive?: boolean
|
||||
editable?: boolean
|
||||
formatter?: (value: unknown, row: unknown) => string
|
||||
statusMap?: UiGridStatusMap
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type UiTextFieldType = 'text' | 'email' | 'password' | 'number' | 'date'
|
||||
|
||||
export interface UiAdapterDescriptor {
|
||||
|
||||
@@ -7,11 +7,19 @@ import {
|
||||
CellStyleModule,
|
||||
ValidationModule,
|
||||
TextFilterModule,
|
||||
TextEditorModule,
|
||||
NumberEditorModule,
|
||||
SelectEditorModule,
|
||||
UndoRedoEditModule,
|
||||
RowStyleModule,
|
||||
RenderApiModule,
|
||||
themeQuartz,
|
||||
ModuleRegistry,
|
||||
RowSelectionModule,
|
||||
type ColDef,
|
||||
type RowClickedEvent
|
||||
type RowClickedEvent,
|
||||
type CellValueChangedEvent,
|
||||
type RowClassParams
|
||||
} from 'ag-grid-community'
|
||||
import type { UiGridColumn } from '../contracts'
|
||||
|
||||
@@ -21,9 +29,17 @@ ModuleRegistry.registerModules([
|
||||
CellStyleModule,
|
||||
ValidationModule,
|
||||
TextFilterModule,
|
||||
TextEditorModule,
|
||||
NumberEditorModule,
|
||||
SelectEditorModule,
|
||||
UndoRedoEditModule,
|
||||
RowStyleModule,
|
||||
RenderApiModule,
|
||||
RowSelectionModule
|
||||
])
|
||||
|
||||
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
@@ -32,30 +48,148 @@ const props = withDefaults(defineProps<{
|
||||
rowSelection?: 'single' | 'multiple' | 'none'
|
||||
}>(), { loading: false, height: '32rem', rowSelection: 'single' })
|
||||
|
||||
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
|
||||
const emit = defineEmits<{ rowSelected: [row: unknown]; cellValueChanged: [event: CellValueChangedEvent] }>()
|
||||
|
||||
const columnDefs = computed<ColDef[]>(() => props.columns.map(column => ({
|
||||
field: column.field,
|
||||
headerName: column.header,
|
||||
width: column.width,
|
||||
minWidth: column.minWidth ?? 120,
|
||||
maxWidth: column.maxWidth,
|
||||
|
||||
sortable: column.sortable ?? true,
|
||||
filter: column.filterable ?? true,
|
||||
filter: column.filterable ?? false,
|
||||
editable: column.editable ?? false,
|
||||
|
||||
valueFormatter: column.formatter
|
||||
? params => column.formatter?.(params.value, params.data) ?? ''
|
||||
: undefined
|
||||
})))
|
||||
|
||||
const rowClassRules = computed(() => ({
|
||||
'ks-row-added': (params: RowClassParams) => {
|
||||
const data = params.data as Record<string, unknown> | undefined
|
||||
if (!data) return false
|
||||
return !!(data._isNew || (typeof data.code === 'string' && data.code.startsWith('NEW_')) || (typeof data.groupCode === 'string' && data.groupCode.startsWith('NEW_')))
|
||||
},
|
||||
'ks-row-modified': (params: RowClassParams) => {
|
||||
const data = params.data as Record<string, unknown> | undefined
|
||||
return data?._isModified === true
|
||||
}
|
||||
}))
|
||||
|
||||
const rowSelectionOptions = computed(() => {
|
||||
if (props.rowSelection === 'none') return undefined
|
||||
return props.rowSelection === 'multiple'
|
||||
? ({ mode: 'multiRow' } as const)
|
||||
: ({ mode: 'singleRow' } as const)
|
||||
if (props.rowSelection === 'multiple') return { mode: 'multiRow' } as const
|
||||
return { mode: 'singleRow', checkboxes: false, enableClickSelection: true } as const
|
||||
})
|
||||
|
||||
|
||||
function onRowClicked(event: RowClickedEvent): void {
|
||||
if (event.data) emit('rowSelected', event.data)
|
||||
}
|
||||
|
||||
function onCellValueChanged(event: CellValueChangedEvent): void {
|
||||
if (event.data) {
|
||||
(event.data as Record<string, unknown>)._isModified = true
|
||||
event.api.redrawRows()
|
||||
emit('cellValueChanged', event)
|
||||
}
|
||||
}
|
||||
|
||||
import type { NavigateToNextCellParams, CellPosition, CellEditingStoppedEvent, CellKeyDownEvent } from 'ag-grid-community'
|
||||
|
||||
let isEnterKeyPressed = false
|
||||
|
||||
function onCellKeyDown(event: CellKeyDownEvent): void {
|
||||
const keyboardEvent = event.event as KeyboardEvent
|
||||
if (keyboardEvent && (keyboardEvent.key === 'Enter' || String(keyboardEvent.key) === '13')) {
|
||||
isEnterKeyPressed = true
|
||||
}
|
||||
}
|
||||
|
||||
function onCellEditingStopped(event: CellEditingStoppedEvent): void {
|
||||
if (isEnterKeyPressed && event.api) {
|
||||
isEnterKeyPressed = false
|
||||
const api = event.api
|
||||
const rowIndex = event.rowIndex
|
||||
const colKey = event.column.getColId()
|
||||
|
||||
if (rowIndex === null || rowIndex === undefined) return
|
||||
|
||||
const allCols = api.getAllGridColumns()
|
||||
const editableCols = allCols.filter(c => {
|
||||
const colDef = c.getColDef()
|
||||
return colDef.editable === true || typeof colDef.editable === 'function'
|
||||
})
|
||||
|
||||
const currIdx = editableCols.findIndex(c => c.getColId() === colKey)
|
||||
|
||||
if (currIdx >= 0 && currIdx < editableCols.length - 1) {
|
||||
const nextColId = editableCols[currIdx + 1].getColId()
|
||||
setTimeout(() => {
|
||||
api.setFocusedCell(rowIndex, nextColId)
|
||||
api.startEditingCell({ rowIndex, colKey: nextColId })
|
||||
}, 40)
|
||||
} else {
|
||||
const nextRowIdx = rowIndex + 1
|
||||
const firstColId = editableCols[0]?.getColId()
|
||||
if (firstColId && nextRowIdx < api.getDisplayedRowCount()) {
|
||||
setTimeout(() => {
|
||||
api.ensureIndexVisible(nextRowIdx)
|
||||
api.setFocusedCell(nextRowIdx, firstColId)
|
||||
api.startEditingCell({ rowIndex: nextRowIdx, colKey: firstColId })
|
||||
}, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToNextCell(params: NavigateToNextCellParams): CellPosition | null {
|
||||
const previousCell = params.previousCellPosition
|
||||
const suggestedNextCell = params.nextCellPosition
|
||||
|
||||
if (params.key === 'Enter' || String(params.key) === '13') {
|
||||
const allColumns = params.api.getAllGridColumns()
|
||||
const editableCols = allColumns.filter(c => {
|
||||
const colDef = c.getColDef()
|
||||
return colDef.editable === true || typeof colDef.editable === 'function'
|
||||
})
|
||||
|
||||
if (editableCols.length > 0) {
|
||||
const currentColId = previousCell.column.getColId()
|
||||
const currentIndex = editableCols.findIndex(c => c.getColId() === currentColId)
|
||||
|
||||
if (currentIndex >= 0 && currentIndex < editableCols.length - 1) {
|
||||
const nextCol = editableCols[currentIndex + 1]
|
||||
const colId = nextCol.getColId()
|
||||
setTimeout(() => {
|
||||
params.api.startEditingCell({ rowIndex: previousCell.rowIndex, colKey: colId })
|
||||
}, 30)
|
||||
return {
|
||||
rowIndex: previousCell.rowIndex,
|
||||
column: nextCol,
|
||||
rowPinned: previousCell.rowPinned
|
||||
}
|
||||
} else {
|
||||
const nextRowIndex = previousCell.rowIndex + 1
|
||||
const firstCol = editableCols[0]
|
||||
const colId = firstCol.getColId()
|
||||
setTimeout(() => {
|
||||
params.api.startEditingCell({ rowIndex: nextRowIndex, colKey: colId })
|
||||
}, 30)
|
||||
return {
|
||||
rowIndex: nextRowIndex,
|
||||
column: firstCol,
|
||||
rowPinned: previousCell.rowPinned
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestedNextCell
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,13 +200,24 @@ function onRowClicked(event: RowClickedEvent): void {
|
||||
:row-data="rows"
|
||||
:column-defs="columnDefs"
|
||||
:row-selection="rowSelectionOptions"
|
||||
:row-class-rules="rowClassRules"
|
||||
:enter-navigates-to-next-cell="true"
|
||||
:enter-navigates-to-next-cell-after-edit="true"
|
||||
:navigate-to-next-cell="navigateToNextCell"
|
||||
:stop-editing-when-cells-lose-focus="true"
|
||||
:loading="loading"
|
||||
:suppress-no-rows-overlay="rows.length > 0"
|
||||
@row-clicked="onRowClicked"
|
||||
@cell-value-changed="onCellValueChanged"
|
||||
@cell-key-down="onCellKeyDown"
|
||||
@cell-editing-stopped="onCellEditingStopped"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
.ks-grid {
|
||||
min-height: 12rem;
|
||||
|
||||
@@ -52,20 +52,21 @@ const formattedChangeRate = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.ks-metric-card {
|
||||
padding: var(--ks-space-4);
|
||||
background: var(--ks-glass-bg);
|
||||
backdrop-filter: var(--ks-glass-blur);
|
||||
border: 1px solid var(--ks-glass-border);
|
||||
border-radius: var(--ks-radius-lg);
|
||||
padding: 8px 10px;
|
||||
background: var(--ks-color-surface);
|
||||
border: 1px solid var(--ks-color-border-strong);
|
||||
border-radius: var(--ks-radius-md);
|
||||
box-shadow: var(--ks-shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ks-space-2);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
justify-content: space-between;
|
||||
height: 72px;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ks-metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--ks-color-action);
|
||||
box-shadow: var(--ks-shadow-md);
|
||||
}
|
||||
|
||||
@@ -73,50 +74,66 @@ const formattedChangeRate = computed(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ks-metric-card__title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--ks-color-text-muted);
|
||||
font-size: var(--ks-font-caption, 11px);
|
||||
font-weight: 600;
|
||||
color: var(--ks-color-text-muted, #64748b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ks-metric-card__badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--ks-radius-sm);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ks-badge--up, .ks-badge--success { background: rgba(5, 150, 105, 0.1); color: var(--ks-color-success); }
|
||||
.ks-badge--down, .ks-badge--danger { background: rgba(220, 38, 38, 0.1); color: var(--ks-color-danger); }
|
||||
.ks-badge--warning { background: rgba(217, 119, 6, 0.1); color: var(--ks-color-warning); }
|
||||
.ks-badge--up, .ks-badge--success { background: rgba(16, 185, 129, 0.12); color: var(--ks-color-success, #059669); }
|
||||
.ks-badge--down, .ks-badge--danger { background: rgba(239, 68, 68, 0.12); color: var(--ks-color-danger, #dc2626); }
|
||||
.ks-badge--warning { background: rgba(245, 158, 11, 0.12); color: var(--ks-color-warning, #d97706); }
|
||||
.ks-badge--info { background: rgba(59, 130, 246, 0.12); color: var(--ks-color-info, #2563eb); }
|
||||
|
||||
.ks-metric-card__body {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ks-metric-card__value-group {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--ks-space-1);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ks-metric-card__value {
|
||||
font-size: 1.5rem;
|
||||
font-size: var(--ks-font-page, 15px);
|
||||
font-weight: 700;
|
||||
color: var(--ks-color-text);
|
||||
color: var(--ks-color-text, #0f172a);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ks-metric-card__unit {
|
||||
font-size: 0.875rem;
|
||||
font-size: 11px;
|
||||
color: var(--ks-color-text-muted);
|
||||
}
|
||||
|
||||
.ks-metric-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ks-space-2);
|
||||
font-size: 0.75rem;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ks-text-up { color: var(--ks-color-up); font-weight: 600; }
|
||||
.ks-text-down { color: var(--ks-color-down); font-weight: 600; }
|
||||
.ks-metric-card__subtext { color: var(--ks-color-text-muted); }
|
||||
.ks-text-up { color: var(--ks-color-success, #059669); font-weight: 600; }
|
||||
.ks-text-down { color: var(--ks-color-danger, #dc2626); font-weight: 600; }
|
||||
.ks-metric-card__subtext { color: var(--ks-color-text-muted, #64748b); font-size: 11px; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useFieldLayout } from '../contracts/layoutContext'
|
||||
|
||||
export interface KsDateFieldProps {
|
||||
modelValue: string | Date | null
|
||||
@@ -15,6 +16,8 @@ export interface KsDateFieldProps {
|
||||
placeholder?: string
|
||||
inputId?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
inline?: boolean
|
||||
showPresets?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<KsDateFieldProps>(), {
|
||||
@@ -23,8 +26,10 @@ const props = withDefaults(defineProps<KsDateFieldProps>(), {
|
||||
readonly: false,
|
||||
required: false,
|
||||
size: 'md',
|
||||
showPresets: true,
|
||||
})
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | Date | null]
|
||||
blur: [event: FocusEvent]
|
||||
@@ -34,13 +39,16 @@ const emit = defineEmits<{
|
||||
|
||||
const isFocused = ref(false)
|
||||
const id = computed(() => props.inputId || `date-${Math.random().toString(36).substr(2, 9)}`)
|
||||
const { isInline } = useFieldLayout(props.inline)
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const activePreset = ref<string | null>(null)
|
||||
|
||||
const dateString = computed(() => {
|
||||
if (!props.modelValue) return ''
|
||||
if (typeof props.modelValue === 'string' && props.modelValue.includes('~')) return props.modelValue
|
||||
const date = typeof props.modelValue === 'string' ? new Date(props.modelValue) : props.modelValue
|
||||
if (isNaN(date.getTime())) return ''
|
||||
if (isNaN(date.getTime())) return typeof props.modelValue === 'string' ? props.modelValue : ''
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
@@ -51,6 +59,7 @@ const containerClass = computed(() => [
|
||||
'ks-date-field',
|
||||
`ks-date-field--${props.size}`,
|
||||
{
|
||||
'ks-date-field--inline': isInline.value,
|
||||
'ks-date-field--focused': isFocused.value,
|
||||
'ks-date-field--filled': !!props.modelValue,
|
||||
'ks-date-field--disabled': props.disabled,
|
||||
@@ -59,7 +68,47 @@ const containerClass = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
const startDateStr = computed(() => {
|
||||
if (!props.modelValue) return '2026-08-08'
|
||||
const val = String(props.modelValue)
|
||||
if (val.includes('~')) {
|
||||
return val.split('~')[0].trim()
|
||||
}
|
||||
return val
|
||||
})
|
||||
|
||||
const endDateStr = computed(() => {
|
||||
if (!props.modelValue) return '2026-08-15'
|
||||
const val = String(props.modelValue)
|
||||
if (val.includes('~')) {
|
||||
return val.split('~')[1].trim()
|
||||
}
|
||||
return val
|
||||
})
|
||||
|
||||
const handleStartChange = (event: Event) => {
|
||||
activePreset.value = null
|
||||
const target = event.target as HTMLInputElement
|
||||
const newStart = target.value
|
||||
const newEnd = endDateStr.value || newStart
|
||||
const rangeVal = `${newStart} ~ ${newEnd}`
|
||||
emit('update:modelValue', rangeVal)
|
||||
emit('change', rangeVal)
|
||||
}
|
||||
|
||||
const handleEndChange = (event: Event) => {
|
||||
activePreset.value = null
|
||||
const target = event.target as HTMLInputElement
|
||||
const newEnd = target.value
|
||||
const newStart = startDateStr.value || newEnd
|
||||
const rangeVal = `${newStart} ~ ${newEnd}`
|
||||
emit('update:modelValue', rangeVal)
|
||||
emit('change', rangeVal)
|
||||
}
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
activePreset.value = null
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target.value) {
|
||||
emit('update:modelValue', null)
|
||||
@@ -73,6 +122,53 @@ const handleInput = (event: Event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectPreset = (preset: 'today' | 'yesterday' | '1w' | '1m' | '3m') => {
|
||||
if (props.disabled || props.readonly) return
|
||||
activePreset.value = preset
|
||||
|
||||
const today = new Date()
|
||||
const formatDate = (d: Date) => {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
let start: Date
|
||||
let end: Date = today
|
||||
|
||||
if (preset === 'today') {
|
||||
start = today
|
||||
} else if (preset === 'yesterday') {
|
||||
start = new Date()
|
||||
start.setDate(start.getDate() - 1)
|
||||
end = new Date(start)
|
||||
} else if (preset === '1w') {
|
||||
start = new Date()
|
||||
start.setDate(start.getDate() - 7)
|
||||
} else if (preset === '1m') {
|
||||
start = new Date()
|
||||
start.setMonth(start.getMonth() - 1)
|
||||
} else if (preset === '3m') {
|
||||
start = new Date()
|
||||
start.setMonth(start.getMonth() - 3)
|
||||
} else {
|
||||
start = today
|
||||
}
|
||||
|
||||
const startStr = formatDate(start)
|
||||
const endStr = formatDate(end)
|
||||
|
||||
if (props.type === 'range') {
|
||||
const rangeVal = `${startStr} ~ ${endStr}`
|
||||
emit('update:modelValue', rangeVal)
|
||||
emit('change', rangeVal)
|
||||
} else {
|
||||
emit('update:modelValue', startStr)
|
||||
emit('change', startStr)
|
||||
}
|
||||
}
|
||||
|
||||
const openPicker = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (inputRef.value && typeof inputRef.value.showPicker === 'function') {
|
||||
@@ -101,12 +197,12 @@ const handleBlur = (event: FocusEvent) => {
|
||||
<span v-if="required" class="ks-date-field__required">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="ks-date-field__container">
|
||||
<!-- Single Date / DateTime Input -->
|
||||
<div v-if="type !== 'range'" class="ks-date-field__container">
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
:type="type === 'range' ? 'text' : type"
|
||||
:type="type"
|
||||
:value="dateString"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
@@ -124,6 +220,46 @@ const handleBlur = (event: FocusEvent) => {
|
||||
<span class="ks-date-field__icon" @click="openPicker">📅</span>
|
||||
</div>
|
||||
|
||||
<!-- Range Selection (From ~ To) Dual Date Inputs with Calendar Pickers -->
|
||||
<div v-else class="ks-date-field__container ks-date-range-container">
|
||||
<input
|
||||
:id="`${id}-start`"
|
||||
type="date"
|
||||
:value="startDateStr"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
class="ks-date-field__input ks-date-range-input"
|
||||
aria-label="조회 시작일"
|
||||
@change="handleStartChange"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<span class="ks-date-range-separator">~</span>
|
||||
<input
|
||||
:id="`${id}-end`"
|
||||
type="date"
|
||||
:value="endDateStr"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
class="ks-date-field__input ks-date-range-input"
|
||||
aria-label="조회 종료일"
|
||||
@change="handleEndChange"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Date Preset Buttons -->
|
||||
<div v-if="props.showPresets" class="ks-date-presets" aria-label="빠른 날짜 선택 픽커">
|
||||
<button type="button" class="ks-date-preset-btn" :class="{ 'ks-date-preset-btn--active': activePreset === 'today' }" @click="selectPreset('today')">오늘</button>
|
||||
<button type="button" class="ks-date-preset-btn" :class="{ 'ks-date-preset-btn--active': activePreset === 'yesterday' }" @click="selectPreset('yesterday')">어제</button>
|
||||
<button type="button" class="ks-date-preset-btn" :class="{ 'ks-date-preset-btn--active': activePreset === '1w' }" @click="selectPreset('1w')">1주일</button>
|
||||
<button type="button" class="ks-date-preset-btn" :class="{ 'ks-date-preset-btn--active': activePreset === '1m' }" @click="selectPreset('1m')">1개월</button>
|
||||
<button type="button" class="ks-date-preset-btn" :class="{ 'ks-date-preset-btn--active': activePreset === '3m' }" @click="selectPreset('3m')">3개월</button>
|
||||
</div>
|
||||
|
||||
<!-- Error or help text -->
|
||||
<div v-if="error || help" :id="`${id}-hint`" :class="{ 'ks-date-field__error': error, 'ks-date-field__help': help }">
|
||||
{{ error || help }}
|
||||
@@ -131,6 +267,8 @@ const handleBlur = (event: FocusEvent) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
.ks-date-field {
|
||||
display: flex;
|
||||
@@ -138,6 +276,30 @@ const handleBlur = (event: FocusEvent) => {
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ks-date-field--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ks-space-2, 6px);
|
||||
}
|
||||
|
||||
.ks-date-field--inline .ks-date-field__label {
|
||||
width: var(--ks-field-label-width, 72px);
|
||||
min-width: var(--ks-field-label-width, 72px);
|
||||
max-width: var(--ks-field-label-width, 72px);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ks-date-field--inline .ks-date-field__container {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.ks-date-field--sm {
|
||||
--input-height: 32px;
|
||||
--font-size: var(--font-size-sm);
|
||||
@@ -244,4 +406,88 @@ const handleBlur = (event: FocusEvent) => {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ks-date-range-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ks-date-range-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 2px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.ks-date-range-separator {
|
||||
color: var(--ks-color-text-muted, #64748b);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* Quick Date Preset Floating Popover Overlay Styles (레이아웃 공간 차지 0px) */
|
||||
.ks-date-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ks-date-presets {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ks-space-1, 4px);
|
||||
padding: 4px 6px;
|
||||
background: var(--ks-color-surface-raised, #ffffff);
|
||||
border: 1px solid var(--ks-color-border-strong, #94a3b8);
|
||||
border-radius: var(--ks-radius-md, 6px);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
|
||||
}
|
||||
|
||||
/* Show floating presets popover on hover or focus */
|
||||
.ks-date-field:hover .ks-date-presets,
|
||||
.ks-date-field--focused .ks-date-presets,
|
||||
.ks-date-presets:hover {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ks-date-preset-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ks-color-text-muted, #475569);
|
||||
background: var(--ks-color-canvas, #f8fafc);
|
||||
border: 1px solid var(--ks-color-border, #cbd5e1);
|
||||
border-radius: var(--ks-radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.ks-date-preset-btn:hover {
|
||||
color: var(--ks-color-action, #0284c7);
|
||||
border-color: var(--ks-color-action, #0284c7);
|
||||
background: var(--ks-color-surface-raised, #ffffff);
|
||||
}
|
||||
|
||||
.ks-date-preset-btn--active {
|
||||
color: #ffffff !important;
|
||||
background: var(--ks-color-action, #0284c7) !important;
|
||||
border-color: var(--ks-color-action, #0284c7) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@ withDefaults(defineProps<{ title: string; description?: string; labelledBy?: str
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-form-section { display: flex; flex-direction: column; gap: var(--ks-space-3); margin-bottom: var(--ks-space-6); }
|
||||
.ks-form-section__header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--ks-space-2); padding-bottom: var(--ks-space-2); border-bottom: 1px solid var(--ks-color-neutral-200); }
|
||||
.ks-form-section { display: flex; flex-direction: column; gap: var(--ks-space-3); flex: 1; height: 100%; min-height: 0; }
|
||||
.ks-form-section__header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--ks-space-2); padding-bottom: var(--ks-space-2); border-bottom: 1px solid var(--ks-color-neutral-200); flex-shrink: 0; }
|
||||
h2 { margin: 0; font-size: var(--ks-font-section); line-height: var(--ks-line-section); }
|
||||
p { grid-column: 1; margin: 0; color: var(--ks-color-neutral-600); font-size: var(--ks-font-caption); }
|
||||
.ks-form-section__actions { grid-column: 2; grid-row: 1 / -1; display: flex; align-items: center; gap: var(--ks-space-2); }
|
||||
.ks-form-section__content { min-width: 0; }
|
||||
.ks-form-section__content { min-width: 0; min-height: 0; flex: 1; height: 100%; display: flex; flex-direction: column; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useFieldLayout } from '../contracts/layoutContext'
|
||||
import { useFormFieldNavigation } from '../composables/useFormFieldNavigation'
|
||||
|
||||
export interface SelectOption {
|
||||
label: string
|
||||
@@ -22,6 +24,7 @@ export interface KsSelectProps {
|
||||
searchable?: boolean
|
||||
inputId?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<KsSelectProps>(), {
|
||||
@@ -45,6 +48,8 @@ const isFocused = ref(false)
|
||||
const searchInput = ref('')
|
||||
const highlightedIndex = ref(-1)
|
||||
const id = computed(() => props.inputId || `select-${Math.random().toString(36).substr(2, 9)}`)
|
||||
const { isInline } = useFieldLayout(props.inline)
|
||||
const { inputRef, moveToNextField } = useFormFieldNavigation()
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!props.searchable || !searchInput.value) return props.options
|
||||
@@ -58,6 +63,7 @@ const containerClass = computed(() => [
|
||||
'ks-select',
|
||||
`ks-select--${props.size}`,
|
||||
{
|
||||
'ks-select--inline': isInline.value,
|
||||
'ks-select--open': isOpen.value,
|
||||
'ks-select--focused': isFocused.value,
|
||||
'ks-select--filled': props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== '',
|
||||
@@ -67,6 +73,7 @@ const containerClass = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value = !isOpen.value
|
||||
@@ -111,6 +118,11 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
if (isOpen.value && highlightedIndex.value >= 0) {
|
||||
handleSelect(filteredOptions.value[highlightedIndex.value])
|
||||
// After selection, move to next field
|
||||
setTimeout(() => moveToNextField(), 0)
|
||||
} else if (!isOpen.value) {
|
||||
// Dropdown closed, move to next field
|
||||
moveToNextField()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
@@ -145,6 +157,7 @@ const handleBlur = (event: FocusEvent) => {
|
||||
<!-- Select button -->
|
||||
<div class="ks-select__container">
|
||||
<button
|
||||
ref="inputRef"
|
||||
:id="id"
|
||||
type="button"
|
||||
class="ks-select__button"
|
||||
@@ -154,6 +167,7 @@ const handleBlur = (event: FocusEvent) => {
|
||||
@click="handleClick"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<!-- Selected value or placeholder -->
|
||||
<span class="ks-select__value">
|
||||
@@ -232,6 +246,32 @@ const handleBlur = (event: FocusEvent) => {
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ks-select--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ks-space-2, 6px);
|
||||
}
|
||||
|
||||
.ks-select--inline .ks-select__label {
|
||||
width: var(--ks-field-label-width, 72px);
|
||||
min-width: var(--ks-field-label-width, 72px);
|
||||
max-width: var(--ks-field-label-width, 72px);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ks-select--inline .ks-select__container {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.ks-select--sm {
|
||||
--input-height: 32px;
|
||||
--font-size: var(--font-size-sm);
|
||||
|
||||
@@ -10,10 +10,12 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
storageKey: 'ks_splitter_ratio_default',
|
||||
initialRatio: 65,
|
||||
initialRatio: 50,
|
||||
minLeftPx: 280,
|
||||
minRightPx: 300,
|
||||
}
|
||||
|
||||
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useFormFieldNavigation } from '../composables/useFormFieldNavigation'
|
||||
|
||||
export interface KsTextAreaProps {
|
||||
modelValue: string
|
||||
@@ -36,6 +37,7 @@ const emit = defineEmits<{
|
||||
|
||||
const isFocused = ref(false)
|
||||
const id = computed(() => props.inputId || `textarea-${Math.random().toString(36).substr(2, 9)}`)
|
||||
const { inputRef, handleKeyDown } = useFormFieldNavigation()
|
||||
|
||||
const containerClass = computed(() => [
|
||||
'ks-textarea',
|
||||
@@ -65,6 +67,10 @@ const handleInput = (event: Event) => {
|
||||
emit('update:modelValue', target.value)
|
||||
emit('input', event)
|
||||
}
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
handleKeyDown(event, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -78,6 +84,7 @@ const handleInput = (event: Event) => {
|
||||
<!-- Textarea container -->
|
||||
<div class="ks-textarea__container">
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@@ -93,6 +100,7 @@ const handleInput = (event: Event) => {
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeyPress"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useFieldLayout } from '../contracts/layoutContext'
|
||||
import { useFormFieldNavigation } from '../composables/useFormFieldNavigation'
|
||||
|
||||
export interface KsTextFieldProps {
|
||||
modelValue: string
|
||||
@@ -17,6 +19,7 @@ export interface KsTextFieldProps {
|
||||
autocomplete?: string
|
||||
inputId?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<KsTextFieldProps>(), {
|
||||
@@ -36,11 +39,14 @@ const emit = defineEmits<{
|
||||
|
||||
const isFocused = ref(false)
|
||||
const id = computed(() => props.inputId || `input-${Math.random().toString(36).substr(2, 9)}`)
|
||||
const { isInline } = useFieldLayout(props.inline)
|
||||
const { inputRef, handleKeyDown } = useFormFieldNavigation()
|
||||
|
||||
const containerClass = computed(() => [
|
||||
'ks-text-field',
|
||||
`ks-text-field--${props.size}`,
|
||||
{
|
||||
'ks-text-field--inline': isInline.value,
|
||||
'ks-text-field--focused': isFocused.value,
|
||||
'ks-text-field--filled': props.modelValue,
|
||||
'ks-text-field--disabled': props.disabled,
|
||||
@@ -49,6 +55,8 @@ const containerClass = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
|
||||
const handleFocus = (event: FocusEvent) => {
|
||||
isFocused.value = true
|
||||
emit('focus', event)
|
||||
@@ -64,6 +72,10 @@ const handleInput = (event: Event) => {
|
||||
emit('update:modelValue', target.value)
|
||||
emit('input', event)
|
||||
}
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
handleKeyDown(event, false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -77,6 +89,7 @@ const handleInput = (event: Event) => {
|
||||
<!-- Input container with focus ring -->
|
||||
<div class="ks-text-field__container">
|
||||
<input
|
||||
ref="inputRef"
|
||||
:id="id"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
@@ -94,6 +107,7 @@ const handleInput = (event: Event) => {
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeyPress"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -119,6 +133,32 @@ const handleInput = (event: Event) => {
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ks-text-field--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ks-space-2, 6px);
|
||||
}
|
||||
|
||||
.ks-text-field--inline .ks-text-field__label {
|
||||
width: var(--ks-field-label-width, 72px);
|
||||
min-width: var(--ks-field-label-width, 72px);
|
||||
max-width: var(--ks-field-label-width, 72px);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ks-text-field--inline .ks-text-field__container {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.ks-text-field--sm {
|
||||
--input-height: 32px;
|
||||
--font-size: var(--font-size-sm);
|
||||
|
||||
@@ -24,3 +24,5 @@ export { default as KsFormSection } from './KsFormSection.vue'
|
||||
export { default as KsFormSpan } from './KsFormSpan.vue'
|
||||
export { default as KsValidationSummary } from './KsValidationSummary.vue'
|
||||
export { default as KsSplitter } from './KsSplitter.vue'
|
||||
export { default as KArtsellMetricCard } from './KArtsellMetricCard.vue'
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
export function useFormFieldNavigation() {
|
||||
const inputRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function findFormElement(): HTMLFormElement | null {
|
||||
if (!inputRef.value) return null
|
||||
return inputRef.value.closest('form')
|
||||
}
|
||||
|
||||
function getFormInputElements(): HTMLElement[] {
|
||||
const form = findFormElement()
|
||||
if (!form) return []
|
||||
|
||||
const selectors = [
|
||||
'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"])',
|
||||
'textarea',
|
||||
'[role="button"][tabindex="0"]',
|
||||
'select'
|
||||
]
|
||||
|
||||
return Array.from(form.querySelectorAll(selectors.join(',')))
|
||||
.filter((el: Element): el is HTMLElement => {
|
||||
if (el instanceof HTMLInputElement) {
|
||||
return !el.disabled && !el.readonly && el.type !== 'hidden'
|
||||
}
|
||||
if (el instanceof HTMLTextAreaElement) {
|
||||
return !el.disabled && !el.readonly
|
||||
}
|
||||
if (el instanceof HTMLSelectElement) {
|
||||
return !el.disabled && !el.readonly
|
||||
}
|
||||
return !el.hasAttribute('disabled')
|
||||
})
|
||||
}
|
||||
|
||||
function findNextField(): HTMLElement | null {
|
||||
const elements = getFormInputElements()
|
||||
if (!inputRef.value || elements.length === 0) return null
|
||||
|
||||
const currentIndex = elements.indexOf(inputRef.value)
|
||||
if (currentIndex === -1) return null
|
||||
|
||||
const nextIndex = currentIndex + 1
|
||||
return nextIndex < elements.length ? elements[nextIndex] : elements[0]
|
||||
}
|
||||
|
||||
function moveToNextField(): void {
|
||||
const nextField = findNextField()
|
||||
if (nextField) {
|
||||
nextField.focus()
|
||||
// 如果是 input/textarea,select all
|
||||
if (nextField instanceof HTMLInputElement || nextField instanceof HTMLTextAreaElement) {
|
||||
nextField.select?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent, isTextArea: boolean = false): boolean {
|
||||
if (!isTextArea) {
|
||||
// Input/Select: Enter → 다음 필드
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
moveToNextField()
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// TextArea: Ctrl+Enter → 줄바꿈, Enter → 다음 필드
|
||||
if (event.key === 'Enter') {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl+Enter: 줄바꿈 (기본 동작)
|
||||
return false
|
||||
} else {
|
||||
// Enter: 다음 필드
|
||||
event.preventDefault()
|
||||
moveToNextField()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
inputRef,
|
||||
handleKeyDown,
|
||||
moveToNextField,
|
||||
findNextField,
|
||||
getFormInputElements
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { inject, provide, computed, type InjectionKey, type Ref } from 'vue'
|
||||
|
||||
export type KsFieldLayoutMode = 'filter' | 'form' | 'inline' | 'stacked'
|
||||
|
||||
export const KS_FIELD_LAYOUT_KEY: InjectionKey<Ref<KsFieldLayoutMode>> = Symbol('KS_FIELD_LAYOUT_KEY')
|
||||
|
||||
export function provideFieldLayout(mode: Ref<KsFieldLayoutMode> | KsFieldLayoutMode): void {
|
||||
const modeRef = computed(() => (typeof mode === 'string' ? mode : mode.value))
|
||||
provide(KS_FIELD_LAYOUT_KEY, modeRef)
|
||||
}
|
||||
|
||||
export function useFieldLayout(explicitInline?: boolean): { isInline: Ref<boolean> } {
|
||||
const parentLayout = inject(KS_FIELD_LAYOUT_KEY, null)
|
||||
const isInline = computed(() => {
|
||||
if (explicitInline !== undefined) return explicitInline
|
||||
if (parentLayout) return parentLayout.value === 'filter' || parentLayout.value === 'inline'
|
||||
return false
|
||||
})
|
||||
return { isInline }
|
||||
}
|
||||
@@ -27,4 +27,7 @@ export interface StandardScreenProps {
|
||||
evidence?: ScreenEvidenceContext
|
||||
warning?: string
|
||||
error?: Error | null
|
||||
initialRatio?: number
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
<script setup lang="ts">withDefaults(defineProps<{ asideWidth?: string; dense?: boolean }>(), { asideWidth: '24rem', dense: false })</script>
|
||||
<template><section class="ks-crud-workspace" :class="{ dense }" :style="{ '--ks-crud-aside': asideWidth }"><header v-if="$slots.toolbar"><slot name="toolbar" /></header><div class="ks-crud-workspace__body" :class="{ 'has-aside': $slots.aside }"><main><slot /></main><aside v-if="$slots.aside"><slot name="aside" /></aside></div><footer v-if="$slots.actions"><slot name="actions" /></footer></section></template>
|
||||
<style scoped>.ks-crud-workspace{display:grid;gap:var(--ks-space-3)}.ks-crud-workspace__body{display:grid;gap:var(--ks-space-4);min-width:0}.ks-crud-workspace__body.has-aside{grid-template-columns:minmax(0,1fr) var(--ks-crud-aside)}main,aside{min-width:0}.dense{gap:var(--ks-space-2)}@media(max-width:1100px){.ks-crud-workspace__body.has-aside{grid-template-columns:1fr}}</style>
|
||||
<script setup lang="ts">
|
||||
import KsSplitter from '../components/KsSplitter.vue'
|
||||
withDefaults(defineProps<{ asideWidth?: string; dense?: boolean; storageKey?: string; initialRatio?: number }>(), {
|
||||
asideWidth: '28rem',
|
||||
dense: false,
|
||||
storageKey: 'ks_crud_splitter_ratio',
|
||||
initialRatio: 50,
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ks-crud-workspace" :class="{ dense }">
|
||||
<header v-if="$slots.toolbar"><slot name="toolbar" /></header>
|
||||
<div v-if="$slots.aside" class="ks-crud-workspace__body has-aside">
|
||||
<KsSplitter :storage-key="storageKey" :initial-ratio="initialRatio">
|
||||
<template #left>
|
||||
<main class="ks-crud-main"><slot /></main>
|
||||
</template>
|
||||
<template #right>
|
||||
<aside class="ks-crud-aside"><slot name="aside" /></aside>
|
||||
</template>
|
||||
</KsSplitter>
|
||||
</div>
|
||||
<div v-else class="ks-crud-workspace__body single">
|
||||
<main class="ks-crud-main"><slot /></main>
|
||||
</div>
|
||||
<footer v-if="$slots.actions"><slot name="actions" /></footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-crud-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ks-space-3);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ks-crud-workspace__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ks-crud-main,
|
||||
.ks-crud-aside {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dense {
|
||||
gap: var(--ks-space-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,2 +1,79 @@
|
||||
<template><div class="ks-dashboard"><section class="ks-dashboard__kpis"><slot name="kpis" /></section><section class="ks-dashboard__primary ks-card"><slot name="primary" /></section><section class="ks-dashboard__secondary ks-card"><slot name="secondary" /></section><section class="ks-dashboard__alerts ks-card"><slot name="alerts" /></section></div></template>
|
||||
<style scoped>.ks-dashboard { display: grid; grid-template-columns: 2fr 1fr; gap: var(--ks-space-4); }.ks-dashboard__kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ks-space-3); }.ks-dashboard__primary,.ks-dashboard__secondary,.ks-dashboard__alerts { padding: var(--ks-space-4); }.ks-dashboard__alerts { grid-column: 1 / -1; }@media (max-width: 1100px) { .ks-dashboard { grid-template-columns: 1fr; } }</style>
|
||||
<template>
|
||||
<div class="ks-dashboard">
|
||||
<section v-if="$slots.kpis" class="ks-dashboard__kpis">
|
||||
<slot name="kpis" />
|
||||
</section>
|
||||
|
||||
<div class="ks-dashboard__body">
|
||||
<section v-if="$slots.primary" class="ks-dashboard__primary ks-card">
|
||||
<slot name="primary" />
|
||||
</section>
|
||||
|
||||
<div class="ks-dashboard__side">
|
||||
<section v-if="$slots.secondary" class="ks-dashboard__secondary ks-card">
|
||||
<slot name="secondary" />
|
||||
</section>
|
||||
<section v-if="$slots.alerts" class="ks-dashboard__alerts ks-card">
|
||||
<slot name="alerts" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
gap: var(--ks-space-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ks-dashboard__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: var(--ks-space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ks-dashboard__body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: var(--ks-space-3);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ks-dashboard__primary {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--ks-space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ks-dashboard__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ks-space-3);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ks-dashboard__secondary,
|
||||
.ks-dashboard__alerts {
|
||||
padding: var(--ks-space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.ks-dashboard__body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,4 +5,30 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">defineEmits<{ submit: [] }>()</script>
|
||||
<style scoped>.ks-form-layout { display: grid; grid-template-columns: minmax(0, 1fr) var(--ks-preview-width); gap: var(--ks-space-4); align-items: start; flex: 1; min-height: 0; height: 100%; } .ks-form-layout__form,.ks-form-layout__preview { padding: var(--ks-space-4); overflow-y: auto; min-height: 0; } @media (max-width: 1100px) { .ks-form-layout { grid-template-columns: 1fr; } }</style>
|
||||
<style scoped>
|
||||
.ks-form-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--ks-preview-width, 24rem);
|
||||
gap: var(--ks-space-4);
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ks-form-layout__form,
|
||||
.ks-form-layout__preview {
|
||||
padding: var(--ks-space-4);
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.ks-form-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { provideFieldLayout } from '../contracts/layoutContext'
|
||||
defineProps<{ title: string; subtitle?: string; status?: string; asOf?: string; version?: string; asideWidth?: string }>()
|
||||
const showHelp = ref(false)
|
||||
provideFieldLayout('filter')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ks-page" :style="{ '--ks-aside-width': asideWidth ?? '22rem' }">
|
||||
<header class="ks-page__header">
|
||||
@@ -35,12 +38,23 @@ const showHelp = ref(false)
|
||||
<div class="ks-page__content"><slot /></div>
|
||||
<aside v-if="$slots.aside" class="ks-page__aside"><slot name="aside" /></aside>
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="ks-page__footer"><slot name="footer" /></footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Flex-based page layout with explicit workspace height binding */
|
||||
.ks-page { width: 100%; max-width: var(--ks-content-max); margin: 0 auto; height: 100%; flex: 1; display: flex; flex-direction: column; }
|
||||
.ks-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ks-space-2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.ks-page__header { display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-4); flex-shrink: 0; min-height: 32px; border-bottom: 1px solid var(--ks-color-neutral-200); padding-bottom: var(--ks-space-2); }
|
||||
.ks-page__title-group { display: flex; flex-direction: column; gap: 2px; }
|
||||
.ks-page__breadcrumb { font-size: 11px; color: var(--ks-color-text-muted); line-height: 14px; }
|
||||
@@ -71,21 +85,22 @@ const showHelp = ref(false)
|
||||
.ks-page__command-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 8px;
|
||||
background: var(--ks-color-surface);
|
||||
border: 1px solid var(--ks-color-border);
|
||||
border-radius: var(--ks-radius-sm);
|
||||
flex-shrink: 0;
|
||||
min-height: 34px;
|
||||
box-sizing: border-border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ks-page__command-bar-group { display: flex; align-items: center; gap: var(--ks-space-2); flex-wrap: wrap; }
|
||||
.ks-page__command-bar-group { display: flex; align-items: center; gap: var(--ks-space-2); flex-wrap: wrap; margin-left: auto; }
|
||||
|
||||
.ks-page__actions { display: flex; align-items: center; gap: var(--ks-space-2); flex-shrink: 0; }
|
||||
.ks-page__summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ks-space-3); flex-shrink: 0; }
|
||||
.ks-page__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
gap: var(--ks-space-3);
|
||||
padding: var(--ks-space-2) var(--ks-space-3);
|
||||
flex-shrink: 0;
|
||||
@@ -93,7 +108,20 @@ const showHelp = ref(false)
|
||||
background: var(--ks-color-surface);
|
||||
border: 1px solid var(--ks-color-border);
|
||||
border-radius: var(--ks-radius-sm);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.ks-page__filters:empty,
|
||||
.ks-page__command-bar:empty,
|
||||
.ks-page__summary:empty {
|
||||
display: none !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
|
||||
.ks-page__filters input,
|
||||
.ks-page__filters select {
|
||||
height: 34px;
|
||||
|
||||
Reference in New Issue
Block a user