From 3aad6a806a4c10344d3f882a62771ab0b699a3cc Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Wed, 16 Sep 2026 13:44:24 -0700 Subject: [PATCH 1/6] feat(app-bar): implement Material 3 app bar specification (#62) --- all.js | 2 + app/README.md | 119 ++++++-- app/bar.js | 598 ++++++++++++++++++++++++++++++++++++++--- demo/app-bar-demo.html | 324 ++++++++++++++++++++++ demo/index.html | 16 +- 5 files changed, 997 insertions(+), 62 deletions(-) create mode 100644 demo/app-bar-demo.html diff --git a/all.js b/all.js index bd5f0a1..c5566dc 100644 --- a/all.js +++ b/all.js @@ -4,6 +4,7 @@ * WARNING: This import is intended for prototyping and development builds only. * Import only the individual components used for production. */ +import './app/bar.js' import './buttons/button.js' import './buttons/button-group.js' import './carousel/carousel.js' @@ -39,6 +40,7 @@ import './text/text-field.js' // LINT.ThenChange(:exports) // LINT.IfChange(exports) // go/keep-sorted start +export * from './app/bar.js' export * from './buttons/button.js' export * from './buttons/button-group.js' export * from './carousel/carousel.js' diff --git a/app/README.md b/app/README.md index d5b8967..b803bd2 100644 --- a/app/README.md +++ b/app/README.md @@ -1,6 +1,14 @@ # App Bar -A Material 3 top app bar component with an integrated search field. +A Material 3 top app bar component implementing the [Material Design 3 App Bar specification](https://m3.material.io/components/app-bars/overview). + +Top app bars display information and actions related to the current screen, supporting: + +- **Small App Bar**: Standard 64px height with left-aligned headline. +- **Center-Aligned App Bar**: 64px height with centered headline. +- **Medium Flexible App Bar**: Two-row flexible layout (112px–136px) that collapses to 64px on scroll. +- **Large Flexible App Bar**: Prominent two-row flexible layout (120px–152px) that collapses to 64px on scroll. +- **Search App Bar**: Top bar with integrated search input field and actions. ## Import @@ -22,30 +30,105 @@ import 'material/icon/icon.js' ## Usage +### 1. Small App Bar (Default) + +```html + + + menu + +
+ search + more_vert +
+
+``` + +### 2. Center-Aligned App Bar + +```html + + + arrow_back + +
+ attach_file +
+
+``` + +### 3. Medium & Large Flexible App Bars + +Flexible app bars support prominent headings and can automatically collapse to 64px when scrolled: + ```html - - + + menu -
- - more_vert - +
+ filter_list
``` -### Properties and Attributes +For Large Flexible, use `type="large-flexible"`. + +### 4. Search App Bar + +```html + + + menu + +
+ mic +
+
+``` + +## Properties and Attributes + +| Property | Attribute | Type | Default | Description | +| ---------------- | ----------------- | --------- | ---------- | ----------------------------------------------------------------------------------------------- | +| `type` | `type` | `string` | `'small'` | Variant: `'small'`, `'center-aligned'`, `'medium-flexible'`, `'large-flexible'`, or `'search'`. | +| `variant` | `variant` | `string` | `''` | Alias for `type`. | +| `headline` | `headline` | `string` | `''` | Main headline / title text. | +| `title` | `title` | `string` | `''` | Alias for `headline`. | +| `subtitle` | `subtitle` | `string` | `''` | Optional subtitle text. | +| `centerAligned` | `center-aligned` | `boolean` | `false` | Shorthand boolean to center-align the headline. | +| `scrolled` | `scrolled` | `boolean` | `false` | Reflects whether container color / elevation is in the scrolled state. | +| `collapsed` | `collapsed` | `boolean` | `false` | Whether a medium or large flexible bar is collapsed down to 64px. | +| `scrollBehavior` | `scroll-behavior` | `string` | `'none'` | Auto-scroll mode: `'none'`, `'scroll'` (elevates on scroll), or `'collapse'` (also collapses). | +| `scrollTarget` | `scroll-target` | `string` | `'window'` | CSS selector of the scroll container element, or `'window'`. | +| `placeholder` | `placeholder` | `string` | `'Search'` | Placeholder for the search variant input field. | +| `value` | `value` | `string` | `''` | Current text in the search input field. | + +## Slots -| Property | Attribute | Type | Default | Description | -| ------------- | ------------- | -------- | ---------- | ----------------------------------------------------- | -| `label` | `label` | `string` | `'Search'` | The label for the inner search text field. | -| `placeholder` | `placeholder` | `string` | `'Search'` | The placeholder text for the inner search text field. | -| `type` | `type` | `string` | `'search'` | Input type for the inner search text field. | +| Slot | Description | +| -------------------------- | ------------------------------------------------------------------------------------------ | +| `navigation-icon` | Leading navigation button (e.g. hamburger menu icon or back arrow). Alias: `leading-icon`. | +| `headline` | Custom HTML or component for the headline. Alias: `title`. | +| `subtitle` | Custom HTML or component for the subtitle. | +| `action-items` | Trailing action icon buttons or user avatar. Alias: `trailing-icon`. | +| `search-field` | Custom search input field override for the `search` variant. | +| `leading-icon-text-field` | Icon inside the search input field. | +| `trailing-icon-text-field` | Clear or trailing icon inside the search input field. | +| `(default)` | Additional under-bar content (such as tabs or divider). | -### Slots +## CSS Custom Properties -- `leading-icon`: Element placed before the search bar (e.g. navigation drawer menu button). -- `trailing-icon`: Element placed after the search bar (e.g. profile avatar, action buttons). -- `leading-icon-text-field`: Leading icon inside the search text field. -- `trailing-icon-text-field`: Trailing icon inside the search text field. +| Custom Property | Default Value | Description | +| --------------------------------------- | ---------------------------------------- | ------------------------------------- | +| `--md-app-bar-container-color` | `var(--md-sys-color-surface)` | Background color when unscrolled. | +| `--md-app-bar-scrolled-container-color` | `var(--md-sys-color-surface-container)` | Background color when scrolled. | +| `--md-app-bar-headline-color` | `var(--md-sys-color-on-surface)` | Text color of the headline. | +| `--md-app-bar-subtitle-color` | `var(--md-sys-color-on-surface-variant)` | Text color of the subtitle. | +| `--md-app-bar-leading-icon-color` | `var(--md-sys-color-on-surface)` | Color of the leading navigation icon. | +| `--md-app-bar-trailing-icon-color` | `var(--md-sys-color-on-surface-variant)` | Color of the trailing action icons. | diff --git a/app/bar.js b/app/bar.js index 6a0dd42..3fdcf7a 100644 --- a/app/bar.js +++ b/app/bar.js @@ -1,59 +1,583 @@ -import { LitElement, html, css } from 'lit' +/** + * @license + * Copyright 2024 Google LLC / Material ESM Authors + * SPDX-License-Identifier: Apache-2.0 + */ +import { LitElement, html, css, nothing } from 'lit' import '../text/text-field.js' -import { sharedStyles } from '../shared/shared.css.js' +import '../icon/icon.js' +import '../internal/elevation/elevation.js' -class AppBar extends LitElement { +/** + * Material Design 3 Top App Bar component. + * + * Implements the Material Design 3 App Bar specification: + * https://m3.material.io/components/app-bars/overview + * + * Variants supported: + * - `small`: Standard 64px single-row top app bar with left-aligned title. + * - `center-aligned`: 64px single-row top app bar with centered title. + * - `medium-flexible`: 112px–136px two-row flexible bar, collapses to 64px on scroll. + * - `large-flexible`: 120px–152px two-row flexible bar, collapses to 64px on scroll. + * - `search`: 64px search bar with embedded search text field. + * + * @fires search {CustomEvent<{value: string}>} Dispatched when search is submitted. + * @fires input {Event} Dispatched when search input value changes. + * @fires change {Event} Dispatched when search input commits value. + */ +export class AppBar extends LitElement { static properties = { + /** App bar variant: 'small' | 'center-aligned' | 'medium-flexible' | 'large-flexible' | 'search' */ + type: { type: String, reflect: true }, + /** Alias for `type` */ + variant: { type: String }, + /** Main headline / title text */ + headline: { type: String }, + /** Alias for `headline` */ + title: { type: String }, + /** Backwards-compatible label property (used as title or search field label) */ label: { type: String }, + /** Optional subtitle text */ + subtitle: { type: String }, + /** Shorthand boolean attribute to center-align the headline */ + centerAligned: { type: Boolean, attribute: 'center-aligned', reflect: true }, + /** Whether the app bar has elevation and surface-container color applied from scrolling */ + scrolled: { type: Boolean, reflect: true }, + /** Whether a flexible (medium/large) app bar is collapsed down to 64px */ + collapsed: { type: Boolean, reflect: true }, + /** Automatic scroll response: 'none' | 'scroll' | 'collapse' */ + scrollBehavior: { type: String, attribute: 'scroll-behavior' }, + /** CSS selector of scroll target or 'window' */ + scrollTarget: { type: String, attribute: 'scroll-target' }, + /** Placeholder for search variant text field */ placeholder: { type: String }, - type: { type: String }, + /** Value for search variant text field */ + value: { type: String }, + /** Internal state indicating whether search field has text */ + _hasSearchValue: { state: true }, } constructor() { super() - console.log('SEARCH IS A WIP') - - this.label = 'Search' + this.type = 'small' + this.variant = '' + this.headline = '' + this.title = '' + this.label = '' + this.subtitle = '' + this.centerAligned = false + this.scrolled = false + this.collapsed = false + this.scrollBehavior = 'none' + this.scrollTarget = 'window' this.placeholder = 'Search' - this.type = 'search' + this.value = '' + this._hasSearchValue = false + + this._onScroll = this._handleScroll.bind(this) + this._currentTarget = null + } + + connectedCallback() { + super.connectedCallback() + this._attachScrollListener() + } + + disconnectedCallback() { + super.disconnectedCallback() + this._detachScrollListener() + } + + updated(changedProperties) { + super.updated(changedProperties) + if (changedProperties.has('scrollBehavior') || changedProperties.has('scrollTarget')) { + this._detachScrollListener() + this._attachScrollListener() + } + if (changedProperties.has('value')) { + this._hasSearchValue = Boolean(this.value) + } + } + + /** + * Returns current effective variant type. + */ + get _effectiveType() { + const v = (this.variant || this.type || 'small').toLowerCase() + if (this.centerAligned || v === 'center-aligned' || v === 'center') { + return 'center-aligned' + } + if (v === 'medium' || v === 'medium-flexible') { + return 'medium-flexible' + } + if (v === 'large' || v === 'large-flexible') { + return 'large-flexible' + } + if (v === 'search') { + return 'search' + } + return 'small' + } + + get _isSearch() { + return this._effectiveType === 'search' + } + + get _isFlexible() { + const t = this._effectiveType + return t === 'medium-flexible' || t === 'large-flexible' + } + + get _isCenterAligned() { + return this._effectiveType === 'center-aligned' + } + + get _headlineText() { + return this.headline || this.title || (!this._isSearch ? this.label : '') || '' + } + + get _hasSubtitle() { + return Boolean(this.subtitle) + } + + _getScrollTargetElement() { + if (!this.scrollTarget || this.scrollTarget === 'window') { + return window + } + return document.querySelector(this.scrollTarget) || window + } + + _attachScrollListener() { + if (!this.scrollBehavior || this.scrollBehavior === 'none') { + return + } + const target = this._getScrollTargetElement() + if (target) { + target.addEventListener('scroll', this._onScroll, { passive: true }) + this._currentTarget = target + // Check initial scroll offset + this._handleScroll() + } + } + + _detachScrollListener() { + if (this._currentTarget) { + this._currentTarget.removeEventListener('scroll', this._onScroll) + this._currentTarget = null + } + } + + _handleScroll() { + const target = this._currentTarget || this._getScrollTargetElement() + const scrollTop = target === window ? window.scrollY : target.scrollTop + const isScrolled = scrollTop > 4 + + if (this.scrolled !== isScrolled) { + this.scrolled = isScrolled + } + + if (this.scrollBehavior === 'collapse' && this._isFlexible) { + const collapseThreshold = this._effectiveType === 'large-flexible' ? 56 : 40 + const isCollapsed = scrollTop > collapseThreshold + if (this.collapsed !== isCollapsed) { + this.collapsed = isCollapsed + } + } + } + + _handleSearchInput(e) { + const input = e.target + this.value = input.value + this._hasSearchValue = Boolean(input.value) + this.dispatchEvent(new CustomEvent('input', { bubbles: true, composed: true })) + } + + _handleSearchChange(e) { + const input = e.target + this.value = input.value + this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })) + } + + _handleSearchKeyDown(e) { + if (e.key === 'Enter') { + this.dispatchEvent( + new CustomEvent('search', { + detail: { value: this.value }, + bubbles: true, + composed: true, + }), + ) + } + } + + _clearSearch() { + this.value = '' + this._hasSearchValue = false + const input = this.renderRoot?.querySelector('#search-input') + if (input) { + input.value = '' + input.dispatchEvent(new Event('input', { bubbles: true, composed: true })) + input.dispatchEvent(new Event('change', { bubbles: true, composed: true })) + } } render() { + if (this._isSearch) { + return this._renderSearchAppBar() + } + + const type = this._effectiveType + const isFlexible = this._isFlexible + const isCollapsed = isFlexible && this.collapsed + const isCenterAligned = this._isCenterAligned + return html` -
- - - - - - -
+ ` } - get value() { - return this.renderRoot.querySelector('#input').value + _renderSearchAppBar() { + return html` + + ` } - static styles = [ - sharedStyles, - css` - :host { - --md-text-field-container-shape: 24px; - /* width: 100%; */ - height: 48px; - } - .main { - width: 100%; - } - `, - ] + static styles = css` + :host { + display: block; + width: 100%; + box-sizing: border-box; + --_container-color: var(--md-app-bar-container-color, var(--md-sys-color-surface, #fff)); + --_scrolled-container-color: var( + --md-app-bar-scrolled-container-color, + var(--md-sys-color-surface-container, #efedf4) + ); + --_on-container-color: var(--md-app-bar-headline-color, var(--md-sys-color-on-surface, #1a1b21)); + --_subtitle-color: var(--md-app-bar-subtitle-color, var(--md-sys-color-on-surface-variant, #45464f)); + --_leading-icon-color: var(--md-app-bar-leading-icon-color, var(--md-sys-color-on-surface, #1a1b21)); + --_trailing-icon-color: var(--md-app-bar-trailing-icon-color, var(--md-sys-color-on-surface-variant, #45464f)); + --md-elevation-level: 0; + } + + :host([scrolled]) { + --md-elevation-level: 2; + } + + .md3-app-bar { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + background-color: var(--_container-color); + color: var(--_on-container-color); + transition: + background-color 200ms cubic-bezier(0.2, 0, 0, 1), + box-shadow 200ms cubic-bezier(0.2, 0, 0, 1), + min-height 250ms cubic-bezier(0.2, 0, 0, 1), + height 250ms cubic-bezier(0.2, 0, 0, 1); + user-select: none; + } + + .md3-app-bar.scrolled { + background-color: var(--_scrolled-container-color); + } + + /* Elevations */ + md-elevation { + border-radius: 0; + transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1); + } + + /* Top row standard 64px */ + .md3-app-bar__row { + display: flex; + align-items: center; + box-sizing: border-box; + height: 64px; + padding: 0 4px; + position: relative; + } + + .md3-app-bar__leading { + display: flex; + align-items: center; + justify-content: center; + min-width: 48px; + color: var(--_leading-icon-color); + flex-shrink: 0; + } + + .md3-app-bar__trailing { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 48px; + margin-left: auto; + gap: 4px; + padding-right: 4px; + color: var(--_trailing-icon-color); + flex-shrink: 0; + } + + /* Headline containers */ + .md3-app-bar__headline-container { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + padding-left: 12px; + padding-right: 12px; + min-width: 0; + overflow: hidden; + } + + .md3-app-bar__headline { + font-family: var(--md-ref-typeface-brand, inherit); + font-size: var(--md-app-bar-headline-size, 22px); + line-height: 28px; + font-weight: 500; + color: var(--_on-container-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1); + } + + .md3-app-bar__subtitle { + font-family: var(--md-ref-typeface-plain, inherit); + font-size: 14px; + line-height: 20px; + font-weight: 400; + color: var(--_subtitle-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1); + } + + /* Center-aligned variant */ + .md3-app-bar--center-aligned .md3-app-bar__headline-container.center-aligned { + align-items: center; + text-align: center; + padding-left: 0; + padding-right: 0; + } + + /* Medium & Large Flexible App Bars */ + .md3-app-bar--medium-flexible { + min-height: 112px; + } + + .md3-app-bar--medium-flexible.has-subtitle { + min-height: 136px; + } + + .md3-app-bar--large-flexible { + min-height: 120px; + } + + .md3-app-bar--large-flexible.has-subtitle { + min-height: 152px; + } + + .md3-app-bar__flexible-row { + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 0 16px 20px 16px; + box-sizing: border-box; + transition: + opacity 200ms cubic-bezier(0.2, 0, 0, 1), + max-height 250ms cubic-bezier(0.2, 0, 0, 1), + padding 250ms cubic-bezier(0.2, 0, 0, 1); + max-height: 120px; + overflow: hidden; + } + + .md3-app-bar--medium-flexible .md3-app-bar__flexible-headline { + font-family: var(--md-ref-typeface-brand, inherit); + font-size: 24px; + line-height: 32px; + font-weight: 400; + color: var(--_on-container-color); + overflow: hidden; + text-overflow: ellipsis; + } + + .md3-app-bar--large-flexible .md3-app-bar__flexible-headline { + font-family: var(--md-ref-typeface-brand, inherit); + font-size: 28px; + line-height: 36px; + font-weight: 400; + color: var(--_on-container-color); + overflow: hidden; + text-overflow: ellipsis; + } + + /* When flexible is expanded, the top row headline is hidden */ + .md3-app-bar .flexible-top-headline { + opacity: 0; + pointer-events: none; + transition: opacity 150ms cubic-bezier(0.2, 0, 0, 1); + } + + /* When flexible is collapsed */ + .md3-app-bar.collapsed { + min-height: 64px; + height: 64px; + } + + .md3-app-bar.collapsed .flexible-top-headline { + opacity: 1; + pointer-events: auto; + } + + .md3-app-bar.collapsed .md3-app-bar__flexible-row { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + pointer-events: none; + } + + /* Search variant */ + .md3-app-bar--search .md3-app-bar__search-row { + height: 64px; + padding: 0 8px; + gap: 8px; + } + + .md3-app-bar__search-field-container { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + } + + .md3-app-bar__search-input { + width: 100%; + --md-text-field-container-shape: 28px; + } + + .md3-app-bar__clear-icon { + cursor: pointer; + } + ` } customElements.define('md-app-bar', AppBar) diff --git a/demo/app-bar-demo.html b/demo/app-bar-demo.html new file mode 100644 index 0000000..6c9959a --- /dev/null +++ b/demo/app-bar-demo.html @@ -0,0 +1,324 @@ + + + + Material 3 App Bar Demo + + + + + + + + + + + + + + + + + + + + + + +

Material 3 App Bar Demo

+

+ Top app bars display information and actions related to the current screen according to the + Material Design 3 App Bar specification. +

+ + +
+
+
+

1. Small App Bar (Default)

+ Standard 64px height with left-aligned headline and subtitle +
+
+ Toggle Scrolled +
+
+
+ + + menu + +
+ search + favorite + more_vert +
+
+
+
+ + +
+
+
+

2. Center-Aligned App Bar

+ 64px height with headline and subtitle centered +
+
+ Toggle Scrolled +
+
+
+ + + arrow_back + +
+ attach_file + more_vert +
+
+
+
+ + +
+
+
+

3. Medium Flexible App Bar

+ 112px–136px expanded height with lower headline, collapses to 64px +
+
+ Toggle Collapsed + Toggle Scrolled +
+
+
+ + + menu + +
+ filter_list + more_vert +
+
+
+
+ + +
+
+
+

4. Large Flexible App Bar

+ 120px–152px expanded height with prominent headline +
+
+ Toggle Collapsed + Toggle Scrolled +
+
+
+ + + arrow_back + +
+ bookmark + share + more_vert +
+
+
+
+ + +
+
+
+

5. Search App Bar

+ Full top app bar containing an embedded search input and actions +
+
+
+ + + menu + +
+ mic + more_vert +
+
+
+
+ + +
+
+
+

6. Interactive Scroll Collapse (`scroll-behavior="collapse"`)

+ Scroll the container below to see automatic collapse and elevation changes +
+
+
+
+ + + menu + +
+ refresh + more_vert +
+
+ +
+

+ Scroll down to see the medium flexible app bar automatically collapse into a small 64px + app bar and raise elevation with the container surface color. +

+

Item 1: Material Design 3 Expressive typography and colors.

+

Item 2: Seamless transitions between expanded and collapsed states.

+

Item 3: Fully responsive across desktop, tablet, and mobile devices.

+

Item 4: Accessible keyboard navigation and aria landmarks.

+

Item 5: Automatic scroll listeners support either the window or custom container selectors.

+

Item 6: Scroll back up to expand again!

+
+
+
+
+ + + + diff --git a/demo/index.html b/demo/index.html index 60ce383..3b925b2 100644 --- a/demo/index.html +++ b/demo/index.html @@ -105,19 +105,20 @@
- +
@@ -158,6 +159,7 @@
Other demo pages:
    +
  • App Bar Demo
  • List Demo
  • Carousel Demo
  • Date Picker Demo
  • From bc5de8b7db65fca86e5c75c0d321b4ff11b44e15 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Wed, 16 Sep 2026 13:55:14 -0700 Subject: [PATCH 2/6] fix(app-bar): address PR review comments on slot assignment, event bubbling, and shadow DOM scroll targets --- app/bar.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/app/bar.js b/app/bar.js index 3fdcf7a..de9f8ca 100644 --- a/app/bar.js +++ b/app/bar.js @@ -144,6 +144,11 @@ export class AppBar extends LitElement { if (!this.scrollTarget || this.scrollTarget === 'window') { return window } + const root = this.getRootNode() + if (root && typeof root.querySelector === 'function') { + const el = root.querySelector(this.scrollTarget) + if (el) return el + } return document.querySelector(this.scrollTarget) || window } @@ -189,13 +194,11 @@ export class AppBar extends LitElement { const input = e.target this.value = input.value this._hasSearchValue = Boolean(input.value) - this.dispatchEvent(new CustomEvent('input', { bubbles: true, composed: true })) } _handleSearchChange(e) { const input = e.target this.value = input.value - this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })) } _handleSearchKeyDown(e) { @@ -250,9 +253,11 @@ export class AppBar extends LitElement {
    - - ${this._headlineText} - + ${ + !isFlexible || isCollapsed + ? html`${this._headlineText}` + : this._headlineText + }
    ${ !isFlexible && this._hasSubtitle @@ -279,9 +284,11 @@ export class AppBar extends LitElement { ? html`
    - - ${this._headlineText} - + ${ + !isCollapsed + ? html`${this._headlineText}` + : this._headlineText + }
    ${ this._hasSubtitle From 5982e6f5fc1c7a959212f2e57c7ae0efa03c136c Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Wed, 16 Sep 2026 14:09:30 -0700 Subject: [PATCH 3/6] fix(app-bar): use logical CSS properties and isolate clear icon click listener --- app/bar.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/bar.js b/app/bar.js index de9f8ca..7032b25 100644 --- a/app/bar.js +++ b/app/bar.js @@ -341,8 +341,8 @@ export class AppBar extends LitElement { ${ this._hasSearchValue ? html` - - close + + close ` : html`` @@ -434,9 +434,9 @@ export class AppBar extends LitElement { align-items: center; justify-content: flex-end; min-width: 48px; - margin-left: auto; + margin-inline-start: auto; gap: 4px; - padding-right: 4px; + padding-inline-end: 4px; color: var(--_trailing-icon-color); flex-shrink: 0; } @@ -447,8 +447,7 @@ export class AppBar extends LitElement { flex-direction: column; justify-content: center; flex: 1; - padding-left: 12px; - padding-right: 12px; + padding-inline: 12px; min-width: 0; overflow: hidden; } From a4d8bb8f0779a1ca3166033f8431936e5c6b381c Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Wed, 16 Sep 2026 14:18:27 -0700 Subject: [PATCH 4/6] fix(app-bar): support slotted subtitle without attribute and use logical padding --- app/bar.js | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/app/bar.js b/app/bar.js index 7032b25..521839e 100644 --- a/app/bar.js +++ b/app/bar.js @@ -55,6 +55,8 @@ export class AppBar extends LitElement { value: { type: String }, /** Internal state indicating whether search field has text */ _hasSearchValue: { state: true }, + /** Internal state indicating whether custom subtitle markup is slotted */ + _hasSlottedSubtitle: { state: true }, } constructor() { @@ -73,6 +75,7 @@ export class AppBar extends LitElement { this.placeholder = 'Search' this.value = '' this._hasSearchValue = false + this._hasSlottedSubtitle = false this._onScroll = this._handleScroll.bind(this) this._currentTarget = null @@ -137,7 +140,14 @@ export class AppBar extends LitElement { } get _hasSubtitle() { - return Boolean(this.subtitle) + return Boolean(this.subtitle || this._hasSlottedSubtitle) + } + + _handleSubtitleSlotChange(e) { + const assigned = e.target.assignedNodes({ flatten: true }) + this._hasSlottedSubtitle = assigned.some( + (n) => n.nodeType === Node.ELEMENT_NODE || (n.textContent || '').trim().length > 0, + ) } _getScrollTargetElement() { @@ -260,10 +270,10 @@ export class AppBar extends LitElement { }
    ${ - !isFlexible && this._hasSubtitle + !isFlexible ? html` -
    - ${this.subtitle} +
    + ${this.subtitle}
    ` : nothing @@ -290,15 +300,9 @@ export class AppBar extends LitElement { : this._headlineText }
    - ${ - this._hasSubtitle - ? html` -
    - ${this.subtitle} -
    - ` - : nothing - } +
    + ${this.subtitle} +
    ` : nothing @@ -476,12 +480,15 @@ export class AppBar extends LitElement { transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1); } + .md3-app-bar__subtitle[hidden] { + display: none; + } + /* Center-aligned variant */ .md3-app-bar--center-aligned .md3-app-bar__headline-container.center-aligned { align-items: center; text-align: center; - padding-left: 0; - padding-right: 0; + padding-inline: 0; } /* Medium & Large Flexible App Bars */ From 890bdefb4b5516e895449ee83bebebdc64f52fe3 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Wed, 16 Sep 2026 14:30:33 -0700 Subject: [PATCH 5/6] feat(demo): open nav rail as modal drawer from app bar hamburger menu on mobile --- demo/css/styles.css | 54 +++++++++++++++++ demo/index.html | 139 +++++++++++++++++++++++++++----------------- nav/rail.js | 9 ++- 3 files changed, 149 insertions(+), 53 deletions(-) diff --git a/demo/css/styles.css b/demo/css/styles.css index 6976bd5..6acfa3a 100644 --- a/demo/css/styles.css +++ b/demo/css/styles.css @@ -83,6 +83,60 @@ a:hover { } } +/* Nav Rail & Modal Drawer */ +.nav-scrim { + display: none; +} + +.nav-drawer-container { + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + flex-shrink: 0; +} + +@media (width < 600px) { + .nav-scrim { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 998; + opacity: 0; + visibility: hidden; + transition: + opacity 250ms cubic-bezier(0.2, 0, 0, 1), + visibility 250ms; + } + + .nav-scrim.open { + opacity: 1; + visibility: visible; + } + + .nav-drawer-container { + position: fixed; + top: 0; + left: 0; + bottom: 0; + height: 100vh; + z-index: 999; + transform: translateX(-100%); + visibility: hidden; + transition: + transform 250ms cubic-bezier(0.2, 0, 0, 1), + visibility 250ms; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + background-color: var(--md-sys-color-surface); + } + + .nav-drawer-container.open { + transform: translateX(0); + visibility: visible; + } +} + .layout-container { width: 100%; min-width: 0; diff --git a/demo/index.html b/demo/index.html index 3b925b2..d11ec5a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -73,34 +73,28 @@
    - -
    -
    - -
    + + +
    @@ -124,29 +118,68 @@
    @@ -215,7 +248,9 @@

    Loading Indicator (M3 Expressive)

    -