feat(item): add <md-item> layout component

PiperOrigin-RevId: 567095805
This commit is contained in:
Elizabeth Mitchell 2023-09-20 15:20:56 -07:00 committed by Copybara-Service
parent 54fbb2ed5e
commit ffe4f79b5d
9 changed files with 537 additions and 0 deletions

25
item/demo/demo.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import './index.js';
import './material-collection.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js';
import {boolInput, Knob, textInput} from './index.js';
import {stories, StoryKnobs} from './stories.js';
const collection =
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Item', [
new Knob('overline', {ui: textInput()}),
new Knob('trailingSupportingText', {ui: textInput()}),
new Knob('leadingIcon', {ui: boolInput()}),
new Knob('trailingIcon', {ui: boolInput()}),
]);
collection.addStories(...materialInitsToStoryInits(stories));
setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});

9
item/demo/project.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "/assets/stories/base.json",
"files": {
"demo.ts": {
"hidden": true
},
"stories.ts": {}
}
}

151
item/demo/stories.ts Normal file
View File

@ -0,0 +1,151 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@material/web/icon/icon.js';
import '@material/web/item/item.js';
import {MaterialStoryInit} from './material-collection.js';
import {css, html, nothing} from 'lit';
import {classMap} from 'lit/directives/class-map.js';
/** Knob types for item stories. */
export interface StoryKnobs {
overline: string;
trailingSupportingText: string;
leadingIcon: boolean;
trailingIcon: boolean;
}
const styles = css`
/* Use this CSS to prevent lines from wrapping */
.nowrap {
white-space: nowrap;
}
/* Use this CSS on items to limit the number of wrapping lines */
.clamp-lines {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Use this on start/end content when the item requires it,
typically 3+ line height items) */
.align-start {
align-self: flex-start;
/* Optional, some items line icons and text should visually appear 16px from
the top. Others, like interactive controls, should hug the top at 12px */
padding-top: 4px;
}
.container {
align-items: flex-start;
display: flex;
gap: 32px;
flex-wrap: wrap;
}
md-item {
border-radius: 16px;
outline: 1px solid var(--md-sys-color-outline);
width: 300px;
}
`;
const LOREM_IPSUM =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus condimentum rhoncus est volutpat venenatis.';
const items: MaterialStoryInit<StoryKnobs> = {
name: 'Items',
styles,
render(knobs) {
return html`
<div class="container">
<md-item>
Single line item
${getKnobContent(knobs)}
</md-item>
<md-item>
Two line item
<div slot="supporting-text">Supporting text</div>
${getKnobContent(knobs)}
</md-item>
<md-item>
Three line item
<div slot="supporting-text">
<div>Second line text</div>
<div>Third line text</div>
</div>
${getKnobContent(knobs, /* threeLines */ true)}
</md-item>
</div>
`;
}
};
const longText: MaterialStoryInit<StoryKnobs> = {
name: 'Items with long text',
styles,
render(knobs) {
return html`
<div class="container">
<md-item class="nowrap">
Item with a truncated headline and supporting text.
<div slot="supporting-text">
Supporting text. ${LOREM_IPSUM}
</div>
${getKnobContent(knobs)}
</md-item>
<md-item>
Item with clamped lines
<div slot="supporting-text" class="clamp-lines">
Supporting text that wraps up to two lines. ${LOREM_IPSUM}
</div>
${getKnobContent(knobs, /* threeLines */ true)}
</md-item>
<md-item>
Item that always shows long wrapping text.
<div slot="supporting-text">
Supporting text. ${LOREM_IPSUM}
</div>
${getKnobContent(knobs, /* threeLines */ true)}
</md-item>
</div>
`;
}
};
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 html`${overline}${trailingText}${leadingIcon}${trailingIcon}`;
}
/** Item stories. */
export const stories = [items, longText];

99
item/internal/_item.scss Normal file
View File

