From 2996a8b8266ac721e66d88fce9b1097e23f8ff49 Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Thu, 10 Aug 2023 16:33:46 -0700 Subject: [PATCH] refactor(dialog)!: match native `` and fix spacing Fixes #4647 Fixes #4285 BREAKING CHANGE: See https://github.com/material-components/material-web/discussions/4675 for more details. Dialogs use the native `` interface, which uses a `
` to set up dialog actions. ```html
A simple dialog
This is a dialog with text content.
Close OK
``` PiperOrigin-RevId: 555702952 --- dialog/demo/demo.ts | 3 +- dialog/demo/stories.ts | 166 ++++++----- dialog/dialog_test.ts | 188 ++++++------ dialog/harness.ts | 51 +--- dialog/internal/_dialog.scss | 335 ++++++++++----------- dialog/internal/_tokens.scss | 35 --- dialog/internal/animations.ts | 159 ++++++++++ dialog/internal/dialog.ts | 534 +++++++++++++++++----------------- tokens/_md-comp-dialog.scss | 3 +- 9 files changed, 787 insertions(+), 687 deletions(-) delete mode 100644 dialog/internal/_tokens.scss create mode 100644 dialog/internal/animations.ts diff --git a/dialog/demo/demo.ts b/dialog/demo/demo.ts index 47062fa304..355168131f 100644 --- a/dialog/demo/demo.ts +++ b/dialog/demo/demo.ts @@ -8,13 +8,12 @@ import './index.js'; import './material-collection.js'; import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; -import {boolInput, Knob, textInput} from './index.js'; +import {Knob, textInput} from './index.js'; import {stories, StoryKnobs} from './stories.js'; const collection = new MaterialCollection>('Dialog', [ - new Knob('footerHidden', {defaultValue: false, ui: boolInput()}), new Knob('icon', {defaultValue: '', ui: textInput()}), new Knob('headline', {defaultValue: 'Dialog', ui: textInput()}), new Knob( diff --git a/dialog/demo/stories.ts b/dialog/demo/stories.ts index 58478a0891..07b1edfaa2 100644 --- a/dialog/demo/stories.ts +++ b/dialog/demo/stories.ts @@ -15,11 +15,10 @@ import '@material/web/dialog/dialog.js'; import {MdDialog} from '@material/web/dialog/dialog.js'; import {MaterialStoryInit} from './material-collection.js'; -import {css, html} from 'lit'; +import {css, html, nothing} from 'lit'; /** Knob types for dialog stories. */ export interface StoryKnobs { - footerHidden: boolean; icon: string; headline: string; supportingText: string; @@ -31,53 +30,61 @@ function clickHandler(event: Event) { const standard: MaterialStoryInit = { - name: '', - render({footerHidden, icon, headline, supportingText}) { + name: 'Standard', + render({icon, headline, supportingText}) { return html` Open - - ${icon} - ${headline} - ${supportingText} - Close - `; + + ${icon ? html`${icon}` : nothing} +
${headline}
+
+ ${supportingText} +
+
+ Close + OK +
+
+ `; } }; const alert: MaterialStoryInit = { - name: 'Alert', - render({footerHidden, icon, headline, supportingText}) { + render() { return html` Open - - Alert dialog - This is a standard alert dialog. Alert dialogs interrupt users with urgent information, details, or actions. - OK - `; + +
Alert dialog
+
+ This is a standard alert dialog. Alert dialogs interrupt users with + urgent information, details, or actions. +
+
+ OK +
+
+ `; } }; const confirm: MaterialStoryInit = { name: 'Confirm', - render({footerHidden, icon, headline, supportingText}) { + render() { return html` Open - - delete_outline - Permanently delete? -
+ +
Permanently delete?
+ delete_outline +
Deleting the selected photos will also remove them from all synced devices. +
+
+ Delete + Cancel
- Delete - Cancel -
`; + + `; } }; @@ -90,32 +97,26 @@ const choose: MaterialStoryInit = { align-items: center; } `, - render({footerHidden, icon, headline, supportingText}) { + render() { return html` Open - - Choose your favorite pet -

- This is a standard choice dialog. These dialogs give users the ability to make - a decision and confirm it. This gives them a chance to change their minds if necessary. -

- - - - Cancel - OK -
`; + +
Choose your favorite pet
+
+
+ This is a standard choice dialog. These dialogs give users the ability to make + a decision and confirm it. This gives them a chance to change their minds if necessary. +
+ + + +
+
+ Cancel + OK +
+
+ `; } }; @@ -123,7 +124,7 @@ const contacts: MaterialStoryInit = { name: 'Contacts', styles: css` .contacts { - --md-dialog-container-min-inline-size: calc(100vw - 212px); + min-width: calc(100vw - 212px); } .contacts [slot="header"] { @@ -148,17 +149,17 @@ const contacts: MaterialStoryInit = { .contact-row > * { flex: 1; }`, - render({footerHidden, icon, headline, supportingText}) { + render() { return html` Open - - - close + + + + close + Create new contact -
+
@@ -169,31 +170,36 @@ const contacts: MaterialStoryInit = {
+
+
+ Cancel + Save
- Cancel - Save - `; + + `; } }; const floatingSheet: MaterialStoryInit = { name: 'Floating sheet', - render({footerHidden, icon, headline, supportingText}) { + render() { return html` Open - - - Floating Sheet - close - -
This is a floating sheet with title. - Floating sheets offer no action buttons at the bottom, - but there's a close icon button at the top right. - They accept any HTML content. -
-
`; + + + Floating Sheet + + close + + +
+ This is a floating sheet with title. + Floating sheets offer no action buttons at the bottom, + but there's a close icon button at the top right. + They accept any HTML content. +
+
+ `; } }; diff --git a/dialog/dialog_test.ts b/dialog/dialog_test.ts index 983bea7e8c..52c8150755 100644 --- a/dialog/dialog_test.ts +++ b/dialog/dialog_test.ts @@ -13,156 +13,166 @@ import {MdDialog} from './dialog.js'; import {DialogHarness} from './harness.js'; describe('', () => { - const realTimeout = globalThis.setTimeout; const env = new Environment(); - function setClockEnabled(enable = false) { - const isEnabled = globalThis.setTimeout !== realTimeout; - if (isEnabled !== enable) { - if (enable) { - jasmine.clock().install(); - } else { - jasmine.clock().uninstall(); - } - } - } - async function setupTest() { const root = env.render(html` -
Content +
+ Content +
+
+
- `); await env.waitForStability(); - setClockEnabled(false); - const dialog = root.querySelector('md-dialog')!; + const dialog = root.querySelector('md-dialog'); + if (!dialog) { + throw new Error('Failed to query rendered '); + } + const harness = new DialogHarness(dialog); - const contentElement = root.querySelector('.content')!; - const focusElement = root.querySelector('[autofocus]')!; - return {harness, root, contentElement, focusElement}; - } + const dialogElement = dialog.shadowRoot?.querySelector('dialog'); + if (!dialogElement) { + throw new Error('Failed to query rendered '); + } + const contentElement = root.querySelector('[slot=content]'); + if (!contentElement) { + throw new Error('Failed to query rendered content.'); + } - afterEach(() => { - setClockEnabled(true); - }); + const focusElement = root.querySelector('[autofocus]'); + if (!focusElement) { + throw new Error('Failed to query rendered autofocus element.'); + } + + return {harness, root, dialogElement, contentElement, focusElement}; + } describe('.styles', () => { createTokenTests(MdDialog.styles); }); describe('basic', () => { - it('initializes as an md-dialog', async () => { + it('open property calls show() and close()', async () => { const {harness} = await setupTest(); - expect(harness.element).toBeInstanceOf(MdDialog); - expect(await harness.getInteractiveElement()) - .toBeInstanceOf(HTMLDialogElement); - }); + spyOn(harness.element, 'show'); + spyOn(harness.element, 'close'); - it('renders open state by setting open property', async () => { - const {harness} = await setupTest(); - expect(await harness.isDialogVisible()).toBeFalse(); harness.element.open = true; - expect(await harness.isDialogVisible()).toBeTrue(); + await env.waitForStability(); + expect(harness.element.show).toHaveBeenCalled(); harness.element.open = false; - expect(await harness.isDialogVisible()).toBeFalse(); - harness.element.open = true; - expect(await harness.isDialogVisible()).toBeTrue(); - harness.element.open = false; - expect(await harness.isDialogVisible()).toBeFalse(); + await env.waitForStability(); + expect(harness.element.close).toHaveBeenCalled(); }); it('renders open state by calling show()/close()', async () => { - const {harness} = await setupTest(); - harness.element.show(); - expect(await harness.isDialogVisible()).toBeTrue(); - harness.element.close(); - expect(await harness.isDialogVisible()).toBeFalse(); - }); - - it('renders scrim', async () => { - const {harness} = await setupTest(); - expect(await harness.isScrimVisible()).toBeFalse(); - harness.element.open = true; - expect(await harness.isScrimVisible()).toBeTrue(); - harness.element.open = false; - expect(await harness.isScrimVisible()).toBeFalse(); + const {harness, dialogElement} = await setupTest(); + await harness.element.show(); + expect(dialogElement.open).toBeTrue(); + await harness.element.close(); + expect(dialogElement.open).toBeFalse(); }); it('fires open/close events', async () => { const {harness} = await setupTest(); - const openingHandler = jasmine.createSpy('openingHandler'); + const openHandler = jasmine.createSpy('openHandler'); const openedHandler = jasmine.createSpy('openedHandler'); - const closingHandler = jasmine.createSpy('closingHandler'); + const closeHandler = jasmine.createSpy('closeHandler'); const closedHandler = jasmine.createSpy('closedHandler'); - harness.element.addEventListener('opening', openingHandler); + harness.element.addEventListener('open', openHandler); harness.element.addEventListener('opened', openedHandler); - harness.element.addEventListener('closing', closingHandler); + harness.element.addEventListener('close', closeHandler); harness.element.addEventListener('closed', closedHandler); - harness.element.show(); - await harness.transitionComplete(); - expect(openingHandler).toHaveBeenCalledTimes(1); + await harness.element.show(); + expect(openHandler).toHaveBeenCalledTimes(1); expect(openedHandler).toHaveBeenCalledTimes(1); - expect(closingHandler).toHaveBeenCalledTimes(0); + expect(closeHandler).toHaveBeenCalledTimes(0); expect(closedHandler).toHaveBeenCalledTimes(0); - harness.element.close('testing'); - await harness.transitionComplete(); - expect(openingHandler).toHaveBeenCalledTimes(1); + await harness.element.close('testing'); + expect(openHandler).toHaveBeenCalledTimes(1); expect(openedHandler).toHaveBeenCalledTimes(1); - expect(closingHandler).toHaveBeenCalledTimes(1); - expect(closingHandler.calls.mostRecent().args[0].detail.action) - .toBe('testing'); + expect(closeHandler).toHaveBeenCalledTimes(1); expect(closedHandler).toHaveBeenCalledTimes(1); - expect(closedHandler.calls.mostRecent().args[0].detail.action) - .toBe('testing'); + expect(harness.element.returnValue).toBe('testing'); }); it('closes when element with action is clicked', async () => { const {harness} = await setupTest(); - harness.element.show(); - await harness.transitionComplete(); - const closedHandler = jasmine.createSpy('closedHandler'); - harness.element.addEventListener('closed', closedHandler); - harness.element - .querySelector( - '[dialog-action="button"]')!.click(); - await harness.transitionComplete(); + await harness.element.show(); + const closedPromise = new Promise(resolve => { + harness.element.addEventListener('closed', () => { + resolve(); + }, {once: true}); + }); + + harness.element.querySelector( + '[value="button"]')!.click(); + await closedPromise; expect(harness.element.open).toBeFalse(); - expect(closedHandler.calls.mostRecent().args[0].detail.action) - .toBe('button'); + expect(harness.element.returnValue).toBe('button'); }); it('closes with click outside dialog', async () => { - const {harness, contentElement} = await setupTest(); - harness.element.show(); + const {harness, dialogElement, contentElement} = await setupTest(); + const isClosing = jasmine.createSpy('isClosing'); + harness.element.addEventListener('close', isClosing); + await harness.element.show(); contentElement.click(); - await harness.transitionComplete(); - expect(harness.element.open).toBeTrue(); - const dialogElement = await harness.getInteractiveElement(); + expect(isClosing).not.toHaveBeenCalled(); dialogElement.click(); - await harness.transitionComplete(); - expect(harness.element.open).toBeFalse(); + expect(isClosing).toHaveBeenCalled(); }); - it('focses element with focus attribute when shown and previously focused element when closed', + it('focuses element with autofocus when shown and previously focused element when closed', async () => { const {harness, focusElement} = await setupTest(); const button = document.createElement('button'); document.body.append(button); button.focus(); expect(document.activeElement).toBe(button); - harness.element.show(); - await harness.transitionComplete(); + await harness.element.show(); expect(document.activeElement).toBe(focusElement); - harness.element.close(); - await harness.transitionComplete(); + await harness.element.close(); expect(document.activeElement).toBe(button); button.remove(); }); }); + + it('should set returnValue during the close event', async () => { + const {harness} = await setupTest(); + + let returnValueDuringClose = ''; + harness.element.addEventListener('close', () => { + returnValueDuringClose = harness.element.returnValue; + }); + + await harness.element.show(); + const returnValue = 'foo'; + await harness.element.close(returnValue); + expect(returnValueDuringClose) + .withContext('dialog.returnValue during close event') + .toBe(returnValue); + }); + + it('should not change returnValue if close event is canceled', async () => { + const {harness} = await setupTest(); + + harness.element.addEventListener('close', event => { + event.preventDefault(); + }); + + await harness.element.show(); + const prevReturnValue = harness.element.returnValue; + await harness.element.close('new return value'); + expect(harness.element.returnValue) + .withContext('dialog.returnValue after close event canceled') + .toBe(prevReturnValue); + }); }); diff --git a/dialog/harness.ts b/dialog/harness.ts index 2d71cfeb6b..20a5a1a020 100644 --- a/dialog/harness.ts +++ b/dialog/harness.ts @@ -8,60 +8,13 @@ import {Harness} from '../testing/harness.js'; import {Dialog} from './internal/dialog.js'; - /** * Test harness for dialog. */ export class DialogHarness extends Harness { override async getInteractiveElement() { await this.element.updateComplete; - return this.element.renderRoot.querySelector('.dialog') as - HTMLDialogElement; - } - - isOpening() { - // Test access to state - // tslint:disable-next-line:no-dict-access-on-struct-type - return Boolean(this.element.open && this.element['opening']); - } - - isClosing() { - // Test access to state - // tslint:disable-next-line:no-dict-access-on-struct-type - return Boolean(!this.element.open && this.element['closing']); - } - - async transitionComplete() { - await this.element.updateComplete; - let resolve = () => {}; - const doneTransitioning = new Promise(resolver => { - resolve = () => { - resolver(); - }; - }); - if (this.isOpening()) { - this.element.addEventListener('opened', resolve, {once: true}); - } else if (this.isClosing()) { - this.element.addEventListener('closed', resolve, {once: true}); - } else { - resolve(); - } - await doneTransitioning; - } - - async isDialogVisible() { - await this.transitionComplete(); - const dialogElement = await this.getInteractiveElement(); - const {display} = getComputedStyle(dialogElement); - return display !== 'none'; - } - - async isScrimVisible() { - await this.transitionComplete(); - const dialogElement = await this.getInteractiveElement(); - const {backgroundColor, display} = - getComputedStyle(dialogElement, '::backdrop'); - const hiddenBg = `rgba(0, 0, 0, 0)`; - return backgroundColor !== hiddenBg && display !== 'none'; + return this.element.querySelector('[autocomplete]') ?? + this.element; } } diff --git a/dialog/internal/_dialog.scss b/dialog/internal/_dialog.scss index 737e716f7d..6fc7e72de8 100644 --- a/dialog/internal/_dialog.scss +++ b/dialog/internal/_dialog.scss @@ -4,256 +4,257 @@ // // go/keep-sorted start +@use 'sass:list'; @use 'sass:map'; -@use 'sass:math'; // go/keep-sorted end // go/keep-sorted start -@use '../../elevation/elevation'; -@use '../../internal/sass/theme'; @use '../../tokens'; -@use './tokens' as md-dialog-tokens; // go/keep-sorted end -$_md-sys-color: tokens.md-sys-color-values-light(); -$_md-sys-motion: tokens.md-sys-motion-values(); - -// Basing on https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration#27a05b8b-02b1-4695-a7e4-70797f205222 -$_opening-transition-duration: map.get($_md-sys-motion, 'duration-medium4'); -$_opening-transition-easing: map.get( - $_md-sys-motion, - 'easing-emphasized-decelerate' -); -$_closing-transition-duration: map.get($_md-sys-motion, 'duration-short4'); -$_closing-transition-easing: map.get( - $_md-sys-motion, - 'easing-emphasized-accelerate' -); +@mixin theme($tokens) { + $supported-tokens: list.join( + tokens.$md-comp-dialog-supported-tokens, + ( + 'container-shape-start-start', + 'container-shape-start-end', + 'container-shape-end-end', + 'container-shape-end-start' + ) + ); + + @each $token, $value in $tokens { + @if list.index($supported-tokens, $token) == null { + @error 'Token `#{$token}` is not a supported token.'; + } + + @if $value { + --md-dialog-#{$token}: #{$value}; + } + } +} @mixin styles() { - $tokens: md-dialog-tokens.md-comp-dialog-values(); - $tokens: theme.create-theme-vars($tokens, 'dialog'); + $tokens: tokens.md-comp-dialog-values(); + $md-sys-color: tokens.md-sys-color-values-light(); + $md-sys-motion: tokens.md-sys-motion-values(); :host { @each $token, $value in $tokens { - --_#{$token}: #{$value}; + --_#{$token}: var(--md-dialog-#{$token}, #{$value}); } - } - .dialog { + // Support logical shape properties + --_container-shape-start-start: var( + --md-dialog-container-shape-start-start, + var(--_container-shape) + ); + --_container-shape-start-end: var( + --md-dialog-container-shape-start-end, + var(--_container-shape) + ); + --_container-shape-end-end: var( + --md-dialog-container-shape-end-end, + var(--_container-shape) + ); + --_container-shape-end-start: var( + --md-dialog-container-shape-end-start, + var(--_container-shape) + ); + + border-start-start-radius: var(--_container-shape-start-start); + border-start-end-radius: var(--_container-shape-start-end); + border-end-end-radius: var(--_container-shape-end-end); + border-end-start-radius: var(--_container-shape-end-start); + display: contents; + margin: auto; + max-height: min(560px, calc(100% - 48px)); + max-width: min(560px, calc(100% - 48px)); + min-height: 140px; + min-width: 280px; position: fixed; - align-items: center; - justify-content: center; - box-sizing: border-box; - inset: 0; - block-size: 100dvh; - inline-size: 100dvw; - max-block-size: 100dvh; - max-inline-size: 100dvw; - border: none; + height: fit-content; + width: fit-content; + } + + dialog { background: transparent; + border: none; + border-radius: inherit; + flex-direction: column; + height: inherit; + margin: inherit; + max-height: inherit; + max-width: inherit; + min-height: inherit; + min-width: inherit; + outline: none; + overflow: visible; padding: 0; - margin: 0; - overflow: clip; + width: inherit; } - .dialog[open] { + dialog[open] { display: flex; } - .dialog::backdrop { - // Per spec, custom properties don't inherit to backdrop so we avoid using it. + ::backdrop { + // Can't use ::backdrop since Firefox does not allow animations on it. background: none; } - .container { - position: absolute; - - inset-inline-start: var(--_container-inset-inline-start); - inset-inline-end: var(--_container-inset-inline-end); - inset-block-start: var(--_container-inset-block-start); - inset-block-end: var(--_container-inset-block-end); + .scrim { + background: map.get($md-sys-color, 'scrim'); + display: none; + inset: 0; + opacity: 32%; + pointer-events: none; + position: fixed; + z-index: 1; + } - background-color: var(--_container-color); - border-radius: var(--_container-shape); + :host([open]) .scrim { display: flex; - flex-direction: column; - box-sizing: border-box; - pointer-events: auto; - min-block-size: var(--_container-min-block-size); - max-block-size: var(--_container-max-block-size); - min-inline-size: var(--_container-min-inline-size); - max-inline-size: var(--_container-max-inline-size); - padding-block-start: 24px; - padding-block-end: 24px; - } - - md-elevation { - @include elevation.theme( - ( - 'level': var(--_container-elevation), - ) - ); } - .container > * { - box-sizing: border-box; - // Apply pad left/right here so scrollbar is not indented. - padding-inline-start: 24px; - padding-inline-end: 24px; + h2 { + all: unset; + align-self: stretch; } - .header { - display: flex; - flex-direction: column; + .headline { align-items: center; - gap: 16px; - -webkit-font-smoothing: antialiased; color: var(--_headline-color); + display: flex; + flex-direction: column; font: var(--_headline-type); + position: relative; } - .content { - flex: 1; - overflow: auto; - margin-block-start: 16px; - margin-block-end: 24px; - -webkit-font-smoothing: antialiased; - color: var(--_supporting-text-color); - font: var(--_supporting-text-type); - } - - .footer { - display: flex; - position: relative; - flex-wrap: wrap; + slot[name='headline']::slotted(*) { align-items: center; - justify-content: flex-end; - box-sizing: border-box; + align-self: stretch; + display: flex; gap: 8px; + margin: 24px 24px 0; } - .footerHidden .content { - margin-block-end: 0px; + .icon { + display: flex; } - .footerHidden .footer { - display: none; + slot[name='icon']::slotted(*) { + color: var(--_icon-color); + fill: currentColor; + font-size: var(--_icon-size); + margin-top: 24px; + height: var(--_icon-size); + width: var(--_icon-size); } - .scrollable .content { - border-block-start: 1px solid transparent; - border-block-end: 1px solid transparent; + .has-icon slot[name='headline']::slotted(*) { + justify-content: center; + margin-top: 16px; } - .scroll-divider-header .content { - border-block-start-color: map.get($_md-sys-color, 'outline'); + .scrollable slot[name='headline']::slotted(*) { + margin-bottom: 16px; } - .scroll-divider-footer:not(.footerHidden) .content { - border-block-end-color: map.get($_md-sys-color, 'outline'); + .scrollable.has-headline slot[name='content']::slotted(*) { + margin-top: 8px; } - // Transitions for open/closed states .container { - will-change: transform, opacity; - transition-property: transform; + border-radius: inherit; + display: flex; + flex: 1; + flex-direction: column; overflow: hidden; + position: relative; + transform-origin: top; } - .container > * { - transition-timing-function: inherit; - transition-duration: inherit; - transition-property: opacity, transform; - will-change: transform, opacity; - opacity: 0; + .container::before { + background: var(--_container-color); + content: ''; + inset: 0; + position: absolute; } - :host([showing-open]) .container > * { - opacity: 1; - transform: none; + .scroller { + flex: 1; + overflow: hidden; + z-index: 0; // needed to display scrollbars on Chrome linux } - :host([showing-open]) .container { - opacity: 1; - transform: none; + .scrollable .scroller { + // Only add scrollbars if the content is overflowing. This prevents extra + // space from appearing on platforms that reserve scrollbar space. + // Note: we only scroll vertically. Horizontal scrolling should be handled + // by the content. + overflow-y: scroll; } - .dialog::backdrop { - transition: background-color linear; - background-color: transparent; + .content { + color: var(--_supporting-text-color); + font: var(--_supporting-text-type); + position: relative; } - :host([showing-open]) .dialog::backdrop { - background-color: rgb(0 0 0 / 32%); + slot[name='content']::slotted(*) { + margin: 24px; } - :host([opening]) .dialog::backdrop { - transition-duration: math.div($_opening-transition-duration, 2); + // Anchors are used with an IntersectionObserver to determine when the content + // has scrolled. + .anchor { + position: absolute; } - :host([closing]) .dialog::backdrop { - transition-duration: math.div($_closing-transition-duration, 2); + .top.anchor { + top: 0; } - :host([opening]) .container { - transition-duration: $_opening-transition-duration; - transition-timing-function: $_opening-transition-easing; + .bottom.anchor { + bottom: 0; } - :host([closing]) .container { - transition-duration: $_closing-transition-duration; - transition-timing-function: $_closing-transition-easing; + .actions { + position: relative; } - :host([transition][closing]) .container > * { - transform: none; - opacity: 0; + slot[name='actions']::slotted(*) { + display: flex; + gap: 8px; + justify-content: flex-end; + margin: 16px 24px 24px; } - :host { - --_opening-transform: scale(1, 0.1) translateY(-20%); - --_closing-transform: scale(1, 0.9) translateY(-10%); - --_origin: top; - --_opening-content-transform: scale(1, 2); - --_origin-footer: bottom; + .has-actions slot[name='content']::slotted(*) { + margin-bottom: 8px; } - .container { - transform-origin: var(--_origin); - transform: var(--_opening-transform); + md-divider { + display: none; + position: absolute; } - .container > * { - transform-origin: var(--_origin); - transform: var(--_opening-content-transform); + .has-headline.show-top-divider .headline md-divider, + .has-actions.show-bottom-divider .actions md-divider { + display: flex; } - .footer { - transform-origin: var(--_origin-footer); + .headline md-divider { + bottom: 0; } - :host([closing]) { - transform: var(--_closing-transform); + .actions md-divider { + top: 0; } - // High contrast mode - @media screen and (forced-colors: active), (-ms-high-contrast: active) { - .container { - outline: windowtext solid 2px; + @media (forced-colors: active) { + dialog { + outline: 2px solid WindowText; } } - - // Slot styling - [name='headline-prefix']::slotted(*), - [name='headline-suffix']::slotted(*) { - color: var(--_icon-color); - font-size: var(--_icon-size); - } - - [name='header']::slotted(*) { - flex: 1; - align-self: stretch; - display: flex; - align-items: center; - } } diff --git a/dialog/internal/_tokens.scss b/dialog/internal/_tokens.scss deleted file mode 100644 index acbf273c6a..0000000000 --- a/dialog/internal/_tokens.scss +++ /dev/null @@ -1,35 +0,0 @@ -// -// Copyright 2023 Google LLC -// SPDX-License-Identifier: Apache-2.0 -// - -// go/keep-sorted start -@use 'sass:map'; -@use 'sass:string'; -// go/keep-sorted end -// go/keep-sorted start -@use '../../internal/sass/string-ext'; -@use '../../internal/sass/theme'; -@use '../../tokens'; -// go/keep-sorted end - -$_md-sys-motion: tokens.md-sys-motion-values(); -$_md-sys-color: tokens.md-sys-color-values-light(); - -$_tokens: ( - // Container size - container-max-inline-size: min(560px, calc(100% - 48px)), - container-min-inline-size: 280px, - container-max-block-size: min(560px, calc(100% - 48px)), - container-min-block-size: 140px, - // Container position - container-inset-inline-start: auto, - container-inset-inline-end: auto, - container-inset-block-start: auto, - container-inset-block-end: auto -); - -// Extended token set for dialog. -@function md-comp-dialog-values() { - @return map.merge(tokens.md-comp-dialog-values(), $_tokens); -} diff --git a/dialog/internal/animations.ts b/dialog/internal/animations.ts new file mode 100644 index 0000000000..826a9187eb --- /dev/null +++ b/dialog/internal/animations.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {EASING} from '../../internal/motion/animation.js'; + +/** + * A dialog animation's arguments. See `Element.prototype.animate`. + */ +export type DialogAnimationArgs = Parameters; + +/** + * A collection of dialog animations. Each element of a dialog may have multiple + * animations. + */ +export interface DialogAnimation { + /** + * Animations for the dialog itself. + */ + dialog?: DialogAnimationArgs[]; + + /** + * Animations for the scrim backdrop. + */ + scrim?: DialogAnimationArgs[]; + + /** + * Animations for the container of the dialog. + */ + container?: DialogAnimationArgs[]; + + /** + * Animations for the headline section. + */ + headline?: DialogAnimationArgs[]; + + /** + * Animations for the contents section. + */ + content?: DialogAnimationArgs[]; + /** + * Animations for the actions section. + */ + actions?: DialogAnimationArgs[]; +} + +/** + * The default dialog open animation. + */ +export const DIALOG_DEFAULT_OPEN_ANIMATION: DialogAnimation = { + dialog: [ + [ + // Dialog slide down + [{'transform': 'translateY(-50px)'}, {'transform': 'translateY(0)'}], + {duration: 500, easing: EASING.EMPHASIZED} + ], + ], + scrim: [ + [ + // Scrim fade in + [{'opacity': 0}, {'opacity': 0.32}], {duration: 500, easing: 'linear'} + ], + ], + container: [ + [ + // Container fade in + [{'opacity': 0}, {'opacity': 1}], + {duration: 50, easing: 'linear', pseudoElement: '::before'} + ], + [ + // Container grow + // Note: current spec says to grow from 0dp->100% and shrink from + // 100%->35%. We change this to 35%->100% to simplify the animation that + // is supposed to clip content as it grows. From 0dp it's possible to see + // text/actions appear before the container has fully grown. + [{'height': '35%'}, {'height': '100%'}], + {duration: 500, easing: EASING.EMPHASIZED, pseudoElement: '::before'}, + ], + ], + headline: [ + [ + // Headline fade in + [{'opacity': 0}, {'opacity': 0, offset: 0.2}, {'opacity': 1}], + {duration: 250, easing: 'linear', fill: 'forwards'} + ], + ], + content: [ + [ + // Content fade in + [{'opacity': 0}, {'opacity': 0, offset: 0.2}, {'opacity': 1}], + {duration: 250, easing: 'linear', fill: 'forwards'} + ], + ], + actions: [ + [ + // Actions fade in + [{'opacity': 0}, {'opacity': 0, offset: 0.5}, {'opacity': 1}], + {duration: 300, easing: 'linear', fill: 'forwards'} + ], + ], +}; + +/** + * The default dialog close animation. + */ +export const DIALOG_DEFAULT_CLOSE_ANIMATION: DialogAnimation = { + dialog: [ + [ + // Dialog slide up + [{'transform': 'translateY(0)'}, {'transform': 'translateY(-50px)'}], + {duration: 150, easing: EASING.EMPHASIZED_ACCELERATE} + ], + ], + scrim: [ + [ + // Scrim fade out + [{'opacity': 0.32}, {'opacity': 0}], {duration: 150, easing: 'linear'} + ], + ], + container: [ + [ + // Container shrink + [{'height': '100%'}, {'height': '35%'}], + { + duration: 150, + easing: EASING.EMPHASIZED_ACCELERATE, + pseudoElement: '::before', + }, + ], + [ + // Container fade out + [{'opacity': '1'}, {'opacity': '0'}], + {delay: 100, duration: 50, easing: 'linear', pseudoElement: '::before'}, + ] + ], + headline: [ + [ + // Headline fade out + [{'opacity': 1}, {'opacity': 0}], + {duration: 100, easing: 'linear', fill: 'forwards'} + ], + ], + content: [ + [ + // Content fade out + [{'opacity': 1}, {'opacity': 0}], + {duration: 100, easing: 'linear', fill: 'forwards'} + ], + ], + actions: [ + [ + // Actions fade out + [{'opacity': 1}, {'opacity': 0}], + {duration: 100, easing: 'linear', fill: 'forwards'} + ], + ], +}; diff --git a/dialog/internal/dialog.ts b/dialog/internal/dialog.ts index cbeff1e94e..a9be7ab39b 100644 --- a/dialog/internal/dialog.ts +++ b/dialog/internal/dialog.ts @@ -4,348 +4,354 @@ * SPDX-License-Identifier: Apache-2.0 */ -import '../../elevation/elevation.js'; +import '../../divider/divider.js'; -import {html, LitElement, PropertyValues} from 'lit'; +import {html, isServer, LitElement, nothing} from 'lit'; import {property, query, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; +import {ARIAMixinStrict} from '../../internal/aria/aria.js'; +import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; import {redispatchEvent} from '../../internal/controller/events.js'; -import {createThrottle, msFromTimeCSSValue} from '../../internal/motion/animation.js'; -/** - * Default close action. - */ -export const CLOSE_ACTION = 'close'; +import {DIALOG_DEFAULT_CLOSE_ANIMATION, DIALOG_DEFAULT_OPEN_ANIMATION, DialogAnimation, DialogAnimationArgs} from './animations.js'; /** * A dialog component. * - * @fires opening Dispatched when the dialog is opening before any animations. + * @fires open Dispatched when the dialog is opening before any animations. * @fires opened Dispatched when the dialog has opened after any animations. - * @fires closing Dispatched when the dialog is closing before any animations. + * @fires close Dispatched when the dialog is closing before any animations. * @fires closed Dispatched when the dialog has closed after any animations. - * @fires cancel The native HTMLDialogElement cancel event. + * @fires cancel Dispatched when the dialog has been canceled by clicking on the + * scrim or pressing Escape. */ export class Dialog extends LitElement { + static { + requestUpdateOnAriaChange(Dialog); + } + + static override shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true + }; + /** * Opens the dialog when set to `true` and closes it when set to `false`. */ - @property({type: Boolean}) open = false; + @property({type: Boolean}) + get open() { + return this.isOpen; + } - /** - * Hides the dialog footer, making any content slotted into the footer - * inaccessible. - */ - @property({type: Boolean, attribute: 'footer-hidden'}) footerHidden = false; + set open(open: boolean) { + if (open === this.isOpen) { + return; + } + + this.isOpen = open; + if (open) { + this.setAttribute('open', ''); + this.show(); + } else { + this.removeAttribute('open'); + this.close(); + } + } /** - * When the dialog is closed it disptaches `closing` and `closed` events. - * These events have an action property which has a default value of - * the value of this property. Specific actions have explicit values but when - * a value is not specified, the default is used. For example, clicking the - * scrim, pressing escape, or clicking a button with an action attribute set - * produce an explicit action. + * Gets or sets the dialog's return value, usually to indicate which button + * a user pressed to close it. * - * Defaults to `close`. + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/returnValue */ - @property({attribute: 'default-action'}) defaultAction = CLOSE_ACTION; + @property({attribute: false}) returnValue = ''; /** - * The name of an attribute which can be placed on any element slotted into - * the dialog. If an element has an action attribute set, clicking it will - * close the dialog and the `closing` and `closed` events dispatched will - * have their action property set the value of this attribute on the - * clicked element.The default value is `dialog-action`. For example, - * - * - * Content - * - * Buy - * - * + * The type of dialog for accessibility. Set this to `alert` to announce a + * dialog as an alert dialog. */ - @property({attribute: 'action-attribute'}) actionAttribute = 'dialog-action'; + @property() type?: 'alert'; /** - * Clicking on the scrim surrounding the dialog closes the dialog. - * The `closing` and `closed` events this produces have an `action` property - * which is the value of this property and defaults to `close`. + * Gets the opening animation for a dialog. Set to a new function to customize + * the animation. */ - @property({attribute: 'scrim-click-action'}) scrimClickAction = CLOSE_ACTION; + getOpenAnimation = () => DIALOG_DEFAULT_OPEN_ANIMATION; /** - * Pressing the `escape` key while the dialog is open closes the dialog. - * The `closing` and `closed` events this produces have an `action` property - * which is the value of this property and defaults to `close`. + * Gets the closing animation for a dialog. Set to a new function to customize + * the animation. */ - @property({attribute: 'escape-key-action'}) escapeKeyAction = CLOSE_ACTION; - - private readonly throttle = createThrottle(); - - @query('.dialog', true) - private readonly dialogElement!: HTMLDialogElement|null; - - // slots tracked to find focusable elements. - @query('slot[name=footer]', true) - private readonly footerSlot!: HTMLSlotElement; - @query('slot:not([name])', true) - private readonly contentSlot!: HTMLSlotElement; - // for scrolling related styling - @query(`.content`, true) - private readonly contentElement!: HTMLDivElement|null; - @query(`.container`, true) - private readonly containerElement!: HTMLDivElement|null; + getCloseAnimation = () => DIALOG_DEFAULT_CLOSE_ANIMATION; + + private isOpen = false; + @query('dialog') private readonly dialog!: HTMLDialogElement|null; + @query('.scrim') private readonly scrim!: HTMLDialogElement|null; + @query('.container') private readonly container!: HTMLDialogElement|null; + @query('.headline') private readonly headline!: HTMLDialogElement|null; + @query('.content') private readonly content!: HTMLDialogElement|null; + @query('.actions') private readonly actions!: HTMLDialogElement|null; + @state() private isAtScrollTop = false; + @state() private isAtScrollBottom = false; + @query('.scroller') private readonly scroller!: HTMLElement|null; + @query('.top.anchor') private readonly topAnchor!: HTMLElement|null; + @query('.bottom.anchor') private readonly bottomAnchor!: HTMLElement|null; + private nextClickIsFromContent = false; + private intersectionObserver?: IntersectionObserver; + // Dialogs should not be SSR'd while open, so we can just use runtime checks. + @state() private hasHeadline = false; + @state() private hasActions = false; + @state() private hasIcon = false; + + constructor() { + super(); + if (!isServer) { + this.addEventListener('submit', this.handleSubmit); + } + } /** - * Private properties that reflect for styling manually in `updated`. + * Opens the dialog and fires a cancelable `open` event. After a dialog's + * animation, an `opened` event is fired. + * + * Add an `autocomplete` attribute to a child of the dialog that should + * receive focus after opening. + * + * @return A Promise that resolves after the animation is finished and the + * `opened` event was fired. */ - @state() private showingOpen = false; - @state() private opening = false; - @state() private closing = false; + async show() { + const {dialog, container} = this; + if (!dialog || !container || dialog.open) { + return; + } - private currentAction: string|undefined; + const preventOpen = + !this.dispatchEvent(new Event('open', {cancelable: true})); + if (preventOpen) { + this.open = false; + return; + } - /** - * Opens and shows the dialog. This is equivalent to setting the `open` - * property to true. - */ - show() { + // All Material dialogs are modal. + dialog.showModal(); this.open = true; + // Reset scroll position if re-opening a dialog with the same content. + if (this.scroller) { + this.scroller.scrollTop = 0; + } + // Native modal dialogs ignore autofocus and instead force focus to the + // first focusable child. Override this behavior if there is a child with + // an autofocus attribute. + this.querySelector('[autofocus]')?.focus(); + + await this.animateDialog(this.getOpenAnimation()); + this.dispatchEvent(new Event('opened')); } /** - * Closes the dialog. This is equivalent to setting the `open` - * property to false. + * Closes the dialog and fires a cancelable `close` event. After a dialog's + * animation, a `closed` event is fired. + * + * @param returnValue A return value usually indicating which button was used + * to close a dialog. If a dialog is canceled by clicking the scrim or + * pressing Escape, it will not change the return value after closing. + * @return A Promise that resolves after the animation is finished and the + * `closed` event was fired. */ - close(action = '') { - this.currentAction = action; - this.open = false; - } + async close(returnValue = this.returnValue) { + const {dialog, container} = this; + if (!dialog || !container || !dialog.open) { + return; + } - private getContentScrollInfo() { - if (!this.hasUpdated || !this.contentElement) { - return {isScrollable: false, isAtScrollTop: true, isAtScrollBottom: true}; + const prevReturnValue = this.returnValue; + this.returnValue = returnValue; + const preventClose = + !this.dispatchEvent(new Event('close', {cancelable: true})); + if (preventClose) { + this.returnValue = prevReturnValue; + return; } - const {scrollTop, scrollHeight, offsetHeight, clientHeight} = - this.contentElement; - return { - isScrollable: scrollHeight > offsetHeight, - isAtScrollTop: scrollTop === 0, - isAtScrollBottom: - Math.abs(Math.round(scrollHeight - scrollTop) - clientHeight) <= 2 - }; + + await this.animateDialog(this.getCloseAnimation()); + dialog.close(returnValue); + this.open = false; + this.dispatchEvent(new Event('closed')); } protected override render() { - const {isScrollable, isAtScrollTop, isAtScrollBottom} = - this.getContentScrollInfo(); - return html` - -
- -
- - - - - -
-
- -
-
- -
-
-
`; - } + const scrollable = + this.open && !(this.isAtScrollTop && this.isAtScrollBottom); + const classes = { + 'has-headline': this.hasHeadline, + 'has-actions': this.hasActions, + 'has-icon': this.hasIcon, + 'scrollable': scrollable, + 'show-top-divider': scrollable && !this.isAtScrollTop, + 'show-bottom-divider': scrollable && !this.isAtScrollBottom, + }; - protected override willUpdate(changed: PropertyValues) { - if (changed.has('open')) { - this.opening = this.open; - // only closing if was opened previously... - this.closing = !this.open && changed.get('open'); - } + const {ariaLabel} = this as ARIAMixinStrict; + return html` +
+ +
+
+
+ +
+

+ +

+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+ `; } protected override firstUpdated() { - // Update when content size changes to show/hide scroll dividers. - new ResizeObserver(() => { - if (this.showingOpen) { - this.requestUpdate(); + this.intersectionObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + this.handleAnchorIntersection(entry); } - }).observe(this.contentElement!); + }, {root: this.scroller!}); + + this.intersectionObserver.observe(this.topAnchor!); + this.intersectionObserver.observe(this.bottomAnchor!); } - protected override updated(changed: PropertyValues) { - // Reflect internal state to facilitate styling. - this.reflectStateProp(changed, 'opening', this.opening); - this.reflectStateProp(changed, 'closing', this.closing); - this.reflectStateProp( - changed, 'showingOpen', this.showingOpen, 'showing-open'); - if (!changed.has('open')) { + private handleDialogClick() { + if (this.nextClickIsFromContent) { + // Avoid doing a layout calculation below if we know the click came from + // content. + this.nextClickIsFromContent = false; return; } - if (this.open) { - this.contentElement!.scrollTop = 0; - // Note, native focus handling fails when focused element is in an - // overflow: auto container. - this.dialogElement!.showModal(); - } - // Avoids dispatching initial state. - const shouldDispatchAction = changed.get('open') !== undefined; - this.performTransition(shouldDispatchAction); - } - /** - * Internal state is reflected here as attributes to effect styling. This - * could be done via internal classes, but it's published on the host - * to facilitate the (currently undocumented) possibility of customizing - * styling of user content based on these states. - * Note, in the future this could be done with `:state(...)` when browser - * support improves. - */ - private reflectStateProp( - changed: PropertyValues, key: string, value: unknown, - attribute?: string) { - attribute ??= key; - if (!changed.has(key)) { + // Click originated on the backdrop. Native ``s will not cancel, + // but Material dialogs do. + const preventDefault = + !this.dispatchEvent(new Event('cancel', {cancelable: true})); + if (preventDefault) { return; } - if (value) { - this.setAttribute(attribute, ''); - } else { - this.removeAttribute(attribute); - } + + this.close(); } - private dialogClosedResolver?: () => void; + private handleContentClick() { + this.nextClickIsFromContent = true; + } - private async performTransition(shouldDispatchAction: boolean) { - // TODO: pause here only to avoid a double update warning. - await this.updateComplete; - // Focus initial element. - if (this.open) { - this.focus(); - } - this.showingOpen = this.open; - if (shouldDispatchAction) { - this.dispatchActionEvent(this.open ? 'opening' : 'closing'); - } - // Compute desired transition duration. - const duration = msFromTimeCSSValue( - getComputedStyle(this.containerElement!).transitionDuration); - let promise = this.updateComplete; - if (duration > 0) { - promise = new Promise((r) => { - setTimeout(r, duration); - }); - } - await promise; - this.opening = false; - this.closing = false; - if (!this.open && this.dialogElement?.open) { - // Closing the dialog triggers an asynchronous `close` event. - // It's important to wait for this event to fire since it changes the - // state of `open` to false. - // Without waiting, this element's `closed` event can be called before - // the dialog's `close` event, which is problematic since the user - // can set `open` in the `closed` event. - // The timing of the event appears to vary via browser and does *not* - // seem to resolve by "task" timing; therefore an explicit promise is - // used. - const closedPromise = new Promise(resolve => { - this.dialogClosedResolver = resolve; - }); - this.dialogElement?.close(this.currentAction || this.defaultAction); - await closedPromise; - } - if (shouldDispatchAction) { - this.dispatchActionEvent(this.open ? 'opened' : 'closed'); + private handleSubmit(event: SubmitEvent) { + const form = event.target as HTMLFormElement; + const {submitter} = event; + if (form.method !== 'dialog' || !submitter) { + return; } - this.currentAction = undefined; - } - private dispatchActionEvent(type: string) { - const detail = {action: this.open ? 'none' : this.currentAction}; - this.dispatchEvent(new CustomEvent(type, {detail, bubbles: true})); + // Close reason is the submitter's value attribute, or the dialog's + // `returnValue` if there is no attribute. + this.close(submitter.getAttribute('value') ?? this.returnValue); } - // handles native close/cancel events and we just ensure - // internal state is in sync. - private handleDialogDismiss(event: Event) { - if (event.type === 'cancel') { - this.currentAction = this.escapeKeyAction; - // Prevents the element from closing when - // `escapeKeyAction` is set to an empty string. - // It also early returns and avoids internal state - // changes. - if (this.escapeKeyAction === '') { - event.preventDefault(); - return; - } + private handleCancel(event: Event) { + if (event.target !== this.dialog) { + // Ignore any cancel events dispatched by content. + return; } - this.dialogClosedResolver?.(); - this.dialogClosedResolver = undefined; - this.open = false; - this.opening = false; - this.closing = false; - redispatchEvent(this, event); + + const preventDefault = !redispatchEvent(this, event); + // We always prevent default on the original dialog event since we'll + // animate closing it before it actually closes. + event.preventDefault(); + if (preventDefault) { + return; + } + + this.close(); } - private handleDialogClick(event: Event) { - if (!this.open) { + private async animateDialog(animation: DialogAnimation) { + const {dialog, scrim, container, headline, content, actions} = this; + if (!dialog || !scrim || !container || !headline || !content || !actions) { return; } - this.currentAction = - (event.target as Element).getAttribute(this.actionAttribute) ?? - (this.containerElement && - !event.composedPath().includes(this.containerElement) ? - this.scrimClickAction : - ''); - if (this.currentAction !== '') { - this.close(this.currentAction); + + const { + container: containerAnimate, + dialog: dialogAnimate, + scrim: scrimAnimate, + headline: headlineAnimate, + content: contentAnimate, + actions: actionsAnimate + } = animation; + + const elementAndAnimation: Array<[Element, DialogAnimationArgs[]]> = [ + [dialog, dialogAnimate ?? []], [scrim, scrimAnimate ?? []], + [container, containerAnimate ?? []], [headline, headlineAnimate ?? []], + [content, contentAnimate ?? []], [actions, actionsAnimate ?? []] + ]; + + const animations: Animation[] = []; + for (const [element, animation] of elementAndAnimation) { + for (const animateArgs of animation) { + animations.push(element.animate(...animateArgs)); + } } + + await Promise.all(animations.map(animation => animation.finished)); } - /* This allows the dividers to dynamically show based on scrolling. */ - private handleContentScroll() { - this.throttle('scroll', () => { - this.requestUpdate(); - }); + private handleHeadlineChange(event: Event) { + const slot = event.target as HTMLSlotElement; + this.hasHeadline = slot.assignedElements().length > 0; } - private getFocusElement(): HTMLElement|null { - const selector = `[autofocus]`; - const slotted = [this.footerSlot, this.contentSlot].flatMap( - slot => slot.assignedElements({flatten: true})); - for (const el of slotted) { - const focusEl = el.matches(selector) ? el : el.querySelector(selector); - if (focusEl) { - return focusEl as HTMLElement; - } - } - return null; + private handleActionsChange(event: Event) { + const slot = event.target as HTMLSlotElement; + this.hasActions = slot.assignedElements().length > 0; } - override focus() { - this.getFocusElement()?.focus(); + private handleIconChange(event: Event) { + const slot = event.target as HTMLSlotElement; + this.hasIcon = slot.assignedElements().length > 0; } - override blur() { - this.getFocusElement()?.blur(); + private handleAnchorIntersection(entry: IntersectionObserverEntry) { + const {target, isIntersecting} = entry; + if (target === this.topAnchor) { + this.isAtScrollTop = isIntersecting; + } + + if (target === this.bottomAnchor) { + this.isAtScrollBottom = isIntersecting; + } } } diff --git a/tokens/_md-comp-dialog.scss b/tokens/_md-comp-dialog.scss index afa81fdc5a..7e49a018d4 100644 --- a/tokens/_md-comp-dialog.scss +++ b/tokens/_md-comp-dialog.scss @@ -19,7 +19,6 @@ $supported-tokens: ( // go/keep-sorted start 'container-color', - 'container-elevation', 'container-shape', 'headline-color', 'headline-type', @@ -58,6 +57,8 @@ $unsupported-tokens: ( 'action-pressed-label-text-color', 'action-pressed-state-layer-color', 'action-pressed-state-layer-opacity', + 'container-elevation', + // Unused without a shadow color 'headline-font', 'headline-line-height', 'headline-size',