Skip to content

Commit 0c19bf5

Browse files
sddonnealbertoblaz
authored andcommitted
[ES|QL] Index editor- Improve accessibility (elastic#240462)
Part of elastic#235730 ## Summary Provides a more consistent keyboard accessibility experience for the index editor. * `Tab` moves the focus to the next element in the page (The Close X button). * `Arrows` allow to move between cells and headers. * `Enter` on cells trigger the edit mode. * `Enter` on headers opens the header actions (Edit or Delete). * `Enter` on the input edit mode, saves the changes and focus the cell/header. * `Esc` goes out of the edit mode without saving, keeps focus on the cell/header. ### Before * Focus was lost from the flyout after saving a column name or hitting `Esc`. * 2 enters were required to edit a column name, after the first enter, this was shown: <img width="321" height="56" alt="image" src="https://github.com/user-attachments/assets/c3056fcb-bc13-443d-ac94-893c4d05b503" /> ![index-editor](https://github.com/user-attachments/assets/b0d5da49-8cdd-44e9-94ed-d2eae690fcf0) ### After * Focus remains set to column header cell after exiting the name input, allowing to continue to do editions without using the mouse. * Editing the column name is now a header action. <img width="317" height="187" alt="image" src="https://github.com/user-attachments/assets/1f7ba575-a91d-49fd-84cf-a72297a09ba3" /> * You can still access the edition mode by clicking the name with the mouse. * For editing using the keyboard, you need to hit enter, and then select the edit action. **Why is this needed?** The datagrid wont let us handle correctly the focus if there are other actions available in the header, in our case we have `Delete columns and values`, this has a good accessibility reason as this action will become inaccessible if we open the edition mode straight away. * Hitting tab while editing the next column, now moves to the first column of the next row. ![index-editor-2](https://github.com/user-attachments/assets/8ddd0283-63e0-4ce6-9c40-2c36f028df8b)
1 parent 78660ac commit 0c19bf5

File tree

3 files changed

+153
-69
lines changed

3 files changed

+153
-69
lines changed

src/platform/packages/private/kbn-index-editor/src/components/data_grid.tsx

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import useObservable from 'react-use/lib/useObservable';
2929
import { difference, intersection, isEqual } from 'lodash';
3030
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
3131
import { FormattedMessage } from '@kbn/i18n-react';
32+
import { memoize } from 'lodash';
3233
import { RowColumnCreator } from './row_column_creator';
3334
import { getColumnInputRenderer } from './grid_custom_renderers/column_input_renderer';
3435
import { type KibanaContextExtra } from '../types';
@@ -67,6 +68,8 @@ const DataGrid: React.FC<ESQLDataGridProps> = (props) => {
6768
},
6869
} = useKibana<KibanaContextExtra>();
6970

71+
const [editingColumnIndex, setEditingColumnIndex] = useState<number | null>(null);
72+
7073
const isFetching = useObservable(indexUpdateService.isFetching$, false);
7174
const sortOrder = useObservable(indexUpdateService.sortOrder$, []);
7275

