Skip to content

Commit 5334390

Browse files
committed
token editor focus control
1 parent df198a3 commit 5334390

File tree

11 files changed

+210
-103
lines changed

11 files changed

+210
-103
lines changed

src/autosuggest/options-controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const useAutosuggestItems = ({
6363
const enteredItemLabel = i18n('enteredTextLabel', enteredTextLabel?.(filterValue), format =>
6464
format({ value: filterValue })
6565
);
66+
6667
if (!enteredItemLabel) {
6768
warnOnce('Autosuggest', 'A value for enteredTextLabel must be provided.');
6869
}

src/internal/components/focus-lock/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ const tabbables = [
2525
'[autofocus]',
2626
].join(',');
2727

28+
export function isFocusable(element: HTMLElement): boolean {
29+
return element.matches(tabbables);
30+
}
31+
2832
export function getAllFocusables(container: HTMLElement): HTMLElement[] {
2933
return Array.prototype.slice.call(container.querySelectorAll(tabbables));
3034
}

src/internal/components/token-list/index.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import React, { useState } from 'react';
55
import clsx from 'clsx';
66

7-
import { useListFocusController } from '../../hooks/use-list-focus-controller';
87
import { useUniqueId } from '../../hooks/use-unique-id';
98
import { TokenListProps } from './interfaces';
109
import TokenLimitToggle from './token-limit-toggle';
@@ -13,9 +12,6 @@ import styles from './styles.css.js';
1312

1413
export { TokenListProps };
1514

16-
const listItemSelector = `.${styles['list-item']}`;
17-
const showMoreSelector = `.${styles.toggle}`;
18-
1915
export default function TokenList<Item>({
2016
items,
2117
alignment,
@@ -25,11 +21,8 @@ export default function TokenList<Item>({
2521
i18nStrings,
2622
limitShowFewerAriaLabel,
2723
limitShowMoreAriaLabel,
28-
moveFocusNextToIndex: nextFocusIndex = null,
29-
onFocusMoved,
3024
onExpandedClick = () => undefined,
3125
}: TokenListProps<Item>) {
32-
const tokenListRef = useListFocusController({ nextFocusIndex, onFocusMoved, listItemSelector, showMoreSelector });
3326
const controlId = useUniqueId();
3427

3528
const [expanded, setExpanded] = useState(false);
@@ -59,7 +52,7 @@ export default function TokenList<Item>({
5952

6053
if (alignment === 'inline') {
6154
return (
62-
<div ref={tokenListRef} className={clsx(styles.root, styles.horizontal)}>
55+
<div className={clsx(styles.root, styles.horizontal)}>
6356
{hasItems && (
6457
<ul id={controlId} className={styles.list}>
6558
{visibleItems.map((item, itemIndex) => (
@@ -82,7 +75,7 @@ export default function TokenList<Item>({
8275
}
8376

8477
return (
85-
<div ref={tokenListRef} className={clsx(styles.root, styles.vertical)}>
78+
<div className={clsx(styles.root, styles.vertical)}>
8679
{hasVisibleItems && (
8780
<ul id={controlId} className={clsx(styles.list, styles[alignment])}>
8881
{visibleItems.map((item, itemIndex) => (

src/internal/components/token-list/interfaces.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ export interface TokenListProps<Item> {
1111
renderItem: (item: Item, itemIndex: number) => React.ReactNode;
1212
i18nStrings?: I18nStrings;
1313
moveFocusNextToIndex?: null | number;
14-
onFocusMoved?: () => void;
1514
onExpandedClick?: (isExpanded: boolean) => void;
1615
limitShowFewerAriaLabel?: string;
1716
limitShowMoreAriaLabel?: string;

src/internal/hooks/use-list-focus-controller.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { useEffect, useRef } from 'react';
55

6-
import { getFirstFocusable } from '../components/focus-lock/utils.js';
6+
import { getFirstFocusable, isFocusable } from '../components/focus-lock/utils.js';
77

88
interface UseListFocusControllerOptions {
99
nextFocusIndex: null | number;
@@ -28,8 +28,8 @@ export function useListFocusController({
2828
}
2929

3030
const tokenElements = tokenListRef.current.querySelectorAll(listItemSelector);
31-
const outsideElement = outsideSelector ? tokenListRef.current.closest(outsideSelector) : null;
32-
const toggleButton = showMoreSelector ? tokenListRef.current.querySelector(showMoreSelector) : null;
31+
const outsideElement = outsideSelector ? selectElement(tokenListRef.current, outsideSelector) : null;
32+
const toggleButton = showMoreSelector ? selectElement(tokenListRef.current, showMoreSelector) : null;
3333

3434
let closestPrevIndex = Number.NEGATIVE_INFINITY;
3535
let closestNextIndex = Number.POSITIVE_INFINITY;
@@ -46,12 +46,7 @@ export function useListFocusController({
4646

4747
const nextElement = tokenElements[closestNextIndex];
4848
const prevElement = tokenElements[closestPrevIndex];
49-
const focusTarget = getFirstEligible(
50-
nextElement instanceof HTMLElement ? getFirstFocusable(nextElement) : null,
51-
prevElement instanceof HTMLElement ? getFirstFocusable(prevElement) : null,
52-
toggleButton,
53-
outsideElement
54-
);
49+
const focusTarget = getFirstEligible(nextElement, prevElement, toggleButton, outsideElement);
5550

5651
if (focusTarget) {
5752
focusTarget.focus();
@@ -67,9 +62,27 @@ export function useListFocusController({
6762

6863
function getFirstEligible(...elements: Array<null | Element>): null | HTMLElement {
6964
for (const element of elements) {
70-
if (element instanceof HTMLElement) {
71-
return element;
65+
const focusable = element ? getFocusableElement(element) : null;
66+
if (focusable) {
67+
return focusable;
7268
}
7369
}
7470
return null;
7571
}
72+
73+
function getFocusableElement(element: Element): null | HTMLElement {
74+
if (!(element instanceof HTMLElement)) {
75+
return null;
76+
}
77+
if (isFocusable(element)) {
78+
return element;
79+
}
80+
return getFirstFocusable(element);
81+
}
82+
83+
function selectElement(container: HTMLElement, selector: string): null | Element {
84+
if (container.matches(selector)) {
85+
return container;
86+
}
87+
return container.querySelector(selector);
88+
}

src/property-filter/__tests__/property-filter-token-editor.test.tsx

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import React from 'react';
5-
import { act, fireEvent, render } from '@testing-library/react';
5+
import { act, fireEvent, render as reactRender } from '@testing-library/react';
66

77
import TestI18nProvider from '../../../lib/components/i18n/testing';
88
import { useMobile } from '../../../lib/components/internal/hooks/use-mobile';
9-
import { FilteringOption, FilteringProperty, Ref } from '../../../lib/components/property-filter/interfaces';
9+
import { FilteringOption, FilteringProperty } from '../../../lib/components/property-filter/interfaces';
1010
import PropertyFilterInternal, { PropertyFilterInternalProps } from '../../../lib/components/property-filter/internal';
1111
import createWrapper from '../../../lib/components/test-utils/dom';
1212
import { PropertyFilterWrapperInternal } from '../../../lib/components/test-utils/dom/property-filter';
13-
import { createDefaultProps, i18nStrings, i18nStringsTokenGroups, providedI18nStrings } from './common';
13+
import {
14+
createDefaultProps,
15+
i18nStrings,
16+
i18nStringsTokenGroups,
17+
providedI18nStrings,
18+
StatefulInternalPropertyFilter,
19+
} from './common';
1420

1521
jest.mock('../../../lib/components/internal/hooks/use-mobile', () => ({
1622
...jest.requireActual('../../../lib/components/internal/hooks/use-mobile'),
@@ -65,25 +71,22 @@ const filteringOptions: readonly FilteringOption[] = [
6571
{ propertyKey: 'default-operator', value: 'value' },
6672
];
6773

68-
const defaultProps = {
74+
const defaultProps: PropertyFilterInternalProps = {
6975
filteringOptions: [],
7076
customGroupsText: [],
7177
disableFreeTextFiltering: false,
7278
i18nStringsTokenGroups,
7379
...createDefaultProps(filteringProperties, filteringOptions),
7480
};
7581

76-
function renderComponent(
77-
props?: Partial<PropertyFilterInternalProps & { ref: React.Ref<Ref> }>,
78-
withI18nProvider = false
79-
) {
82+
function renderComponent(props?: Partial<PropertyFilterInternalProps>, withI18nProvider = false) {
8083
return withI18nProvider
81-
? render(
84+
? reactRender(
8285
<TestI18nProvider messages={providedI18nStrings}>
8386
<PropertyFilterInternal {...defaultProps} {...props} />
8487
</TestI18nProvider>
8588
)
86-
: render(<PropertyFilterInternal {...defaultProps} {...props} />);
89+
: reactRender(<PropertyFilterInternal {...defaultProps} {...props} />);
8790
}
8891

8992
function openEditor(tokenIndex: number, options: { expandToViewport?: boolean; isMobile?: boolean }) {
@@ -438,6 +441,10 @@ describe('token editor with groups', () => {
438441
return renderComponent({ enableTokenGroups: true, ...props });
439442
}
440443

444+
function renderStateful(props?: Partial<PropertyFilterInternalProps>) {
445+
return reactRender(<StatefulInternalPropertyFilter {...defaultProps} {...props} enableTokenGroups={true} />);
446+
}
447+
441448
test('changes property name', () => {
442449
const onChange = jest.fn();
443450
render({
@@ -735,6 +742,55 @@ describe('token editor with groups', () => {
735742
}
736743
);
737744

745+
test('moves focus to adjacent property when a filter is removed', () => {
746+
renderStateful({
747+
query: {
748+
operation: 'and',
749+
tokenGroups: [{ operation: 'or', tokens: [tokenJohn, tokenJane, tokenJack] }],
750+
tokens: [],
751+
},
752+
});
753+
const editor = openEditor(0, { expandToViewport: false });
754+
755+
// Removing 2nd filter (Jane)
756+
editor.removeActions(2).actionsMenu.openDropdown();
757+
editor.removeActions(2).removeAction().click();
758+
759+
// Focus is on new second filter (Jack)
760+
expect(editor.propertySelect(2).find('button')!.getElement()).toHaveFocus();
761+
762+
// Removing 2nd filter (Jack)
763+
editor.removeActions(2).actionsMenu.openDropdown();
764+
editor.removeActions(2).removeFromGroupAction().click();
765+
766+
// Focus is on new first filter (John)
767+
expect(editor.propertySelect(1).find('button')!.getElement()).toHaveFocus();
768+
});
769+
770+
test('moves focus to the new filter when it is added', () => {
771+
renderStateful({
772+
query: {
773+
operation: 'and',
774+
tokenGroups: [{ operation: 'or', tokens: [tokenJohn] }, tokenJane],
775+
tokens: [],
776+
},
777+
});
778+
const editor = openEditor(0, { expandToViewport: false });
779+
780+
// Adding new filter
781+
editor.addActions!.findMainAction()!.click();
782+
783+
// Focus is on newly added 2nd filter
784+
expect(editor.propertySelect(2).find('button')!.getElement()).toHaveFocus();
785+
786+
// Adding standalone token (Jane)
787+
editor.addActions!.openDropdown();
788+
editor.addActions!.findItems()[0].click();
789+
790+
// Focus is on newly added 3rd filter
791+
expect(editor.propertySelect(3).find('button')!.getElement()).toHaveFocus();
792+
});
793+
738794
test('standalone property menu items have indices as IDs', () => {
739795
const onChange = jest.fn();
740796
render({

src/property-filter/__tests__/property-filter-token-list.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ describe('filtering tokens', () => {
212212
expect(handleChange).not.toHaveBeenCalled();
213213
});
214214

215-
test('moves focus to the first token in the group when the last one is removed', () => {
215+
test('moves focus to the adjacent grouped token and to the single remaining token', () => {
216216
const wrapper = renderStatefulInternalComponent({
217217
query: {
218218
operation: 'and',
@@ -233,11 +233,11 @@ describe('filtering tokens', () => {
233233

234234
wrapper.findTokens()[0].findGroupTokens()[3].findRemoveButton()!.click();
235235
expect(wrapper.findTokens()[0].findGroupTokens()).toHaveLength(3);
236-
expect(wrapper.findTokens()[0].findGroupTokens()[0].findRemoveButton()!.getElement()).toHaveFocus();
236+
expect(wrapper.findTokens()[0].findGroupTokens()[2]!.find('button')!.getElement()).toHaveFocus();
237237

238-
wrapper.findTokens()[0].findGroupTokens()[2].findRemoveButton()!.click();
238+
wrapper.findTokens()[0].findGroupTokens()[0].findRemoveButton()!.click();
239239
expect(wrapper.findTokens()[0].findGroupTokens()).toHaveLength(2);
240-
expect(wrapper.findTokens()[0].findGroupTokens()[0].findRemoveButton()!.getElement()).toHaveFocus();
240+
expect(wrapper.findTokens()[0].findGroupTokens()[0]!.find('button')!.getElement()).toHaveFocus();
241241

242242
wrapper.findTokens()[0].findGroupTokens()[1].findRemoveButton()!.click();
243243
expect(wrapper.findTokens()[0].findGroupTokens()).toHaveLength(0);

0 commit comments

Comments
 (0)