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
@@ -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;