@@ -164,22 +167,37 @@ const DataGrid: React.FC<ESQLDataGridProps> = (props) => {
164167

165168
// We render an editable header for columns that are not saved in the index.
166169
const customGridColumnsConfiguration = useMemo<CustomGridColumnsConfiguration>(() => {
167-
return renderedColumns.reduce<CustomGridColumnsConfiguration>((acc, columnName) => {
168-
if (!props.dataView.fields.getByName(columnName)) {
169-
acc[columnName] = getColumnInputRenderer(
170-
columnName,
171-
indexUpdateService,
172-
indexEditorTelemetryService
173-
);
174-
} else {
175-
acc[columnName] = (customGridColumnProps: CustomGridColumnProps) => ({
176-
...customGridColumnProps.column,
177-
actions: { showHide: false },
178-
});
179-
}
180-
return acc;
181-
}, {} as CustomGridColumnsConfiguration);
182-
}, [renderedColumns, props.dataView.fields, indexUpdateService, indexEditorTelemetryService]);
170+
return renderedColumns.reduce<CustomGridColumnsConfiguration>(
171+
(acc, columnName, columnIndex) => {
172+
if (!props.dataView.fields.getByName(columnName)) {
173+
const editMode = editingColumnIndex === columnIndex;
174+
acc[columnName] = memoize(
175+
getColumnInputRenderer(
176+
columnName,
177+
columnIndex,
178+
editMode,
179+
setEditingColumnIndex,
180+
indexUpdateService,
181+
indexEditorTelemetryService
182+
)
183+
);
184+
} else {
185+
acc[columnName] = (customGridColumnProps: CustomGridColumnProps) => ({
186+
...customGridColumnProps.column,
187+
actions: { showHide: false, showSortAsc: false, showSortDesc: false },
188+
});
189+
}
190+
return acc;
191+
},
192+
{} as CustomGridColumnsConfiguration
193+
);
194+
}, [
195+
renderedColumns,
196+
props.dataView.fields,
197+
editingColumnIndex,
198+
indexUpdateService,
199+
indexEditorTelemetryService,
200+
]);
183201

184202
const bulkActions = useMemo<
185203
React.ComponentProps<typeof UnifiedDataTable>['customBulkActions']

src/platform/packages/private/kbn-index-editor/src/components/grid_custom_renderers/column_input_renderer.tsx

Lines changed: 106 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,67 @@
99

1010
import type { EuiDataGridColumn } from '@elastic/eui';
1111
import type { CustomGridColumnProps } from '@kbn/unified-data-table';
12-
import { EuiFieldText, EuiButtonEmpty, EuiForm, EuiToolTip, useEuiTheme } from '@elastic/eui';
13-
import type { KeyboardEvent } from 'react';
14-
import React, { useState, useCallback, useMemo } from 'react';
12+
import {
13+
EuiFieldText,
14+
EuiButtonEmpty,
15+
EuiForm,
16+
EuiToolTip,
17+
useEuiTheme,
18+
EuiFocusTrap,
19+
findElementBySelectorOrRef,
20+
} from '@elastic/eui';
21+
import type { HTMLAttributes, KeyboardEvent } from 'react';
22+
import React, { useCallback, useMemo } from 'react';
1523
import { FormattedMessage } from '@kbn/i18n-react';
24+
import { i18n } from '@kbn/i18n';
1625
import { isPlaceholderColumn } from '../../utils';
1726
import type { IndexUpdateService } from '../../index_update_service';
1827
import { useAddColumnName, errorMessages } from '../../hooks/use_add_column_name';
1928
import type { IndexEditorTelemetryService } from '../../telemetry/telemetry_service';
2029