@ -0,0 +1,99 @@
//
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0
//
// go/keep-sorted start
@use 'sass:map';
// go/keep-sorted end
// go/keep-sorted start
@use '../../tokens';
// go/keep-sorted end
/// `<md-item>` does not provide `--md-item-*` custom properties. Instead, use
/// CSS on slotted elements to change their styles.
///
/// @example css
/// md-item {
/// color: var(--headline-color);
/// font: var(--headline-font);
/// }
/// md-item [slot='supporting-text'] {
/// color: var(--supporting-text-color);
/// font: var(--supporting-text-font);
/// }
/// // ...
///
@mixin styles() {
$tokens: tokens.md-comp-item-values();
:host {
color: map.get($tokens, 'label-text-color');
font-family: map.get($tokens, 'label-text-font');
font-size: map.get($tokens, 'label-text-size');
font-weight: map.get($tokens, 'label-text-weight');
line-height: map.get($tokens, 'label-text-line-height');
align-items: center;
box-sizing: border-box;
display: flex;
gap: 16px;
min-height: 56px;
overflow: hidden;
padding: 12px 16px;
position: relative;
text-overflow: ellipsis;
}
:host([multiline]) {
min-height: 72px;
}
[name='overline'] {
color: map.get($tokens, 'overline-color');
font-family: map.get($tokens, 'overline-font');
font-size: map.get($tokens, 'overline-size');
font-weight: map.get($tokens, 'overline-weight');
line-height: map.get($tokens, 'overline-line-height');
}
[name='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');
font-weight: map.get($tokens, 'supporting-text-weight');
line-height: map.get($tokens, 'supporting-text-line-height');
}
[name='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');
font-weight: map.get($tokens, 'trailing-supporting-text-weight');
line-height: map.get($tokens, 'trailing-supporting-text-line-height');
}
// A slot for background container elements, such as ripples and focus rings.
[name='container']::slotted(*) {
inset: 0;
position: absolute;
}
.default-slot {
// Needed since the default slot can have just text content, and ellipsis
// need an inline display.
display: inline;
}
.default-slot,
::slotted(*) {
overflow: hidden;
text-overflow: ellipsis;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
}

View File

@ -0,0 +1,10 @@
//
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0
//
// go/keep-sorted start
@use './item';
// go/keep-sorted end
@include item.styles;

78
item/internal/item.ts Normal file
View File

@ -0,0 +1,78 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {html, LitElement} from 'lit';
import {property, queryAll} from 'lit/decorators.js';
/**
* An item layout component.
*/
export class Item extends LitElement {
/**
* Only needed for SSR.
*
* Add this attribute when an item has two lines to avoid a Flash Of Unstyled
* Content. This attribute is not needed for single line items or items with
* three or more lines.
*/
@property({type: Boolean, reflect: true}) multiline = false;
@queryAll('.text slot') private readonly textSlots!: HTMLSlotElement[];
override render() {
return html`
<slot name="container"></slot>
<slot class="non-text" name="start"></slot>
<div class="text">
<slot name="overline"
@slotchange=${this.handleTextSlotChange}></slot>
<slot class="default-slot"
@slotchange=${this.handleTextSlotChange}></slot>
<slot name="headline"
@slotchange=${this.handleTextSlotChange}></slot>
<slot name="supporting-text"
@slotchange=${this.handleTextSlotChange}></slot>
</div>
<slot class="non-text" name="trailing-supporting-text"></slot>
<slot class="non-text" name="end"></slot>
`;
}
private handleTextSlotChange() {
// Check if there's more than one text slot with content. If so, the item is
// multiline, which has a different min-height than single line items.
let isMultiline = false;
let slotsWithContent = 0;
for (const slot of this.textSlots) {
if (slotHasContent(slot)) {
slotsWithContent += 1;
}
if (slotsWithContent > 1) {
isMultiline = true;
break;
}
}
this.multiline = isMultiline;
}
}
function slotHasContent(slot: HTMLSlotElement) {
for (const node of slot.assignedNodes({flatten: true})) {
// Assume there's content if there's an element slotted in
const isElement = node.nodeType === Node.ELEMENT_NODE;
// If there's only text nodes for the default slot, check if there's
// non-whitespace.
const isTextWithContent =
node.nodeType === Node.TEXT_NODE && node.textContent?.match(/\S/);
if (isElement || isTextWithContent) {
return true;
}
}
return false;
}

77
item/item.ts Normal file
View File

@ -0,0 +1,77 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {customElement} from 'lit/decorators.js';
import {Item} from './internal/item.js';
import {styles} from './internal/item-styles.css.js';
declare global {
interface HTMLElementTagNameMap {
'md-item': MdItem;
}
}
/**
* An item layout component that can be used inside list items to give them
* their customizable structure.
*
* `<md-item>` does not have any functionality, which must be added by the
* component using it.
*
* All text will wrap unless `white-space: nowrap` is set on the item or any of
* its children.
*
* Slots available:
* - `<default>`: The headline, or custom content.
* - `headline`: The first line.
* - `supporting-text`: Supporting text lines underneath the headline.
* - `trailing-supporting-text`: A small text snippet at the end of the item.
* - `start`: Any leading content, such as icons, avatars, or checkboxes.
* - `end`: Any trailing content, such as icons and buttons.
* - `container`: Background container content, intended for adding additional
* styles, such as ripples or focus rings.
*
* @example
* ```html
* <md-item>Single line</md-item>
*
* <md-item>
* <div class="custom-content">...</div>
* </md-item>
*
* <!-- Classic 1 to 3+ line list items -->
* <md-item>
* <md-icon slot="start">image</md-icon>
* <div slot="overline">Overline</div>
* <div slot="headline">Headline</div>
* <div="supporting-text">Supporting text</div>
* <div="trailing-supporting-text">Trailing</div>
* <md-icon slot="end">image</md-icon>
* </md-item>
* ```
*
* When wrapping `<md-item>`, forward the available slots to use the same slot
* structure for the wrapping component (this is what `<md-list-item>` does).
*
* @example
* ```html
* <md-item>
* <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>
* <slot name="start" slot="start"></slot>
* <slot name="end" slot="end"></slot>
* </md-item>
* ```
*/
@customElement('md-item')
export class MdItem extends Item {
static override styles = [styles];
}

