diff --git a/item/demo/demo.ts b/item/demo/demo.ts new file mode 100644 index 000000000..d6a6541cb --- /dev/null +++ b/item/demo/demo.ts @@ -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>('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'}); diff --git a/item/demo/project.json b/item/demo/project.json new file mode 100644 index 000000000..92f3e7958 --- /dev/null +++ b/item/demo/project.json @@ -0,0 +1,9 @@ +{ + "extends": "/assets/stories/base.json", + "files": { + "demo.ts": { + "hidden": true + }, + "stories.ts": {} + } +} diff --git a/item/demo/stories.ts b/item/demo/stories.ts new file mode 100644 index 000000000..3cea195fc --- /dev/null +++ b/item/demo/stories.ts @@ -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 = { + name: 'Items', + styles, + render(knobs) { + return html` +
+ + Single line item + ${getKnobContent(knobs)} + + + + Two line item +
Supporting text
+ ${getKnobContent(knobs)} +
+ + + Three line item +
+
Second line text
+
Third line text
+
+ ${getKnobContent(knobs, /* threeLines */ true)} +
+
+ `; + } +}; + +const longText: MaterialStoryInit = { + name: 'Items with long text', + styles, + render(knobs) { + return html` +
+ + Item with a truncated headline and supporting text. +
+ Supporting text. ${LOREM_IPSUM} +
+ ${getKnobContent(knobs)} +
+ + + Item with clamped lines +
+ Supporting text that wraps up to two lines. ${LOREM_IPSUM} +
+ ${getKnobContent(knobs, /* threeLines */ true)} +
+ + + Item that always shows long wrapping text. +
+ Supporting text. ${LOREM_IPSUM} +
+ ${getKnobContent(knobs, /* threeLines */ true)} +
+
+ `; + } +}; + +function getKnobContent(knobs: StoryKnobs, threeLines = false) { + const overline = knobs.overline ? + html`
${knobs.overline}
` : + nothing; + + const classes = { + 'align-start': threeLines, + }; + + const trailingText = knobs.trailingSupportingText ? + html`
${ + knobs.trailingSupportingText}
` : + nothing; + + const leadingIcon = knobs.leadingIcon ? + html`event` : + nothing; + + const trailingIcon = knobs.trailingIcon ? + html`star` : + nothing; + + return html`${overline}${trailingText}${leadingIcon}${trailingIcon}`; +} + +/** Item stories. */ +export const stories = [items, longText]; diff --git a/item/internal/_item.scss b/item/internal/_item.scss new file mode 100644 index 000000000..a71533770 --- /dev/null +++ b/item/internal/_item.scss @@ -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 + +/// `` 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; + } +} diff --git a/item/internal/item-styles.scss b/item/internal/item-styles.scss new file mode 100644 index 000000000..8664aa167 --- /dev/null +++ b/item/internal/item-styles.scss @@ -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; diff --git a/item/internal/item.ts b/item/internal/item.ts new file mode 100644 index 000000000..bf30e3313 --- /dev/null +++ b/item/internal/item.ts @@ -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` + + +
+ + + + +
+ + + `; + } + + 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; +} diff --git a/item/item.ts b/item/item.ts new file mode 100644 index 000000000..249dc0ea7 --- /dev/null +++ b/item/item.ts @@ -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. + * + * `` 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: + * - ``: 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 + * Single line + * + * + *
...
+ *
+ * + * + * + * image + *
Overline
+ *
Headline
+ * Supporting text + * Trailing + * image + *
+ * ``` + * + * When wrapping ``, forward the available slots to use the same slot + * structure for the wrapping component (this is what `` does). + * + * @example + * ```html + * + * + * + * + * + * + * + * + * + * ``` + */ +@customElement('md-item') +export class MdItem extends Item { + static override styles = [styles]; +} diff --git a/tokens/_index.scss b/tokens/_index.scss index 6d7c0eec4..128070b49 100644 --- a/tokens/_index.scss +++ b/tokens/_index.scss @@ -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-*; diff --git a/tokens/_md-comp-item.scss b/tokens/_md-comp-item.scss new file mode 100644 index 000000000..28e4909e1 --- /dev/null +++ b/tokens/_md-comp-item.scss @@ -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); +}