30+
const COLUMN_INDEX_PROP = 'data-column-index';
31+
2132
export const getColumnInputRenderer = (
2233
columnName: string,
34+
columnIndex: number,
35+
isColumnInEditMode: boolean,
36+
setEditingColumnIndex: (columnIndex: number | null) => void,
2337
indexUpdateService: IndexUpdateService,
2438
telemetryService: IndexEditorTelemetryService
2539
): ((props: CustomGridColumnProps) => EuiDataGridColumn) => {
2640
return ({ column }) => ({
2741
...column,
2842
display: (
2943
<AddColumnHeader
44+
isColumnInEditMode={isColumnInEditMode}
45+
setEditingColumnIndex={setEditingColumnIndex}
3046
initialColumnName={columnName}
31-
containerId={column.id}
47+
columnIndex={columnIndex}
3248
telemetryService={telemetryService}
3349
/>
3450
),
51+
displayHeaderCellProps: { [COLUMN_INDEX_PROP]: columnIndex } as HTMLAttributes<HTMLDivElement>,
3552
actions: {
3653
showHide: false,
54+
showSortAsc: false,
55+
showSortDesc: false,
56+
showMoveLeft: false,
57+
showMoveRight: false,
3758
additional: [
59+
{
60+
'data-test-subj': 'indexEditorindexEditorEditColumnButton',
61+
label: (
62+
<FormattedMessage
63+
id="indexEditor.flyout.grid.columnHeader.editAction"
64+
defaultMessage="Edit name"
65+
/>
66+
),
67+
size: 'xs',
68+
iconType: 'pencil',
69+
onClick: () => {
70+
setEditingColumnIndex(columnIndex);
71+
},
72+
},
3873
{
3974
'data-test-subj': 'indexEditorindexEditorDeleteColumnButton',
4075
label: (
@@ -55,43 +90,49 @@ export const getColumnInputRenderer = (
5590
};
5691

5792
interface AddColumnHeaderProps {
93+
isColumnInEditMode: boolean;
94+
setEditingColumnIndex: (columnIndex: number | null) => void;
5895
initialColumnName: string;
59-
containerId: string;
96+
columnIndex: number;
6097
telemetryService: IndexEditorTelemetryService;
6198
}
6299

63-
export const AddColumnHeader = ({ initialColumnName, telemetryService }: AddColumnHeaderProps) => {
100+
export const AddColumnHeader = ({
101+
isColumnInEditMode,
102+
setEditingColumnIndex,
103+
initialColumnName,
104+
columnIndex,
105+
telemetryService,
106+
}: AddColumnHeaderProps) => {
64107
const { euiTheme } = useEuiTheme();
65108
const { columnName, setColumnName, saveColumn, resetColumnName, validationError } =
66109
useAddColumnName(initialColumnName);
67110

68-
const [isEditing, setIsEditing] = useState(false);
69-
70111
const onBlur = useCallback(() => {
71112
if (columnName && !validationError) {
72113
saveColumn();
73114
} else {
74115
resetColumnName();
75116
}
76-
setIsEditing(false);
77-
}, [columnName, validationError, saveColumn, resetColumnName]);
117+
setEditingColumnIndex(null);
118+
}, [columnName, validationError, setEditingColumnIndex, saveColumn, resetColumnName]);
78119

79120
const onSubmit = useCallback(
80121
(event: React.FormEvent<HTMLFormElement>) => {
81122
event.preventDefault();
82123
event.stopPropagation();
83124

84125
if (columnName && !validationError) {
126+
setEditingColumnIndex(null);
85127
saveColumn();
86-
setIsEditing(false);
87128
} else {
88129
telemetryService.trackEditInteraction({
89130
actionType: 'add_column',
90131
failureReason: validationError || 'EMPTY_NAME',
91132
});
92133
}
93134
},
94-
[columnName, validationError, saveColumn, telemetryService]
135+
[columnName, validationError, setEditingColumnIndex, saveColumn, telemetryService]
95136
);
96137

97138
const columnLabel = isPlaceholderColumn(initialColumnName) ? (
@@ -110,62 +151,77 @@ export const AddColumnHeader = ({ initialColumnName, telemetryService }: AddColu
110151
: validationError;
111152
}, [validationError, columnName]);
112153

113-
if (isEditing) {
154+
const returnFocus = useCallback(() => {
155+
requestAnimationFrame(() => {
156+
const headerWrapper = findElementBySelectorOrRef(`[${COLUMN_INDEX_PROP}="${columnIndex}"]`);
157+
158+
if (headerWrapper) {
159+
headerWrapper.focus();
160+
}
161+
});
162+
163+
return false;
164+
}, [columnIndex]);
165+
166+
if (isColumnInEditMode) {
114167
return (
115-
<EuiForm component="form" onSubmit={onSubmit}>
116-
<EuiToolTip position="top" content={errorMessage} anchorProps={{ css: { width: '100%' } }}>
117-
<EuiFieldText
118-
data-test-subj="indexEditorindexEditorColumnNameInput"
119-
value={columnName}
120-
autoFocus
121-
fullWidth
122-
controlOnly
123-
compressed
124-
onChange={(e) => {
125-
setColumnName(e.target.value);
126-
}}
127-
onBlur={onBlur}
128-
onKeyDown={(e: KeyboardEvent) => {
129-
e.stopPropagation();
130-
if (e.key === 'Escape') {
131-
e.preventDefault();
132-
resetColumnName();
133-
setIsEditing(false);
134-
}
135-
}}
136-
css={{
137-
'&:focus-within': {
138-
outline: 'none',
139-
},
140-
}}
141-
/>
142-
</EuiToolTip>
143-
</EuiForm>
168+
<EuiFocusTrap initialFocus="input" returnFocus={returnFocus}>
169+
<EuiForm component="form" onSubmit={onSubmit}>
170+
<EuiToolTip
171+
position="top"
172+
content={errorMessage}
173+
anchorProps={{ css: { width: '100%' } }}
174+
>
175+
<EuiFieldText
176+
data-test-subj="indexEditorindexEditorColumnNameInput"
177+
value={columnName}
178+
fullWidth
179+
controlOnly
180+
compressed
181+
onChange={(e) => {
182+
setColumnName(e.target.value);
183+
}}
184+
onBlur={onBlur}
185+
onKeyDown={(e: KeyboardEvent) => {
186+
e.stopPropagation();
187+
188+
if (e.key === 'Escape') {
189+
e.preventDefault();
190+
resetColumnName();
191+
setEditingColumnIndex(null);
192+
}
193+
}}
194+
css={{
195+
'&:focus-within': {
196+
outline: 'none',
197+
},
198+
}}
199+
/>
200+
</EuiToolTip>
201+
</EuiForm>
202+
</EuiFocusTrap>
144203
);
145204
}
146205

147206
return (
148207
<EuiButtonEmpty
149208
data-test-subj="indexEditorindexEditorColumnNameButton"
209+
aria-label={i18n.translate('indexEditor.columnHeaderEdit.aria', {
210+
defaultMessage: 'Edit column name',
211+
})}
150212
css={{
151213
color: euiTheme.colors.textSubdued,
152214
width: '100%',
153215
height: euiTheme.size.xl,
154216
}}
217+
tabIndex={-1}
155218
flush="left"
156219
contentProps={{
157220
css: {
158221
justifyContent: 'left',
159222
},
160223
}}
161-
onClick={() => setIsEditing(true)}
162-
onKeyDown={(e: KeyboardEvent) => {
163-
e.preventDefault();
164-
e.stopPropagation();
165-
if (e.key === 'Enter') {
166-
setIsEditing(true);
167-
}
168-
}}
224+
onClick={() => setEditingColumnIndex(columnIndex)}
169225
>
170226
{columnLabel}
171227
</EuiButtonEmpty>

src/platform/packages/private/kbn-index-editor/src/components/grid_custom_renderers/value_input_popover.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,20 @@ export const getValueInputPopover =
8383
return;
8484
}
8585

86-
dataTableRef?.current?.closeCellPopover();
87-
if (columns.length > colIndex) {
86+
const dataTable = dataTableRef?.current;
87+
if (!dataTable) return;
88+
89+
dataTable.closeCellPopover();
90+
91+
// Calculate next cell position
92+
const nextColIndex = colIndex + 1;
93+
const nextRowIndex = nextColIndex > columns.length ? rowIndex + 1 : rowIndex;
94+
const finalColIndex = nextColIndex > columns.length ? 1 : nextColIndex;
95+
96+
// Only navigate if there's a next row available
97+
if (nextRowIndex < rows.length) {
8898
requestAnimationFrame(() => {
89-
dataTableRef?.current?.openCellPopover({ rowIndex, colIndex: colIndex + 1 });
99+
dataTable.openCellPopover({ rowIndex: nextRowIndex, colIndex: finalColIndex });
90100
});
91101
}
92102
}

0 commit comments

Comments
 (0)