diff --git a/labs/gb/components/snackbar/_snackbar-tokens.scss b/labs/gb/components/snackbar/_snackbar-tokens.scss new file mode 100644 index 0000000000..f0d3254b2a --- /dev/null +++ b/labs/gb/components/snackbar/_snackbar-tokens.scss @@ -0,0 +1,86 @@ +// +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// Auto-generated by cascade_tokens +// Add `// EDIT:` comments for manual adjustments. +// +// Token sets: ['md.comp.snackbar'] +// Version: 40.0.0 +// --prefix=md.comp.snackbar +// --exclude="state-layer,container.shadow-color" + +@mixin root { + // LINT.IfChange + // md.comp.snackbar.action.label-text.color + --action-label-text-color: var(--md-sys-color-inverse-primary); + // ┌ md.comp.snackbar.action.label-text.font + // ├ md.comp.snackbar.action.label-text.line-height + // ├ md.comp.snackbar.action.label-text.size + // ├ md.comp.snackbar.action.label-text.weight + --action-label-text: var(--md-sys-typescale-label-lg); + // ┌┄ md.comp.snackbar.action.label-text + --action-label-text-axes: var(--md-sys-typescale-label-lg-axes); + // md.comp.snackbar.action.label-text.tracking + --action-label-text-tracking: var(--md-sys-typescale-label-lg-tracking); + // md.comp.snackbar.container.color + --container-color: var(--md-sys-color-inverse-surface); + // md.comp.snackbar.container.elevation + --container-elevation: var(--md-sys-elevation-shadow-3); + // md.comp.snackbar.container.shape + --container-shape: var(--md-sys-shape-corner-xs); + // md.comp.snackbar.icon.color + --icon-color: var(--md-sys-color-inverse-on-surface); + // md.comp.snackbar.icon.size + --icon-size: 24px; + // ┌ md.comp.snackbar.supporting-text.font + // ├ md.comp.snackbar.supporting-text.line-height + // ├ md.comp.snackbar.supporting-text.size + // ├ md.comp.snackbar.supporting-text.weight + --supporting-text: var(--md-sys-typescale-body-md); + // ┌┄ md.comp.snackbar.supporting-text + --supporting-text-axes: var(--md-sys-typescale-body-md-axes); + // md.comp.snackbar.supporting-text.color + --supporting-text-color: var(--md-sys-color-inverse-on-surface); + // md.comp.snackbar.supporting-text.tracking + --supporting-text-tracking: var(--md-sys-typescale-body-md-tracking); + // EDIT: No upstream Spec token. + --min-width: 344px; + // EDIT: No upstream Spec token. + --max-width: 672px; + // EDIT: No upstream Spec token. + --min-height: 48px; + // EDIT: No upstream Spec token. + --bottom-offset: 16px; + // EDIT: No upstream Spec token. + --position: absolute; + // EDIT: No upstream Spec token. + --z-index: 1000; + // LINT.ThenChange(snackbar-element.ts) keep in sync with `@cssprop` jsdoc tags +} + +// Inherits every `root` token from the parent. `md-gb-snackbar` includes +// `root` on its host and this on the `.snackbar` inside its shadow root, so +// token values set on the host element still apply. +@mixin inherit { + --action-label-text-color: inherit; + --action-label-text: inherit; + --action-label-text-axes: inherit; + --action-label-text-tracking: inherit; + --container-color: inherit; + --container-elevation: inherit; + --container-shape: inherit; + --icon-color: inherit; + --icon-size: inherit; + --supporting-text: inherit; + --supporting-text-axes: inherit; + --supporting-text-color: inherit; + --supporting-text-tracking: inherit; + --min-width: inherit; + --max-width: inherit; + --min-height: inherit; + --bottom-offset: inherit; + --position: inherit; + --z-index: inherit; +} diff --git a/labs/gb/components/snackbar/demo/demo.ts b/labs/gb/components/snackbar/demo/demo.ts new file mode 100644 index 0000000000..cea708e70d --- /dev/null +++ b/labs/gb/components/snackbar/demo/demo.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './material-collection.js'; +import './index.js'; + +import { + type KnobTypesToKnobs, + MaterialCollection, + materialInitsToStoryInits, + setUpDemo, +} from './material-collection.js'; +import { + boolInput, + Knob, + selectDropdown, + textInput, +} from './index.js'; + +import {stories, type StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'Snackbar', + [ + new Knob('timeoutMs', { + ui: selectDropdown({ + options: [ + {value: '-1', label: '-1 (Indeterminate)'}, + {value: '5000', label: '5000 ms'}, + {value: '3000', label: '3000 ms'}, + {value: '10000', label: '10000 ms'}, + ], + }), + defaultValue: '-1', + }), + new Knob('message', { + ui: textInput(), + defaultValue: 'Your message has been sent.', + }), + new Knob('action', { + ui: textInput(), + defaultValue: 'Undo', + }), + new Knob('hasCloseButton', { + ui: boolInput(), + defaultValue: true, + }), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/snackbar/demo/stories.ts b/labs/gb/components/snackbar/demo/stories.ts new file mode 100644 index 0000000000..d141591b61 --- /dev/null +++ b/labs/gb/components/snackbar/demo/stories.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/gb/components/button/md-gb-button.js'; +import '@material/web/labs/gb/components/snackbar/md-gb-snackbar.js'; + +import {type MaterialStoryInit} from './material-collection.js'; +import {type SnackbarElement} from '@material/web/labs/gb/components/snackbar/snackbar-element.js'; +import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js'; +import {css, html, nothing, type TemplateResult} from 'lit'; +import {createRef, ref} from 'lit/directives/ref.js'; + +import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.css' with {type: 'css'}; // github-only +// import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; // google3-only + +/** Knob types for snackbar stories. */ +export interface StoryKnobs { + timeoutMs: string; + message: string; + action: string; + hasCloseButton: boolean; +} + +adoptStyles(document, [m3Styles]); + +const styles = css` + .demo-container { + display: flex; + flex-direction: column; + gap: 32px; + padding: 24px; + box-sizing: border-box; + } + + .demo-section { + display: flex; + flex-direction: column; + gap: 16px; + + h2 { + font: var(--md-sys-typescale-headline-sm); + letter-spacing: var(--md-sys-typescale-headline-sm-tracking); + margin: 0; + color: var(--md-sys-color-on-surface); + } + } + + .static-grid { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: 24px; + } + + /* + * Each cell is a container the snackbar anchors to: it sits --bottom-offset + * (16px) above the cell's bottom edge and, below the 600px breakpoint, 16px + * in from each side. The fixed height fits the tallest example (a wrapped + * action row) with clear space above, and lines up every snackbar's bottom + * edge across the grid. + */ + .cell-stage { + width: 376px; + block-size: 176px; + outline: 1px dashed var(--md-sys-color-outline-variant); + border: none; + border-radius: 8px; + background-color: var(--md-sys-color-surface-container-low); + box-sizing: border-box; + position: relative; + } + + .interactive-stage { + resize: horizontal; + overflow: hidden; + position: relative; + width: 100%; + min-width: 360px; + min-height: 240px; + border: 2px dashed var(--md-sys-color-outline-variant); + border-radius: 16px; + background-color: var(--md-sys-color-surface-container-low); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 24px; + } + + .demo-controls { + display: flex; + gap: 12px; + margin-block-end: 16px; + } + + .demo-hint { + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + color: var(--md-sys-color-on-surface-variant); + text-align: center; + margin: 0; + } +`; + +interface StaticExample { + /** + * A string is passed as `label-text`; a template is slotted, so two-line + * labels can break where Figma breaks them. + */ + label: string | TemplateResult; + action?: string; + close?: boolean; +} + +// Figma 44:43717 "Examples", row-major: without close on the left, with close +// on the right. +const staticExamples: StaticExample[] = [ + {label: 'Single-line snackbar'}, + {label: 'Single-line snackbar with close affordance', close: true}, + {label: 'Single-line snackbar with action', action: 'Action'}, + {label: 'Single-line snackbar with action', action: 'Action', close: true}, + {label: html`Two-line snackbar
without action`}, + {label: html`Two-line snackbar with
close affordance`, close: true}, + {label: html`Two-line snackbar
with action`, action: 'Action'}, + { + label: html`Two-line snackbar with longer
action and close affordance`, + action: 'Action', + close: true, + }, + { + label: html`Two-line snackbar with
longer action`, + action: 'Longer Action', + }, + { + label: html`Two-line snackbar with longer
action and close affordance`, + action: 'Longer Action', + close: true, + }, +]; + +const playground: MaterialStoryInit = { + name: 'Playground', + styles, + render(knobs) { + const snackbarRef = createRef(); + + return html` +
+
+

