refactor(list)!: refactor list using md-item

BREAKING CHANGE: `<md-list-item>` now uses slots instead of properties and has removed many prescriptive items (such as avatar, image, and video items). The default slot can be used for any custom content.
```html
<md-list-item>
  <div slot="overline">OVERLINE</div>
  <div slot="headline">First line</div>
  <div slot="supporting-text">Second+ lines</div>
  <div slot="trailing-supporting-text">Trailing</div>
  <md-icon slot="start">star</md-icon>
  <md-icon slot="end">star</md-icon>
</md-list-item>
```
Add `type="button"` or `type="link"` for interactive list items.

PiperOrigin-RevId: 567732201
This commit is contained in:
Elizabeth Mitchell 2023-09-22 15:29:39 -07:00 committed by Copybara-Service
parent 5fad4f088f
commit 753677489b
14 changed files with 304 additions and 988 deletions

View File

@ -144,7 +144,7 @@ function getKnobContent(knobs: StoryKnobs, threeLines = false) {
html`<md-icon class=${classMap(classes)} slot="end">star</md-icon>` :
nothing;
return html`${overline}${trailingText}${leadingIcon}${trailingIcon}`;
return [overline, trailingText, leadingIcon, trailingIcon];
}
/** Item stories. */

File diff suppressed because one or more lines are too long

View File

@ -10,159 +10,113 @@ import '@material/web/list/list.js';
import '@material/web/icon/icon.js';
import {MaterialStoryInit} from './material-collection.js';
import {css, html} from 'lit';
import {css, html, nothing} from 'lit';
import {classMap} from 'lit/directives/class-map.js';
/** Knob types for list stories. */
export interface StoryKnobs {
'md-list-item': void;
disabled: boolean;
interactive: boolean;
multiLineSupportingText: boolean;
headline: string;
supportingText: string;
overline: string;
trailingSupportingText: string;
href: string;
target: string;
'link end icon': string;
'slot[name=start|end-icon]': void;
'start icon': string;
'end icon': string;
'slot[name=start-avatar]': void;
'avatar img': string;
'avatar label': string;
'slot[name=start-image]': void;
image: string;
'slot[name=start-video]': void;
'slot[name=start-video-large]': boolean;
'video src': string;
leadingIcon: boolean;
trailingIcon: boolean;
}
const styles = css`
md-list {
border-radius: 8px;
outline: 1px solid var(--md-sys-color-outline);
max-width: 360px;
overflow: hidden;
width: 100%;
}
`;
const standard: MaterialStoryInit<StoryKnobs> = {
name: '<md-list>',
styles: css`
.list-demo {
border-radius: 8px;
border: 1px solid var(--md-sys-color-outline);
max-width: 360px;
overflow: hidden;
width: 100%;
}
.list {
max-width: 200px;
}`,
name: 'List',
styles,
render(knobs) {
const {
disabled,
interactive,
multiLineSupportingText,
headline,
supportingText,
trailingSupportingText,
href,
target,
image,
} = knobs;
return html`
<div class="list-demo">
<md-list class="list" role="listbox">
<md-list-item
.headline=${headline}
.supportingText=${supportingText}
.multiLineSupportingText=${multiLineSupportingText}
.trailingSupportingText=${trailingSupportingText}
.disabled=${disabled}
.interactive=${interactive}>
</md-list-item>
<md-list>
<md-list-item ?disabled=${knobs.disabled}>
Single line item
${getKnobContent(knobs)}
</md-list-item>
<md-list-item
.headline=${headline}
.supportingText=${supportingText}
.multiLineSupportingText=${multiLineSupportingText}
.trailingSupportingText=${trailingSupportingText}
.disabled=${disabled}
.interactive=${interactive}>
<md-icon slot="start-icon">
${knobs['start icon']}
</md-icon>
<md-icon slot="end-icon">
${knobs['end icon']}
</md-icon>
</md-list-item>
<md-list-item ?disabled=${knobs.disabled}>
Two line item
<div slot="supporting-text">Supporting text</div>
${getKnobContent(knobs)}
</md-list-item>
<md-list-item
.headline=${headline}
.supportingText=${supportingText}
.multiLineSupportingText=${multiLineSupportingText}
.trailingSupportingText=${trailingSupportingText}
.disabled=${disabled}
.interactive=${interactive}
.href=${href}
.target=${target as '' | '_blank' | '_parent' | '_self' | '_top'}>
<md-icon slot="end-icon">${knobs['link end icon']}</md-icon>
</md-list-item>
<md-divider></md-divider>
<md-list-item
.headline=${headline}
.supportingText=${supportingText}
.multiLineSupportingText=${multiLineSupportingText}
.trailingSupportingText=${trailingSupportingText}
.disabled=${disabled}
.interactive=${interactive}>
<img src=${knobs['avatar img']} slot="start-avatar">
</md-list-item>
<md-list-item
.headline=${headline}
.supportingText=${supportingText}
.multiLineSupportingText=${multiLineSupportingText}
.trailingSupportingText=${trailingSupportingText}
.disabled=${disabled}
.interactive=${interactive}>
<span slot="start-avatar">
${knobs['avatar label']}
</span>
</md-list-item>
<md-list-item
.headline=${headline}
.supportingText=${supportingText}
.multiLineSupportingText=${multiLineSupportingText}
.trailingSupportingText=${trailingSupportingText}
.disabled=${disabled}
.interactive=${interactive}>
<img .src=${image} slot="start-image">
</md-list-item>
<md-list-item
.headline=${headline}
.supportingText=${supportingText}
.multiLineSupportingText=${multiLineSupportingText}
.trailingSupportingText=${trailingSupportingText}
.disabled=${disabled}
.interactive=${interactive}>
<video
muted
autoplay
loop
playsinline
.src=${knobs['video src']}
slot=${
knobs['slot[name=start-video-large]'] ? 'start-video-large' :
'start-video'}
></video>
</md-list-item>
</md-list>
</div>
<md-list-item ?disabled=${knobs.disabled}>
Three line item
<div slot="supporting-text">
<div>Second line text</div>
<div>Third line text</div>
</div>
${getKnobContent(knobs, /* threeLines */ true)}
</md-list-item>
</md-list>
`;
},
};
const interactive: MaterialStoryInit<StoryKnobs> = {
name: 'Interactive list',
styles,
render(knobs) {
const knobsNoTrailing = {...knobs, trailingIcon: false};
return html`
<md-list>
<md-list-item ?disabled=${knobs.disabled}
type="link"
href="https://google.com"
target="_blank"
>
Link item
<md-icon slot="end">link</md-icon>
${getKnobContent(knobsNoTrailing)}
</md-list-item>
<md-list-item type="button" ?disabled=${knobs.disabled}>
Button item
${getKnobContent(knobs)}
</md-list-item>
<md-list-item ?disabled=${knobs.disabled}>
Non-interactive item
${getKnobContent(knobs)}
</md-list-item>
</md-list>
`;
},
};
function getKnobContent(knobs: StoryKnobs, threeLines = false) {
const overline = knobs.overline ?
html`<div slot="overline">${knobs.overline}</div>` :
nothing;
const classes = {
'align-start': threeLines,
};
const trailingText = knobs.trailingSupportingText ?
html`<div class=${classMap(classes)} slot="trailing-supporting-text">${
knobs.trailingSupportingText}</div>` :
nothing;
const leadingIcon = knobs.leadingIcon ?
html`<md-icon class=${classMap(classes)} slot="start">event</md-icon>` :
nothing;
const trailingIcon = knobs.trailingIcon ?
html`<md-icon class=${classMap(classes)} slot="end">star</md-icon>` :
nothing;
return [overline, trailingText, leadingIcon, trailingIcon];
}
/** List stories. */
export const stories = [standard];
export const stories = [standard, interactive];

