Skip to content

Commit

Permalink
XIVY-15861 Improve keyboard navigation in editable tables
Browse files Browse the repository at this point in the history
- Add tab-navigation through editable tables
- Add keydown/-up navigation through editable tables
- Change useEffect to useMemo in PropertyTable
- Fix Escape-Key behavior in Monaco
  • Loading branch information
ivy-edp committed Feb 4, 2025
1 parent 961d8e9 commit 75f0d0d
Show file tree
Hide file tree
Showing 18 changed files with 376 additions and 109 deletions.
2 changes: 1 addition & 1 deletion integration/inscription/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"dependencies": {
"@axonivy/process-editor-inscription-core": "~13.1.0-next",
"@axonivy/process-editor-inscription-view": "~13.1.0-next",
"@axonivy/ui-icons": "~13.1.0-next.451",
"@axonivy/ui-icons": "~13.1.0-next.502",
"path-browserify": "^1.0.1"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion integration/standalone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"@axonivy/process-editor": "~13.1.0-next",
"@axonivy/process-editor-inscription": "~13.1.0-next",
"@axonivy/process-editor-inscription-view": "~13.1.0-next",
"@axonivy/ui-components": "~13.1.0-next.451",
"@axonivy/ui-components": "~13.1.0-next.502",
"@eclipse-glsp/client": "2.3.0"
},
"devDependencies": {
Expand Down
297 changes: 221 additions & 76 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"publish:next": "lerna publish --exact --canary --preid next --pre-dist-tag next --no-git-tag-version --no-push --ignore-scripts --yes"
},
"devDependencies": {
"@axonivy/eslint-config": "~13.1.0-next.451",
"@axonivy/eslint-config": "~13.1.0-next.502",
"@types/node": "^22.10.7",
"lerna": "^8.1.9",
"prettier": "^2.8.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/inscription-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"src"
],
"dependencies": {
"@axonivy/jsonrpc": "~13.1.0-next.451",
"@axonivy/jsonrpc": "~13.1.0-next.502",
"@axonivy/process-editor-inscription-protocol": "~13.1.0-next",
"monaco-editor": "^0.44.0",
"monaco-editor-workers": "^0.44.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/inscription-view/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"dependencies": {
"@axonivy/process-editor-inscription-core": "~13.1.0-next",
"@axonivy/process-editor-inscription-protocol": "~13.1.0-next",
"@axonivy/ui-components": "~13.1.0-next.451",
"@axonivy/ui-icons": "~13.1.0-next.451",
"@axonivy/ui-components": "~13.1.0-next.502",
"@axonivy/ui-icons": "~13.1.0-next.502",
"@monaco-editor/react": "^4.7.0-rc.0",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-radio-group": "1.2.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ const CustomFieldTable = ({ data, onChange, type }: CustomFieldTableProps) => {
removeRowAction
]
: [];

return (
<PathCollapsible path='customFields' label='Custom Fields' defaultOpen={data.length > 0} controls={tableActions}>
<div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import type { ColumnDef, RowSelectionState, SortingState } from '@tanstack/react-table';
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table';
import { Property } from './properties';
Expand All @@ -24,10 +24,7 @@ type PropertyTableProps = {
const EMPTY_PROPERTY: Property = { expression: '', name: '' };

export const PropertyTable = ({ properties, update, knownProperties, hideProperties, label, defaultOpen }: PropertyTableProps) => {
const [data, setData] = useState<Property[]>([]);
useEffect(() => {
setData(Property.of(properties));
}, [properties]);
const data = useMemo(() => Property.of(properties), [properties]);

const onChange = (props: Property[]) => update(Property.to(props));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const focusNewCell = (domTable: HTMLTableElement | undefined, rowIndex: number, cellType: 'input' | 'button') => {
setTimeout(() => {
if (!domTable) return;
const validRows = Array.from(domTable.rows).filter(row => !row.classList.contains('ui-message-row'));
validRows[rowIndex]?.cells[0]?.querySelector(cellType)?.focus();
}, 0);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { deepEqual } from '../../../../utils/equals';
import { IvyIcons } from '@axonivy/ui-icons';
import { TableAddRow } from '@axonivy/ui-components';
import type { FieldsetControl } from '../../../widgets/fieldset/fieldset-control';
import { focusNewCell } from './cellFocus-utils';

interface UseResizableEditableTableProps<TData> {
data: TData[];
Expand Down Expand Up @@ -66,10 +67,13 @@ const useResizableEditableTable = <TData,>({
});

const addRow = () => {
const activeElement = document.activeElement;
const domTable = activeElement?.parentElement?.previousElementSibling?.getElementsByTagName('table')[0];
const newData = [...tableData];
newData.push(emptyDataObject);
updateTableData(newData);
setRowSelection({ [`${newData.length - 1}`]: true });
focusNewCell(domTable, newData.length, 'input');
};

const showAddButton = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useEditorContext } from '../../../../context/useEditorContext';
import { useMeta } from '../../../../context/useMeta';
import type { SelectItem } from '../../../widgets/select/Select';
import { PathCollapsible } from '../../common/path/PathCollapsible';
import { focusNewCell } from '../../common/table/cellFocus-utils';

type OrderDirection = keyof typeof QUERY_ORDER;

Expand All @@ -34,13 +35,7 @@ export const TableSort = () => {
const [data, setData] = useState<Column[]>([]);

const { elementContext: context } = useEditorContext();
const columnItems = useMeta(
'meta/database/columns',
{ context, database: config.query.dbName, table: config.query.sql.table },
[]
).data.map<SelectItem>(c => {
return { label: c.name, value: c.name };
});
const columnItems = useMeta('meta/database/columns', { context, database: config.query.dbName, table: config.query.sql.table }, []).data;
const orderItems = useMemo<SelectItem[]>(() => Object.entries(QUERY_ORDER).map(([label, value]) => ({ label, value })), []);

useEffect(() => {
Expand All @@ -61,7 +56,7 @@ export const TableSort = () => {
{
accessorKey: 'name',
header: () => <span>Column</span>,
cell: cell => <SelectCell cell={cell} items={columnItems} />
cell: cell => <SelectCell cell={cell} items={columnItems.map(c => ({ label: c.name, value: c.name }))} />
},
{
accessorKey: 'sorting',
Expand Down Expand Up @@ -132,10 +127,13 @@ export const TableSort = () => {
};

const addRow = () => {
const activeElement = document.activeElement;
const domTable = activeElement?.parentElement?.previousElementSibling?.getElementsByTagName('table')[0];
const newData = [...data];
newData.push(EMPTY_COLUMN);
setData(newData);
setRowSelection({ [`${newData.length - 1}`]: true });
focusNewCell(domTable, newData.length, 'button');
};

const updateOrder = (moveId: string, targetId: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { PathContext } from '../../../../../context/usePath';
import Fieldset from '../../../../widgets/fieldset/Fieldset';
import { ValidationRow } from '../../../common/path/validation/ValidationRow';
import { ScriptCell } from '../../../../widgets/table/cell/ScriptCell';
import { focusNewCell } from '../../../common/table/cellFocus-utils';

const EMPTY_PARAMETER: RestParam = { name: '', expression: '', known: false };

Expand Down Expand Up @@ -61,10 +62,13 @@ export const RestForm = () => {
};

const addRow = () => {
const activeElement = document.activeElement;
const domTable = activeElement?.parentElement?.previousElementSibling?.getElementsByTagName('table')[0];
const newData = [...data];
newData.push(EMPTY_PARAMETER);
onChange(newData);
setRowSelection({ [`${newData.length - 1}`]: true });
focusNewCell(domTable, newData.length, 'input');
};

const removeRow = (index: number) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { SelectItem } from '../../../../widgets/select/Select';
import { ScriptCell } from '../../../../widgets/table/cell/ScriptCell';
import { PathCollapsible } from '../../../common/path/PathCollapsible';
import { ValidationRow } from '../../../common/path/validation/ValidationRow';
import { focusNewCell } from '../../../common/table/cellFocus-utils';

const EMPTY_PARAMETER: Parameter = { kind: 'Query', name: '', expression: '', known: false };

Expand Down Expand Up @@ -79,10 +80,13 @@ export const RestParameters = () => {
};

const addRow = () => {
const activeElement = document.activeElement;
const domTable = activeElement?.parentElement?.previousElementSibling?.getElementsByTagName('table')[0];
const newData = [...data];
newData.push(EMPTY_PARAMETER);
onChange(newData);
setRowSelection({ [`${newData.length - 1}`]: true });
focusNewCell(domTable, newData.length, 'button');
};

const removeRow = (index: number) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CodeEditorProps } from './CodeEditor';
import { CodeEditor } from './CodeEditor';
import { monacoAutoFocus } from './useCodeEditor';
import type { BrowserType } from '../../browser/useBrowser';
import { focusAdjacentTabIndexMonaco } from '../../../utils/focus';

type EditorOptions = {
editorOptions?: {
Expand All @@ -14,6 +15,8 @@ type EditorOptions = {
enter?: () => void;
tab?: () => void;
escape?: () => void;
arrowDown?: () => void;
arrowUp?: () => void;
};
modifyAction?: (value: string) => string;
};
Expand Down Expand Up @@ -50,7 +53,7 @@ export const SingleLineCodeEditor = ({ onChange, onMountFuncs, editorOptions, ke
triggerAcceptSuggestion(editor);
} else {
if (editor.hasTextFocus() && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
focusAdjacentTabIndexMonaco('next');
}
if (keyActions?.tab) {
keyActions.tab();
Expand All @@ -59,10 +62,43 @@ export const SingleLineCodeEditor = ({ onChange, onMountFuncs, editorOptions, ke
},
'singleLine'
);
editor.addCommand(
MonacoEditorUtil.KeyCode.DownArrow,
() => {
if (isSuggestWidgetOpen(editor)) {
editor.trigger(undefined, 'selectNextSuggestion', undefined);
} else if (keyActions?.arrowDown) {
keyActions.arrowDown();
}
},
'singleLine'
);
editor.addCommand(
MonacoEditorUtil.KeyCode.UpArrow,
() => {
if (isSuggestWidgetOpen(editor)) {
editor.trigger(undefined, 'selectPrevSuggestion', undefined);
} else if (keyActions?.arrowUp) {
keyActions.arrowUp();
}
},
'singleLine'
);
editor.addCommand(
MonacoEditorUtil.KeyCode.Shift | MonacoEditorUtil.KeyCode.Tab,
() => {
if (editor.hasTextFocus() && document.activeElement instanceof HTMLElement) {
focusAdjacentTabIndexMonaco('previous');
}
},
'singleLine'
);
editor.addCommand(
MonacoEditorUtil.KeyCode.Escape,
() => {
if (!isSuggestWidgetOpen(editor) && keyActions?.escape) {
if (isSuggestWidgetOpen(editor)) {
editor.trigger(undefined, 'hideSuggestWidget', undefined);
} else if (keyActions?.escape) {
keyActions.escape();
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
import { useMonacoEditor } from '../../code-editor/useCodeEditor';
import { useOnFocus } from '../../../browser/useOnFocus';
import useMaximizedCodeEditor from '../../../browser/useMaximizedCodeEditor';
import { Button } from '@axonivy/ui-components';
import { Button, selectNextPreviousCell } from '@axonivy/ui-components';
import { useBrowser, type BrowserType } from '../../../browser/useBrowser';
import { usePath } from '../../../../context/usePath';
import { MaximizedCodeEditorBrowser } from '../../../browser/MaximizedCodeEditorBrowser';
import { SingleLineCodeEditor } from '../../code-editor/SingleLineCodeEditor';
import Browser from '../../../browser/Browser';
import Input from '../../input/Input';
import { focusAdjacentTabIndexMonaco } from '../../../../utils/focus';

type CodeEditorCellProps<TData> = {
cell: CellContext<TData, string>;
Expand Down Expand Up @@ -49,13 +50,6 @@ export function CodeEditorCell<TData>({ cell, macro, type, browsers, placeholder
}
}, [cell.row, isFocusWithin]);

const activeElementBlur = () => {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement) {
activeElement.blur();
}
};

return (
<div className='script-input' {...focusWithinProps} tabIndex={1}>
{isFocusWithin || browser.open || maximizeState.isMaximizedCodeEditorOpen ? (
Expand All @@ -77,8 +71,18 @@ export function CodeEditorCell<TData>({ cell, macro, type, browsers, placeholder
{...focusValue}
context={{ type, location: path }}
keyActions={{
enter: activeElementBlur,
escape: activeElementBlur
enter: () => focusAdjacentTabIndexMonaco('previous'),
escape: () => focusAdjacentTabIndexMonaco('previous'),
arrowDown: () => {
if (document.activeElement) {
selectNextPreviousCell(document.activeElement, cell, 1);
}
},
arrowUp: () => {
if (document.activeElement) {
selectNextPreviousCell(document.activeElement, cell, -1);
}
}
}}
onMountFuncs={[setEditor]}
macro={macro}
Expand Down
5 changes: 4 additions & 1 deletion packages/inscription-view/src/monaco/monaco-editor-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ export namespace MonacoEditorUtil {
Tab = 2,
Enter = 3,
Escape = 9,
F2 = 60
F2 = 60,
UpArrow = 16,
DownArrow = 18,
Shift = 1024
}

let monacoEditorReactApiPromise: Promise<MonacoEditorReactApi>;
Expand Down
48 changes: 48 additions & 0 deletions packages/inscription-view/src/utils/focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, test, expect, beforeEach } from 'vitest';
import { focusAdjacentTabIndexMonaco } from './focus';

describe('focusAdjacentTabIndexMonaco', () => {
let input1: HTMLInputElement, input2: HTMLInputElement, button: HTMLButtonElement, div: HTMLDivElement;

beforeEach(() => {
document.body.innerHTML = `
<input type="text" id="input1" />
<button id="button">Click me</button>
<input type="text" id="input2" />
<div tabindex="0" id="div1" />
`;

input1 = document.getElementById('input1') as HTMLInputElement;
button = document.getElementById('button') as HTMLButtonElement;
input2 = document.getElementById('input2') as HTMLInputElement;
div = document.getElementById('div1') as HTMLDivElement;
});

test('focus the next focusable element', () => {
input1.focus();
focusAdjacentTabIndexMonaco('next');
expect(document.activeElement).toBe(button);
});

test('focus the previous focusable element', () => {
input2.focus();
focusAdjacentTabIndexMonaco('previous');
expect(document.activeElement).toBe(input1);
});

test('do nothing if there is no active element', () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
focusAdjacentTabIndexMonaco('next');
expect(document.activeElement).not.toBe(input1);
expect(document.activeElement).not.toBe(button);
});

test('do nothing if there is no next or previous element', () => {
div.focus();
focusAdjacentTabIndexMonaco('next');
expect(document.activeElement).toBe(div);
});
});
18 changes: 18 additions & 0 deletions packages/inscription-view/src/utils/focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const focusAdjacentTabIndexMonaco = (direction: 'next' | 'previous') => {
if (!(document.activeElement instanceof HTMLElement)) return;

const focusableElements = Array.from(
document.querySelectorAll<HTMLElement>('input, button, select, textarea, [tabindex]:not([tabindex="-1"])')
);

const currentElement = document.activeElement;
const currentIndex = focusableElements.indexOf(currentElement);
if (currentIndex === -1) return;

//For previous, we need to go back 2 steps to ensure to jump out of monaco editor
const nextElement = direction === 'next' ? focusableElements[currentIndex + 1] : focusableElements[currentIndex - 2];

if (nextElement) {
nextElement.focus();
}
};

0 comments on commit 75f0d0d

Please sign in to comment.