View File

@ -28,6 +28,7 @@
@forward './md-comp-icon' as md-comp-icon-*;
@forward './md-comp-icon-button' as md-comp-icon-button-*;
@forward './md-comp-input-chip' as md-comp-input-chip-*;
@forward './md-comp-item' as md-comp-item-*;
@forward './md-comp-linear-progress' as md-comp-linear-progress-*;
@forward './md-comp-list' as md-comp-list-*;
@forward './md-comp-list-item' as md-comp-list-item-*;

87
tokens/_md-comp-item.scss Normal file
View File

@ -0,0 +1,87 @@
//
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0
//
// go/keep-sorted start
@use 'sass:map';
// go/keep-sorted end
// go/keep-sorted start
@use './md-sys-color';
@use './md-sys-typescale';
@use './v0_192/md-comp-list';
@use './values';
// go/keep-sorted end
$supported-tokens: (
// go/keep-sorted start
'label-text-color',
'label-text-font',
'label-text-line-height',
'label-text-size',
'label-text-weight',
'overline-color',
'overline-font',
'overline-line-height',
'overline-size',
'overline-weight',
'supporting-text-color',
'supporting-text-font',
'supporting-text-line-height',
'supporting-text-size',
'supporting-text-weight',
'trailing-supporting-text-color',
'trailing-supporting-text-font',
'trailing-supporting-text-line-height',
'trailing-supporting-text-size',
'trailing-supporting-text-weight',
// go/keep-sorted end
);
$_default: (
'md-sys-color': md-sys-color.values-light(),
'md-sys-typescale': md-sys-typescale.values(),
);
@function values($deps: $_default, $exclude-hardcoded-values: false) {
$list-tokens: md-comp-list.values($deps, $exclude-hardcoded-values);
$tokens: (
// go/keep-sorted start
'label-text-color': map.get($list-tokens, 'list-item-label-text-color'),
'label-text-font': map.get($list-tokens, 'list-item-label-text-font'),
'label-text-line-height':
map.get($list-tokens, 'list-item-label-text-line-height'),
'label-text-size': map.get($list-tokens, 'list-item-label-text-size'),
'label-text-weight': map.get($list-tokens, 'list-item-label-text-weight'),
'overline-color': map.get($list-tokens, 'list-item-overline-color'),
'overline-font': map.get($list-tokens, 'list-item-overline-font'),
'overline-line-height':
map.get($list-tokens, 'list-item-overline-line-height'),
'overline-size': map.get($list-tokens, 'list-item-overline-size'),
'overline-weight': map.get($list-tokens, 'list-item-overline-weight'),
'supporting-text-color':
map.get($list-tokens, 'list-item-supporting-text-color'),
'supporting-text-font':
map.get($list-tokens, 'list-item-supporting-text-font'),
'supporting-text-line-height':
map.get($list-tokens, 'list-item-supporting-text-line-height'),
'supporting-text-size':
map.get($list-tokens, 'list-item-supporting-text-size'),
'supporting-text-weight':
map.get($list-tokens, 'list-item-supporting-text-weight'),
'trailing-supporting-text-color':
map.get($list-tokens, 'list-item-trailing-supporting-text-color'),
'trailing-supporting-text-font':
map.get($list-tokens, 'list-item-trailing-supporting-text-font'),
'trailing-supporting-text-line-height':
map.get($list-tokens, 'list-item-trailing-supporting-text-line-height'),
'trailing-supporting-text-size':
map.get($list-tokens, 'list-item-trailing-supporting-text-size'),
'trailing-supporting-text-weight':
map.get($list-tokens, 'list-item-trailing-supporting-text-weight'),
// go/keep-sorted end
);
@return values.validate($tokens, $supported-tokens: $supported-tokens);
}