fix(web-shell): refine markdown table interactions (#6500)

* fix(web-shell): refine markdown table interactions

* fix(web-shell): preserve active column when closing filters
This commit is contained in:
jifeng 2026-07-08 16:37:56 +08:00 committed by GitHub
parent 727c2d580c
commit 5b2d1369b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 246 additions and 42 deletions

View file

@ -451,11 +451,13 @@ tr:hover .selectedCell {
background: var(--card);
}
.activeHeaderCell.frozenHeaderCell {
background: color-mix(in srgb, var(--agent-blue-500) 8%, var(--card));
}
.evenRow .stickyActionCell,
.evenRow .frozenCell {
background:
linear-gradient(var(--subtle-bg), var(--subtle-bg)),
var(--card);
background: linear-gradient(var(--subtle-bg), var(--subtle-bg)), var(--card);
}
tr:hover .stickyActionCell,

View file

@ -10,6 +10,10 @@ import { EnhancedMarkdownTable } from './EnhancedMarkdownTable';
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
const originalDocumentHidden = Object.getOwnPropertyDescriptor(
document,
'hidden',
);
const originalElementFromPoint = document.elementFromPoint;
const COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column';
@ -21,6 +25,11 @@ afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
document.getSelection()?.removeAllRanges();
if (originalDocumentHidden) {
Object.defineProperty(document, 'hidden', originalDocumentHidden);
} else {
Reflect.deleteProperty(document, 'hidden');
}
if (originalElementFromPoint) {
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
@ -866,7 +875,7 @@ describe('EnhancedMarkdownTable', () => {
);
});
it('stops resizing a column when page visibility changes', () => {
it('flushes pending resize width when the window blurs', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
@ -879,6 +888,35 @@ describe('EnhancedMarkdownTable', () => {
}),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 200 }),
);
window.dispatchEvent(new Event('blur'));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'260px',
);
});
it('stops resizing a column when page visibility changes', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
Object.defineProperty(document, 'hidden', {
configurable: true,
value: true,
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
@ -893,6 +931,38 @@ describe('EnhancedMarkdownTable', () => {
);
});
it('keeps resizing when page visibility changes while visible', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
Object.defineProperty(document, 'hidden', {
configurable: true,
value: false,
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 220 }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'280px',
);
});
it('resizes a column with keyboard arrows', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
@ -924,6 +994,25 @@ describe('EnhancedMarkdownTable', () => {
);
});
it('ignores keyboard resize arrows with modifiers', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
ctrlKey: true,
key: 'ArrowRight',
}),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'160px',
);
});
it('shows column move handles only for the active column', () => {
const container = renderWideTable();
const teamHandle = button(container, 'Move Team');
@ -945,6 +1034,66 @@ describe('EnhancedMarkdownTable', () => {
).toContain('activeHeaderCell');
});
it('clears the active column move handle on outside click, cell selection, and Escape', () => {
const container = renderWideTable();
const teamHandle = button(container, 'Move Team');
click(button(container, 'Sort by Team'));
act(() => {
document.body.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true }),
);
});
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
click(button(container, 'Sort by Team, ascending'));
act(() => {
dataCell(container, 0, 0).dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
);
});
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
click(button(container, 'Sort by Team, descending'));
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
});
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
});
it('keeps the active column when Escape closes an open filter menu first', () => {
const container = renderWideTable();
const teamHandle = button(container, 'Move Team');
click(button(container, 'Sort by Team'));
click(button(container, 'Filter Team'));
expect(container.textContent).toContain('Custom filter');
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
});
expect(container.textContent).not.toContain('Custom filter');
expect(teamHandle.className).toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(0);
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
});
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
});
it('reorders columns and quick copies in the visible order', () => {
const writeText = mockClipboard();
const container = renderWideTable();
@ -978,7 +1127,10 @@ describe('EnhancedMarkdownTable', () => {
const source = renderWideTable();
const target = renderWideTable();
dragColumnElements(button(source, 'Move Score'), button(target, 'Move Team'));
dragColumnElements(
button(source, 'Move Score'),
button(target, 'Move Team'),
);
expect(rowTexts(source)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
expect(rowTexts(target)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
@ -1033,6 +1185,14 @@ describe('EnhancedMarkdownTable', () => {
).toContain('frozenHeaderCell');
expect(dataCell(container, 0, 0).className).toContain('frozenCell');
click(button(container, 'Sort by Team'));
expect(
button(container, 'Sort by Team, ascending').closest('th')?.className,
).toContain('activeHeaderCell');
expect(
button(container, 'Sort by Team, ascending').closest('th')?.className,
).toContain('frozenHeaderCell');
dragColumn(container, 'Move Score', 'Move Team');
expect(
button(container, 'Sort by Score').closest('th')?.className,

View file

@ -329,6 +329,24 @@ function sanitizeForClipboard(value: string): string {
return /^[=+\-@]/.test(inspectedValue) ? `'${value}` : value;
}
function applyColumnWidth(
current: Record<number, number>,
columnIndex: number,
nextWidth: number,
): Record<number, number> {
const currentWidth = current[columnIndex] ?? DEFAULT_COLUMN_WIDTH;
if (currentWidth === nextWidth) return current;
if (nextWidth === DEFAULT_COLUMN_WIDTH) {
const next = { ...current };
delete next[columnIndex];
return next;
}
return {
...current,
[columnIndex]: nextWidth,
};
}
function getSelectionText(
range: SelectionRange | null,
rows: EnhancedTableRow[],
@ -412,11 +430,7 @@ function moveVisibleColumn(
const visibleColumns = order.filter(
(columnIndex) => !hiddenColumns.has(columnIndex),
);
const nextVisibleColumns = moveColumn(
visibleColumns,
fromColumn,
toColumn,
);
const nextVisibleColumns = moveColumn(visibleColumns, fromColumn, toColumn);
if (nextVisibleColumns === visibleColumns) return order;
let visibleIndex = 0;
return order.map((columnIndex) => {
@ -1181,6 +1195,8 @@ export function EnhancedTable({
columnIndex: number;
} | null>(null);
const selectionFrameRef = useRef(0);
const resizeFrameRef = useRef(0);
const pendingResizeWidthRef = useRef<number | null>(null);
const draggingColumnRef = useRef<number | null>(null);
const tableStructureKey = useMemo(
() =>
@ -1328,6 +1344,7 @@ export function EnhancedTable({
};
const handleMenuKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
closeFilterMenu();
return;
}
@ -1377,6 +1394,28 @@ export function EnhancedTable({
};
}, [closeFilterMenu, openFilterMenu]);
useEffect(() => {
const clearActiveColumnOnOutsideMouseDown = (event: MouseEvent) => {
const target = event.target;
if (target instanceof Node && !shellRef.current?.contains(target)) {
setActiveColumn(null);
}
};
const clearActiveColumnOnEscape = (event: KeyboardEvent) => {
if (event.defaultPrevented || openFilterMenu) return;
if (event.key === 'Escape') setActiveColumn(null);
};
document.addEventListener('mousedown', clearActiveColumnOnOutsideMouseDown);
document.addEventListener('keydown', clearActiveColumnOnEscape);
return () => {
document.removeEventListener(
'mousedown',
clearActiveColumnOnOutsideMouseDown,
);
document.removeEventListener('keydown', clearActiveColumnOnEscape);
};
}, [openFilterMenu]);
const filteredRows = useMemo(
() => applyFilters(table.rows, filters),
[filters, table.rows],
@ -1418,35 +1457,46 @@ export function EnhancedTable({
useEffect(() => {
if (!resizingColumn) return;
const applyPendingResize = () => {
const nextWidth = pendingResizeWidthRef.current;
pendingResizeWidthRef.current = null;
if (resizeFrameRef.current) {
cancelAnimationFrame(resizeFrameRef.current);
resizeFrameRef.current = 0;
}
if (nextWidth === null) return;
setColumnWidths((current) =>
applyColumnWidth(current, resizingColumn.columnIndex, nextWidth),
);
};
const resizeColumn = (event: MouseEvent) => {
const nextWidth = clampColumnWidth(
pendingResizeWidthRef.current = clampColumnWidth(
resizingColumn.startWidth + event.clientX - resizingColumn.startX,
);
setColumnWidths((current) => {
const currentWidth =
current[resizingColumn.columnIndex] ?? DEFAULT_COLUMN_WIDTH;
if (currentWidth === nextWidth) return current;
if (nextWidth === DEFAULT_COLUMN_WIDTH) {
const next = { ...current };
delete next[resizingColumn.columnIndex];
return next;
}
return {
...current,
[resizingColumn.columnIndex]: nextWidth,
};
});
if (resizeFrameRef.current) return;
resizeFrameRef.current = requestAnimationFrame(applyPendingResize);
};
const stopResize = () => {
applyPendingResize();
setResizingColumn(null);
};
const stopResizeWhenHidden = () => {
if (document.hidden) stopResize();
};
const stopResize = () => setResizingColumn(null);
window.addEventListener('mousemove', resizeColumn);
window.addEventListener('mouseup', stopResize);
window.addEventListener('blur', stopResize);
document.addEventListener('visibilitychange', stopResize);
document.addEventListener('visibilitychange', stopResizeWhenHidden);
return () => {
window.removeEventListener('mousemove', resizeColumn);
window.removeEventListener('mouseup', stopResize);
window.removeEventListener('blur', stopResize);
document.removeEventListener('visibilitychange', stopResize);
document.removeEventListener('visibilitychange', stopResizeWhenHidden);
if (resizeFrameRef.current) {
cancelAnimationFrame(resizeFrameRef.current);
resizeFrameRef.current = 0;
}
pendingResizeWidthRef.current = null;
};
}, [resizingColumn]);
@ -1615,6 +1665,9 @@ export function EnhancedTable({
event: ReactKeyboardEvent<HTMLButtonElement>,
columnIndex: number,
) => {
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey)
return;
let delta = 0;
if (event.key === 'ArrowRight') {
delta = KEYBOARD_COLUMN_RESIZE_STEP;
@ -1629,16 +1682,7 @@ export function EnhancedTable({
setColumnWidths((current) => {
const width = current[columnIndex] ?? DEFAULT_COLUMN_WIDTH;
const nextWidth = clampColumnWidth(width + delta);
if (width === nextWidth) return current;
if (nextWidth === DEFAULT_COLUMN_WIDTH) {
const next = { ...current };
delete next[columnIndex];
return next;
}
return {
...current,
[columnIndex]: nextWidth,
};
return applyColumnWidth(current, columnIndex, nextWidth);
});
};
@ -1674,10 +1718,7 @@ export function EnhancedTable({
) => {
const sourceColumnIndex = draggingColumnRef.current;
stopColumnDrag();
if (
sourceColumnIndex === null ||
!hasColumnDragData(event.dataTransfer)
) {
if (sourceColumnIndex === null || !hasColumnDragData(event.dataTransfer)) {
return;
}
event.preventDefault();
@ -1698,6 +1739,7 @@ export function EnhancedTable({
if (openFilterMenu !== null) {
setOpenFilterMenu(null);
}
setActiveColumn(null);
draggingRef.current = true;
setSelection({
anchorRow: rowIndex,