Skip to content

Commit

Permalink
feat(menu): add no-navigation-wrap to fix select accessibility
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 610514684
  • Loading branch information
asyncLiz authored and copybara-github committed Feb 26, 2024
1 parent ec0a8eb commit c6ffd70
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 12 deletions.
55 changes: 45 additions & 10 deletions list/internal/list-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export interface ListControllerConfig<Item extends ListItem> {
* disabled.
*/
isActivatable?: (item: Item) => boolean;
/**
* Whether or not navigating past the end of the list wraps to the beginning
* and vice versa. Defaults to true.
*/
wrapNavigation?: () => boolean;
}

/**
Expand All @@ -84,6 +89,7 @@ export class ListController<Item extends ListItem> {
private readonly activateItem: (item: Item) => void;
private readonly isNavigableKey: (key: string) => boolean;
private readonly isActivatable?: (item: Item) => boolean;
private readonly wrapNavigation: () => boolean;

constructor(config: ListControllerConfig<Item>) {
const {
Expand All @@ -94,6 +100,7 @@ export class ListController<Item extends ListItem> {
activateItem,
isNavigableKey,
isActivatable,
wrapNavigation,
} = config;
this.isItem = isItem;
this.getPossibleItems = getPossibleItems;
Expand All @@ -102,6 +109,7 @@ export class ListController<Item extends ListItem> {
this.activateItem = activateItem;
this.isNavigableKey = isNavigableKey;
this.isActivatable = isActivatable;
this.wrapNavigation = wrapNavigation ?? (() => true);
}

/**
Expand Down Expand Up @@ -149,10 +157,6 @@ export class ListController<Item extends ListItem> {

const activeItemRecord = getActiveItem(items, this.isActivatable);

if (activeItemRecord) {
activeItemRecord.item.tabIndex = -1;
}

event.preventDefault();

const isRtl = this.isRtl();
Expand All @@ -163,32 +167,53 @@ export class ListController<Item extends ListItem> {
? NavigableKeys.ArrowLeft
: NavigableKeys.ArrowRight;

let nextActiveItem: Item | null = null;
switch (key) {
// Activate the next item
case NavigableKeys.ArrowDown:
case inlineNext:
activateNextItem(items, activeItemRecord, this.isActivatable);
nextActiveItem = activateNextItem(
items,
activeItemRecord,
this.isActivatable,
this.wrapNavigation(),
);
break;

// Activate the previous item
case NavigableKeys.ArrowUp:
case inlinePrevious:
activatePreviousItem(items, activeItemRecord, this.isActivatable);
nextActiveItem = activatePreviousItem(
items,
activeItemRecord,
this.isActivatable,
this.wrapNavigation(),
);
break;

// Activate the first item
case NavigableKeys.Home:
activateFirstItem(items, this.isActivatable);
nextActiveItem = activateFirstItem(items, this.isActivatable);
break;

// Activate the last item
case NavigableKeys.End:
activateLastItem(items, this.isActivatable);
nextActiveItem = activateLastItem(items, this.isActivatable);
break;

default:
break;
}

if (
nextActiveItem &&
activeItemRecord &&
activeItemRecord.item !== nextActiveItem
) {
// If a new item was activated, remove the tabindex of the previous
// activated item.
activeItemRecord.item.tabIndex = -1;
}
};

/**
Expand All @@ -203,7 +228,12 @@ export class ListController<Item extends ListItem> {
if (activeItemRecord) {
activeItemRecord.item.tabIndex = -1;
}
return activateNextItem(items, activeItemRecord, this.isActivatable);
return activateNextItem(
items,
activeItemRecord,
this.isActivatable,
this.wrapNavigation(),
);
}

/**
Expand All @@ -218,7 +248,12 @@ export class ListController<Item extends ListItem> {
if (activeItemRecord) {
activeItemRecord.item.tabIndex = -1;
}
return activatePreviousItem(items, activeItemRecord, this.isActivatable);
return activatePreviousItem(
items,
activeItemRecord,
this.isActivatable,
this.wrapNavigation(),
);
}

/**
Expand Down
32 changes: 30 additions & 2 deletions list/internal/list-navigation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,23 @@ export function getLastActivatableItem<Item extends ListItem>(
* @param index {{index: number}} The index to search from.
* @param isActivatable Function to determine if an item can be activated.
* Defaults to non-disabled items.
* @param wrap If true, then the next item at the end of the list is the first
* item. Defaults to true.
* @return The next activatable item or `null` if none are activatable.
*/
export function getNextItem<Item extends ListItem>(
items: Item[],
index: number,
isActivatable = isItemNotDisabled<Item>,
wrap = true,
) {
for (let i = 1; i < items.length; i++) {
const nextIndex = (i + index) % items.length;
if (nextIndex < index && !wrap) {
// Return if the index loops back to the beginning and not wrapping.
return null;
}

const item = items[nextIndex];
if (isActivatable(item)) {
return item;
Expand All @@ -187,15 +195,23 @@ export function getNextItem<Item extends ListItem>(
* @param index {{index: number}} The index to search from.
* @param isActivatable Function to determine if an item can be activated.
* Defaults to non-disabled items.
* @param wrap If true, then the previous item at the beginning of the list is
* the last item. Defaults to true.
* @return The previous activatable item or `null` if none are activatable.
*/
export function getPrevItem<Item extends ListItem>(
items: Item[],
index: number,
isActivatable = isItemNotDisabled<Item>,
wrap = true,
) {
for (let i = 1; i < items.length; i++) {
const prevIndex = (index - i + items.length) % items.length;
if (prevIndex > index && !wrap) {
// Return if the index loops back to the end and not wrapping.
return null;
}

const item = items[prevIndex];

if (isActivatable(item)) {
Expand All @@ -214,9 +230,15 @@ export function activateNextItem<Item extends ListItem>(
items: Item[],
activeItemRecord: null | ItemRecord<Item>,
isActivatable = isItemNotDisabled<Item>,
wrap = true,
): Item | null {
if (activeItemRecord) {
const next = getNextItem(items, activeItemRecord.index, isActivatable);
const next = getNextItem(
items,
activeItemRecord.index,
isActivatable,
wrap,
);

if (next) {
next.tabIndex = 0;
Expand All @@ -237,9 +259,15 @@ export function activatePreviousItem<Item extends ListItem>(
items: Item[],
activeItemRecord: null | ItemRecord<Item>,
isActivatable = isItemNotDisabled<Item>,
wrap = true,
): Item | null {
if (activeItemRecord) {
const prev = getPrevItem(items, activeItemRecord.index, isActivatable);
const prev = getPrevItem(
items,
activeItemRecord.index,
isActivatable,
wrap,
);
if (prev) {
prev.tabIndex = 0;
prev.focus();
Expand Down
9 changes: 9 additions & 0 deletions menu/internal/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ export abstract class Menu extends LitElement {
@property({attribute: 'default-focus'})
defaultFocus: FocusState = FocusState.FIRST_ITEM;

/**
* Turns off navigation wrapping. By default, navigating past the end of the
* menu items will wrap focus back to the beginning and vice versa. Use this
* for ARIA patterns that do not wrap focus, like combobox.
*/
@property({type: Boolean, attribute: 'no-navigation-wrap'})
noNavigationWrap = false;

@queryAssignedElements({flatten: true}) protected slotItems!: HTMLElement[];
@state() private typeaheadActive = true;

Expand Down Expand Up @@ -282,6 +290,7 @@ export abstract class Menu extends LitElement {

return submenuNavKeys.has(key);
},
wrapNavigation: () => !this.noNavigationWrap,
});

/**
Expand Down
1 change: 1 addition & 0 deletions select/internal/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ export abstract class Select extends selectBaseClass {
? `${this.selectWidth}px`
: undefined,
})}
no-navigation-wrap
.open=${this.open}
.quick=${this.quick}
.positioning=${this.menuPositioning}
Expand Down

0 comments on commit c6ffd70

Please sign in to comment.