refactor(list,menu)!: add href to (list|menu)-item and remove (menu|list)-item-link

BREAKING CHANGE: list-item-link and menu-item-link have been removed and their functionality has been added to menu-item and list-item respectively.

PiperOrigin-RevId: 553894393
This commit is contained in:
Elliott Marquez 2023-08-04 13:18:02 -07:00 committed by Copybara-Service
parent 28af606b40
commit 09cb6da8fb
17 changed files with 74 additions and 324 deletions

4
all.ts
View File

@ -38,10 +38,8 @@ import './iconbutton/icon-button.js';
import './iconbutton/outlined-icon-button.js';
import './list/list.js';
import './list/list-item.js';
import './list/list-item-link.js';
import './menu/menu.js';
import './menu/menu-item.js';
import './menu/menu-item-link.js';
import './menu/sub-menu-item.js';
import './progress/circular-progress.js';
import './progress/linear-progress.js';
@ -86,10 +84,8 @@ export * from './iconbutton/icon-button.js';
export * from './iconbutton/outlined-icon-button.js';
export * from './list/list.js';
export * from './list/list-item.js';
export * from './list/list-item-link.js';
export * from './menu/menu.js';
export * from './menu/menu-item.js';
export * from './menu/menu-item-link.js';
export * from './menu/sub-menu-item.js';
export * from './progress/circular-progress.js';
export * from './progress/linear-progress.js';

View File

@ -59,13 +59,13 @@
<md-list listtabindex="-1">
{% for component in collections.component|filtersort('data.name') %}
<li>
<md-list-item-link
<md-list-item
href="{{ component.url }}"
headline="{{ component.data.name }}"
{% if component.url == page.url %}selected{% endif %}
data-role=""
itemtabindex="0"
></md-list-item-link>
></md-list-item>
</li>
{% endfor %}
</md-list>

View File

