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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user