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:
2026-08-16 21:50:50 +09:00
parent 5213ea142b
commit b12fc7411d
25 changed files with 1105 additions and 85 deletions
+2
View File
@@ -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'] } }
]
})
+114 -5
View File
@@ -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;
+75 -14
View File
@@ -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;
}
+2 -4
View File
@@ -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;
+1 -3
View File
@@ -76,14 +76,12 @@ onMounted(() => {
</main>
</div>
<!-- Footer -->
<KsFooter />
<!-- Notifications (toasts) -->
<KsNotifications />
</div>
</template>
<style scoped>
.ks-app-shell {
display: flex;
+10
View File
@@ -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 => {
+21 -3
View File
@@ -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"
+15 -2
View File
@@ -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/textareaselect 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>
+35 -7
View File
@@ -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;