From 69bdcde346337472e5eef71b4b01ffd56d43dab1 Mon Sep 17 00:00:00 2001 From: Material Web Team Date: Tue, 21 Jul 2026 09:02:51 -0700 Subject: [PATCH] feat(labs): add appbar utility classes to component PiperOrigin-RevId: 951521394 --- .../gb/components/appbar/_app-bar-tokens.scss | 136 ++++++++++++ labs/gb/components/appbar/app-bar-element.ts | 139 ++++++++++++ labs/gb/components/appbar/app-bar.scss | 167 ++++++++++++++ labs/gb/components/appbar/app-bar.ts | 134 ++++++++++++ labs/gb/components/appbar/demo/demo.ts | 46 ++++ labs/gb/components/appbar/demo/stories.ts | 206 ++++++++++++++++++ labs/gb/components/appbar/md-gb-app-bar.ts | 16 ++ 7 files changed, 844 insertions(+) create mode 100644 labs/gb/components/appbar/_app-bar-tokens.scss create mode 100644 labs/gb/components/appbar/app-bar-element.ts create mode 100644 labs/gb/components/appbar/app-bar.scss create mode 100644 labs/gb/components/appbar/app-bar.ts create mode 100644 labs/gb/components/appbar/demo/demo.ts create mode 100644 labs/gb/components/appbar/demo/stories.ts create mode 100644 labs/gb/components/appbar/md-gb-app-bar.ts diff --git a/labs/gb/components/appbar/_app-bar-tokens.scss b/labs/gb/components/appbar/_app-bar-tokens.scss new file mode 100644 index 0000000000..0424790130 --- /dev/null +++ b/labs/gb/components/appbar/_app-bar-tokens.scss @@ -0,0 +1,136 @@ +// +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// Auto-generated by cascade_tokens +// Add `// EDIT:` comments for manual adjustments. +// +// --prefix=md.comp.app-bar +// --states=":small|[medium-flexible=medium]|[large-flexible=large],with-subtitle,search,on-scroll" +// --exclude=".medium.,.large." + +@mixin root { + // LINT.IfChange + // md.comp.app-bar.avatar.size + --avatar-size: 32px; + // md.comp.app-bar.container.color + --container-color: var(--md-sys-color-surface); + // md.comp.app-bar.container.elevation + --container-elevation: var(--md-sys-elevation-shadow-0); + // md.comp.app-bar.small.container.height + --container-height: 64px; + // md.comp.app-bar.container.shape + --container-shape: var(--md-sys-shape-corner-none); + // md.comp.app-bar.icon-button-space + --icon-button-space: var(--md-sys-space-0); + // md.comp.app-bar.icon.size + --icon-size: 24px; + --label-text: unset; + --label-text-axes: unset; + --label-text-tracking: unset; + --label-color: unset; + // md.comp.app-bar.leading-icon.color + --leading-icon-color: var(--md-sys-color-on-surface); + // md.comp.app-bar.leading-space + --leading-space: var(--md-sys-space-50); + // ┌ md.comp.app-bar.small.subtitle.font + --subtitle: var(--md-sys-typescale-label-md); + // ┌┄ md.comp.app-bar.small.subtitle + --subtitle-axes: var(--md-sys-typescale-label-md-axes); + // md.comp.app-bar.subtitle.color + --subtitle-color: var(--md-sys-color-on-surface-variant); + // ┌┄ md.comp.app-bar.small.subtitle + --subtitle-tracking: var(--md-sys-typescale-label-md-tracking); + // ┌ md.comp.app-bar.small.title.font + --title: var(--md-sys-typescale-title-lg); + // ┌┄ md.comp.app-bar.small.title + --title-axes: var(--md-sys-typescale-title-lg-axes); + // md.comp.app-bar.title.color + --title-color: var(--md-sys-color-on-surface); + // ┌┄ md.comp.app-bar.small.title + --title-tracking: var(--md-sys-typescale-title-lg-tracking); + // md.comp.app-bar.trailing-icon.color + --trailing-icon-color: var(--md-sys-color-on-surface-variant); + // md.comp.app-bar.trailing-space + --trailing-space: var(--md-sys-space-50); + // LINT.ThenChange(md-gb-app-bar.ts) keep in sync with `@cssprop` jsdoc tags +} + +@mixin medium { + // md.comp.app-bar.medium-flexible.container.height + --container-height: 112px; + // ┌ md.comp.app-bar.medium-flexible.subtitle.font + --subtitle: var(--md-sys-typescale-label-lg); + // ┌┄ md.comp.app-bar.medium-flexible.subtitle + --subtitle-axes: var(--md-sys-typescale-label-lg-axes); + // ┌┄ md.comp.app-bar.medium-flexible.subtitle + --subtitle-tracking: var(--md-sys-typescale-label-lg-tracking); + // ┌ md.comp.app-bar.medium-flexible.title.font + --title: var(--md-sys-typescale-headline-md); + // ┌┄ md.comp.app-bar.medium-flexible.title + --title-axes: var(--md-sys-typescale-headline-md-axes); + // ┌┄ md.comp.app-bar.medium-flexible.title + --title-tracking: var(--md-sys-typescale-headline-md-tracking); +} + +@mixin large { + // md.comp.app-bar.large-flexible.container.height + --container-height: 120px; + // ┌ md.comp.app-bar.large-flexible.subtitle.font + --subtitle: var(--md-sys-typescale-title-md); + // ┌┄ md.comp.app-bar.large-flexible.subtitle + --subtitle-axes: var(--md-sys-typescale-title-md-axes); + // ┌┄ md.comp.app-bar.large-flexible.subtitle + --subtitle-tracking: var(--md-sys-typescale-title-md-tracking); + // ┌ md.comp.app-bar.large-flexible.title.font + --title: var(--md-sys-typescale-display-sm); + // ┌┄ md.comp.app-bar.large-flexible.title + --title-axes: var(--md-sys-typescale-display-sm-axes); + // ┌┄ md.comp.app-bar.large-flexible.title + --title-tracking: var(--md-sys-typescale-display-sm-tracking); +} + +@mixin with-subtitle { + // ┌ md.comp.app-bar.medium-flexible.with-subtitle.container.height + --container-height: 136px; +} + +@mixin with-subtitle-large { + // md.comp.app-bar.large-flexible.with-subtitle.container.height + --container-height: 152px; +} + +@mixin search { + // md.comp.app-bar.search.container.color + --container-color: var(--md-sys-color-surface-container); + // md.comp.app-bar.small.search.container.height + --container-height: 56px; + // md.comp.app-bar.small.search.container.shape + --container-shape: var(--md-sys-shape-corner-full); + // ┌ md.comp.app-bar.small.search.label-text.font + --label-text: var(--md-sys-typescale-body-lg); + // ┌┄ md.comp.app-bar.small.search.label-text + --label-text-axes: var(--md-sys-typescale-body-lg-axes); + // ┌┄ md.comp.app-bar.small.search.label-text + --label-text-tracking: var(--md-sys-typescale-body-lg-tracking); + // md.comp.app-bar.search.label.color + --label-color: var(--md-sys-color-on-surface-variant); + // md.comp.app-bar.search.leading-space + --leading-space: var(--md-sys-space-100); + // md.comp.app-bar.search.trailing-space + --trailing-space: var(--md-sys-space-100); +} + +@mixin on-scroll { + // md.comp.app-bar.on-scroll.container.color + --container-color: var(--md-sys-color-surface-container); + // md.comp.app-bar.on-scroll.container.elevation + --container-elevation: var(--md-sys-elevation-shadow-2); +} + +@mixin on-scroll-search { + // md.comp.app-bar.search.on-scroll.container.color + --container-color: var(--md-sys-color-surface-container-highest); +} + diff --git a/labs/gb/components/appbar/app-bar-element.ts b/labs/gb/components/appbar/app-bar-element.ts new file mode 100644 index 0000000000..ce6fede1ca --- /dev/null +++ b/labs/gb/components/appbar/app-bar-element.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {css, CSSResultOrNative, html, LitElement} from 'lit'; +import {property, state} from 'lit/decorators.js'; + +import {appBar, type AppBarSize, type AppBarVariant} from './app-bar.js'; +import {styles as appBarStyles} from './app-bar.cssresult.js'; + +/** + * A Material Design Expressive App Bar component (`md-gb-app-bar`). + * + * @slot - Used to display the primary title text of the app bar. + * @slot subtitle - Used to display an optional subtitle below the title in medium/large bars. + * @slot leading - Used to display the leading navigation icon button (e.g. menu or back). + * @slot trailing - Used to display trailing action buttons and/or user avatar. + * @slot search - Used to display search bar input or controls when `variant="search"`. + * @fires {CustomEvent<{scrolled: boolean}>} scrollstatechange - Fired when the scrolled state changes. --bubbles --composed + * @csspart app-bar - The root container element. + * @cssprop --avatar-size + * @cssprop --container-color + * @cssprop --container-elevation + * @cssprop --container-height + * @cssprop --container-shape + * @cssprop --icon-button-space + * @cssprop --icon-size + * @cssprop --label-text + * @cssprop --label-text-axes + * @cssprop --label-text-tracking + * @cssprop --label-color + * @cssprop --leading-icon-color + * @cssprop --leading-space + * @cssprop --subtitle + * @cssprop --subtitle-axes + * @cssprop --subtitle-color + * @cssprop --subtitle-tracking + * @cssprop --title + * @cssprop --title-axes + * @cssprop --title-color + * @cssprop --title-tracking + * @cssprop --trailing-icon-color + * @cssprop --trailing-space + */ +export class AppBarElement extends LitElement { + static override styles: CSSResultOrNative[] = [ + appBarStyles, + css` + :host { + display: flex; + width: 100%; + position: relative; + } + `, + ]; + + /** The size of the app bar (`sm`, `md`, `lg`). Defaults to `sm`. */ + @property({reflect: true}) size: AppBarSize = 'sm'; + + /** The behavioral variant of the app bar (`standard` or `search`). Defaults to `standard`. */ + @property({reflect: true}) variant: AppBarVariant = 'standard'; + + /** Whether the app bar is in its scrolled state. Defaults to `false`. */ + @property({type: Boolean, reflect: true}) scrolled = false; + + @state() private hasSubtitle = false; + + protected override render() { + const withSubtitle = this.size !== 'sm' && this.hasSubtitle; + const directive = appBar({ + size: this.size, + withSubtitle, + variant: this.variant, + scrolled: this.scrolled, + }); + + if (this.variant === 'search') { + return html`
+
+ +
+
+ +
+
+ +
+
`; + } + + if (this.size !== 'sm') { + return html`
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
`; + } + + return html`
+
+ +
+
+
+ +
+
+
+ +
+
`; + } + + private handleSubtitleSlotChange(event: Event) { + const slot = event.target as HTMLSlotElement; + this.hasSubtitle = slot.assignedNodes({flatten: true}).length > 0; + } +} diff --git a/labs/gb/components/appbar/app-bar.scss b/labs/gb/components/appbar/app-bar.scss new file mode 100644 index 0000000000..e475fbc532 --- /dev/null +++ b/labs/gb/components/appbar/app-bar.scss @@ -0,0 +1,167 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'app-bar-tokens'; +// go/keep-sorted end + +@layer md.sys; +@layer md.comp.app-bar { + // Component tokens + .app-bar { + @include app-bar-tokens.root; + } + .app-bar-md { + @include app-bar-tokens.medium; + } + .app-bar-lg { + @include app-bar-tokens.large; + } + .app-bar-with-subtitle { + @include app-bar-tokens.with-subtitle; + } + .app-bar-with-subtitle.app-bar-lg { + @include app-bar-tokens.with-subtitle-large; + } + .app-bar-search { + @include app-bar-tokens.search; + } + .app-bar-scrolled { + @include app-bar-tokens.on-scroll; + } + .app-bar-scrolled.app-bar-search { + @include app-bar-tokens.on-scroll-search; + } + + // Component styles + .app-bar { + box-sizing: border-box; + display: flex; + position: relative; + width: 100%; + min-height: var(--container-height); + background-color: var(--container-color); + border-radius: var(--container-shape); + box-shadow: var(--container-elevation); + color: var(--title-color); + transition: + background-color 200ms ease, + box-shadow 200ms ease, + min-height 200ms ease; + padding-inline: var(--leading-space) var(--trailing-space); + } + + // Small and Search layouts (single row) + .app-bar-sm, + .app-bar-search { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + // Medium and Large layouts (two rows) + .app-bar-md, + .app-bar-lg { + flex-direction: column; + justify-content: space-between; + align-items: stretch; + + .app-bar-top-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + min-height: 64px; + width: 100%; + } + + .app-bar-bottom-row { + display: flex; + flex-direction: column; + justify-content: flex-end; + padding-bottom: 24px; + padding-inline: 12px; + flex: 1 1 auto; + min-height: 0; + } + } + + // Slot containers + .app-bar-leading, + .app-bar-trailing { + display: flex; + align-items: center; + gap: var(--icon-button-space); + flex-shrink: 0; + } + + .app-bar-leading { + color: var(--leading-icon-color); + } + + .app-bar-trailing { + color: var(--trailing-icon-color); + } + + .app-bar-title-wrapper { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + } + + .app-bar-sm .app-bar-title-wrapper { + margin-inline: 4px; + } + + .app-bar-title { + margin: 0; + font: var(--title); + font-variation-settings: var(--title-axes); + letter-spacing: var(--title-tracking); + color: var(--title-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + ::slotted(*) { + margin: 0; + font: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + } + } + + .app-bar-subtitle { + margin: 0; + margin-top: 2px; + font: var(--subtitle); + font-variation-settings: var(--subtitle-axes); + letter-spacing: var(--subtitle-tracking); + color: var(--subtitle-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + ::slotted(*) { + margin: 0; + font: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + } + } + + .app-bar-search-container { + display: flex; + flex: 1 1 auto; + align-items: center; + min-width: 0; + margin-inline: 8px; + } +} diff --git a/labs/gb/components/appbar/app-bar.ts b/labs/gb/components/appbar/app-bar.ts new file mode 100644 index 0000000000..7e4a857115 --- /dev/null +++ b/labs/gb/components/appbar/app-bar.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {type ClassInfo} from 'lit/directives/class-map.js'; + +import {createClassMapDirective} from '../shared/directives.js'; + +/** App Bar size configuration types. */ +export type AppBarSize = 'sm' | 'md' | 'lg'; + +/** App Bar size configurations. */ +export const APP_BAR_SIZES = { + sm: 'sm', + md: 'md', + lg: 'lg', +} as const; + +/** App Bar variant configuration types. */ +export type AppBarVariant = 'standard' | 'search'; + +/** App Bar variant configurations. */ +export const APP_BAR_VARIANTS = { + standard: 'standard', + search: 'search', +} as const; + +/** App Bar classes. */ +export const APP_BAR_CLASSES = { + appBar: 'app-bar', + appBarSm: 'app-bar-sm', + appBarMd: 'app-bar-md', + appBarLg: 'app-bar-lg', + appBarWithSubtitle: 'app-bar-with-subtitle', + appBarSearch: 'app-bar-search', + appBarScrolled: 'app-bar-scrolled', +} as const; + +/** The state provided to the `appBarClasses()` function. */ +export interface AppBarClassesState { + /** The size of the app bar (`sm`, `md`, `lg`). */ + size?: AppBarSize; + /** Whether the app bar has a subtitle. */ + withSubtitle?: boolean; + /** The behavior variant (`standard` or `search`). */ + variant?: AppBarVariant; + /** Whether the app bar is in a scrolled or docked state (`.app-bar-scrolled`). */ + scrolled?: boolean; +} + +/** + * Returns the app bar classes to apply to an element based on the given state. + * + * @param state The state of the app bar. + * @return An object of class names and truthy values if they apply. + */ +export function appBarClasses({ + size = 'sm', + withSubtitle = false, + variant = 'standard', + scrolled = false, +}: AppBarClassesState = {}): ClassInfo { + return { + [APP_BAR_CLASSES.appBar]: true, + [APP_BAR_CLASSES.appBarSm]: size === APP_BAR_SIZES.sm || !size, + [APP_BAR_CLASSES.appBarMd]: size === APP_BAR_SIZES.md, + [APP_BAR_CLASSES.appBarLg]: size === APP_BAR_SIZES.lg, + [APP_BAR_CLASSES.appBarWithSubtitle]: withSubtitle, + [APP_BAR_CLASSES.appBarSearch]: variant === APP_BAR_VARIANTS.search, + [APP_BAR_CLASSES.appBarScrolled]: scrolled, + }; +} + +/** + * Sets up optional scroll target tracking for the given app bar element when an explicit `scrollTarget` is provided. + * + * @param appBar The element on which to set up app bar functionality. + * @param opts Setup options, containing an explicit `scrollTarget` and optional cleanup `signal`. + */ +export function setupAppBar( + appBar: HTMLElement, + opts?: {signal?: AbortSignal; scrollTarget?: HTMLElement | Window}, +): void { + if (!opts?.scrollTarget) { + return; + } + const target = opts.scrollTarget; + const signal = opts.signal; + + const onScroll = () => { + if (signal?.aborted) return; + let scrollTop = 0; + if (target instanceof Window) { + scrollTop = target.scrollY || target.document.documentElement.scrollTop; + } else if (target instanceof HTMLElement) { + scrollTop = target.scrollTop; + } + const isScrolled = scrollTop > 0; + const wasScrolled = appBar.classList.contains( + APP_BAR_CLASSES.appBarScrolled, + ); + if (isScrolled !== wasScrolled) { + appBar.classList.toggle(APP_BAR_CLASSES.appBarScrolled, isScrolled); + appBar.dispatchEvent( + new CustomEvent('scrollstatechange', { + bubbles: true, + composed: true, + detail: {scrolled: isScrolled}, + }), + ); + } + }; + + target.addEventListener('scroll', onScroll, {passive: true, signal}); + onScroll(); +} + +/** + * A Lit directive that adds app bar styling and optional scroll tracking functionality to its element. + * + * @example + * ```ts + * html`
+ *
...
+ *
...
+ *
`; + * ``` + */ +export const appBar = createClassMapDirective({ + getClasses: appBarClasses, + setupElement: setupAppBar, +}); diff --git a/labs/gb/components/appbar/demo/demo.ts b/labs/gb/components/appbar/demo/demo.ts new file mode 100644 index 0000000000..3c9ed84c27 --- /dev/null +++ b/labs/gb/components/appbar/demo/demo.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './material-collection.js'; +import './index.js'; + +import { + KnobTypesToKnobs, + MaterialCollection, + materialInitsToStoryInits, + setUpDemo, +} from './material-collection.js'; +import {boolInput, Knob, selectDropdown} from './index.js'; + +import {stories, StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'App Bar', + [ + new Knob('size', { + ui: selectDropdown({ + options: [ + {value: 'sm', label: 'Small (sm)'}, + {value: 'md', label: 'Medium (md)'}, + {value: 'lg', label: 'Large (lg)'}, + ], + }), + }), + new Knob('variant', { + ui: selectDropdown({ + options: [ + {value: 'standard', label: 'Standard'}, + {value: 'search', label: 'Search'}, + ], + }), + }), + new Knob('scrolled', {ui: boolInput()}), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/appbar/demo/stories.ts b/labs/gb/components/appbar/demo/stories.ts new file mode 100644 index 0000000000..1e039dcbbf --- /dev/null +++ b/labs/gb/components/appbar/demo/stories.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/gb/components/appbar/md-gb-app-bar.js'; +import '@material/web/labs/gb/components/iconbutton/md-gb-icon-button.js'; +import '@material/web/labs/gb/styles/icon/md-gb-icon.js'; + +import {MaterialStoryInit} from './material-collection.js'; +import { + type AppBarSize, + type AppBarVariant, +} from '@material/web/labs/gb/components/appbar/app-bar.js'; +import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js'; +import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; +import {css, html, nothing} from 'lit'; + +/** Knob types for app bar stories. */ +export interface StoryKnobs { + size?: AppBarSize; + variant?: AppBarVariant; + scrolled: boolean; +} + +adoptStyles(document, [ + m3Styles, + css` + :root { + --md-icon-font: 'Material Symbols Outlined'; + } + `, +]); + +const styles = css` + .demo-container { + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: 16px; + overflow: hidden; + margin-bottom: 24px; + background-color: var(--md-sys-color-surface); + box-shadow: var(--md-sys-elevation-shadow-1); + } +`; + +const playground: MaterialStoryInit = { + name: 'Playground', + styles, + render(knobs) { + const size = knobs.size || 'sm'; + const variant = knobs.variant || 'standard'; + const scrolled = Boolean(knobs.scrolled); + + if (variant === 'search') { + return html` +
+ + + menu + + + + account_circle + + +
+ `; + } + + const showSubtitle = size === 'md' || size === 'lg'; + + return html` +
+ + + menu + + Expressive App Bar (${size}) + ${showSubtitle + ? html`Two-tier expressive hierarchy (${size})` + : nothing} + + search + + + more_vert + + +
+ `; + }, +}; + +const allSizes: MaterialStoryInit = { + name: 'Expressive Sizes & Variants', + styles, + render() { + return html` +
+
+ + + menu + + Small App Bar (sm) + + search + + + account_circle + + +
+ +
+ + + arrow_back + + Medium App Bar (md) + + more_vert + + +
+ +
+ + + menu + + Medium App Bar with Subtitle + Two-tier expressive hierarchy + + edit + + +
+ +
+ + + menu + + Large App Bar (lg) + + settings + + +
+ +
+ + + menu + + Large App Bar with Subtitle + Dynamic headline layout + + account_circle + + +
+ +
+ + + search + + + + close + + +
+ +
+ + + menu + + Scrolled App Bar (Declarative property) + Demonstrating elevated scrolled style + + filter_list + + +
+
+ `; + }, +}; + +/** App Bar stories. */ +export const stories = [playground, allSizes]; diff --git a/labs/gb/components/appbar/md-gb-app-bar.ts b/labs/gb/components/appbar/md-gb-app-bar.ts new file mode 100644 index 0000000000..623e9c913c --- /dev/null +++ b/labs/gb/components/appbar/md-gb-app-bar.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AppBarElement} from './app-bar-element.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design app bar component. */ + 'md-gb-app-bar': AppBarElement; + } +} + +customElements.define('md-gb-app-bar', AppBarElement);