Skip to content

Commit

Permalink
refactor(menu)!: update menu to use host-aria
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Menu no longer uses md-list internally which means the list-related properties such as `list-tabindex` and `type` should now be on the host of md-menu. The new attributes should be `tabindex` and `role` respectively.

PiperOrigin-RevId: 567702229
  • Loading branch information
Elliott Marquez authored and copybara-github committed Sep 22, 2023
1 parent 375b766 commit 0384507
Show file tree
Hide file tree
Showing 8 changed files with 668 additions and 116 deletions.
3 changes: 1 addition & 2 deletions menu/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ export class MenuHarness extends Harness<Menu> {
*/
protected override async getInteractiveElement() {
await this.element.updateComplete;
return this.element.renderRoot.querySelector('md-list')!.renderRoot
.querySelector('.list') as HTMLElement;
return this.element as Menu;
}

/** @return ListItem harnesses for the menu's items. */
Expand Down
42 changes: 23 additions & 19 deletions menu/internal/_menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,38 +32,33 @@

@mixin styles() {
$tokens: tokens.md-comp-menu-values();
@each $token, $value in $tokens {
$tokens: map.set($tokens, $token, var(--md-menu-#{$token}, #{$value}));
}

:host {
@each $token, $value in $tokens {
--_#{$token}: var(--md-menu-#{$token}, #{$value});
}

@include md-list.theme(
(
'container-color': var(--_container-color),
)
);

@include elevation.theme(
(
'level': var(--_container-elevation),
'shadow-color': var(--_container-shadow-color),
'level': map.get($tokens, 'container-elevation'),
'shadow-color': map.get($tokens, 'container-shadow-color'),
)
);

min-width: 112px;
color: unset;
display: contents;
}

md-focus-ring {
@include focus-ring.theme(
(
'shape': var(--_container-shape),
'shape': map.get($tokens, 'container-shape'),
)
);
}

.menu {
border-radius: var(--_container-shape);
border-radius: map.get($tokens, 'container-shape');
display: none;
opacity: 0;
z-index: 20;
Expand All @@ -79,25 +74,34 @@
position: fixed;
}

md-list {
.items {
display: block;
list-style-type: none;
margin: 0;
outline: none;
box-sizing: border-box;
background-color: map.get($tokens, 'container-color');
height: inherit;
max-height: inherit;
display: block;
overflow: auto;
min-width: inherit;
max-width: inherit;
border-radius: inherit;
}

.has-overflow md-list {
.item-padding {
padding-block: 8px;
}

.has-overflow .items {
overflow: visible;
}

.animating md-list {
.animating .items {
overflow: hidden;
}

.has-overflow.animating md-list {
.has-overflow.animating .items {
// Often has-overlow is set because there are submenus. Since we need
// overflow to be hidden to make the animation work, we need to disable
// submenus opening mid-animation or else it looks completely wrong.
Expand Down
261 changes: 261 additions & 0 deletions menu/internal/list-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {activateFirstItem, activateLastItem, activateNextItem, activatePreviousItem, getActiveItem, getFirstActivatableItem, ListItem} from './list-navigation-helpers.js';

// TODO: move this file to List and make List use this

/**
* Default keys that trigger navigation.
*/
// tslint:disable:enforce-name-casing Following Enum style
export const NavigableKeys = {
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowUp: 'ArrowUp',
ArrowRight: 'ArrowRight',
Home: 'Home',
End: 'End',
} as const;
// tslint:enable:enforce-name-casing

/**
* Default set of navigable keys.
*/
export type NavigableKeys = typeof NavigableKeys[keyof typeof NavigableKeys];

/**
* The configuration object to customize the behavior of the List Controller
*/
export interface ListControllerConfig<Item extends ListItem> {
/**
* A function that determines whether or not the given element is an Item
*/
isItem: (item: HTMLElement) => item is Item;
/**
* A function that returns an array of elements to consider as items. For
* example, all the slotted elements.
*/
getPossibleItems: () => HTMLElement[];
/**
* A function that returns whether or not the list is in an RTL context.
*/
isRtl: () => boolean;
/**
* Deactivates an item such as setting the tabindex to -1 and or sets selected
* to false.
*/
deactivateItem: (item: Item) => void;
/**
* Activates an item such as setting the tabindex to 1 and or sets selected to
* true (but does not focus).
*/
activateItem: (item: Item) => void;
/**
* Whether or not the key should be handled by the list for navigation.
*/
isNavigableKey: (key: string) => boolean;
}

/**
* A controller that handles list keyboard navigation and item management.
*/
export class ListController<Item extends ListItem> {
isItem: (item: HTMLElement) => item is Item;
private readonly getPossibleItems: () => HTMLElement[];
private readonly isRtl: () => boolean;
private readonly deactivateItem: (item: Item) => void;
private readonly activateItem: (item: Item) => void;
private readonly isNavigableKey: (key: string) => boolean;

constructor(config: ListControllerConfig<Item>) {
const {
isItem,
getPossibleItems,
isRtl,
deactivateItem,
activateItem,
isNavigableKey,
} = config;
this.isItem = isItem;
this.getPossibleItems = getPossibleItems;
this.isRtl = isRtl;
this.deactivateItem = deactivateItem;
this.activateItem = activateItem;
this.isNavigableKey = isNavigableKey;
}

/**
* The items being managed by the list. Additionally, attempts to see if the
* object has a sub-item in the `.item` property.
*/
get items(): Item[] {
const maybeItems = this.getPossibleItems();
const items: Item[] = [];

for (const itemOrParent of maybeItems) {
const isItem = this.isItem(itemOrParent);
// if the item is a list item, add it to the list of items
if (isItem) {
items.push(itemOrParent);
continue;
}

// If the item exposes an `item` property check if it is a list item.
const subItem = (itemOrParent as HTMLElement & {item?: Item}).item;
if (subItem && this.isItem(subItem)) {
items.push(subItem);
}
}

return items;
}

/**
* Handles keyboard navigation. Should be bound to the node that will act as
* the List.
*/
handleKeydown = (event: KeyboardEvent) => {
const key = event.key;
if (event.defaultPrevented || !this.isNavigableKey(key)) {
return;
}
// do not use this.items directly in upcoming calculations so we don't
// re-query the DOM unnecessarily
const items = this.items;

if (!items.length) {
return;
}

const activeItemRecord = getActiveItem(items);

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

event.preventDefault();

const isRtl = this.isRtl();
const inlinePrevious =
isRtl ? NavigableKeys.ArrowRight : NavigableKeys.ArrowLeft;
const inlineNext =
isRtl ? NavigableKeys.ArrowLeft : NavigableKeys.ArrowRight;

switch (key) {
// Activate the next item
case NavigableKeys.ArrowDown:
case inlineNext:
activateNextItem(items, activeItemRecord);
break;

// Activate the previous item
case NavigableKeys.ArrowUp:
case inlinePrevious:
activatePreviousItem(items, activeItemRecord);
break;

// Activate the first item
case NavigableKeys.Home:
activateFirstItem(items);
break;

// Activate the last item
case NavigableKeys.End:
activateLastItem(items);
break;

default:
break;
}
};

/**
* Activates the next item in the list. If at the end of the list, the first
* item will be activated.
*
* @return The activated list item or `null` if there are no items.
*/
activateNextItem(): ListItem|null {
const items = this.items;
const activeItemRecord = getActiveItem(items);
if (activeItemRecord) {
activeItemRecord.item.tabIndex = -1;
}
return activateNextItem(items, activeItemRecord);
}

/**
* Activates the previous item in the list. If at the start of the list, the
* last item will be activated.
*
* @return The activated list item or `null` if there are no items.
*/
activatePreviousItem(): ListItem|null {
const items = this.items;
const activeItemRecord = getActiveItem(items);
if (activeItemRecord) {
activeItemRecord.item.tabIndex = -1;
}
return activatePreviousItem(items, activeItemRecord);
}

/**
* Listener to be bound to the `deactivate-items` item event.
*/
onDeactivateItems() {
const items = this.items;

for (const item of items) {
this.deactivateItem(item);
}
}

/**
* Listener to be bound to the `request-activation` item event..
*/
onRequestActivation(event: Event) {
this.onDeactivateItems();
const target = event.target as Item;
this.activateItem(target);
target.focus();
}

/**
* Listener to be bound to the `slotchange` event for the slot that renders
* the items.
*/
onSlotchange = () => {
const items = this.items;
// Whether we have encountered an item that has been activated
let encounteredActivated = false;

for (const item of items) {
const isActivated = !item.disabled && item.tabIndex > -1;

if (isActivated && !encounteredActivated) {
encounteredActivated = true;
item.tabIndex = 0;
continue;
}

// Deactivate the rest including disabled
item.tabIndex = -1;
}

if (encounteredActivated) {
return;
}

const firstActivatableItem = getFirstActivatableItem(items);

if (!firstActivatableItem) {
return;
}

firstActivatableItem.tabIndex = 0;
};
}
Loading

0 comments on commit 0384507

Please sign in to comment.