Interactive

+
+ snackbarRef.value?.show()}> + Show Snackbar + + snackbarRef.value?.close()}> + Close Snackbar + +
+
+

+ Drag the corner to resize: below a 600px container width the + snackbar uses the compact layout. Use Show/Close to replay it. +

+ + +
+
+ +
+

Static examples

+
+ ${staticExamples.map( + ({label, action = '', close = false}) => html` +
+ { + e.preventDefault(); + }} + >${typeof label === 'string' + ? nothing + : label} +
+ `, + )} +
+
+
+ `; + }, +}; + +/** Snackbar demo stories. */ +export const stories = [playground]; diff --git a/labs/gb/components/snackbar/harness.ts b/labs/gb/components/snackbar/harness.ts new file mode 100644 index 0000000000..78ec66fea7 --- /dev/null +++ b/labs/gb/components/snackbar/harness.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Harness} from '../../../../testing/harness.js'; +import {SnackbarElement} from './snackbar-element.js'; + +/** + * Test harness for snackbar. + */ +export class SnackbarHarness extends Harness { + override async getInteractiveElement(): Promise { + await this.element.updateComplete; + return ( + this.getActionButton() ?? + this.getDismissButton() ?? + this.getSurfaceElement() ?? + this.element + ); + } + + getSurfaceElement(): HTMLElement | null { + return this.element.shadowRoot?.querySelector('.snackbar-surface') ?? null; + } + + getLabelElement(): HTMLElement | null { + return this.element.shadowRoot?.querySelector('.snackbar-label') ?? null; + } + + getDismissButton(): HTMLElement | null { + return ( + this.element.querySelector(':scope > [slot="dismiss"]') ?? + this.element.shadowRoot?.querySelector( + '.snackbar-dismiss', + ) ?? + null + ); + } + + getActionButton(): HTMLElement | null { + return ( + this.element.querySelector(':scope > [slot="action"]') ?? + this.element.shadowRoot?.querySelector('.snackbar-action') ?? + null + ); + } + + /** + * Hovers and clicks the action button (slotted or built-in) with a simulated + * mouse. This will generate a `click` event. + * + * @param init Additional event options. + */ + async clickActionWithMouse(init: PointerEventInit = {}) { + await this.element.updateComplete; + this.simulateClickWithMouse(this.getActionButton(), 'action', init); + } + + /** + * Hovers and clicks the dismiss button (slotted or built-in close button) + * with a simulated mouse. This will generate a `click` event. + * + * @param init Additional event options. + */ + async clickDismissWithMouse(init: PointerEventInit = {}) { + await this.element.updateComplete; + this.simulateClickWithMouse(this.getDismissButton(), 'dismiss', init); + } + + private simulateClickWithMouse( + element: HTMLElement | null, + name: string, + init: PointerEventInit, + ) { + if (!element) { + throw new Error(` has no ${name} button to click`); + } + this.simulateStartHover(element, init); + this.simulateMousePress(element, init); + this.simulateMouseRelease(element, init); + this.simulateClick(element, init); + } +} diff --git a/labs/gb/components/snackbar/md-gb-snackbar.ts b/labs/gb/components/snackbar/md-gb-snackbar.ts new file mode 100644 index 0000000000..66f39ce293 --- /dev/null +++ b/labs/gb/components/snackbar/md-gb-snackbar.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {SnackbarElement} from './snackbar-element.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design snackbar component. */ + 'md-gb-snackbar': SnackbarElement; + } +} + +customElements.define('md-gb-snackbar', SnackbarElement); diff --git a/labs/gb/components/snackbar/snackbar-element.ts b/labs/gb/components/snackbar/snackbar-element.ts new file mode 100644 index 0000000000..5eeb051bbe --- /dev/null +++ b/labs/gb/components/snackbar/snackbar-element.ts @@ -0,0 +1,470 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {css, CSSResultOrNative, html, LitElement, nothing} from 'lit'; +import {property, state} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import {button} from '../button/button.js'; +import {iconButton} from '../iconbutton/icon-button.js'; +import {hasSlotted} from '../shared/has-slotted.js'; +import {CLOSE_REASONS, snackbarClasses} from './snackbar.js'; + +import buttonStyles from '../button/button.css' with {type: 'css'}; // github-only +// import buttonStyles from '../button/button.cssresult.js'; // google3-only +import focusRingStyles from '../focus/focus-ring.css' with {type: 'css'}; // github-only +// import focusRingStyles from '../focus/focus-ring.cssresult.js'; // google3-only +import iconButtonStyles from '../iconbutton/icon-button.css' with {type: 'css'}; // github-only +// import iconButtonStyles from '../iconbutton/icon-button.cssresult.js'; // google3-only +import rippleStyles from '../ripple/ripple.css' with {type: 'css'}; // github-only +// import rippleStyles from '../ripple/ripple.cssresult.js'; // google3-only +import snackbarStyles from './snackbar.css' with {type: 'css'}; // github-only +// import snackbarStyles from './snackbar.cssresult.js'; // google3-only + +/** + * Animation duration for snackbar appearance in milliseconds. + */ +export const OPEN_DURATION_MS = 150; + +/** + * Animation duration for snackbar disappearance in milliseconds. + */ +export const CLOSE_DURATION_MS = 75; + +/** + * A Material Design snackbar component. + * + * @fires {Event} open - Fired when the snackbar begins opening. + * @fires {Event} opened - Fired when the snackbar has opened and animations finished. + * @fires {CustomEvent<{reason: string}>} close - Fired when the snackbar begins closing. + * @fires {CustomEvent<{reason: string}>} closed - Fired when the snackbar has closed. + * @slot - Default slot for rich content (rendered after `label-text`). If both `label-text` and slotted content are provided, both render. + * @slot action - Slot for a custom action button; replaces the built-in action rendered from `action-label`. Prefer the `action-label` attribute, which renders a text button matching the spec. A slotted plain ` + ` + : nothing} + + + ${this.hasCloseButton + ? html` + + ` + : nothing} + + + + + + `; + } +} diff --git a/labs/gb/components/snackbar/snackbar.scss b/labs/gb/components/snackbar/snackbar.scss new file mode 100644 index 0000000000..91288dda35 --- /dev/null +++ b/labs/gb/components/snackbar/snackbar.scss @@ -0,0 +1,385 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'sass:math'; +@use '../../../../tokens/versions/latest/sass/md-sys-state'; +@use '../../../../tokens/versions/latest/sass/md-sys-state-focus-indicator'; +@use 'snackbar-tokens'; +// go/keep-sorted end + +// Margin around the 40px close `.icon-btn` that fills the 48px close slot. +$close-touch-margin: 4px; + +// Figma 44:43679 layout dimensions. +$content-padding-inline: 16px; +$action-gap: 4px; +$close-slot-size: 48px; +// End margins of the actions group, pulling it into the content padding: 8px +// visual end padding after an action alone, flush close slot otherwise. +$action-only-end-margin: 8px - $content-padding-inline; +$close-end-margin: -$content-padding-inline; + +// Action / close presence on the row. An action is either slotted or the +// built-in `.snackbar-action` fallback, which doesn't set `has-slotted` on +// its slot; likewise for the close button. +$has-action: ":has(slot[name='action'].has-slotted), :has(.snackbar-action)"; +$has-close: ":has(slot[name='dismiss'].has-slotted), :has(.snackbar-dismiss)"; + +// Figma 44:43654: the action text button has 12px horizontal padding. +$action-padding-inline: 12px; + +// GM3 state layer opacities. Figma 44:43651 draws no focus state layer, only +// the focus ring. +$hover-opacity: math.percentage(md-sys-state.$hover-state-layer-opacity); +$pressed-opacity: math.percentage(md-sys-state.$pressed-state-layer-opacity); + +// Cascade layer order follows first declaration. Declaring the shared layers +// here pins `md.comp.snackbar` above them, whatever order the element's +// `styles` array uses, so snackbar overrides beat the button and icon-button +// defaults. +@layer md.sys, md.comp.ripple, md.comp.focus-ring, md.comp.icon-btn, + md.comp.button; +@layer md.comp.snackbar { + // The token and positioning root: the `md-gb-snackbar` host, or a + // `.snackbar` used in Light DOM without the element. + :host, + .snackbar { + @include snackbar-tokens.root; + // `.icon-btn` redefines `--icon-color` and `--icon-size` on the close + // button. Forward the public values through private names so consumer + // overrides still reach the close icon. + --_close-icon-color: var(--icon-color); + --_close-icon-size: var(--icon-size); + position: var(--position); + inset-block-end: var(--bottom-offset); + inset-inline: 0; + inline-size: 100%; + box-sizing: border-box; + pointer-events: none; + z-index: var(--z-index); + } + + :host([fixed]), + .snackbar.snackbar-fixed { + position: fixed; + } + + // The container queries below resolve against `.snackbar`, which fills the + // host inside the element. + .snackbar { + container: snackbar / inline-size; + display: none; + align-items: center; + justify-content: center; + } + + // Inside the element, the host is the token and positioning root. The inner + // `.snackbar` inherits the host's tokens, so values set on the host element + // still apply, and stays in normal flow. This must follow + // `.snackbar.snackbar-fixed`, which has the same specificity. + :host > .snackbar { + @include snackbar-tokens.inherit; + position: static; + z-index: auto; + } + + .snackbar.snackbar-opening, + .snackbar.snackbar-open, + .snackbar.snackbar-closing { + display: flex; + } + + .snackbar-surface { + position: relative; + background-color: var(--container-color); + box-shadow: var(--container-elevation); + border-radius: var(--container-shape); + min-block-size: var(--min-height); + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + opacity: 0; + // Enter scales vertically, growing out of the surface's bottom edge. + transform: scaleY(0); + transform-origin: center bottom; + pointer-events: none; + transition: + opacity 75ms cubic-bezier(0.4, 0, 1, 1), + transform 75ms cubic-bezier(0.4, 0, 1, 1); + } + + .snackbar.snackbar-open .snackbar-surface { + opacity: 1; + transform: none; + pointer-events: auto; + transition: + opacity 150ms cubic-bezier(0, 0, 0.2, 1), + transform 150ms cubic-bezier(0, 0, 0.2, 1); + } + + // `.snackbar` leaves `display: none` in the same render that adds + // `-open`, so there is no prior style to transition from. Start the enter + // transition from the collapsed state. + @starting-style { + .snackbar.snackbar-open .snackbar-surface { + opacity: 0; + transform: scaleY(0); + } + } + + // Exit is a fade only, at full size. + .snackbar.snackbar-closing .snackbar-surface { + opacity: 0; + transform: none; + pointer-events: none; + transition: opacity 75ms cubic-bezier(0.4, 0, 1, 1); + } + + @media (prefers-reduced-motion: reduce) { + .snackbar-surface { + transition-duration: 0.001ms; + } + } + + // The wrapping flex row: content box W = surface - 32px (312px at a 344px compact surface). + // Row padding-block is 0 4px per the Figma 44:43679 vertical metrics. + .snackbar-content { + --_actions-chrome: 0px; + flex: 1 1 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + row-gap: 0; + padding-inline: $content-padding-inline; + padding-block: 0 4px; + inline-size: 100%; + box-sizing: border-box; + } + + // Detect action / close presence on the row to configure --_actions-chrome, + // the inline space the actions group adds beyond the label's row share: + // the action gap plus the actions group's end margin, plus the close slot. + // 1) Action present, no close: 4px gap - 8px end margin = -4px. + .snackbar-content:is(#{$has-action}):not(#{$has-close}) { + --_actions-chrome: #{$action-gap + $action-only-end-margin}; + } + + // 2) Action present with close: 4px gap + 48px close slot - 16px end margin = 36px. + .snackbar-content:is(#{$has-action}):is(#{$has-close}) { + --_actions-chrome: #{$action-gap + $close-slot-size + $close-end-margin}; + } + + // Label: 14px padding-block with -4px bottom margin per the Figma 44:43679 + // vertical metrics. In compact mode, wraps if action exceeds 30% of W + // (~93.6px at W=312). + .snackbar-label { + color: var(--supporting-text-color); + font: var(--supporting-text); + letter-spacing: var(--supporting-text-tracking); + font-variation-settings: var(--supporting-text-axes); + box-sizing: border-box; + padding-block: 14px; + margin: 0; + margin-block-end: -4px; + overflow-wrap: anywhere; + } + + .snackbar-actions { + flex: none; + display: flex; + align-items: center; + gap: 0; + margin-inline-start: auto; + box-sizing: border-box; + } + + // Action present, no close: 8px visual end padding, +4px top margin for + // 40px height alignment. + .snackbar-actions:is(#{$has-action}):not(#{$has-close}) { + margin-inline-end: $action-only-end-margin; + margin-block-start: 4px; + } + + // Close present: close touch target flush with surface end, -4px bottom + // margin for 48px height alignment. + .snackbar-actions:is(#{$has-close}) { + margin-inline-end: $close-end-margin; + margin-block-end: -4px; + } + + // Empty actions group contributes 0 inline size + .snackbar-actions:not(#{$has-action}, #{$has-close}) { + display: none; + } + + @container snackbar (inline-size < 600px) { + .snackbar-surface { + inline-size: calc(100% - 32px); + margin-inline: 16px; + } + + .snackbar-label { + flex: 1 1 0; + min-inline-size: calc(70% - var(--_actions-chrome)); + } + } + + @container snackbar (inline-size >= 600px) { + .snackbar-surface { + inline-size: fit-content; + min-inline-size: var(--min-width); + max-inline-size: min(var(--max-width), calc(100% - 32px)); + margin-inline: auto; + } + + // The label reserves the lesser of its text width and 70% of the widest + // surface's content box (~448px at the 672px default), so a long action + // wraps to its own line instead of squeezing the message. Unlike compact, + // this is measured against the max width through `cqi` rather than `%`: + // the fit-content surface's width includes the action, so a percentage + // would size the label from the action it's meant to limit. Capping at + // `max-content` keeps short messages from widening the surface. + .snackbar-label { + flex: 1 1 + calc( + 0.7 * + ( + min(var(--max-width), 100cqi - 32px) - #{$content-padding-inline * + 2} + ) - var(--_actions-chrome) + ); + max-inline-size: max-content; + min-inline-size: 0; + } + } + + .snackbar-actions ::slotted([slot='action']) { + margin-inline-start: $action-gap; + } + + // Built-in action: a text `.btn` rendered from `action-label`. The shared + // button, ripple and focus-ring styles draw the container, state layers and + // focus ring; point the button's component tokens at the action tokens. + .snackbar-action { + --leading-space: #{$action-padding-inline}; + --trailing-space: #{$action-padding-inline}; + // The text button's outline is transparent. Drop its width so the 12px + // padding measures from the container edge. + --outline-width: 0; + --label-text-color: var(--action-label-text-color); + --icon-color: var(--action-label-text-color); + // The shared ripple draws with `currentColor` (the label color); this + // keeps the documented state layer token consistent with it. + --state-layer-color: var(--action-label-text-color); + --label-text: var(--action-label-text); + --label-text-axes: var(--action-label-text-axes); + --label-text-tracking: var(--action-label-text-tracking); + margin-inline-start: $action-gap; + white-space: nowrap; + -webkit-tap-highlight-color: transparent; + } + + // Figma 44:43660 / 44:43675: pressed keeps the pill / circle shape, instead + // of the shared buttons' pressed shape morph. + .snackbar-action:is(:active, .active), + .snackbar-dismiss:is(:active, .active) { + --container-shape: calc(var(--container-height) / 2); + } + + // Slotted text ``: best effort. Its shadow DOM owns the + // container, ripple and focus ring, and it redefines `--container-*` and + // `--icon-*` on its inner button, so the snackbar's values don't leak in, + // and its padding and pressed shape can't be set from here. The text + // variant colors its label, icon and state layer with + // `--md-sys-color-primary` and uses the label-lg typescale; point those at + // the snackbar's action tokens. + .snackbar-actions ::slotted(md-gb-button[slot='action']) { + --md-sys-color-primary: var(--action-label-text-color); + --md-sys-typescale-label-lg: var(--action-label-text); + --md-sys-typescale-label-lg-axes: var(--action-label-text-axes); + --md-sys-typescale-label-lg-tracking: var(--action-label-text-tracking); + } + + // Slotted plain element such as ` + `, + ); + await snackbar.show(); + const slotted = snackbar.querySelector('#slotted'); + expect(harness.getActionButton()).toBe(slotted as HTMLElement); + }); + + it('closes with reason "action" when clicked', async () => { + const {snackbar, harness} = await setupTest( + html``, + ); + await snackbar.show(); + const closeSpy = jasmine.createSpy('close'); + const closedSpy = jasmine.createSpy('closed'); + snackbar.addEventListener('close', closeSpy); + snackbar.addEventListener('closed', closedSpy); + + await harness.clickActionWithMouse(); + await env.waitForStability(); + + expect(snackbar.open).toBeFalse(); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(closedSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({detail: {reason: CLOSE_REASONS.ACTION}}), + ); + }); + + it('does not render for a whitespace-only action-label', async () => { + const {harness} = await setupTest( + html``, + ); + expect(harness.getActionButton()).toBeNull(); + }); + }); + + describe('slotted action', () => { + it('closes with reason "action" when a slotted + `, + ); + await snackbar.show(); + + let closedReason = ''; + snackbar.addEventListener('closed', (e: Event) => { + closedReason = (e as CustomEvent<{reason: string}>).detail.reason; + }); + + await harness.clickActionWithMouse(); + await env.waitForStability(); + + expect(snackbar.open).toBeFalse(); + expect(closedReason).toBe(CLOSE_REASONS.ACTION); + }); + + it('closes with reason "action" when a slotted md-gb-button host is clicked', async () => { + const {snackbar, harness} = await setupTest( + html` + Undo + `, + ); + await snackbar.show(); + const closedSpy = jasmine.createSpy('closed'); + snackbar.addEventListener('closed', closedSpy); + + await harness.clickActionWithMouse(); + await env.waitForStability(); + + expect(snackbar.open).toBeFalse(); + expect(closedSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({detail: {reason: CLOSE_REASONS.ACTION}}), + ); + }); + }); + + describe('dismiss button', () => { + it('renders close button when hasCloseButton is true and closes on click with reason "dismiss"', async () => { + const {snackbar, harness} = await setupTest( + html``, + ); + await snackbar.show(); + + const dismissBtn = harness.getDismissButton(); + expect(dismissBtn).withContext('dismiss button').not.toBeNull(); + expect(dismissBtn?.getAttribute('aria-label')).toBe('Close'); + + let closedReason = ''; + snackbar.addEventListener('closed', (e: Event) => { + closedReason = (e as CustomEvent<{reason: string}>).detail.reason; + }); + + await harness.clickDismissWithMouse(); + await env.waitForStability(); + + expect(snackbar.open).toBeFalse(); + expect(closedReason).toBe(CLOSE_REASONS.DISMISS); + }); + + it('closes with reason "dismiss" when a slotted dismiss element is clicked', async () => { + const {snackbar, harness} = await setupTest( + html` + + `, + ); + await snackbar.show(); + const closedSpy = jasmine.createSpy('closed'); + snackbar.addEventListener('closed', closedSpy); + + await harness.clickDismissWithMouse(); + await env.waitForStability(); + + expect(snackbar.open).toBeFalse(); + expect(closedSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({detail: {reason: CLOSE_REASONS.DISMISS}}), + ); + }); + }); + + describe('slots', () => { + it('renders both labelText and default slot content', async () => { + const {snackbar, harness} = await setupTest( + html` + Custom slotted text + `, + ); + await snackbar.show(); + const labelEl = harness.getLabelElement(); + expect(labelEl).not.toBeNull(); + expect(labelEl!.textContent).toContain('Label text'); + + const customContent = snackbar.querySelector('#custom-content'); + expect(customContent).not.toBeNull(); + expect(customContent?.textContent).toBe('Custom slotted text'); + + const slot = labelEl!.querySelector('slot'); + expect(slot?.assignedNodes({flatten: true})).toContain(customContent!); + }); + + it('renders label visibly when open and has whitespace plus a slotted action child', async () => { + const {snackbar, harness} = await setupTest( + html` + + `, + ); + await snackbar.show(); + const labelEl = harness.getLabelElement(); + expect(labelEl).not.toBeNull(); + expect(labelEl!.innerText.trim()).toBe('Test message'); + }); + }); + + describe('fixed property', () => { + it('adds fixed class when fixed is true', async () => { + const {snackbar} = await setupTest( + html``, + ); + const rootEl = snackbar.shadowRoot?.querySelector('.snackbar'); + expect(rootEl?.classList.contains(SNACKBAR_CLASSES.fixed)).toBeTrue(); + }); + }); + + describe('disconnect and reconnect semantics', () => { + it('cleans up mid-open transition on disconnect, does not leave isOpening stuck true, and restarts timeout on reconnect', async () => { + const {snackbar, root} = await setupTest( + html``, + ); + const closedSpy = jasmine.createSpy('closed'); + snackbar.addEventListener('closed', closedSpy); + + const showPromise = snackbar.show(); + await snackbar.updateComplete; + jasmine.clock().tick(50); + + snackbar.remove(); + jasmine.clock().tick(100); + await showPromise; + + jasmine.clock().tick(2000); + expect(closedSpy).not.toHaveBeenCalled(); + + snackbar.quick = true; + root.appendChild(snackbar); + await env.waitForStability(); + expect(snackbar.open).toBeTrue(); + + jasmine.clock().tick(1001); + await env.waitForStability(); + expect(snackbar.open).toBeFalse(); + expect(closedSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + detail: {reason: CLOSE_REASONS.TIMEOUT}, + }), + ); + }); + + it('still finishes in closed state and dispatches closed event when disconnected mid-close', async () => { + const {snackbar} = await setupTest( + html``, + ); + await snackbar.show(); + expect(snackbar.open).toBeTrue(); + + const closedSpy = jasmine.createSpy('closed'); + snackbar.addEventListener('closed', closedSpy); + + snackbar.quick = false; + const closePromise = snackbar.close('dismiss'); + await snackbar.updateComplete; + jasmine.clock().tick(25); + + snackbar.remove(); + jasmine.clock().tick(100); + await closePromise; + + expect(snackbar.open).toBeFalse(); + expect(closedSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + detail: {reason: 'dismiss'}, + }), + ); + }); + }); + + describe('harness getDismissButton', () => { + it('returns slotted light-DOM dismiss element when present, falling back to shadow dismiss button', async () => { + const t1 = await setupTest( + html` + + `, + ); + const customDismiss = t1.harness.getDismissButton(); + expect(customDismiss?.id).toBe('custom-dismiss'); + + const t2 = await setupTest( + html``, + ); + const shadowDismiss = t2.harness.getDismissButton(); + expect(shadowDismiss?.classList.contains('snackbar-dismiss')).toBeTrue(); + }); + }); + + describe('setupSnackbar', () => { + it('does nothing on Escape when open is false, and calls preventDefault and closes when open is true', () => { + const el = document.createElement('div') as HTMLElement & { + open?: boolean; + close?: (reason?: string) => void; + }; + setupSnackbar(el); + + el.open = false; + let closeDispatched = false; + el.addEventListener('close', () => { + closeDispatched = true; + }); + const event1 = new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true, + }); + el.dispatchEvent(event1); + expect(closeDispatched).toBeFalse(); + expect(event1.defaultPrevented).toBeFalse(); + + el.open = true; + const event2 = new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true, + }); + el.dispatchEvent(event2); + expect(closeDispatched).toBeTrue(); + expect(event2.defaultPrevented).toBeTrue(); + }); + }); + + describe('attributes', () => { + it('sets properties from the kebab-case attributes', async () => { + const {snackbar, harness} = await setupTest( + html``, + ); + expect(snackbar.labelText).toBe('Kebab label'); + expect(snackbar.timeoutMs).toBe(1234); + expect(snackbar.actionLabel).toBe('Undo'); + expect(snackbar.closeLabel).toBe('Dismiss'); + expect(snackbar.hasCloseButton).toBeTrue(); + expect(harness.getLabelElement()!.textContent).toContain('Kebab label'); + }); + + it('ignores the lowercase labeltext and timeoutms attributes', async () => { + const {snackbar} = await setupTest( + html``, + ); + snackbar.setAttribute('labeltext', 'Old label'); + snackbar.setAttribute('timeoutms', '1234'); + await snackbar.updateComplete; + expect(snackbar.labelText).toBe(''); + expect(snackbar.timeoutMs).toBe(5000); + }); + }); +});