Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(list): fixed a bug where keyboard navigation was not scoped within sub-lists #458

Merged
merged 1 commit into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/lib/core/base/base-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { emitEvent, toggleAttribute } from '@tylertech/forge-core';
import { IBaseComponent } from './base-component';

export interface IBaseAdapter {
export interface IBaseAdapter<T extends HTMLElement = HTMLElement> {
readonly hostElement: T;
readonly isConnected: boolean;
removeHostAttribute(name: string): void;
getHostAttribute(name: string): string | null;
Expand All @@ -19,9 +20,13 @@ export interface IBaseAdapter {
removeBodyAttribute(name: string): void;
}

export class BaseAdapter<T extends IBaseComponent> implements IBaseAdapter {
export class BaseAdapter<T extends IBaseComponent> implements IBaseAdapter<T> {
constructor(protected _component: T) {}

public get hostElement(): T {
return this._component;
}

public getHostAttribute(name: string): string | null {
return this._component.getAttribute(name);
}
Expand Down
57 changes: 32 additions & 25 deletions src/lib/list/list/list-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import { IListItemComponent, LIST_ITEM_CONSTANTS } from '../list-item';
import { IListComponent } from './list';
import { LIST_CONSTANTS } from './list-constants';

export interface IListAdapter extends IBaseAdapter {
export interface IListAdapter extends IBaseAdapter<IListComponent> {
initializeAccessibility(): void;
addListener(type: string, listener: (evt: Event) => void): void;
removeListener(type: string, listener: (evt: Event) => void): void;
getListItems(): IListItemComponent[];
focusNextListItem(): void;
focusPreviousListItem(): void;
focusFirstListItem(): void;
Expand Down Expand Up @@ -49,23 +48,14 @@ export class ListAdapter extends BaseAdapter<IListComponent> implements IListAda
this._component.removeEventListener(type, listener);
}

/**
* Returns all child `<forge-list-item>` elements.
*/
public getListItems(): IListItemComponent[] {
return Array.from(this._component.children).filter(child => child.tagName === LIST_ITEM_CONSTANTS.elementName.toUpperCase()) as IListItemComponent[];
}

/**
* Sets focus to the next item in the list.
*/
public focusNextListItem(): void {
const listItems = deepQuerySelectorAll(this._component, LIST_CONSTANTS.selectors.FOCUSABLE_LIST_ITEMS, false) as HTMLElement[];

if (listItems && listItems.length > 0) {
const focusedListItemIndex = listItems.indexOf(getActiveElement(this._component.ownerDocument) as HTMLElement);
const listItems = this._getOwnListItems();
if (listItems.length > 0) {
const focusedListItemIndex = listItems.findIndex(li => li.matches(':focus-within'));
const nextIndex = focusedListItemIndex < listItems.length - 1 ? focusedListItemIndex + 1 : 0;

if (nextIndex <= listItems.length - 1) {
listItems[nextIndex].focus();
}
Expand All @@ -76,12 +66,10 @@ export class ListAdapter extends BaseAdapter<IListComponent> implements IListAda
* Sets focus to the previous item in the list.
*/
public focusPreviousListItem(): void {
const listItems = deepQuerySelectorAll(this._component, LIST_CONSTANTS.selectors.FOCUSABLE_LIST_ITEMS, false) as HTMLElement[];

if (listItems && listItems.length > 0) {
const focusedListItemIndex = listItems.indexOf(getActiveElement(this._component.ownerDocument) as HTMLElement);
const listItems = this._getOwnListItems();
if (listItems.length > 0) {
const focusedListItemIndex = listItems.findIndex(li => li.matches(':focus-within'));
const nextIndex = focusedListItemIndex > 0 ? focusedListItemIndex - 1 : listItems.length - 1;

if (nextIndex >= 0) {
listItems[nextIndex].focus();
}
Expand All @@ -92,9 +80,8 @@ export class ListAdapter extends BaseAdapter<IListComponent> implements IListAda
* Sets focus to the first item in the list.
*/
public focusFirstListItem(): void {
const listItems = deepQuerySelectorAll(this._component, LIST_CONSTANTS.selectors.FOCUSABLE_LIST_ITEMS, false) as HTMLElement[];

if (listItems && listItems.length > 0) {
const listItems = this._getOwnListItems();
if (listItems.length > 0) {
listItems[0].focus();
}
}
Expand All @@ -103,8 +90,8 @@ export class ListAdapter extends BaseAdapter<IListComponent> implements IListAda
* Sets focus to the last item in the list.
*/
public focusLastListItem(): void {
const listItems = deepQuerySelectorAll(this._component, LIST_CONSTANTS.selectors.FOCUSABLE_LIST_ITEMS, false) as HTMLElement[];
if (listItems && listItems.length > 0) {
const listItems = this._getOwnListItems();
if (listItems.length > 0) {
listItems[listItems.length - 1].focus();
}
}
Expand All @@ -119,6 +106,26 @@ export class ListAdapter extends BaseAdapter<IListComponent> implements IListAda
}

public updateListItems(cb: (li: IListItemComponent) => void): void {
this.getListItems().forEach(li => cb(li));
this._getOwnListItems().forEach(li => cb(li));
}

private _getOwnListItems(): IListItemComponent[] {
// Find all deeply nested list items
const allChildListItems = deepQuerySelectorAll(this._component, LIST_ITEM_CONSTANTS.elementName) as IListItemComponent[];

// Get all list items that are scoped to this component only (not within sub-lists).
const scopedListItems: IListItemComponent[] = [];
const listener: EventListener = evt => {
const composedPath = evt.composedPath();
const composedBeforeUs = composedPath.slice(0, composedPath.indexOf(this._component));
if (!composedBeforeUs.some((el: HTMLElement) => el.localName === LIST_CONSTANTS.elementName.toLowerCase())) {
scopedListItems.push(evt.target as IListItemComponent);
}
};
this._component.addEventListener(LIST_CONSTANTS.events.SCOPE_TEST, listener);
allChildListItems.forEach(li => li.dispatchEvent(new CustomEvent(LIST_CONSTANTS.events.SCOPE_TEST, { bubbles: true, composed: true })));
this._component.removeEventListener(LIST_CONSTANTS.events.SCOPE_TEST, listener);

return scopedListItems;
}
}
8 changes: 4 additions & 4 deletions src/lib/list/list/list-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ const attributes = {
SELECTED_VALUE: 'selected-value'
};

const selectors = {
FOCUSABLE_LIST_ITEMS: '.forge-list-item:not(.forge-list-item--static):not(.forge-list-item--disabled)'
};
const events = {
SCOPE_TEST: `${elementName}-item-scope-test`
} as const;

export const LIST_CONSTANTS = {
elementName,
attributes,
selectors
events
};
7 changes: 7 additions & 0 deletions src/lib/list/list/list-foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export class ListFoundation implements IListFoundation {
}

private _onKeydown(evt: KeyboardEvent): void {
const path = evt.composedPath();
const composedBeforeUs = path.slice(0, path.indexOf(this._adapter.hostElement));
const fromListDescendant = composedBeforeUs.some((el: HTMLElement) => el.localName === LIST_CONSTANTS.elementName.toLowerCase());
if (fromListDescendant) {
return; // We ignore keydown events coming from sub-lists because they are already handling it themselves
}

const isArrowDown = evt.key === 'ArrowDown' || evt.keyCode === 40;
const isArrowUp = evt.key === 'ArrowUp' || evt.keyCode === 38;
const isHome = evt.key === 'Home' || evt.keyCode === 36;
Expand Down
Loading