Skip to content

Commit

Permalink
feat(item): add <md-item> layout component
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 567095805
  • Loading branch information
asyncLiz authored and Copybara-Service committed Sep 20, 2023
1 parent 54fbb2e commit ffe4f79
Show file tree
Hide file tree
Showing 9 changed files with 537 additions and 0 deletions.
25 changes: 25 additions & 0 deletions item/demo/demo.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions item/demo/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "/assets/stories/base.json",
"files": {
"demo.ts": {
"hidden": true
},
"stories.ts": {}
}
}
151 changes: 151 additions & 0 deletions item/demo/stories.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 99 additions & 0 deletions item/internal/_item.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
10 changes: 10 additions & 0 deletions item/internal/item-styles.scss
Original file line number Diff line number Diff line change
@@ -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 changes: 78 additions & 0 deletions item/internal/item.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit ffe4f79

Please sign in to comment.