diff --git a/apps/admin/src/components/EditableCell/editable-cell.integration.test.ts b/apps/admin/src/components/EditableCell/editable-cell.integration.test.ts index d1a7099..e97fe18 100644 --- a/apps/admin/src/components/EditableCell/editable-cell.integration.test.ts +++ b/apps/admin/src/components/EditableCell/editable-cell.integration.test.ts @@ -13,6 +13,21 @@ let root: ReturnType | null = null; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +const dispatchPointer = ( + target: Element, + type: 'pointerdown' | 'pointerup', + init: { pointerType: 'touch' | 'mouse'; pointerId?: number; clientX?: number; clientY?: number }, +) => { + const event = new Event(type, { bubbles: true }); + Object.defineProperties(event, { + pointerType: { value: init.pointerType }, + pointerId: { value: init.pointerId ?? 1 }, + clientX: { value: init.clientX ?? 0 }, + clientY: { value: init.clientY ?? 0 }, + }); + target.dispatchEvent(event); +}; + afterEach(async () => { if (root) { await act(async () => root?.unmount()); @@ -51,6 +66,70 @@ describe('editable cell value mapping', () => { }); describe('editable cell interactions', () => { + const renderTextCell = async () => { + const onSave = vi.fn(async () => undefined); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(EditableCell, { value: '张三', onSave }, '张三'), + ); + }); + return container.querySelector('.editable-cell') as HTMLElement; + }; + + it('keeps a single touch tap read-only', async () => { + const cell = await renderTextCell(); + + await act(async () => { + dispatchPointer(cell, 'pointerdown', { pointerType: 'touch', clientX: 10, clientY: 10 }); + dispatchPointer(cell, 'pointerup', { pointerType: 'touch', clientX: 12, clientY: 11 }); + await flush(); + }); + + expect(container?.querySelector('.editable-cell--editing')).toBeNull(); + }); + + it('enters edit mode after two nearby touch taps', async () => { + const cell = await renderTextCell(); + + await act(async () => { + dispatchPointer(cell, 'pointerdown', { pointerType: 'touch', clientX: 10, clientY: 10 }); + dispatchPointer(cell, 'pointerup', { pointerType: 'touch', clientX: 11, clientY: 10 }); + dispatchPointer(cell, 'pointerdown', { pointerType: 'touch', clientX: 12, clientY: 11 }); + dispatchPointer(cell, 'pointerup', { pointerType: 'touch', clientX: 12, clientY: 11 }); + await flush(); + }); + + expect(container?.querySelector('.editable-cell--editing')).toBeTruthy(); + }); + + it('keeps mouse single-click behavior read-only', async () => { + const cell = await renderTextCell(); + + await act(async () => { + dispatchPointer(cell, 'pointerdown', { pointerType: 'mouse' }); + dispatchPointer(cell, 'pointerup', { pointerType: 'mouse' }); + await flush(); + }); + + expect(container?.querySelector('.editable-cell--editing')).toBeNull(); + }); + + it('does not edit when a touch gesture scrolls the table', async () => { + const cell = await renderTextCell(); + + await act(async () => { + dispatchPointer(cell, 'pointerdown', { pointerType: 'touch', clientX: 10, clientY: 10 }); + dispatchPointer(cell, 'pointerup', { pointerType: 'touch', clientX: 30, clientY: 10 }); + await flush(); + }); + + expect(container?.querySelector('.editable-cell--editing')).toBeNull(); + }); + it('saves a single-select value immediately when an option is clicked', async () => { const onSave = vi.fn(async () => undefined); container = document.createElement('div'); diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index 7e83f98..58ad4c7 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -94,6 +94,8 @@ const EditableCell = ({ const { hasPermission } = usePermission(); const idRef = useRef(crypto.randomUUID()); const rootRef = useRef(null); + const touchStartRef = useRef<{ pointerId: number; x: number; y: number } | null>(null); + const lastTouchTapRef = useRef<{ time: number; x: number; y: number } | null>(null); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [draft, setDraft] = useState(() => @@ -200,6 +202,39 @@ const EditableCell = ({ setEditing(true); }; + const onPointerDown = (event: React.PointerEvent) => { + if (event.pointerType !== 'touch' || editing) return; + touchStartRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + }; + + const onPointerUp = (event: React.PointerEvent) => { + const touchStart = touchStartRef.current; + touchStartRef.current = null; + if (!touchStart || event.pointerType !== 'touch' || event.pointerId !== touchStart.pointerId) { + return; + } + const moved = Math.hypot(event.clientX - touchStart.x, event.clientY - touchStart.y); + if (moved > 8) { + lastTouchTapRef.current = null; + return; + } + + const now = Date.now(); + const lastTap = lastTouchTapRef.current; + const isDoubleTap = + !!lastTap && + now - lastTap.time <= 450 && + Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) <= 24; + lastTouchTapRef.current = isDoubleTap + ? null + : { time: now, x: event.clientX, y: event.clientY }; + if (isDoubleTap) void beginEdit(); + }; + const onKeyDown = async (event: React.KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); @@ -264,11 +299,17 @@ const EditableCell = ({ ref={rootRef} className={`editable-cell${enabled ? ' editable-cell--enabled' : ''}${editing ? ' editable-cell--editing' : ''}`} onDoubleClick={() => void beginEdit()} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + onPointerCancel={() => { + touchStartRef.current = null; + lastTouchTapRef.current = null; + }} > {editing ? ( {control} ) : ( - {children} + {children} )} ); diff --git a/apps/admin/src/components/EditableCell/style.css b/apps/admin/src/components/EditableCell/style.css index e47adf1..9e55823 100644 --- a/apps/admin/src/components/EditableCell/style.css +++ b/apps/admin/src/components/EditableCell/style.css @@ -7,6 +7,7 @@ .editable-cell--enabled { cursor: cell; + touch-action: manipulation; padding: 4px 6px; margin: -4px -6px; border: 1px solid transparent; diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index 6c0fd57..8cd0068 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -188,8 +188,7 @@ canvas { max-width: 100%; } -.ant-table-wrapper .ant-table-container { - overflow-x: auto; +.ant-table-wrapper .ant-table-content { overscroll-behavior-inline: contain; -webkit-overflow-scrolling: touch; }