View File

@ -35,7 +35,7 @@ export class ListHarness extends Harness<List> {
*/
protected override async getInteractiveElement() {
await this.element.updateComplete;
return this.element.renderRoot.querySelector('.list') as HTMLElement;
return this.element as List;
}
/** @return List item harnesses. */
@ -60,8 +60,7 @@ export class ListHarness extends Harness<List> {
* @param key The key to dispatch on the list.
*/
override async keypress(key: string, init = {} as KeyboardEventInit) {
const nativeList = this.element.renderRoot.querySelector('ul')!;
init = {code: key, ...init};
this.simulateKeypress(nativeList, key, init);
await super.keypress(key, init);
}
}

View File

@ -6,7 +6,6 @@
// go/keep-sorted start
@use 'sass:list';
@use 'sass:map';
@use 'sass:string';
// go/keep-sorted end
// go/keep-sorted start
@use '../../tokens';
@ -29,23 +28,15 @@
@mixin styles() {
$tokens: tokens.md-comp-list-values();
:host {
@each $token, $value in $tokens {
--_#{$token}: var(--md-list-#{$token}, #{$value});
}
color: unset;
display: flex;
@each $token, $value in $tokens {
$tokens: map.set($tokens, $token, var(--md-list-#{$token}, #{$value}));
}
.list {
background-color: var(--_container-color);
border-radius: inherit;
display: block;
list-style-type: none;
margin: 0;
min-width: inherit;
:host {
background: map.get($tokens, 'container-color');
color: unset;
display: flex;
flex-direction: column;
outline: none;
padding: 8px 0;
// Add position so the elevation overlay (which is absolutely positioned)

View File

@ -92,30 +92,13 @@ export class List extends LitElement {
}
protected override render() {
return this.renderList();
}
/**
* Renders the main list element.
*/
private renderList() {
return html`
<ul class="list" role="presentation">
${this.renderContent()}
</ul>
`;
}
/**
* The content to be slotted into the list.
*/
private renderContent() {
return html`
<slot
@deactivate-items=${this.onDeactivateItems}
@request-activation=${this.onRequestActivation}
@slotchange=${this.onSlotchange}>
</slot>`;
</slot>
`;
}
/**

View File

@ -6,7 +6,6 @@
// go/keep-sorted start
@use 'sass:list';
@use 'sass:map';
@use 'sass:string';
// go/keep-sorted end
// go/keep-sorted start
@use '../../../focus/focus-ring';
@ -31,35 +30,32 @@
@mixin styles() {
$tokens: tokens.md-comp-list-item-values();
:host {
@each $token, $value in $tokens {
--_#{$token}: var(--md-list-item-#{$token}, #{$value});
}
@each $token, $value in $tokens {
$tokens: map.set($tokens, $token, var(--md-list-item-#{$token}, #{$value}));
}
@include _list-item;
@include _image;
@include _icon;
@include _avatar;
@include _video;
}
@mixin _list-item() {
:host {
color: unset;
border-radius: map.get($tokens, 'container-shape');
display: flex;
@include ripple.theme(
(
hover-color: var(--_hover-state-layer-color),
hover-opacity: var(--_hover-state-layer-opacity),
pressed-color: var(--_pressed-state-layer-color),
pressed-opacity: var(--_pressed-state-layer-opacity),
hover-color: map.get($tokens, 'hover-state-layer-color'),
hover-opacity: map.get($tokens, 'hover-state-layer-opacity'),
pressed-color: map.get($tokens, 'pressed-state-layer-color'),
pressed-opacity: map.get($tokens, 'pressed-state-layer-opacity'),
)
);
}
:host([disabled]) {
opacity: map.get($tokens, 'disabled-opacity');
pointer-events: none;
}
md-focus-ring {
z-index: 1;
@include focus-ring.theme(
(
'shape': 8px,
@ -67,329 +63,84 @@
);
}
.list-item {
align-items: center;
box-sizing: border-box;
display: flex;
outline: none;
position: relative;
width: 100%;
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;
background-color: var(--_container-color);
border-radius: var(--_container-shape);
}
.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).interactive {
cursor: pointer;
}
&.disabled {
pointer-events: none;
}
}
.content-wrapper {
display: flex;
width: 100%;
box-sizing: border-box;
border-radius: inherit;
padding-inline-end: var(--_trailing-space);
[slot='container'] {
pointer-events: none;
}
md-ripple {
border-radius: inherit;
}
.with-one-line {
min-height: var(--_one-line-container-height);
md-item {
border-radius: inherit;
flex: 1;
height: 100%;
color: map.get($tokens, 'label-text-color');
font-family: map.get($tokens, 'label-text-font');
font-size: map.get($tokens, 'label-text-size');
line-height: map.get($tokens, 'label-text-line-height');
font-weight: map.get($tokens, 'label-text-weight');
min-height: map.get($tokens, 'one-line-container-height');
padding-top: map.get($tokens, 'top-space');
padding-bottom: map.get($tokens, 'bottom-space');
padding-inline-start: map.get($tokens, 'leading-space');
padding-inline-end: map.get($tokens, 'trailing-space');
}
.with-two-line {
min-height: var(--_two-line-container-height);
md-item[multiline] {
min-height: map.get($tokens, 'two-line-container-height');
}
.with-three-line {
min-height: var(--_three-line-container-height);
[slot='supporting-text'] {
color: map.get($tokens, 'supporting-text-color');
font-family: map.get($tokens, 'supporting-text-font');
font-size: map.get($tokens, 'supporting-text-size');
line-height: map.get($tokens, 'supporting-text-line-height');
font-weight: map.get($tokens, 'supporting-text-weight');
}
.start {
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex: 0 0 auto;
z-index: 1;
.with-three-line & {
justify-content: start;
}
[slot='trailing-supporting-text'] {
color: map.get($tokens, 'trailing-supporting-text-color');
font-family: map.get($tokens, 'trailing-supporting-text-font');
font-size: map.get($tokens, 'trailing-supporting-text-size');
line-height: map.get($tokens, 'trailing-supporting-text-line-height');
font-weight: map.get($tokens, 'trailing-supporting-text-weight');
}
slot[name='start-icon']::slotted(*),
slot[name='start-image']::slotted(*),
slot[name='start-avatar']::slotted(*) {
margin-inline-start: var(--_leading-element-leading-space);
}
.body {
display: inline-flex;
justify-content: center;
flex-direction: column;
min-width: 0;
box-sizing: border-box;
flex: 1 0 0;
padding-inline-start: var(--_leading-space);
z-index: 1;
}
.end {
display: inline-flex;
flex-direction: column;
justify-content: center;
flex: 0 0 auto;
z-index: 1;
.with-three-line & {
justify-content: start;
}
}
slot[name='end']::slotted(*),
slot[name='end-icon']::slotted(*),
.trailing-supporting-text {
margin-inline-start: var(
--_trailing-element-headline-trailing-element-space
);
}
.label-text {
text-overflow: ellipsis;
overflow-x: hidden;
white-space: nowrap;
color: var(--_label-text-color);
font-family: var(--_label-text-font);
font-size: var(--_label-text-size);
line-height: var(--_label-text-line-height);
font-weight: var(--_label-text-weight);
:hover & {
color: var(--_hover-label-text-color);
}
:focus & {
color: var(--_focus-label-text-color);
}
:active & {
color: var(--_pressed-label-text-color);
}
.disabled & {
color: var(--_disabled-label-text-color);
opacity: var(--_disabled-label-text-opacity);
}
}
.supporting-text {
text-overflow: ellipsis;
white-space: normal;
overflow: hidden;
width: 100%;
color: var(--_supporting-text-color);
font-family: var(--_supporting-text-font);
font-size: var(--_supporting-text-size);
line-height: var(--_supporting-text-line-height);
font-weight: var(--_supporting-text-weight);
// Box is supposed to be deprecated, but line-clamp is not. It's still on
// standards track and can only be applied with display: -webkit-box and
// -webkit-box-orient: vertical. Must change once un-prefixed line-clamp
// ships
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
display: -webkit-box;
.disabled & {
color: var(--_disabled-label-text-color);
opacity: var(--_disabled-label-text-opacity);
}
}
.supporting-text--multi-line {
-webkit-line-clamp: 2;
}
.trailing-supporting-text {
font-family: var(--_trailing-supporting-text-font);
font-size: var(--_trailing-supporting-text-size);
line-height: var(--_trailing-supporting-text-line-height);
font-weight: var(--_trailing-supporting-text-weight);
.list-item:not(.disabled) & {
color: var(--_trailing-supporting-text-color);
}
.disabled & {
color: var(--_disabled-label-text-color);
opacity: var(--_disabled-label-text-opacity);
}
.with-three-line & {
// In three line, trailing-supporting-text must align with the mid-line of
// the headline text.
margin-block-start: calc(
(
var(--_label-text-line-height) -
var(--_trailing-supporting-text-line-height)
) / 2
);
}
}
.focus-ring {
z-index: 1;
}
}
@mixin _image() {
slot[name='start-image']::slotted(*) {
display: inline-flex;
height: var(--_leading-image-height);
width: var(--_leading-image-width);
border-radius: var(--_leading-image-shape);
// Min height is two-line height
margin-block: calc(
(var(--_two-line-container-height) - var(--_leading-image-height)) / 2
);
.with-three-line & {
margin-block: 0;
}
}
}
@mixin _icon() {
::slotted(*) {
:is([slot='start'], [slot='end'])::slotted(*) {
fill: currentColor;
}
slot[name='start-icon']::slotted(*) {
font-size: var(--_leading-icon-size);
width: var(--_leading-icon-size);
height: var(--_leading-icon-size);
color: var(--_leading-icon-color);
.with-three-line & {
// In three line, icon must align with the mid-line of headline text
margin-block-start: calc(
(var(--_label-text-line-height) - var(--_leading-icon-size)) / 2
);
}
[slot='start'] {
color: map.get($tokens, 'leading-icon-color');
}
slot[name='end-icon']::slotted(*) {
font-size: var(--_trailing-icon-size);
width: var(--_trailing-icon-size);
height: var(--_trailing-icon-size);
color: var(--_trailing-icon-color);
.with-three-line & {
// In three line, icon must align with the mid-line of headline text
margin-block-start: calc(
(var(--_label-text-line-height) - var(--_trailing-icon-size)) / 2
);
}
}
:hover {
slot[name='start-icon']::slotted(*) {
color: var(--_hover-leading-icon-icon-color);
}
slot[name='end-icon']::slotted(*) {
color: var(--_hover-trailing-icon-icon-color);
}
}
:focus {
slot[name='start-icon']::slotted(*) {
color: var(--_focus-leading-icon-icon-color);
}
slot[name='end-icon']::slotted(*) {
color: var(--_focus-trailing-icon-icon-color);
}
}
:active {
slot[name='start-icon']::slotted(*) {
color: var(--_pressed-leading-icon-icon-color);
}
slot[name='end-icon']::slotted(*) {
color: var(--_pressed-trailing-icon-icon-color);
}
}
.disabled {
slot[name='start-icon']::slotted(*) {
opacity: var(--_disabled-leading-icon-opacity);
color: var(--_disabled-leading-icon-color);
}
slot[name='end-icon']::slotted(*) {
opacity: var(--_disabled-trailing-icon-opacity);
color: var(--_disabled-trailing-icon-color);
}
}
}
@mixin _avatar() {
slot[name='start-avatar']::slotted(*) {
display: inline-flex;
justify-content: center;
align-items: center;
background-color: var(--_leading-avatar-color);
height: var(--_leading-avatar-size);
width: var(--_leading-avatar-size);
border-radius: var(--_leading-avatar-shape);
color: var(--_leading-avatar-label-color);
font-family: var(--_leading-avatar-label-font);
font-size: var(--_leading-avatar-label-size);
line-height: var(--_leading-avatar-label-line-height);
font-weight: var(--_leading-avatar-label-weight);
}
}
@mixin _video() {
slot[name='start-video']::slotted(*),
slot[name='start-video-large']::slotted(*) {
display: inline-flex;
object-fit: cover;
height: var(--_small-leading-video-height);
width: var(--_leading-video-width);
border-radius: var(--_leading-video-shape);
margin-inline-start: var(--_leading-video-leading-space);
// Min height is three-line height
margin-block: calc(
(var(--_three-line-container-height) - var(--_small-leading-video-height)) /
2
);
// Let it auto-layout so that we don't mess with the icons and supporting
// text that is supposed to be top-aligned in three-line.
.with-three-line & {
margin-block: 0;
}
}
slot[name='start-video-large']::slotted(*) {
// Min height is three-line height
margin-block: calc(
(var(--_three-line-container-height) - var(--_large-leading-video-height)) /
2
);
height: var(--_large-leading-video-height);
[slot='end'] {
color: map.get($tokens, 'trailing-icon-color');
}
}

View File

@ -8,16 +8,9 @@
// go/keep-sorted end
@media (forced-colors: active) {
:host {
@include list-item.theme(
(
disabled-label-text-color: GrayText,
disabled-label-text-opacity: 1,
disabled-leading-icon-color: GrayText,
disabled-leading-icon-opacity: 1,
disabled-trailing-icon-color: GrayText,
disabled-trailing-icon-opacity: 1,
)
);
:host([disabled]),
:host([disabled]) slot {
color: GrayText;
opacity: 1;
}
}

View File

@ -1,43 +0,0 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {nothing} from 'lit';
import {property} from 'lit/decorators.js';
import {createRequestActivationEvent, ListItemEl} from './list-item.js';
// tslint:disable-next-line:enforce-comments-on-exported-symbols
export class ListItemOnly extends ListItemEl {
/**
* Enables focusing the list item, and adds hover and click ripples when set
* to true. By default `interactive` is false.
*/
@property({type: Boolean}) interactive = false;
override getRenderClasses() {
return {
...super.getRenderClasses(),
'interactive': this.interactive,
};
}
override renderRipple() {
return this.interactive ? super.renderRipple() : nothing;
}
override renderFocusRing() {
return this.interactive ? super.renderFocusRing() : nothing;
}
override onFocus() {
if (this.tabIndex !== -1) {
return;
}
// Handles the case where the user clicks on the element and then tabs.
this.dispatchEvent(createRequestActivationEvent());
}
}

View File

@ -6,11 +6,12 @@
import '../../../ripple/ripple.js';
import '../../../focus/md-focus-ring.js';
import '../../../item/item.js';
import {html, LitElement, nothing, TemplateResult} from 'lit';
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 {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';
@ -49,9 +50,9 @@ export type RequestActivationEvent =
ReturnType<typeof createRequestActivationEvent>;
/**
* Supported roles for a list item.
* Supported behaviors for a list item.
*/
export type ListItemRole = 'listitem'|'menuitem'|'option'|'link'|'none';
export type ListItemType = 'text'|'button'|'link';
interface ListItemSelf {
disabled: boolean;
@ -63,7 +64,9 @@ interface ListItemSelf {
*/
export type ListItem = ListItemSelf&HTMLElement;
// tslint:disable-next-line:enforce-comments-on-exported-symbols
/**
* @fires request-activation
*/
export class ListItemEl extends LitElement implements ListItem {
static {
requestUpdateOnAriaChange(ListItemEl);
@ -75,42 +78,16 @@ export class ListItemEl extends LitElement implements ListItem {
delegatesFocus: true
};
/**
* The primary, headline text of the list item.
*/
@property() headline = '';
/**
* The one-line supporting text below the headline. Set
* `multiLineSupportingText` to `true` to support multiple lines in the
* supporting text.
*/
@property({attribute: 'supporting-text'}) supportingText = '';
/**
* Modifies `supportingText` to support multiple lines.
*/
@property({type: Boolean, attribute: 'multi-line-supporting-text'})
multiLineSupportingText = false;
/**
* The supporting text placed at the end of the item. Overridden by elements
* slotted into the `end` slot.
*/
@property({attribute: 'trailing-supporting-text'})
trailingSupportingText = '';
/**
* Disables the item and makes it non-selectable and non-interactive.
*/
@property({type: Boolean, reflect: true}) disabled = false;
/**
* 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.
* Sets the behavior of the list item, defaults to "text". Change to "link" or
* "button" for interactive items.
*/
@property() type: ListItemRole = 'listitem';
@property() type: ListItemType = 'text';
/**
* READONLY. Sets the `md-list-item` attribute on the element.
@ -131,15 +108,25 @@ export class ListItemEl extends LitElement implements ListItem {
@query('.list-item') protected readonly listItemRoot!: HTMLElement|null;
protected override willUpdate(changed: PropertyValues<ListItemEl>) {
if (this.href) {
this.type = 'link';
}
super.willUpdate(changed);
}
protected override render() {
return this.renderListItem(html`
<div class="content-wrapper">
${this.renderStart()}
<md-item>
<div slot="container">
${this.renderRipple()}
${this.renderFocusRing()}
</div>
<slot name="start" slot="start"></slot>
<slot name="end" slot="end"></slot>
${this.renderBody()}
${this.renderEnd()}
${this.renderRipple()}
${this.renderFocusRing()}
</div>
</md-item>
`);
}
@ -149,15 +136,29 @@ export class ListItemEl extends LitElement implements ListItem {
* @param content the child content of the list item.
*/
protected renderListItem(content: unknown) {
const isAnchor = !!this.href;
const tag = isAnchor ? literal`a` : literal`li`;
const role = isAnchor || this.type === 'none' ? nothing : this.type;
const isAnchor = this.type === 'link';
let tag: StaticValue;
switch (this.type) {
case 'link':
tag = literal`a`;
break;
case 'button':
tag = literal`button`;
break;
default:
case 'text':
tag = literal`li`;
break;
}
// TODO(b/265339866): announce "button"/"link" inside of a list item. Until
// then all are "listitem" roles for correct announcement.
const target = isAnchor && !!this.target ? this.target : nothing;
return staticHtml`
<${tag}
id="item"
tabindex="${this.disabled ? -1 : 0}"
role=${role}
tabindex="${this.disabled && !isAnchor ? -1 : 0}"
role="listitem"
aria-selected=${(this as ARIAMixinStrict).ariaSelected || nothing}
aria-checked=${(this as ARIAMixinStrict).ariaChecked || nothing}
aria-expanded=${(this as ARIAMixinStrict).ariaExpanded || nothing}
@ -166,10 +167,6 @@ export class ListItemEl extends LitElement implements ListItem {
href=${this.href || nothing}
target=${target}
@focus=${this.onFocus}
@click=${this.onClick}
@mouseenter=${this.onMouseenter}
@mouseleave=${this.onMouseleave}
@keydown=${this.onKeydown}
>${content}</${tag}>
`;
}
@ -178,6 +175,10 @@ export class ListItemEl extends LitElement implements ListItem {
* Handles rendering of the ripple element.
*/
protected renderRipple(): TemplateResult|typeof nothing {
if (this.type === 'text') {
return nothing;
}
return html`
<md-ripple
part="ripple"
@ -189,10 +190,13 @@ export class ListItemEl extends LitElement implements ListItem {
* Handles rendering of the focus ring.
*/
protected renderFocusRing(): TemplateResult|typeof nothing {
if (this.type === 'text') {
return nothing;
}
return html`
<md-focus-ring
@visibility-changed=${this.onFocusRingVisibilityChanged}
class="focus-ring"
part="focus-ring"
for="item"
inward></md-focus-ring>`;
@ -203,89 +207,33 @@ export class ListItemEl extends LitElement implements ListItem {
/**
* Classes applied to the list item root.
*/
protected getRenderClasses() {
return {
'with-one-line': this.supportingText === '',
'with-two-line':
this.supportingText !== '' && !this.multiLineSupportingText,
'with-three-line':
this.supportingText !== '' && this.multiLineSupportingText,
'disabled': this.disabled
};
}
/**
* The content rendered at the start of the list item.
*/
protected renderStart() {
return html`
<div class="start">
<slot name="start"></slot>
<slot name="start-icon"></slot>
<slot name="start-image"></slot>
<slot name="start-avatar"></slot>
<slot name="start-video"></slot>
<slot name="start-video-large"></slot>
</div>`;
protected getRenderClasses(): ClassInfo {
// TODO(b/265339866): links may not be disabled
return {'disabled': this.disabled};
}
/**
* Handles rendering the headline and supporting text.
*/
protected renderBody() {
const supportingText =
this.supportingText !== '' ? this.renderSupportingText() : '';
return html`<div class="body"
><span class="label-text">${this.headline}</span>${supportingText}</div>`;
}
/**
* Renders the one-line supporting text.
*/
protected renderSupportingText() {
return html`<span
class="supporting-text ${classMap(this.getSupportingTextClasses())}"
>${this.supportingText}</span>`;
}
/**
* Gets the classes for the supporting text node
*/
protected getSupportingTextClasses() {
return {'supporting-text--multi-line': this.multiLineSupportingText};
}
/**
* The content rendered at the end of the list item.
*/
protected renderEnd() {
const supportingText = this.trailingSupportingText !== '' ?
this.renderTrailingSupportingText() :
'';
return html`
<div class="end">
${supportingText}
<slot name="end"></slot>
<slot name="end-icon"></slot>
</div>`;
<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>
`;
}
/**
* Renders the supporting text at the end of the list item.
*/
protected renderTrailingSupportingText() {
return html`<span class="trailing-supporting-text"
>${this.trailingSupportingText}</span>`;
protected onFocus() {
if (this.tabIndex !== -1) {
return;
}
// Handles the case where the user clicks on the element and then tabs.
this.dispatchEvent(createRequestActivationEvent());
}
// For easier overriding in menu-item
protected onClick?(event: Event): void;
protected onKeydown?(event: KeyboardEvent): void;
protected onMouseenter?(event: Event): void;
protected onMouseleave?(event: Event): void;
protected onFocus?(event: FocusEvent): void;
override focus() {
// TODO(b/300334509): needed for some cases where delegatesFocus doesn't
// work programmatically like in FF and select-option

View File

@ -7,10 +7,10 @@
import {customElement} from 'lit/decorators.js';
import {styles as forcedColors} from './internal/listitem/forced-colors-styles.css.js';
import {ListItemOnly as ListItem} from './internal/listitem/list-item-only.js';
import {ListItemEl as ListItem} from './internal/listitem/list-item.js';
import {styles} from './internal/listitem/list-item-styles.css.js';
export {ListItemRole} from './internal/listitem/list-item.js';
export {ListItemType} from './internal/listitem/list-item.js';
declare global {
interface HTMLElementTagNameMap {

View File

@ -1008,85 +1008,6 @@ describe('<md-list-item>', () => {
expect(internalRoot.tabIndex).toBe(-1);
});
it('supportingText is rendered only when set', async () => {
const root = env.render(html`<md-list-item></md-list-item>`);
const listItem = root.querySelector('md-list-item')!;
await env.waitForStability();
let supporingTextEl = listItem.renderRoot.querySelector('.supporting-text');
expect(supporingTextEl).toBeNull();
listItem.supportingText = 'Yolo';
await env.waitForStability();
supporingTextEl = listItem.renderRoot.querySelector('.supporting-text');
expect(supporingTextEl).toBeTruthy();
});
it('trailingSupportingText is rendered only when set', async () => {
const root = env.render(html`<md-list-item></md-list-item>`);
const listItem = root.querySelector('md-list-item')!;
await env.waitForStability();
let supporingTextEl =
listItem.renderRoot.querySelector('.trailing-supporting-text');
expect(supporingTextEl).toBeNull();
listItem.trailingSupportingText = 'Yolo';
await env.waitForStability();
supporingTextEl =
listItem.renderRoot.querySelector('.trailing-supporting-text');
expect(supporingTextEl).toBeTruthy();
});
it('only one "with-*-line" class is set at a time', async () => {
const root = env.render(html`<md-list-item></md-list-item>`);
const listItem = root.querySelector('md-list-item')!;
await env.waitForStability();
const rootEl = listItem.renderRoot.querySelector('#item') as HTMLElement;
expect(rootEl.classList.contains('with-one-line')).toBeTrue();
expect(rootEl.classList.contains('with-two-line')).toBeFalse();
expect(rootEl.classList.contains('with-three-line')).toBeFalse();
listItem.multiLineSupportingText = true;
await env.waitForStability();
expect(rootEl.classList.contains('with-one-line')).toBeTrue();
expect(rootEl.classList.contains('with-two-line')).toBeFalse();
expect(rootEl.classList.contains('with-three-line')).toBeFalse();
listItem.multiLineSupportingText = false;
listItem.supportingText = 'YOLO';
await env.waitForStability();
expect(rootEl.classList.contains('with-one-line')).toBeFalse();
expect(rootEl.classList.contains('with-two-line')).toBeTrue();
expect(rootEl.classList.contains('with-three-line')).toBeFalse();
listItem.multiLineSupportingText = true;
await env.waitForStability();
expect(rootEl.classList.contains('with-one-line')).toBeFalse();
expect(rootEl.classList.contains('with-two-line')).toBeFalse();
expect(rootEl.classList.contains('with-three-line')).toBeTrue();
});
it('ripple and focus ring rendered on interactive', async () => {
const root = env.render(html`<md-list-item></md-list-item>`);
@ -1100,7 +1021,7 @@ describe('<md-list-item>', () => {
expect(rippleEl).toBeNull();
expect(focusRingEl).toBeNull();
listItem.interactive = true;
listItem.type = 'button';
await env.waitForStability();
@ -1133,20 +1054,6 @@ describe('<md-list-item> link', () => {
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>`);
const listItem = root.querySelector('md-list-item')!;
await env.waitForStability();
const internalRoot =
listItem.renderRoot.querySelector('#item') as HTMLElement;
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>`);

View File

@ -10,8 +10,6 @@
// go/keep-sorted end
// go/keep-sorted start
@use './md-sys-color';
@use './md-sys-elevation';
@use './md-sys-shape';
@use './md-sys-state';
@use './md-sys-typescale';
@use './v0_192/md-comp-list';
@ -20,62 +18,29 @@
$supported-tokens: (
// go/keep-sorted start
'container-color',
'container-shape',
'disabled-label-text-color',
'disabled-label-text-opacity',
'disabled-leading-icon-color',
'disabled-leading-icon-opacity',
'disabled-trailing-icon-color',
'disabled-trailing-icon-opacity',
'focus-label-text-color',
'focus-leading-icon-icon-color',
'focus-trailing-icon-icon-color',
'hover-label-text-color',
'hover-leading-icon-icon-color',
'bottom-space',
'disabled-opacity',
'focus-state-layer-color',
'focus-state-layer-opacity',
'hover-state-layer-color',
'hover-state-layer-opacity',
'hover-trailing-icon-icon-color',
'label-text-color',
'label-text-font',
'label-text-line-height',
'label-text-size',
'label-text-weight',
'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-weight',
'leading-avatar-shape',
'leading-avatar-size',
'leading-element-leading-space',
'leading-icon-color',
'leading-icon-size',
'leading-image-height',
'leading-image-shape',
'leading-image-width',
'leading-space',
'leading-video-leading-space',
'leading-video-shape',
'leading-video-width',
'one-line-container-height',
'pressed-label-text-color',
'pressed-leading-icon-icon-color',
'pressed-state-layer-color',
'pressed-state-layer-opacity',
'pressed-trailing-icon-icon-color',
'small-leading-video-height',
'supporting-text-color',
'supporting-text-font',
'supporting-text-line-height',
'supporting-text-size',
'supporting-text-weight',
'three-line-container-height',
'trailing-element-headline-trailing-element-space',
'top-space',
'trailing-icon-color',
'trailing-icon-size',
'trailing-space',
'trailing-supporting-text-color',
'trailing-supporting-text-font',
@ -88,9 +53,17 @@ $supported-tokens: (
$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',
@ -99,12 +72,31 @@ $unsupported-tokens: (
'dragged-state-layer-color',
'dragged-state-layer-opacity',
'dragged-trailing-icon-icon-color',
'focus-state-layer-color',
'focus-state-layer-opacity',
'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',
@ -112,9 +104,15 @@ $unsupported-tokens: (
'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',
@ -123,8 +121,6 @@ $unsupported-tokens: (
$_default: (
'md-sys-color': md-sys-color.values-light(),
'md-sys-elevation': md-sys-elevation.values(),
'md-sys-shape': md-sys-shape.values(),
'md-sys-state': md-sys-state.values(),
'md-sys-typescale': md-sys-typescale.values(),
);
@ -136,7 +132,12 @@ $_default: (
$original-tokens,
$supported-tokens: $supported-tokens,
$unsupported-tokens: $unsupported-tokens,
$new-tokens: _get-new-tokens($deps, $exclude-hardcoded-values),
$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)
);
@ -157,20 +158,3 @@ $_default: (
@return $renamed-tokens;
}
@function _get-new-tokens($deps, $exclude-hardcoded-values) {
// Values pulled from b/198759625 spreadsheet
@return (
// go/keep-sorted start
'leading-element-leading-space': if($exclude-hardcoded-values, null, 16px),
'leading-video-leading-space': if($exclude-hardcoded-values, null, 0px),
// Commented out until the comments in the spreadsheet linked above are
// resolved regarding vertical alignment.
// 'leading-item-top-space': if($exclude-hardcoded-values, null, 8px),
// 'leading-video-top-space': if($exclude-hardcoded-values, null, 0px),
// 'leading-item-bottom-space': if($exclude-hardcoded-values, null, 8px),
// 'leading-video-bottom-space': if($exclude-hardcoded-values, null, 0px),
'trailing-element-headline-trailing-element-space': 16px,
// go/keep-sorted end
);
}

View File

@ -8,10 +8,6 @@
// go/keep-sorted end
// go/keep-sorted start
@use './md-sys-color';
@use './md-sys-elevation';
@use './md-sys-shape';
@use './md-sys-state';
@use './md-sys-typescale';
@use './v0_192/md-comp-list';
@use './values';
// go/keep-sorted end
@ -22,117 +18,15 @@ $supported-tokens: (
// go/keep-sorted end
);
$unsupported-tokens: (
// go/keep-sorted start
'divider-leading-space',
'divider-trailing-space',
'list-item-container-elevation',
'list-item-container-shape',
'list-item-disabled-label-text-color',
'list-item-disabled-label-text-opacity',
'list-item-disabled-leading-icon-color',
'list-item-disabled-leading-icon-opacity',
'list-item-disabled-state-layer-color',
'list-item-disabled-state-layer-opacity',
'list-item-disabled-trailing-icon-color',
'list-item-disabled-trailing-icon-opacity',
'list-item-dragged-container-elevation',
'list-item-dragged-label-text-color',
'list-item-dragged-leading-icon-icon-color',
'list-item-dragged-state-layer-color',
'list-item-dragged-state-layer-opacity',
'list-item-dragged-trailing-icon-icon-color',
'list-item-focus-label-text-color',
'list-item-focus-leading-icon-icon-color',
'list-item-focus-state-layer-color',
'list-item-focus-state-layer-opacity',
'list-item-focus-trailing-icon-icon-color',
'list-item-hover-label-text-color',
'list-item-hover-leading-icon-icon-color',
'list-item-hover-state-layer-color',
'list-item-hover-state-layer-opacity',
'list-item-hover-trailing-icon-icon-color',
'list-item-label-text-color',
'list-item-label-text-font',
'list-item-label-text-line-height',
'list-item-label-text-size',
'list-item-label-text-tracking',
'list-item-label-text-type',
'list-item-label-text-weight',
'list-item-large-leading-video-height',
'list-item-leading-avatar-color',
'list-item-leading-avatar-label-color',
'list-item-leading-avatar-label-font',
'list-item-leading-avatar-label-line-height',
'list-item-leading-avatar-label-size',
'list-item-leading-avatar-label-tracking',
'list-item-leading-avatar-label-type',
'list-item-leading-avatar-label-weight',
'list-item-leading-avatar-shape',
'list-item-leading-avatar-size',
'list-item-leading-icon-color',
'list-item-leading-icon-size',
'list-item-leading-image-height',
'list-item-leading-image-shape',
'list-item-leading-image-width',
'list-item-leading-space',
'list-item-leading-video-shape',
'list-item-leading-video-width',
'list-item-one-line-container-height',
'list-item-overline-color',
'list-item-overline-font',
'list-item-overline-line-height',
'list-item-overline-size',
'list-item-overline-tracking',
'list-item-overline-type',
'list-item-overline-weight',
'list-item-pressed-label-text-color',
'list-item-pressed-leading-icon-icon-color',
'list-item-pressed-state-layer-color',
'list-item-pressed-state-layer-opacity',
'list-item-pressed-trailing-icon-icon-color',
'list-item-selected-trailing-icon-color',
'list-item-small-leading-video-height',
'list-item-supporting-text-color',
'list-item-supporting-text-font',
'list-item-supporting-text-line-height',
'list-item-supporting-text-size',
'list-item-supporting-text-tracking',
'list-item-supporting-text-type',
'list-item-supporting-text-weight',
'list-item-three-line-container-height',
'list-item-trailing-icon-color',
'list-item-trailing-icon-size',
'list-item-trailing-space',
'list-item-trailing-supporting-text-color',
'list-item-trailing-supporting-text-font',
'list-item-trailing-supporting-text-line-height',
'list-item-trailing-supporting-text-size',
'list-item-trailing-supporting-text-tracking',
'list-item-trailing-supporting-text-type',
'list-item-trailing-supporting-text-weight',
'list-item-two-line-container-height',
'list-item-unselected-trailing-icon-color',
// go/keep-sorted end
);
$_default: (
'md-sys-color': md-sys-color.values-light(),
'md-sys-elevation': md-sys-elevation.values(),
'md-sys-shape': md-sys-shape.values(),
'md-sys-state': md-sys-state.values(),
'md-sys-typescale': md-sys-typescale.values(),
);
@function values($deps: $_default, $exclude-hardcoded-values: false) {
$tokens: values.validate(
md-comp-list.values($deps, $exclude-hardcoded-values),
$supported-tokens: $supported-tokens,
$unsupported-tokens: $unsupported-tokens,
$renamed-tokens: (
'list-item-container-color': 'container-color',
)
$list-tokens: md-comp-list.values($deps, $exclude-hardcoded-values);
$tokens: (
'container-color': map.get($list-tokens, 'list-item-container-color'),
);
@return $tokens;
@return values.validate($tokens, $supported-tokens: $supported-tokens);
}