diff --git a/labs/gb/components/navbar/_nav-bar-tokens.scss b/labs/gb/components/navbar/_nav-bar-tokens.scss index 63acbaf32a..caa39b2109 100644 --- a/labs/gb/components/navbar/_nav-bar-tokens.scss +++ b/labs/gb/components/navbar/_nav-bar-tokens.scss @@ -8,8 +8,10 @@ --container-color: var(--md-sys-color-surface-container); --container-elevation: var(--md-sys-elevation-shadow-2); --container-height: 64px; + --container-leading-space: 0px; --container-shadow-color: var(--md-sys-color-shadow); --container-shape: var(--md-sys-shape-corner-none); + --container-trailing-space: 0px; --item-active-icon-color: var(--md-sys-color-on-secondary-container); --item-active-indicator-color: var(--md-sys-color-secondary-container); --item-active-indicator-height: 32px; @@ -43,8 +45,10 @@ } @mixin item-horizontal { + --container-leading-space: 16px; + --container-trailing-space: 16px; --item-active-indicator-height: 40px; - --item-active-indicator-icon-label-space: 8px; + --item-active-indicator-icon-label-space: 4px; --item-active-indicator-leading-space: 16px; --item-active-indicator-trailing-space: 16px; --item-active-indicator-width: auto; diff --git a/labs/gb/components/navbar/demo/stories.ts b/labs/gb/components/navbar/demo/stories.ts index 145c1c2bde..88a6b9348c 100644 --- a/labs/gb/components/navbar/demo/stories.ts +++ b/labs/gb/components/navbar/demo/stories.ts @@ -38,22 +38,23 @@ const styles = css` border: 1px solid var(--md-sys-color-outline-variant); border-radius: 16px; box-shadow: var(--md-sys-elevation-shadow-1); + box-sizing: border-box; display: flex; + inline-size: 412px; justify-content: center; margin-bottom: 24px; min-height: 160px; + min-inline-size: 320px; overflow: hidden; padding: 0; - width: 100%; - max-width: 640px; + resize: horizontal; } .demo-stack { + align-items: flex-start; display: flex; flex-direction: column; gap: 24px; - width: 100%; - max-width: 640px; } `; diff --git a/labs/gb/components/navbar/nav-bar-element.ts b/labs/gb/components/navbar/nav-bar-element.ts index f6cf8531da..5105017006 100644 --- a/labs/gb/components/navbar/nav-bar-element.ts +++ b/labs/gb/components/navbar/nav-bar-element.ts @@ -24,8 +24,10 @@ import {NavBarItemElement} from './nav-bar-item-element.js'; * @cssprop --container-color * @cssprop --container-elevation * @cssprop --container-height + * @cssprop --container-leading-space * @cssprop --container-shadow-color * @cssprop --container-shape + * @cssprop --container-trailing-space * @cssprop --item-active-icon-color * @cssprop --item-active-indicator-color * @cssprop --item-active-indicator-height @@ -83,6 +85,13 @@ export class NavBarElement extends AriaTablistElement { protected override updated(changedProperties: PropertyValues) { super.updated(changedProperties); + // GM3 navigation bars move between items with Tab (not arrow keys), so + // opt out of the `tablist` focusgroup that AriaTablistElement sets. In + // browsers with native focusgroup support, that would otherwise collapse + // each bar to a single Tab stop. + if (this.getAttribute('focusgroup') !== 'none') { + this.setAttribute('focusgroup', 'none'); + } if (changedProperties.has('itemLayout')) { this.syncItemsLayout(); } diff --git a/labs/gb/components/navbar/nav-bar-item-element.ts b/labs/gb/components/navbar/nav-bar-item-element.ts index bdfff927c6..c68fb3621c 100644 --- a/labs/gb/components/navbar/nav-bar-item-element.ts +++ b/labs/gb/components/navbar/nav-bar-item-element.ts @@ -44,6 +44,10 @@ export class NavBarItemElement extends AriaTabElement { min-width: 48px; outline: none; } + + :host([item-layout='horizontal']) { + flex: 0 1 auto; + } `, ]; diff --git a/labs/gb/components/navbar/nav-bar.scss b/labs/gb/components/navbar/nav-bar.scss index 5238c88931..ba92ecdc44 100644 --- a/labs/gb/components/navbar/nav-bar.scss +++ b/labs/gb/components/navbar/nav-bar.scss @@ -37,6 +37,10 @@ :host(md-gb-nav-bar-item[item-layout='horizontal']) { @include nav-bar-tokens.item-horizontal; + + // Horizontal items size to their content; extra space goes to the ends of + // the navigation bar instead of stretching each item. + flex: 0 1 auto; } :host(md-gb-nav-bar-item[item-layout='vertical']) { @@ -78,6 +82,8 @@ justify-content: space-around; margin: 0; padding: 0; + padding-inline: var(--container-leading-space) + var(--container-trailing-space); position: relative; transition: all var(--motion-duration) var(--motion-easing); user-select: none; @@ -90,6 +96,8 @@ &.nav-bar-horizontal { @include nav-bar-tokens.item-horizontal; + + justify-content: center; } &.nav-bar-vertical { @@ -130,6 +138,24 @@ user-select: none; width: 100%; -webkit-tap-highlight-color: transparent; + // The item's positioned descendants (the active indicator and badges) + // paint after the item's own outline, so they covered the focus ring. + // Draw the ring on `::after` above them instead; it inherits the + // `--focus-ring-*` values set by the focus ring classes. + isolation: isolate; + outline: none; + + &::after { + animation: var(--focus-ring-animation, none); + border-radius: inherit; + content: ''; + inset: 0; + outline: var(--focus-ring-outline, none); + outline-offset: var(--focus-ring-offset, 0); + pointer-events: none; + position: absolute; + z-index: 3; + } &:disabled, &[disabled], @@ -160,6 +186,7 @@ border-radius: var(--item-active-indicator-shape); color: var(--item-inactive-icon-color); display: flex; + flex-shrink: 0; height: var(--item-active-indicator-height); isolation: isolate; justify-content: center; @@ -203,6 +230,11 @@ } .nav-bar-item-badge { + // The badge is positioned inside the icon container, which centers the + // icon. These locate the icon's block-start and inline-end edges so badges + // anchor to the icon's top corner in both item layouts. + --_icon-block-start: calc((100% - var(--item-icon-size)) / 2); + --_icon-inline-end: calc(50% + var(--item-icon-size) / 2); align-items: center; background-color: var(--md-sys-color-error); border-radius: var(--md-sys-shape-corner-full); @@ -211,8 +243,10 @@ display: inline-flex; font: var(--md-sys-typescale-label-sm); height: 16px; - inset-block-start: -4px; - inset-inline-start: calc(50% + 2px); + // Large badges overlap the icon's top corner: they start 12px before the + // icon's inline-end edge and 2px above its block-start edge. + inset-block-start: calc(var(--_icon-block-start) - 2px); + inset-inline-start: calc(var(--_icon-inline-end) - 12px); justify-content: center; min-width: 16px; padding-inline: 4px; @@ -224,8 +258,9 @@ .nav-bar-item-badge-dot { height: 6px; - inset-block-start: 0; - inset-inline-start: calc(50% + 6px); + // Small badges sit inside the icon's top corner, touching both edges. + inset-block-start: var(--_icon-block-start); + inset-inline-start: calc(var(--_icon-inline-end) - 6px); min-width: 6px; padding-inline: 0; width: 6px; @@ -254,12 +289,21 @@ .nav-bar-item-horizontal { @include nav-bar-tokens.item-horizontal; + // Horizontal items size to their content; extra space goes to the ends of + // the navigation bar instead of stretching each item. This covers the + // CSS-only class API, where items are direct children of the bar. + .nav-bar-horizontal > & { + flex: 0 1 auto; + width: auto; + } + .nav-bar-item-content { display: inline-flex; flex-direction: row; gap: var(--item-active-indicator-icon-label-space); height: var(--item-active-indicator-height); isolation: isolate; + max-inline-size: 100%; padding-block: 0; padding-inline: var(--item-active-indicator-leading-space) var(--item-active-indicator-trailing-space); diff --git a/labs/gb/components/navbar/nav-bar_test.ts b/labs/gb/components/navbar/nav-bar_test.ts new file mode 100644 index 0000000000..2539711b16 --- /dev/null +++ b/labs/gb/components/navbar/nav-bar_test.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import './md-gb-nav-bar.js'; +import './md-gb-nav-bar-item.js'; + +import {html, render, type TemplateResult} from 'lit'; +import {Environment} from '../../../../testing/environment.js'; +import {Harness} from '../../../../testing/harness.js'; +import {adoptStyles} from '../../styles/adopt-styles.js'; +import {styles as m3Styles} from '../../styles/m3.cssresult.js'; +import {NavBarElement} from './nav-bar-element.js'; +import {NavBarItemElement} from './nav-bar-item-element.js'; +import {styles as navBarStyles} from './nav-bar.cssresult.js'; + +adoptStyles(document, [m3Styles]); + +class NavBarItemHarness extends Harness { + override async getInteractiveElement(): Promise { + return this.element; + } + + part(selector: string): HTMLElement { + const part = this.element.shadowRoot!.querySelector(selector); + if (!part) { + throw new Error(`Missing "${selector}" in nav bar item shadow root.`); + } + return part; + } + + rect(selector: string): DOMRect { + return this.part(selector).getBoundingClientRect(); + } + + /** The active indicator pill for the item's layout. */ + pillRect(): DOMRect { + return this.element.itemLayout === 'horizontal' + ? this.rect('.nav-bar-item-content') + : this.rect('.nav-bar-item-icon-container'); + } +} + +describe('md-gb-nav-bar', () => { + const env = new Environment(); + + async function setup( + template: TemplateResult, + ): Promise<{bar: NavBarElement; items: NavBarItemHarness[]}> { + const root = env.render(template); + await env.waitForStability(); + const bar = root.querySelector('md-gb-nav-bar')!; + const items = Array.from( + root.querySelectorAll('md-gb-nav-bar-item'), + (item) => new NavBarItemHarness(item), + ); + return {bar, items}; + } + + function barRect(bar: NavBarElement): DOMRect { + return bar.shadowRoot!.querySelector('.nav-bar')!.getBoundingClientRect(); + } + + function verticalBar(itemBadges: string[] = ['', '', '', '']) { + return html` +
+ + Home + Explore + Library + Profile + +
+ `; + } + + function horizontalBar(width: number, dir: 'ltr' | 'rtl' = 'ltr') { + return html` +
+ + For You + Saved + Settings + Library + +
+ `; + } + + it('renders item content into its shadow root', async () => { + const {items} = await setup(verticalBar()); + + expect(items[0].element.shadowRoot!.innerHTML.length).toBeGreaterThan(0); + expect(items[0].rect('.nav-bar-item-label').width).toBeGreaterThan(0); + }); + + describe('keyboard', () => { + it('opts out of the tablist focusgroup so Tab moves between items', async () => { + const {bar} = await setup(verticalBar()); + + expect(bar.getAttribute('focusgroup')).toBe('none'); + }); + + it('makes every item a Tab stop, not just the active one', async () => { + const {items} = await setup(verticalBar()); + + expect(items.map((item) => item.element.tabIndex)).toEqual([0, 0, 0, 0]); + }); + + it('draws the focus ring above the active indicator', async () => { + const {items} = await setup(horizontalBar(600)); + + // Set the ring token directly rather than relying on each browser's + // :focus-visible heuristics for synthetic keyboard focus. + const item = items[0].part('.nav-bar-item'); + item.style.setProperty('--focus-ring-outline', '3px solid'); + const ring = getComputedStyle(item, '::after'); + + expect(getComputedStyle(item).outlineStyle).toBe('none'); + expect(ring.outlineStyle).toBe('solid'); + expect(Number(ring.zIndex)).toBeGreaterThan( + Number(getComputedStyle(items[0].part('.nav-bar-item-badge')).zIndex), + ); + }); + }); + + describe('CSS-only class API', () => { + it('sizes horizontal items to their content', async () => { + const host = env.render(html`
`) + .firstElementChild as HTMLElement; + const shadow = host.attachShadow({mode: 'open'}); + adoptStyles(shadow, [navBarStyles]); + render( + html` + + `, + shadow, + ); + await env.waitForStability(); + + const nav = shadow.querySelector('nav')!.getBoundingClientRect(); + const [first, second] = Array.from( + shadow.querySelectorAll('button'), + (button) => button.getBoundingClientRect(), + ); + + expect(first.width).toBeLessThan(nav.width / 4); + expect(first.left - nav.left).toBeCloseTo(nav.right - second.right, 0); + }); + }); + + describe('vertical items', () => { + it('uses a 4px gap between the indicator and the label', async () => { + const {items} = await setup(verticalBar()); + + const pill = items[0].pillRect(); + const label = items[0].rect('.nav-bar-item-label'); + + expect(label.top - pill.bottom).toBeCloseTo(4, 0); + }); + + it('pads the icon by 16px inside the active indicator', async () => { + const {items} = await setup(verticalBar()); + + const pill = items[0].pillRect(); + const icon = items[0].rect('.nav-bar-item-icon'); + + expect(pill.width).toBeCloseTo(56, 0); + expect(icon.left - pill.left).toBeCloseTo(16, 0); + expect(pill.right - icon.right).toBeCloseTo(16, 0); + }); + + it('keeps 6px between the item content and the bar top and bottom', async () => { + const {bar, items} = await setup(verticalBar()); + + const container = barRect(bar); + const pill = items[0].pillRect(); + const label = items[0].rect('.nav-bar-item-label'); + + expect(container.height).toBeCloseTo(64, 0); + expect(pill.top - container.top).toBeCloseTo(6, 0); + expect(container.bottom - label.bottom).toBeCloseTo(6, 0); + }); + + it('keeps the outer indicators away from the bar edges', async () => { + const {bar, items} = await setup(verticalBar()); + + const container = barRect(bar); + + expect(items[0].pillRect().left - container.left).toBeGreaterThan(16); + expect(container.right - items[3].pillRect().right).toBeGreaterThan(16); + }); + }); + + describe('horizontal items', () => { + it('uses a 4px gap between the icon and the label', async () => { + const {items} = await setup(horizontalBar(600)); + + const icon = items[0].rect('.nav-bar-item-icon'); + const label = items[0].rect('.nav-bar-item-label'); + + expect(label.left - icon.right).toBeCloseTo(4, 0); + }); + + it('pads the active indicator by 16px on each side', async () => { + const {items} = await setup(horizontalBar(600)); + + const style = getComputedStyle(items[0].part('.nav-bar-item-content')); + + expect(style.paddingInlineStart).toBe('16px'); + expect(style.paddingInlineEnd).toBe('16px'); + }); + + it('adds extra space to the ends of the bar', async () => { + const {bar, items} = await setup(horizontalBar(800)); + + const container = barRect(bar); + const first = items[0].element.getBoundingClientRect(); + const last = items[3].element.getBoundingClientRect(); + + expect(first.left - container.left).toBeCloseTo( + container.right - last.right, + 0, + ); + expect(first.left - container.left).toBeGreaterThan(16); + }); + + it('keeps 16px between the outer indicators and the bar edges when full', async () => { + const {bar, items} = await setup(horizontalBar(360)); + + const container = barRect(bar); + + expect( + Math.round(items[0].pillRect().left - container.left), + ).toBeGreaterThanOrEqual(16); + expect( + Math.round(container.right - items[3].pillRect().right), + ).toBeGreaterThanOrEqual(16); + }); + }); + + describe('badges', () => { + it('overlaps a large badge on the top corner of the icon', async () => { + const {items} = await setup(verticalBar(['', '3', '', ''])); + + const icon = items[1].rect('.nav-bar-item-icon'); + const badge = items[1].rect('.nav-bar-item-badge'); + + expect(badge.left).toBeCloseTo(icon.right - 12, 0); + expect(badge.top).toBeCloseTo(icon.top - 2, 0); + }); + + it('places a small badge inside the top corner of the icon', async () => { + const {items} = await setup(verticalBar(['', '', 'dot', ''])); + + const icon = items[2].rect('.nav-bar-item-icon'); + const badge = items[2].rect('.nav-bar-item-badge'); + + expect(badge.right).toBeCloseTo(icon.right, 0); + expect(badge.top).toBeCloseTo(icon.top, 0); + }); + + it('anchors badges to the icon in horizontal items', async () => { + const {items} = await setup(horizontalBar(600)); + + const icon = items[0].rect('.nav-bar-item-icon'); + const badge = items[0].rect('.nav-bar-item-badge'); + + expect(badge.right).toBeCloseTo(icon.right, 0); + expect(badge.top).toBeCloseTo(icon.top, 0); + }); + + it('mirrors badges to the icon start corner in RTL', async () => { + const {items} = await setup(horizontalBar(600, 'rtl')); + + const icon = items[0].rect('.nav-bar-item-icon'); + const badge = items[0].rect('.nav-bar-item-badge'); + + expect(badge.left).toBeCloseTo(icon.left, 0); + expect(badge.top).toBeCloseTo(icon.top, 0); + }); + }); +});