@ -85,18 +85,18 @@ a:focus-visible {
text-decoration: underline;
}
nav-drawer md-list-item-link[selected] {
nav-drawer md-list-item[href][selected] {
--md-list-item-list-item-container-color: var(
--md-sys-color-surface-container-highest
);
}
@media (forced-colors: active) {
nav-drawer md-list-item-link[selected] {
nav-drawer md-list-item[href][selected] {
border: 4px double CanvasText;
}
nav-drawer md-list-item-link {
nav-drawer md-list-item[href] {
border-radius: var(--catalog-shape-xl);
border: 1px solid CanvasText;
}
@ -110,12 +110,12 @@ md-list {
min-width: unset;
}
md-list-item-link {
md-list-item[href] {
margin-block: var(--catalog-spacing-m);
display: block;
}
md-list-item-link:first-of-type {
md-list-item[href]:first-of-type {
margin-block: 4px;
}

View File

@ -6,4 +6,4 @@
import '../components/nav-drawer.js';
import '../components/top-app-bar.js';
import '@material/web/list/list-item-link.js';
import '@material/web/list/list-item.js';

View File

@ -14,7 +14,7 @@ import './components/theme-changer.js';
import '@material/web/menu/menu.js';
import '@material/web/checkbox/checkbox.js';
import '@material/web/list/list.js';
import '@material/web/list/list-item-link.js';
import '@material/web/list/list-item.js';
import '@material/web/progress/circular-progress.js';
import '@material/web/tabs/tabs.js';
import '@material/web/tabs/tab.js';

View File

@ -26,10 +26,8 @@ import './icon/icon.js';
import './iconbutton/icon-button.js';
import './list/list.js';
import './list/list-item.js';
import './list/list-item-link.js';
import './menu/menu.js';
import './menu/menu-item.js';
import './menu/menu-item-link.js';
import './menu/sub-menu-item.js';
import './progress/circular-progress.js';
import './progress/linear-progress.js';
@ -55,10 +53,8 @@ export * from './icon/icon.js';
export * from './iconbutton/icon-button.js';
export * from './list/list.js';
export * from './list/list-item.js';
export * from './list/list-item-link.js';
export * from './menu/menu.js';
export * from './menu/menu-item.js';
export * from './menu/menu-item-link.js';
export * from './menu/sub-menu-item.js';
export * from './progress/circular-progress.js';
export * from './progress/linear-progress.js';

View File

@ -48,17 +48,14 @@ const collection =
new Knob('supportingText', {ui: textInput(), defaultValue: ''}),
new Knob('trailingSupportingText', {ui: textInput(), defaultValue: ''}),
new Knob('itemTabIndex', {ui: numberInput(), defaultValue: 0}),
new Knob('href', {ui: textInput(), defaultValue: 'https://google.com'}),
new Knob('target', {ui: textInput(), defaultValue: '_blank'}),
new Knob('link end icon', {ui: textInput(), defaultValue: 'open_in_new'}),
new Knob('data-variant=icon', {ui: title()}),
new Knob('start icon', {ui: textInput(), defaultValue: 'account_circle'}),
new Knob('end icon', {ui: textInput(), defaultValue: 'check_circle'}),
new Knob('data-variant=link', {ui: title()}),
new Knob('href', {ui: textInput(), defaultValue: 'https://google.com'}),
new Knob('target', {ui: textInput(), defaultValue: '_blank'}),
new Knob('link end icon', {ui: textInput(), defaultValue: 'open_in_new'}),
new Knob('data-variant=avatar', {ui: title()}),
new Knob('avatar img', {ui: textInput(), defaultValue: AVATAR_URL}),
new Knob('avatar label', {ui: textInput(), defaultValue: 'EM'}),

View File

@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '@material/web/list/list-item-link.js';
import '@material/web/list/list-item.js';
import '@material/web/divider/divider.js';
import '@material/web/list/list.js';
@ -110,7 +109,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
</md-icon>
</md-list-item>
<md-list-item-link
<md-list-item
.headline=${headline}
.supportingText=${supportingText}
.multiLineSupportingText=${multiLineSupportingText}
@ -119,11 +118,11 @@ const standard: MaterialStoryInit<StoryKnobs> = {
.noninteractive=${noninteractive}
.itemTabIndex=${itemTabIndex}
.href=${href}
.target=${target}
.target=${target as '' | '_blank' | '_parent' | '_self' | '_top'}
.active=${active}>
<md-icon data-variant="icon" slot="end">${
knobs['link end icon']}</md-icon>
</md-list-item-link>
</md-list-item>
<md-divider></md-divider>

View File

@ -10,6 +10,7 @@ import '../../../focus/md-focus-ring.js';
import {html, LitElement, nothing, PropertyValues, TemplateResult} from 'lit';
import {property, query} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {html as staticHtml, literal} from 'lit/static-html.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
@ -82,10 +83,11 @@ export class ListItemEl extends LitElement implements ListItem {
@property({type: Boolean, reflect: true}) active = false;
/**
* Sets the role of the list item. Set to '' to clear the role.
* Sets the role of the list item. Set to 'nothing' to clear the role. This
* property will be ignored if `href` is set since the underlying element will
* be a native anchor tag.
*/
@property()
type: ListItemRole = 'listitem';
@property() type: ListItemRole = 'listitem';
/**
* READONLY. Sets the `md-list-item` attribute on the element.
@ -93,6 +95,17 @@ export class ListItemEl extends LitElement implements ListItem {
@property({type: Boolean, attribute: 'md-list-item', reflect: true})
isListItem = true;
/**
* 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'|'' = '';
@query('.list-item') protected readonly listItemRoot!: HTMLElement|null;
/**
@ -134,19 +147,25 @@ export class ListItemEl extends LitElement implements ListItem {
* @param content the child content of the list item.
*/
protected renderListItem(content: unknown) {
return html`
<li
const isAnchor = !!this.href;
const tag = isAnchor ? literal`a` : literal`li`;
const role = isAnchor || this.type === 'none' ? nothing : this.type;
const target = isAnchor && !!this.target ? this.target : nothing;
return staticHtml`
<${tag}
id="item"
tabindex=${this.disabled ? -1 : this.itemTabIndex}
role=${this.type === 'none' ? nothing : this.type}
role=${role}
aria-selected=${(this as ARIAMixinStrict).ariaSelected || nothing}
aria-checked=${(this as ARIAMixinStrict).ariaChecked || nothing}
class="list-item ${classMap(this.getRenderClasses())}"
href=${this.href || nothing}
target=${target}
@click=${this.onClick}
@pointerenter=${this.onPointerenter}
@pointerleave=${this.onPointerleave}
@keydown=${this.onKeydown}
>${content}</li>
>${content}</${tag}>
`;
}

View File

@ -1,34 +0,0 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {nothing} from 'lit';
import {property} from 'lit/decorators.js';
import {ListItemLink} from './list-item-link.js';
// tslint:disable-next-line:enforce-comments-on-exported-symbols
export class ListItemLinkOnly extends ListItemLink {
/**
* Removes the hover and click ripples from the item when true. Clicking the
* link will still cause link navigation.
*/
@property({type: Boolean}) noninteractive = false;
override getRenderClasses() {
return {
...super.getRenderClasses(),
'noninteractive': this.noninteractive,
};
}
override renderRipple() {
return this.noninteractive ? nothing : super.renderRipple();
}
override renderFocusRing() {
return this.noninteractive ? nothing : super.renderFocusRing();
}
}

View File

@ -1,47 +0,0 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {html, nothing} from 'lit';
import {property} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {ListItemEl, ListItemRole} from '../listitem/list-item.js';
type LinkTarget = '_blank'|'_parent'|'_self'|'_top';
// tslint:disable-next-line:enforce-comments-on-exported-symbols
export class ListItemLink extends ListItemEl {
/**
* Sets the underlying `HTMLAnchorElement`'s `href` resource attribute.
*/
@property() href!: string;
override type: ListItemRole = 'none';
/**
* Sets the underlying `HTMLAnchorElement`'s `target` attribute.
*/
@property() target!: string;
protected override renderListItem(content: unknown) {
return html`
<a
id="item"
tabindex=${this.disabled ? -1 : this.itemTabIndex}
role=${this.type === 'none' ? nothing : this.type}
aria-selected=${(this as ARIAMixinStrict).ariaSelected || nothing}
aria-checked=${(this as ARIAMixinStrict).ariaChecked || nothing}
class="list-item ${classMap(this.getRenderClasses())}"
href=${this.href}
target=${this.target as LinkTarget || nothing}
@click=${this.onClick}
@pointerenter=${this.onPointerenter}
@pointerleave=${this.onPointerleave}
@keydown=${this.onKeydown}
>${content}</a>
`;
}
}

View File

@ -1,61 +0,0 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {customElement} from 'lit/decorators.js';
import {styles as forcedColors} from './internal/listitem/forced-colors-styles.css.js';
import {styles} from './internal/listitem/list-item-styles.css.js';
import {ListItemLinkOnly as ListItemLink} from './internal/listitemlink/list-item-link-only.js';
declare global {
interface HTMLElementTagNameMap {
'md-list-item-link': MdListItemLink;
}
}
/**
* @summary
* Lists are continuous, vertical indexes of text or images. Items are placed
* inside the list. This is a linkable variant.
*
* @description
* Lists consist of one or more list items, and can contain actions represented
* by icons and text. List items come in three sizes: one-line, two-line, and
* three-line.
*
* __Takeaways:__
*
* - Lists should be sorted in logical ways that make content easy to scan, such
* as alphabetical, numerical, chronological, or by user preference.
* - Lists present content in a way that makes it easy to identify a specific
* item in a collection and act on it.
* - Lists should present icons, text, and actions in a consistent format.
*
* Example slottable child variants are:
*
* - `video[data-variant=video]`
* - `img,span[data-variant=avatar]`
* - `img[data-variant=image]`
* - `md-icon[data-variant=icon]`
*
* @example
* ```html
* <md-list-item-link
* headline="User Name"
* supportingText="user@name.com"
* href="/accounts">
* <md-icon data-variant="icon" slot="start">account_circle</md-icon>
* <md-icon data-variant="icon" slot="end">open_in_new</md-icon>
* </md-list-item-link>
* ```
*
* @final
* @suppress {visibility}
*/
@customElement('md-list-item-link')
export class MdListItemLink extends ListItemLink {
static override styles = [styles, forcedColors];
}

View File

@ -7,7 +7,6 @@
// import 'jasmine'; (google3-only)
import './list.js';
import './list-item.js';
import './list-item-link.js';
import {html} from 'lit';
@ -1101,65 +1100,52 @@ describe('<md-list-item>', () => {
});
});
describe('<md-list-item-link>', () => {
describe('<md-list-item> link', () => {
const env = new Environment();
describe('.styles', () => {
createTokenTests(MdListItem.styles);
});
describe('rendering', () => {
// We repeat these tests because technically list-item-link has its own
// implementation of these methods. Everything else is shared.
it('disabled overrides tabIndex', async () => {
const root = env.render(html`<md-list-item-link></md-list-item-link>`);
it('setting href renders an anchor tag', async () => {
const root = env.render(
html`<md-list-item href="https://google.com"></md-list-item>`);
const listItem = root.querySelector('md-list-item-link')!;
const listItem = root.querySelector('md-list-item')!;
await env.waitForStability();
await env.waitForStability();
const internalRoot =
listItem.renderRoot.querySelector('#item') as HTMLElement;
const internalRoot =
listItem.renderRoot.querySelector('#item') as HTMLElement;
expect(internalRoot.tabIndex).toBe(-1);
expect(internalRoot.tagName).toBe('A');
});
it('setting type and href does not render a role', async () => {
const root = env.render(
html`<md-list-item type="menuitem" href="https://google.com"></md-list-item>`);
listItem.itemTabIndex = 2;
const listItem = root.querySelector('md-list-item')!;
await env.waitForStability();
await env.waitForStability();
expect(listItem.disabled).toBeFalse();
expect(internalRoot.tabIndex).toBe(2);
const internalRoot =
listItem.renderRoot.querySelector('#item') as HTMLElement;
listItem.disabled = true;
expect(internalRoot.hasAttribute('role')).toBe(false);
});
it('setting target without href renders nothing', async () => {
const root = env.render(
html`<md-list-item target="_blank"></md-list-item>`);
await env.waitForStability();
const listItem = root.querySelector('md-list-item')!;
expect(listItem.disabled).toBeTrue();
expect(internalRoot.tabIndex).toBe(-1);
});
await env.waitForStability();
it('ripple and focus ring not rendered on noninteractive', async () => {
const root = env.render(html`<md-list-item-link></md-list-item-link>`);
const internalRoot =
listItem.renderRoot.querySelector('#item') as HTMLElement;
const listItem = root.querySelector('md-list-item-link')!;
await env.waitForStability();
let rippleEl = listItem.renderRoot.querySelector('md-ripple');
let focusRingEl = listItem.renderRoot.querySelector('md-focus-ring');
expect(rippleEl).toBeTruthy();
expect(focusRingEl).toBeTruthy();
listItem.noninteractive = true;
await env.waitForStability();
rippleEl = listItem.renderRoot.querySelector('md-ripple');
focusRingEl = listItem.renderRoot.querySelector('md-focus-ring');
expect(rippleEl).toBeNull();
expect(focusRingEl).toBeNull();
});
expect(internalRoot.hasAttribute('target')).toBe(false);
});
});

View File

@ -108,9 +108,6 @@ const collection =
defaultValue: false,
ui: boolInput(),
}),
// menu-item-link knobs
new Knob('menu-item-link', {ui: title()}),
new Knob('href', {
defaultValue: 'https://google.com',
ui: textInput(),

View File

@ -6,7 +6,6 @@
import '@material/web/menu/menu-item.js';
import '@material/web/menu/menu-item-link.js';
import '@material/web/menu/sub-menu-item.js';
import '@material/web/menu/menu.js';
import '@material/web/button/filled-button.js';
@ -41,8 +40,6 @@ export interface StoryKnobs {
'menu-item': void;
keepOpen: boolean;
disabled: boolean;
'menu-item-link': void;
href: string;
target: string;
'link icon': string;
@ -124,7 +121,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
};
const linkable: MaterialStoryInit<StoryKnobs> = {
name: '<md-menu-item-link>',
name: '<md-menu-item>',
styles: sharedStyle,
render(knobs) {
const showMenu = () => {
@ -262,15 +259,15 @@ function renderItems(names: string[], knobs: StoryKnobs) {
function renderLinkableItems(names: string[], knobs: StoryKnobs) {
return names.map(name => html`
<md-menu-item-link
<md-menu-item
headline=${name}
.disabled=${knobs.disabled}
.target=${knobs.target}
.target=${knobs.target as '' | '_blank' | '_parent' | '_self' | '_top'}
.href=${knobs.href}>
<md-icon data-variant="icon" slot="end">
${knobs['link icon']}
</md-icon>
</md-menu-item-link>
</md-menu-item>
`);
}

View File

@ -1,48 +0,0 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {property} from 'lit/decorators.js';
import {ListItemLink} from '../../../list/internal/listitemlink/list-item-link.js';
import {CLOSE_REASON, createDefaultCloseMenuEvent, isClosableKey, MenuItem, SELECTION_KEY} from '../shared.js';
/**
* @fires close-menu {CloseMenuEvent}
*/
export class MenuItemLink extends ListItemLink implements MenuItem {
/**
* READONLY: self-identifies as a menu item and sets its identifying attribute
*/
@property({type: Boolean, attribute: 'md-menu-item', reflect: true})
isMenuItem = true;
/**
* Keeps the menu open if clicked or keyboard selected.
*/
@property({type: Boolean, attribute: 'keep-open'}) keepOpen = false;
protected keepOpenOnClick = false;
protected override onClick() {
if (this.keepOpen || this.keepOpenOnClick) return;
this.dispatchEvent(createDefaultCloseMenuEvent(
this, {kind: CLOSE_REASON.CLICK_SELECTION}));
}
protected override onKeydown(event: KeyboardEvent) {
if (this.keepOpen) return;
const keyCode = event.code;
// Do not preventDefault on enter or else it will prevent from opening links
if (!event.defaultPrevented && isClosableKey(keyCode) &&
keyCode !== SELECTION_KEY.ENTER) {
event.preventDefault();
this.dispatchEvent(createDefaultCloseMenuEvent(
this, {kind: CLOSE_REASON.KEYDOWN, key: keyCode}));
}
}
}

View File

@ -1,47 +0,0 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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 {styles} from './internal/menuitem/menu-item-styles.css.js';
import {MenuItemLink} from './internal/menuitemlink/menu-item-link.js';
export {ListItem} from '../list/internal/listitem/list-item.js';
export {CloseMenuEvent, MenuItem} from './internal/shared.js';
declare global {
interface HTMLElementTagNameMap {
'md-menu-item-link': MdMenuItemLink;
}
}
/**
* @summary Menus display a list of choices on a temporary surface.
*
* @description
* Menu items are the selectable choices within the menu. Menu items must
* implement the `MenuItem` interface and also have the `md-menu-item`
* attribute. Additionally menu items are list items so they must also have the
* `md-list-item` attribute.
*
* Menu items can control a menu by selectively firing the `close-menu` and
* `deselect-items` events.
*
* This is a linkable variant.
*
* @final
* @suppress {visibility}
*/
@customElement('md-menu-item-link')
export class MdMenuItemLink extends MenuItemLink {
static override styles =
[listItemStyles, styles, listItemForcedColorsStyles, forcedColorsStyles];
}