fix(GroupTable): prevent Input cursor jumping to end on keystroke (#4208)

Refactor updateRow/addRow/removeRow to use functional setRows(prev => ...)
and ref-based onChange/duplicateNames access, making columns useMemo stable
across keystrokes so Semi UI Table does not re-mount Input components.
This commit is contained in:
萧邦 2026-04-13 14:41:40 +08:00 committed by GitHub
parent 8b22161527
commit c20060931b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo, useRef } from 'react';
import { import {
Button, Button,
Input, Input,
@ -61,60 +61,63 @@ export function serializeGroupTable(rows) {
}; };
} }
export default function GroupTable({ export default function GroupTable({ groupRatio, userUsableGroups, onChange }) {
groupRatio,
userUsableGroups,
onChange,
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [rows, setRows] = useState(() => const [rows, setRows] = useState(() =>
buildRows(groupRatio, userUsableGroups), buildRows(groupRatio, userUsableGroups),
); );
const emitChange = useCallback( // Use functional setRows to keep updateRow/addRow/removeRow referentially
(newRows) => { // stable, preventing columns useMemo from rebuilding on every keystroke
setRows(newRows); // which causes the Input cursor to jump to end (cursor reset bug).
onChange?.(serializeGroupTable(newRows)); const onChangeRef = useRef(onChange);
}, onChangeRef.current = onChange;
[onChange],
); const emitAndSet = useCallback((updater) => {
setRows((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater;
onChangeRef.current?.(serializeGroupTable(next));
return next;
});
}, []);
const updateRow = useCallback( const updateRow = useCallback(
(id, field, value) => { (id, field, value) => {
const next = rows.map((r) => emitAndSet((prev) =>
r._id === id ? { ...r, [field]: value } : r, prev.map((r) => (r._id === id ? { ...r, [field]: value } : r)),
); );
emitChange(next);
}, },
[rows, emitChange], [emitAndSet],
); );
const addRow = useCallback(() => { const addRow = useCallback(() => {
const existingNames = new Set(rows.map((r) => r.name)); emitAndSet((prev) => {
let counter = 1; const existingNames = new Set(prev.map((r) => r.name));
let newName = `group_${counter}`; let counter = 1;
while (existingNames.has(newName)) { let newName = `group_${counter}`;
counter++; while (existingNames.has(newName)) {
newName = `group_${counter}`; counter++;
} newName = `group_${counter}`;
emitChange([ }
...rows, return [
{ ...prev,
_id: uid(), {
name: newName, _id: uid(),
ratio: 1, name: newName,
selectable: true, ratio: 1,
description: '', selectable: true,
}, description: '',
]); },
}, [rows, emitChange]); ];
});
}, [emitAndSet]);
const removeRow = useCallback( const removeRow = useCallback(
(id) => { (id) => {
emitChange(rows.filter((r) => r._id !== id)); emitAndSet((prev) => prev.filter((r) => r._id !== id));
}, },
[rows, emitChange], [emitAndSet],
); );
const groupNames = useMemo(() => rows.map((r) => r.name), [rows]); const groupNames = useMemo(() => rows.map((r) => r.name), [rows]);
@ -127,6 +130,11 @@ export default function GroupTable({
return new Set(Object.keys(counts).filter((k) => counts[k] > 1)); return new Set(Object.keys(counts).filter((k) => counts[k] > 1));
}, [groupNames]); }, [groupNames]);
// Use ref so column render functions always read the latest duplicate set
// without adding duplicateNames to columns deps (which would break cursor).
const duplicateNamesRef = useRef(duplicateNames);
duplicateNamesRef.current = duplicateNames;
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@ -138,7 +146,9 @@ export default function GroupTable({
<Input <Input
size='small' size='small'
value={record.name} value={record.name}
status={duplicateNames.has(record.name) ? 'warning' : undefined} status={
duplicateNamesRef.current.has(record.name) ? 'warning' : undefined
}
onChange={(v) => updateRow(record._id, 'name', v)} onChange={(v) => updateRow(record._id, 'name', v)}
/> />
), ),
@ -212,7 +222,7 @@ export default function GroupTable({
), ),
}, },
], ],
[t, duplicateNames, updateRow, removeRow], [t, updateRow, removeRow],
); );
return ( return (
@ -223,9 +233,7 @@ export default function GroupTable({
rowKey='_id' rowKey='_id'
hidePagination hidePagination
size='small' size='small'
empty={ empty={<Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>}
<Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>
}
/> />
<div className='mt-3 flex justify-center'> <div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addRow}> <Button icon={<IconPlus />} theme='outline' onClick={addRow}>
@ -234,7 +242,8 @@ export default function GroupTable({
</div> </div>
{duplicateNames.size > 0 && ( {duplicateNames.size > 0 && (
<Text type='warning' size='small' className='mt-2 block'> <Text type='warning' size='small' className='mt-2 block'>
{t('存在重复的分组名称:')}{Array.from(duplicateNames).join(', ')} {t('存在重复的分组名称:')}
{Array.from(duplicateNames).join(', ')}
</Text> </Text>
)} )}
</div> </div>