mirror of
https://github.com/material-components/material-web.git
synced 2024-09-11 13:46:10 +03:00
refactor(menu)!: refactor menu-item to use md-item and not rely on md-list-item
BREAKING CHANGE: This change refactors menu-item to no longer subclass or import from list-item. It also refactors it to use md-item directly which means that the API of menu item has moved from properties to slots. `start-*` and `end-*` slots are now just `start` and `end`, many tokens are now gone in favor of slotting. `headline` property is now a `slot="headline"` slot. Typeahead search text can now be set via `typeaheadText` which defaults to the slotted headline `textContent`. `select-option` now has the `displayText` which is used to display text in the `md-select` when the option is selected; defaults to the slotted headline `textContent`. PiperOrigin-RevId: 567719483
This commit is contained in:
parent
61b382a9df
commit
2a1d8776a7
@ -89,6 +89,9 @@ const sharedStyle = css`
|
||||
[dir=rtl] md-icon {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
[slot="headline"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const standard: MaterialStoryInit<StoryKnobs> = {
|
||||
@ -127,10 +130,10 @@ const standard: MaterialStoryInit<StoryKnobs> = {
|
||||
@closed=${setButtonAriaExpandedFalse}>
|
||||
${fruitNames.map((name, index) => html`
|
||||
<md-menu-item
|
||||
headline=${name}
|
||||
id=${index}
|
||||
.keepOpen=${knobs.keepOpen}
|
||||
.disabled=${knobs.disabled}>
|
||||
<div slot="headline">${name}</div>
|
||||
</md-menu-item>`)}
|
||||
</md-menu>
|
||||
</div>
|
||||
@ -150,13 +153,13 @@ const linkable: MaterialStoryInit<StoryKnobs> = {
|
||||
const isLastItem = index === fruitNames.length - 1;
|
||||
return html`
|
||||
<md-menu-item
|
||||
headline=${name}
|
||||
id=${index}
|
||||
.disabled=${knobs.disabled}
|
||||
.target=${
|
||||
knobs.target as '' | '_blank' | '_parent' | '_self' | '_top'}
|
||||
.href=${knobs.href}>
|
||||
<md-icon slot="end-icon">
|
||||
<div slot="headline">${name}</div>
|
||||
<md-icon slot="end">
|
||||
${knobs['link icon']}
|
||||
</md-icon>
|
||||
</md-menu-item>
|
||||
@ -214,10 +217,9 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
|
||||
return html`
|
||||
<md-menu-item
|
||||
headline=${name}
|
||||
id=${currentIndex}
|
||||
.keepOpen=${knobs.keepOpen}
|
||||
.disabled=${knobs.disabled}>
|
||||
.keepOpen=${knobs.keepOpen}
|
||||
.disabled=${knobs.disabled}>
|
||||
<div slot="headline">${name}</div>
|
||||
</md-menu-item>`;
|
||||
});
|
||||
|
||||
@ -228,16 +230,17 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
|
||||
return html`
|
||||
<md-sub-menu
|
||||
id=${currentIndex}
|
||||
.anchorCorner=${knobs['submenu.anchorCorner']!}
|
||||
.menuCorner=${knobs['submenu.menuCorner']!}
|
||||
.hoverOpenDelay=${knobs.hoverOpenDelay}
|
||||
.hoverCloseDelay=${knobs.hoverCloseDelay}>
|
||||
<md-menu-item
|
||||
slot="item"
|
||||
headline=${name}
|
||||
id=${currentIndex}
|
||||
id=${++currentIndex}
|
||||
.disabled=${knobs.disabled}>
|
||||
<md-icon slot="end-icon">
|
||||
<div slot="headline">${name}</div>
|
||||
<md-icon slot="end">
|
||||
${knobs['submenu item icon']}
|
||||
</md-icon>
|
||||
</md-menu-item>
|
||||
@ -259,10 +262,10 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
|
||||
return html`
|
||||
<md-menu-item
|
||||
headline=${name}
|
||||
id=${currentIndex}
|
||||
.keepOpen=${knobs.keepOpen}
|
||||
.disabled=${knobs.disabled}>
|
||||
<div slot="headline">${name}</div>
|
||||
</md-menu-item>`;
|
||||
}),
|
||||
];
|
||||
@ -273,16 +276,17 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
|
||||
return html`
|
||||
<md-sub-menu
|
||||
id=${currentIndex}
|
||||
.anchorCorner=${knobs['submenu.anchorCorner']!}
|
||||
.menuCorner=${knobs['submenu.menuCorner']!}
|
||||
.hoverOpenDelay=${knobs.hoverOpenDelay}
|
||||
.hoverCloseDelay=${knobs.hoverCloseDelay}>
|
||||
<md-menu-item
|
||||
slot="item"
|
||||
headline=${name}
|
||||
id=${currentIndex}
|
||||
id=${++currentIndex}
|
||||
.disabled=${knobs.disabled}>
|
||||
<md-icon slot="end-icon">
|
||||
<div slot="headline">${name}</div>
|
||||
<md-icon slot="end">
|
||||
${knobs['submenu item icon']}
|
||||
</md-icon>
|
||||
</md-menu-item>
|
||||
@ -382,10 +386,10 @@ const menuWithoutButton: MaterialStoryInit<StoryKnobs> = {
|
||||
@close-menu=${displayCloseEvent}>
|
||||
${fruitNames.map((name, index) => html`
|
||||
<md-menu-item
|
||||
headline=${name}
|
||||
id=${index}
|
||||
.keepOpen=${knobs.keepOpen}
|
||||
.disabled=${knobs.disabled}>
|
||||
<div slot="headline">${name}</div>
|
||||
</md-menu-item>
|
||||
`)}
|
||||
</md-menu>
|
||||
@ -438,9 +442,10 @@ function displayCloseEvent(event: CloseMenuEvent) {
|
||||
|
||||
const stringifyItem = (menuItem: MenuItem&HTMLElement) => {
|
||||
const tagName = menuItem.tagName.toLowerCase();
|
||||
const headline = menuItem.headline;
|
||||
return `${tagName}${menuItem.id ? `[id="${menuItem.id}"]` : ''}[headline="${
|
||||
headline}"]`;
|
||||
const headline = menuItem.typeaheadText;
|
||||
return `${tagName}${
|
||||
menuItem.id ? `[id="${menuItem.id}"]` :
|
||||
''} > [slot="headline"] > ${headline}`;
|
||||
};
|
||||
|
||||
// display the event's details in the inner text of that output element
|
||||
|
@ -9,57 +9,162 @@
|
||||
@use 'sass:string';
|
||||
// go/keep-sorted end
|
||||
// go/keep-sorted start
|
||||
@use '../../../focus/focus-ring';
|
||||
@use '../../../icon/icon';
|
||||
@use '../../../list/list-item';
|
||||
@use '../../../ripple/ripple';
|
||||
@use '../../../tokens';
|
||||
// go/keep-sorted end
|
||||
|
||||
@mixin theme($tokens) {
|
||||
$list-item-supported-tokens: tokens.$md-comp-menu-list-item-supported-tokens;
|
||||
$supported-tokens: tokens.$md-comp-menu-item-supported-tokens;
|
||||
|
||||
@each $token, $value in $tokens {
|
||||
@if list.index($supported-tokens, $token) == null {
|
||||
@if list.index($supported-tokens, $token) ==
|
||||
null and
|
||||
list.index($list-item-supported-tokens, $token) ==
|
||||
null
|
||||
{
|
||||
@error 'Token `#{$token}` is not a supported token.';
|
||||
}
|
||||
|
||||
@if $value {
|
||||
@if $value and list.index($supported-tokens, $token) == null {
|
||||
--md-menu-item-#{$token}: #{$value};
|
||||
}
|
||||
|
||||
@if $value and list.index($list-item-supported-tokens, $token) == null {
|
||||
--md-list-item-#{$token}: #{$value};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin styles() {
|
||||
$list-item-tokens: tokens.md-comp-menu-list-item-values();
|
||||
$tokens: tokens.md-comp-menu-item-values();
|
||||
|
||||
:host {
|
||||
@each $token, $value in $tokens {
|
||||
--_#{$token}: var(--md-menu-item-#{$token}, #{$value});
|
||||
}
|
||||
border-radius: map.get($list-item-tokens, 'container-shape');
|
||||
display: flex;
|
||||
|
||||
@include list-item.theme(
|
||||
@include ripple.theme(
|
||||
(
|
||||
'container-color': var(--_container-color),
|
||||
hover-color: map.get($list-item-tokens, 'hover-state-layer-color'),
|
||||
hover-opacity: map.get($list-item-tokens, 'hover-state-layer-opacity'),
|
||||
pressed-color: map.get($list-item-tokens, 'pressed-state-layer-color'),
|
||||
pressed-opacity:
|
||||
map.get($list-item-tokens, 'pressed-state-layer-opacity'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
:host([disabled]) {
|
||||
opacity: map.get($list-item-tokens, 'disabled-opacity');
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
md-focus-ring {
|
||||
z-index: 1;
|
||||
|
||||
@include focus-ring.theme(
|
||||
(
|
||||
'shape': 8px,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
li {
|
||||
// Resets. These can be removed once we're no longer use these tags
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: unset;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
border-radius: inherit;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
outline: none;
|
||||
// hide android tap color since we have ripple
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
[slot='container'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
md-ripple {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
md-item {
|
||||
border-radius: inherit;
|
||||
flex: 1;
|
||||
color: map.get($list-item-tokens, 'label-text-color');
|
||||
font-family: map.get($list-item-tokens, 'label-text-font');
|
||||
font-size: map.get($list-item-tokens, 'label-text-size');
|
||||
line-height: map.get($list-item-tokens, 'label-text-line-height');
|
||||
font-weight: map.get($list-item-tokens, 'label-text-weight');
|
||||
min-height: map.get($list-item-tokens, 'one-line-container-height');
|
||||
padding-top: map.get($list-item-tokens, 'top-space');
|
||||
padding-bottom: map.get($list-item-tokens, 'bottom-space');
|
||||
padding-inline-start: map.get($list-item-tokens, 'leading-space');
|
||||
padding-inline-end: map.get($list-item-tokens, 'trailing-space');
|
||||
}
|
||||
|
||||
md-item[multiline] {
|
||||
min-height: map.get($list-item-tokens, 'two-line-container-height');
|
||||
}
|
||||
|
||||
[slot='supporting-text'] {
|
||||
color: map.get($list-item-tokens, 'supporting-text-color');
|
||||
font-family: map.get($list-item-tokens, 'supporting-text-font');
|
||||
font-size: map.get($list-item-tokens, 'supporting-text-size');
|
||||
line-height: map.get($list-item-tokens, 'supporting-text-line-height');
|
||||
font-weight: map.get($list-item-tokens, 'supporting-text-weight');
|
||||
}
|
||||
|
||||
[slot='trailing-supporting-text'] {
|
||||
color: map.get($list-item-tokens, 'trailing-supporting-text-color');
|
||||
font-family: map.get($list-item-tokens, 'trailing-supporting-text-font');
|
||||
font-size: map.get($list-item-tokens, 'trailing-supporting-text-size');
|
||||
line-height: map.get(
|
||||
$list-item-tokens,
|
||||
'trailing-supporting-text-line-height'
|
||||
);
|
||||
font-weight: map.get($list-item-tokens, 'trailing-supporting-text-weight');
|
||||
}
|
||||
|
||||
:is([slot='start'], [slot='end'])::slotted(*) {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
[slot='start'] {
|
||||
color: map.get($list-item-tokens, 'leading-icon-color');
|
||||
}
|
||||
|
||||
[slot='end'] {
|
||||
color: map.get($list-item-tokens, 'trailing-icon-color');
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background-color: map.get($list-item-tokens, 'container-color');
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
background-color: var(--_selected-container-color);
|
||||
background-color: map.get($tokens, 'selected-container-color');
|
||||
}
|
||||
|
||||
.selected:not(.disabled) .label-text {
|
||||
color: var(--_selected-label-text-color);
|
||||
}
|
||||
|
||||
// Set the ripple opacity to 0 if there is a submenu that is hovered.
|
||||
.submenu-hover {
|
||||
// Have to use ripple theme directly because :has selector in this case does
|
||||
// not work in this case with the :has selector, thus we cannot override the
|
||||
// custom props set in :host
|
||||
@include ripple.theme(
|
||||
(
|
||||
hover-opacity: 0,
|
||||
)
|
||||
);
|
||||
color: map.get($tokens, 'selected-label-text-color');
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,17 @@
|
||||
//
|
||||
|
||||
@media (forced-colors: active) {
|
||||
:host([disabled]),
|
||||
:host([disabled]) slot {
|
||||
color: GrayText;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Show double border only when selected, and the current list item does not
|
||||
// have a focus ring on it.
|
||||
.list-item.selected:not(.has-focus-ring)::before {
|
||||
.list-item.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
@ -4,18 +4,59 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {property, state} from 'lit/decorators.js';
|
||||
import '../../../ripple/ripple.js';
|
||||
import '../../../focus/md-focus-ring.js';
|
||||
import '../../../item/item.js';
|
||||
|
||||
import type {MdFocusRing} from '../../../focus/md-focus-ring.js';
|
||||
import {ListItemEl, ListItemRole} from '../../../list/internal/listitem/list-item.js';
|
||||
import {html, LitElement, nothing, PropertyValues, TemplateResult} from 'lit';
|
||||
import {property, query, queryAssignedElements} from 'lit/decorators.js';
|
||||
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
|
||||
import {html as staticHtml, literal, StaticValue} from 'lit/static-html.js';
|
||||
|
||||
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
|
||||
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
|
||||
import {CLOSE_REASON, createDefaultCloseMenuEvent, isClosableKey, MenuItem} from '../shared.js';
|
||||
|
||||
export {ListItemRole} from '../../../list/internal/listitem/list-item.js';
|
||||
/**
|
||||
* Supported behaviors for a menu item.
|
||||
*/
|
||||
export type MenuItemType = 'menuitem'|'option'|'button'|'link';
|
||||
|
||||
/**
|
||||
* @fires close-menu {CloseMenuEvent}
|
||||
*/
|
||||
export class MenuItemEl extends ListItemEl implements MenuItem {
|
||||
export class MenuItemEl extends LitElement implements MenuItem {
|
||||
static {
|
||||
requestUpdateOnAriaChange(MenuItemEl);
|
||||
}
|
||||
|
||||
/** @nocollapse */
|
||||
static override shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Disables the item and makes it non-selectable and non-interactive.
|
||||
*/
|
||||
@property({type: Boolean, reflect: true}) disabled = false;
|
||||
|
||||
/**
|
||||
* Sets the behavior and role of the menu item, defaults to "menuitem".
|
||||
*/
|
||||
@property() type: MenuItemType = 'menuitem';
|
||||
|
||||
/**
|
||||
* Sets the underlying `HTMLAnchorElement`'s `href` resource attribute.
|
||||
*/
|
||||
@property() href = '';
|
||||
|
||||
/**
|
||||
* Sets the underlying `HTMLAnchorElement`'s `target` attribute when `href` is
|
||||
* set.
|
||||
*/
|
||||
@property() target: '_blank'|'_parent'|'_self'|'_top'|'' = '';
|
||||
|
||||
/**
|
||||
* READONLY: self-identifies as a menu item and sets its identifying attribute
|
||||
*/
|
||||
@ -32,31 +73,168 @@ export class MenuItemEl extends ListItemEl implements MenuItem {
|
||||
*/
|
||||
@property({type: Boolean}) selected = false;
|
||||
|
||||
@state() protected hasFocusRing = false;
|
||||
@query('.list-item') protected readonly listItemRoot!: HTMLElement|null;
|
||||
|
||||
override readonly type: ListItemRole = 'menuitem';
|
||||
@queryAssignedElements({slot: 'headline'})
|
||||
protected readonly headlineElements!: HTMLElement[];
|
||||
|
||||
protected override onClick() {
|
||||
/**
|
||||
* The text that is selectable via typeahead. If not set, defaults to the
|
||||
* innerText of the item slotted into the `"headline"` slot.
|
||||
*/
|
||||
get typeaheadText() {
|
||||
if (this.internalTypeaheadText !== null) {
|
||||
return this.internalTypeaheadText;
|
||||
}
|
||||
|
||||
const headlineElements = this.headlineElements;
|
||||
|
||||
let text = '';
|
||||
headlineElements.forEach((headlineElement) => {
|
||||
if (headlineElement.textContent && headlineElement.textContent.trim()) {
|
||||
text += ` ${headlineElement.textContent.trim()}`;
|
||||
}
|
||||
});
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
set typeaheadText(text: string) {
|
||||
this.internalTypeaheadText = text;
|
||||
}
|
||||
|
||||
private internalTypeaheadText: string|null = null;
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<MenuItemEl>) {
|
||||
if (this.href) {
|
||||
this.type = 'link';
|
||||
}
|
||||
|
||||
super.willUpdate(changed);
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return this.renderListItem(html`
|
||||
<md-item>
|
||||
<div slot="container">
|
||||
${this.renderRipple()}
|
||||
${this.renderFocusRing()}
|
||||
</div>
|
||||
<slot name="start" slot="start"></slot>
|
||||
<slot name="end" slot="end"></slot>
|
||||
${this.renderBody()}
|
||||
</md-item>
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the root list item.
|
||||
*
|
||||
* @param content the child content of the list item.
|
||||
*/
|
||||
protected renderListItem(content: unknown) {
|
||||
const isAnchor = this.type === 'link';
|
||||
let tag: StaticValue;
|
||||
let role: 'menuitem'|'option' = 'menuitem';
|
||||
switch (this.type) {
|
||||
case 'link':
|
||||
tag = literal`a`;
|
||||
break;
|
||||
case 'button':
|
||||
tag = literal`button`;
|
||||
break;
|
||||
default:
|
||||
case 'menuitem':
|
||||
tag = literal`li`;
|
||||
break;
|
||||
case 'option':
|
||||
tag = literal`li`;
|
||||
role = 'option';
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO(b/265339866): announce "button"/"link" inside of a list item. Until
|
||||
// then all are "menuitem" roles for correct announcement.
|
||||
const target = isAnchor && !!this.target ? this.target : nothing;
|
||||
return staticHtml`
|
||||
<${tag}
|
||||
id="item"
|
||||
tabindex=${this.disabled && !isAnchor ? -1 : 0}
|
||||
role=${role}
|
||||
aria-label=${(this as ARIAMixinStrict).ariaLabel || nothing}
|
||||
aria-selected=${(this as ARIAMixinStrict).ariaSelected || nothing}
|
||||
aria-checked=${(this as ARIAMixinStrict).ariaChecked || nothing}
|
||||
aria-expanded=${(this as ARIAMixinStrict).ariaExpanded || nothing}
|
||||
aria-haspopup=${(this as ARIAMixinStrict).ariaHasPopup || nothing}
|
||||
class="list-item ${classMap(this.getRenderClasses())}"
|
||||
href=${this.href || nothing}
|
||||
target=${target}
|
||||
@click=${this.onClick}
|
||||
@keydown=${this.onKeydown}
|
||||
>${content}</${tag}>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles rendering of the ripple element.
|
||||
*/
|
||||
protected renderRipple(): TemplateResult|typeof nothing {
|
||||
return html`
|
||||
<md-ripple
|
||||
part="ripple"
|
||||
for="item"
|
||||
?disabled=${this.disabled}></md-ripple>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles rendering of the focus ring.
|
||||
*/
|
||||
protected renderFocusRing(): TemplateResult|typeof nothing {
|
||||
return html`
|
||||
<md-focus-ring
|
||||
part="focus-ring"
|
||||
for="item"
|
||||
inward></md-focus-ring>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classes applied to the list item root.
|
||||
*/
|
||||
protected getRenderClasses(): ClassInfo {
|
||||
return {
|
||||
'disabled': this.disabled,
|
||||
'selected': this.selected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles rendering the headline and supporting text.
|
||||
*/
|
||||
protected renderBody() {
|
||||
return html`
|
||||
<slot></slot>
|
||||
<slot name="overline" slot="overline"></slot>
|
||||
<slot name="headline" slot="headline"></slot>
|
||||
<slot name="supporting-text" slot="supporting-text"></slot>
|
||||
<slot name="trailing-supporting-text"
|
||||
slot="trailing-supporting-text"></slot>
|
||||
`;
|
||||
}
|
||||
|
||||
override focus() {
|
||||
// TODO(b/300334509): needed for some cases where delegatesFocus doesn't
|
||||
// work programmatically like in FF and select-option
|
||||
this.listItemRoot?.focus();
|
||||
}
|
||||
|
||||
protected onClick() {
|
||||
if (this.keepOpen) return;
|
||||
|
||||
this.dispatchEvent(createDefaultCloseMenuEvent(
|
||||
this, {kind: CLOSE_REASON.CLICK_SELECTION}));
|
||||
}
|
||||
|
||||
protected override getRenderClasses() {
|
||||
return {
|
||||
...super.getRenderClasses(),
|
||||
'has-focus-ring': this.hasFocusRing,
|
||||
selected: this.selected
|
||||
};
|
||||
}
|
||||
|
||||
protected override onFocusRingVisibilityChanged(e: Event) {
|
||||
const focusRing = e.target as MdFocusRing;
|
||||
this.hasFocusRing = focusRing.visible;
|
||||
}
|
||||
|
||||
protected override onKeydown(event: KeyboardEvent) {
|
||||
protected onKeydown(event: KeyboardEvent) {
|
||||
if (this.keepOpen || event.defaultPrevented) return;
|
||||
const keyCode = event.code;
|
||||
|
||||
|
@ -13,14 +13,14 @@ interface MenuItemAdditions {
|
||||
*/
|
||||
disabled: boolean;
|
||||
/**
|
||||
* The text of the item that will be used for typeahead or Select's visible
|
||||
* text when this item is selected.
|
||||
* The text of the item that will be used for typeahead. If not set, defaults
|
||||
* to the textContent of the element slotted into the headline.
|
||||
*/
|
||||
headline: string;
|
||||
typeaheadText: string;
|
||||
/**
|
||||
* Whether it should keep the menu open after click.
|
||||
*/
|
||||
keepOpen?: boolean;
|
||||
keepOpen: boolean;
|
||||
/**
|
||||
* Whether or not the item is in the selected visual state.
|
||||
*/
|
||||
|
@ -7,8 +7,7 @@
|
||||
import {html, isServer, LitElement} from 'lit';
|
||||
import {property, queryAssignedElements} from 'lit/decorators.js';
|
||||
|
||||
import {List} from '../../../list/internal/list.js';
|
||||
import {createDeactivateItemsEvent, createRequestActivationEvent} from '../../../list/internal/listitem/list-item.js';
|
||||
import {createDeactivateItemsEvent, createRequestActivationEvent, deactivateActiveItem, getFirstActivatableItem} from '../list-navigation-helpers.js';
|
||||
import {Corner, Menu} from '../menu.js';
|
||||
import {CLOSE_REASON, CloseMenuEvent, createActivateTypeaheadEvent, createDeactivateTypeaheadEvent, KEYDOWN_CLOSE_KEYS, MenuItem, NAVIGABLE_KEY, SELECTION_KEY} from '../shared.js';
|
||||
|
||||
@ -110,6 +109,8 @@ export class SubMenu extends LitElement {
|
||||
this.item.ariaExpanded = 'false';
|
||||
this.dispatchEvent(createActivateTypeaheadEvent());
|
||||
this.dispatchEvent(createDeactivateItemsEvent());
|
||||
// aria-hidden required so ChromeVox doesn't announce the closed menu
|
||||
menu.ariaHidden = 'true';
|
||||
}, {once: true});
|
||||
menu.quick = true;
|
||||
// Submenus are in overflow when not fixed. Can remove once we have native
|
||||
@ -119,6 +120,9 @@ export class SubMenu extends LitElement {
|
||||
menu.menuCorner = this.menuCorner;
|
||||
menu.anchorElement = this.item;
|
||||
menu.defaultFocus = 'first-item';
|
||||
// aria-hidden management required so ChromeVox doesn't announce the closed
|
||||
// menu. Remove it here since we are about to show and focus it.
|
||||
menu.removeAttribute('aria-hidden');
|
||||
// This is required in the case where we have a leaf menu open and and the
|
||||
// user hovers a parent menu's item which is not an md-sub-menu item.
|
||||
// If this were set to true, then the menu would close and focus would be
|
||||
@ -186,6 +190,13 @@ export class SubMenu extends LitElement {
|
||||
this.item.setAttribute('aria-controls', this.menu.id);
|
||||
}
|
||||
this.item.keepOpen = true;
|
||||
|
||||
const menu = this.menu;
|
||||
if (!menu) return;
|
||||
|
||||
menu.isSubmenu = true;
|
||||
// Required for ChromeVox to not linearly navigate to the menu while closed
|
||||
menu.ariaHidden = 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -271,7 +282,7 @@ export class SubMenu extends LitElement {
|
||||
if (!submenu) return;
|
||||
|
||||
const submenuItems = submenu.items;
|
||||
const firstActivatableItem = List.getFirstActivatableItem(submenuItems);
|
||||
const firstActivatableItem = getFirstActivatableItem(submenuItems);
|
||||
|
||||
if (firstActivatableItem) {
|
||||
await this.show();
|
||||
@ -317,9 +328,9 @@ export class SubMenu extends LitElement {
|
||||
|
||||
await this.close();
|
||||
|
||||
List.deactivateActiveItem(this.menu.items);
|
||||
deactivateActiveItem(this.menu.items);
|
||||
this.item?.focus();
|
||||
this.tabIndex = 0;
|
||||
this.item.tabIndex = 0;
|
||||
this.item.focus();
|
||||
}
|
||||
|
||||
|
@ -148,7 +148,7 @@ export class TypeaheadController {
|
||||
// Generates the record array data structure which is the index, the element
|
||||
// and a normalized header.
|
||||
this.typeaheadRecords = this.items.map(
|
||||
(el, index) => [index, el, el.headline.trim().toLowerCase()]);
|
||||
(el, index) => [index, el, el.typeaheadText.trim().toLowerCase()]);
|
||||
this.lastActiveRecord =
|
||||
this.typeaheadRecords.find(
|
||||
record => (record[TYPEAHEAD_RECORD.ITEM].tabIndex === 0)) ??
|
||||
|
@ -6,14 +6,10 @@
|
||||
|
||||
import {customElement} from 'lit/decorators.js';
|
||||
|
||||
import {styles as listItemForcedColorsStyles} from '../list/internal/listitem/forced-colors-styles.css.js';
|
||||
import {styles as listItemStyles} from '../list/internal/listitem/list-item-styles.css.js';
|
||||
|
||||
import {styles as forcedColorsStyles} from './internal/menuitem/forced-colors-styles.css.js';
|
||||
import {MenuItemEl} from './internal/menuitem/menu-item.js';
|
||||
import {styles} from './internal/menuitem/menu-item-styles.css.js';
|
||||
|
||||
export {ListItem} from '../list/internal/listitem/list-item.js';
|
||||
export {CloseMenuEvent, MenuItem} from './internal/shared.js';
|
||||
|
||||
declare global {
|
||||
@ -39,6 +35,5 @@ declare global {
|
||||
*/
|
||||
@customElement('md-menu-item')
|
||||
export class MdMenuItem extends MenuItemEl {
|
||||
static override styles =
|
||||
[listItemStyles, styles, listItemForcedColorsStyles, forcedColorsStyles];
|
||||
static override styles = [styles, forcedColorsStyles];
|
||||
}
|
||||
|
@ -82,15 +82,31 @@ function renderIcon(iconName: string, slot: 'leading-icon'|'trailing-icon') {
|
||||
|
||||
function renderItems() {
|
||||
return html`
|
||||
<md-select-option headline=""></md-select-option>
|
||||
<md-select-option selected value="apple" headline="Apple"></md-select-option>
|
||||
<md-select-option value="apricot" headline="Apricot"></md-select-option>
|
||||
<md-select-option value="apricot" headline="Apricots"></md-select-option>
|
||||
<md-select-option value="avocado" headline="Avocado"></md-select-option>
|
||||
<md-select-option value="green_apple" headline="Green Apple"></md-select-option>
|
||||
<md-select-option value="green_grapes" headline="Green Grapes"></md-select-option>
|
||||
<md-select-option value="olive" headline="Olive"></md-select-option>
|
||||
<md-select-option value="orange" headline="Orange"></md-select-option>`;
|
||||
<md-select-option aria-label="blank" value=""></md-select-option>
|
||||
<md-select-option selected value="apple">
|
||||
<div slot="headline">Apple</div>
|
||||
</md-select-option>
|
||||
<md-select-option value="apricot" >
|
||||
<div slot="headline">Apricot</div>
|
||||
</md-select-option>
|
||||
<md-select-option value="apricot" >
|
||||
<div slot="headline">Apricots</div>
|
||||
</md-select-option>
|
||||
<md-select-option value="avocado" >
|
||||
<div slot="headline">Avocado</div>
|
||||
</md-select-option>
|
||||
<md-select-option value="green_apple" >
|
||||
<div slot="headline">Green Apple</div>
|
||||
</md-select-option>
|
||||
<md-select-option value="green_grapes" >
|
||||
<div slot="headline">Green Grapes</div>
|
||||
</md-select-option>
|
||||
<md-select-option value="olive" >
|
||||
<div slot="headline">Olive</div>
|
||||
</md-select-option>
|
||||
<md-select-option value="orange">
|
||||
<div slot="headline">Orange</div>
|
||||
</md-select-option>`;
|
||||
}
|
||||
|
||||
/** Select stories. */
|
||||
|
@ -605,7 +605,7 @@ export abstract class Select extends LitElement {
|
||||
this.lastSelectedOption !== firstSelectedOption;
|
||||
this.lastSelectedOption = firstSelectedOption;
|
||||
this[VALUE] = firstSelectedOption.value;
|
||||
this.displayText = firstSelectedOption.headline;
|
||||
this.displayText = firstSelectedOption.displayText;
|
||||
|
||||
} else {
|
||||
hasSelectedOptionChanged = this.lastSelectedOption !== null;
|
||||
|
@ -7,7 +7,7 @@
|
||||
import {PropertyValues} from 'lit';
|
||||
import {property} from 'lit/decorators.js';
|
||||
|
||||
import {ListItemRole, MenuItemEl} from '../../../menu/internal/menuitem/menu-item.js';
|
||||
import {MenuItemEl} from '../../../menu/internal/menuitem/menu-item.js';
|
||||
import {createRequestDeselectionEvent, createRequestSelectionEvent, SelectOption} from '../shared.js';
|
||||
|
||||
/**
|
||||
@ -23,7 +23,31 @@ export class SelectOptionEl extends MenuItemEl implements SelectOption {
|
||||
*/
|
||||
@property() value = '';
|
||||
|
||||
override readonly type: ListItemRole = 'option';
|
||||
override readonly type = 'option';
|
||||
|
||||
private internalDisplayText: string|null = null;
|
||||
|
||||
/**
|
||||
* The text that is displayed in the select field when selected. If not set,
|
||||
* defaults to the textContent of the item slotted into the `"headline"` slot.
|
||||
*/
|
||||
get displayText() {
|
||||
if (this.internalDisplayText !== null) {
|
||||
return this.internalDisplayText;
|
||||
}
|
||||
|
||||
const headlineElement = this.headlineElements[0];
|
||||
|
||||
if (headlineElement) {
|
||||
return (headlineElement.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
set displayText(text: string) {
|
||||
this.internalDisplayText = text;
|
||||
}
|
||||
|
||||
override willUpdate(changed: PropertyValues<SelectOptionEl>) {
|
||||
if (changed.has('selected')) {
|
||||
|
@ -19,6 +19,11 @@ interface SelectOptionSelf {
|
||||
* Whether or not the SelectOption is selected.
|
||||
*/
|
||||
selected: boolean;
|
||||
/**
|
||||
* The text to display in the select when selected. Defaults to the
|
||||
* textContent of the Element slotted into the headline.
|
||||
*/
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -6,8 +6,6 @@
|
||||
|
||||
import {customElement} from 'lit/decorators.js';
|
||||
|
||||
import {styles as listItemForcedColorsStyles} from '../list/internal/listitem/forced-colors-styles.css.js';
|
||||
import {styles as listItemStyles} from '../list/internal/listitem/list-item-styles.css.js';
|
||||
import {styles as forcedColorsStyles} from '../menu/internal/menuitem/forced-colors-styles.css.js';
|
||||
import {styles} from '../menu/internal/menuitem/menu-item-styles.css.js';
|
||||
|
||||
@ -52,6 +50,5 @@ declare global {
|
||||
*/
|
||||
@customElement('md-select-option')
|
||||
export class MdSelectOption extends SelectOptionEl {
|
||||
static override styles =
|
||||
[listItemStyles, styles, listItemForcedColorsStyles, forcedColorsStyles];
|
||||
static override styles = [styles, forcedColorsStyles];
|
||||
}
|
||||
|
@ -34,6 +34,7 @@
|
||||
@forward './md-comp-list-item' as md-comp-list-item-*;
|
||||
@forward './md-comp-menu' as md-comp-menu-*;
|
||||
@forward './md-comp-menu-item' as md-comp-menu-item-*;
|
||||
@forward './md-comp-menu-list-item' as md-comp-menu-list-item-*;
|
||||
@forward './md-comp-navigation-bar' as md-comp-navigation-bar-*;
|
||||
@forward './md-comp-navigation-drawer' as md-comp-navigation-drawer-*;
|
||||
@forward './md-comp-outlined-button' as md-comp-outlined-button-*;
|
||||
|
162
tokens/_md-comp-menu-list-item.scss
Normal file
162
tokens/_md-comp-menu-list-item.scss
Normal file
@ -0,0 +1,162 @@
|
||||
//
|
||||
// Copyright 2023 Google LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
|
||||
// TODO: delete this file when we merge the list-item fixes
|
||||
|
||||
// go/keep-sorted start
|
||||
@use 'sass:list';
|
||||
@use 'sass:map';
|
||||
@use 'sass:string';
|
||||
// go/keep-sorted end
|
||||
// go/keep-sorted start
|
||||
@use './md-sys-color';
|
||||
@use './md-sys-state';
|
||||
@use './md-sys-typescale';
|
||||
@use './v0_192/md-comp-list';
|
||||
@use './values';
|
||||
// go/keep-sorted end
|
||||
|
||||
$supported-tokens: (
|
||||
// go/keep-sorted start
|
||||
'bottom-space',
|
||||
'disabled-opacity',
|
||||
'focus-state-layer-color',
|
||||
'focus-state-layer-opacity',
|
||||
'hover-state-layer-color',
|
||||
'hover-state-layer-opacity',
|
||||
'label-text-color',
|
||||
'label-text-font',
|
||||
'label-text-line-height',
|
||||
'label-text-size',
|
||||
'label-text-weight',
|
||||
'leading-icon-color',
|
||||
'leading-space',
|
||||
'one-line-container-height',
|
||||
'pressed-state-layer-color',
|
||||
'pressed-state-layer-opacity',
|
||||
'supporting-text-color',
|
||||
'supporting-text-font',
|
||||
'supporting-text-line-height',
|
||||
'supporting-text-size',
|
||||
'supporting-text-weight',
|
||||
'top-space',
|
||||
'trailing-icon-color',
|
||||
'trailing-space',
|
||||
'trailing-supporting-text-color',
|
||||
'trailing-supporting-text-font',
|
||||
'trailing-supporting-text-line-height',
|
||||
'trailing-supporting-text-size',
|
||||
'trailing-supporting-text-weight',
|
||||
'two-line-container-height',
|
||||
// go/keep-sorted end
|
||||
);
|
||||
|
||||
$unsupported-tokens: (
|
||||
// go/keep-sorted start
|
||||
'container-color',
|
||||
'container-elevation',
|
||||
'container-shape',
|
||||
'disabled-label-text-color',
|
||||
'disabled-label-text-opacity',
|
||||
'disabled-leading-icon-color',
|
||||
'disabled-leading-icon-opacity',
|
||||
'disabled-state-layer-color',
|
||||
'disabled-state-layer-opacity',
|
||||
'disabled-trailing-icon-color',
|
||||
'disabled-trailing-icon-opacity',
|
||||
'divider-leading-space',
|
||||
'divider-trailing-space',
|
||||
'dragged-container-elevation',
|
||||
'dragged-label-text-color',
|
||||
'dragged-leading-icon-icon-color',
|
||||
'dragged-state-layer-color',
|
||||
'dragged-state-layer-opacity',
|
||||
'dragged-trailing-icon-icon-color',
|
||||
'focus-label-text-color',
|
||||
'focus-leading-icon-icon-color',
|
||||
'focus-trailing-icon-icon-color',
|
||||
'hover-label-text-color',
|
||||
'hover-leading-icon-icon-color',
|
||||
'hover-trailing-icon-icon-color',
|
||||
'label-text-tracking',
|
||||
'label-text-type',
|
||||
'large-leading-video-height',
|
||||
'leading-avatar-color',
|
||||
'leading-avatar-label-color',
|
||||
'leading-avatar-label-font',
|
||||
'leading-avatar-label-line-height',
|
||||
'leading-avatar-label-size',
|
||||
'leading-avatar-label-tracking',
|
||||
'leading-avatar-label-type',
|
||||
'leading-avatar-label-weight',
|
||||
'leading-avatar-shape',
|
||||
'leading-avatar-size',
|
||||
'leading-icon-size',
|
||||
'leading-image-height',
|
||||
'leading-image-shape',
|
||||
'leading-image-width',
|
||||
'leading-video-shape',
|
||||
'leading-video-width',
|
||||
'overline-color',
|
||||
'overline-font',
|
||||
'overline-line-height',
|
||||
'overline-size',
|
||||
'overline-tracking',
|
||||
'overline-type',
|
||||
'overline-weight',
|
||||
'pressed-label-text-color',
|
||||
'pressed-leading-icon-icon-color',
|
||||
'pressed-trailing-icon-icon-color',
|
||||
'selected-trailing-icon-color',
|
||||
'small-leading-video-height',
|
||||
'supporting-text-tracking',
|
||||
'supporting-text-type',
|
||||
'three-line-container-height',
|
||||
'trailing-icon-size',
|
||||
'trailing-supporting-text-tracking',
|
||||
'trailing-supporting-text-type',
|
||||
'unselected-trailing-icon-color',
|
||||
// go/keep-sorted end
|
||||
);
|
||||
|
||||
$_default: (
|
||||
'md-sys-color': md-sys-color.values-light(),
|
||||
'md-sys-state': md-sys-state.values(),
|
||||
'md-sys-typescale': md-sys-typescale.values(),
|
||||
);
|
||||
|
||||
@function values($deps: $_default, $exclude-hardcoded-values: false) {
|
||||
$original-tokens: md-comp-list.values($deps, $exclude-hardcoded-values);
|
||||
|
||||
$tokens: values.validate(
|
||||
$original-tokens,
|
||||
$supported-tokens: $supported-tokens,
|
||||
$unsupported-tokens: $unsupported-tokens,
|
||||
$new-tokens: (
|
||||
'top-space': if($exclude-hardcoded-values, null, 12px),
|
||||
'bottom-space': if($exclude-hardcoded-values, null, 12px),
|
||||
'disabled-opacity':
|
||||
map.get($original-tokens, 'list-item-disabled-label-text-opacity'),
|
||||
),
|
||||
$renamed-tokens: _get-renamed-tokens($original-tokens)
|
||||
);
|
||||
|
||||
@return $tokens;
|
||||
}
|
||||
|
||||
// remove list-item prefix from tokens
|
||||
@function _get-renamed-tokens($tokens) {
|
||||
$keys: map.keys($tokens);
|
||||
$renamed-tokens: ();
|
||||
|
||||
@each $key in $keys {
|
||||
@if string.index($key, 'list-item-') == 1 {
|
||||
$renamed-key: string.slice($key, string.length('list-item-') + 1);
|
||||
$renamed-tokens: map.set($renamed-tokens, $key, $renamed-key);
|
||||
}
|
||||
}
|
||||
|
||||
@return $renamed-tokens;
|
||||
}
|
Loading…
Reference in New Issue
Block a user