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