chore: format files with prettier

PiperOrigin-RevId: 576601342
This commit is contained in:
Elizabeth Mitchell 2023-10-25 11:58:21 -07:00 committed by Copybara-Service
parent d09bdc47e6
commit c390291687
216 changed files with 6352 additions and 5081 deletions

View File

@ -4,19 +4,26 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {boolInput, Knob, textInput} from './index.js'; import {boolInput, Knob, textInput} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Button', [ 'Button',
new Knob('label', {ui: textInput(), defaultValue: ''}), [
new Knob('disabled', {ui: boolInput(), defaultValue: false}), new Knob('label', {ui: textInput(), defaultValue: ''}),
]); new Knob('disabled', {ui: boolInput(), defaultValue: false}),
],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -4,12 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/icon/icon.js';
import '@material/web/button/elevated-button.js'; import '@material/web/button/elevated-button.js';
import '@material/web/button/filled-button.js'; import '@material/web/button/filled-button.js';
import '@material/web/button/filled-tonal-button.js';
import '@material/web/button/outlined-button.js'; import '@material/web/button/outlined-button.js';
import '@material/web/button/text-button.js'; import '@material/web/button/text-button.js';
import '@material/web/button/filled-tonal-button.js'; import '@material/web/icon/icon.js';
import {MaterialStoryInit} from './material-collection.js'; import {MaterialStoryInit} from './material-collection.js';
import {css, html} from 'lit'; import {css, html} from 'lit';
@ -88,7 +88,7 @@ const buttons: MaterialStoryInit<StoryKnobs> = {
</div> </div>
</div> </div>
`; `;
} },
}; };
const links: MaterialStoryInit<StoryKnobs> = { const links: MaterialStoryInit<StoryKnobs> = {
@ -101,40 +101,35 @@ const links: MaterialStoryInit<StoryKnobs> = {
<md-filled-button <md-filled-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
${label || 'Filled'} ${label || 'Filled'}
</md-filled-button> </md-filled-button>
<md-outlined-button <md-outlined-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
${label || 'Outlined'} ${label || 'Outlined'}
</md-outlined-button> </md-outlined-button>
<md-elevated-button <md-elevated-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
${label || 'Elevated'} ${label || 'Elevated'}
</md-elevated-button> </md-elevated-button>
<md-filled-tonal-button <md-filled-tonal-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
${label || 'Tonal'} ${label || 'Tonal'}
</md-filled-tonal-button> </md-filled-tonal-button>
<md-text-button <md-text-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
${label || 'Text'} ${label || 'Text'}
</md-text-button> </md-text-button>
</div> </div>
@ -142,8 +137,7 @@ const links: MaterialStoryInit<StoryKnobs> = {
<md-filled-button <md-filled-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
<md-icon slot="icon">open_in_new</md-icon> <md-icon slot="icon">open_in_new</md-icon>
${label || 'Filled'} ${label || 'Filled'}
</md-filled-button> </md-filled-button>
@ -151,8 +145,7 @@ const links: MaterialStoryInit<StoryKnobs> = {
<md-outlined-button <md-outlined-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
<md-icon slot="icon">open_in_new</md-icon> <md-icon slot="icon">open_in_new</md-icon>
${label || 'Outlined'} ${label || 'Outlined'}
</md-outlined-button> </md-outlined-button>
@ -160,8 +153,7 @@ const links: MaterialStoryInit<StoryKnobs> = {
<md-elevated-button <md-elevated-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
<md-icon slot="icon">open_in_new</md-icon> <md-icon slot="icon">open_in_new</md-icon>
${label || 'Elevated'} ${label || 'Elevated'}
</md-elevated-button> </md-elevated-button>
@ -169,8 +161,7 @@ const links: MaterialStoryInit<StoryKnobs> = {
<md-filled-tonal-button <md-filled-tonal-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
<md-icon slot="icon">open_in_new</md-icon> <md-icon slot="icon">open_in_new</md-icon>
${label || 'Tonal'} ${label || 'Tonal'}
</md-filled-tonal-button> </md-filled-tonal-button>
@ -178,15 +169,14 @@ const links: MaterialStoryInit<StoryKnobs> = {
<md-text-button <md-text-button
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
trailing-icon trailing-icon>
>
<md-icon slot="icon">open_in_new</md-icon> <md-icon slot="icon">open_in_new</md-icon>
${label || 'Text'} ${label || 'Text'}
</md-text-button> </md-text-button>
</div> </div>
</div> </div>
`; `;
} },
}; };
/** Button stories. */ /** Button stories. */

View File

@ -41,6 +41,9 @@ declare global {
*/ */
@customElement('md-elevated-button') @customElement('md-elevated-button')
export class MdElevatedButton extends ElevatedButton { export class MdElevatedButton extends ElevatedButton {
static override styles = static override styles = [
[sharedStyles, sharedElevationStyles, elevatedStyles]; sharedStyles,
sharedElevationStyles,
elevatedStyles,
];
} }

View File

@ -10,13 +10,20 @@ import '../../ripple/ripple.js';
import {html, isServer, LitElement, nothing} from 'lit'; import {html, isServer, LitElement, nothing} from 'lit';
import {property, query, queryAssignedElements} from 'lit/decorators.js'; import {property, query, queryAssignedElements} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js'; import {classMap} from 'lit/directives/class-map.js';
import {html as staticHtml, literal} from 'lit/static-html.js'; import {literal, html as staticHtml} from 'lit/static-html.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js'; import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {internals} from '../../internal/controller/element-internals.js'; import {internals} from '../../internal/controller/element-internals.js';
import {dispatchActivationClick, isActivationClick} from '../../internal/controller/events.js'; import {
import {FormSubmitter, FormSubmitterType, setupFormSubmitter} from '../../internal/controller/form-submitter.js'; dispatchActivationClick,
isActivationClick,
} from '../../internal/controller/events.js';
import {
FormSubmitter,
FormSubmitterType,
setupFormSubmitter,
} from '../../internal/controller/form-submitter.js';
/** /**
* A button component. * A button component.
@ -31,8 +38,10 @@ export abstract class Button extends LitElement implements FormSubmitter {
static readonly formAssociated = true; static readonly formAssociated = true;
/** @nocollapse */ /** @nocollapse */
static override shadowRootOptions: static override shadowRootOptions: ShadowRootInit = {
ShadowRootInit = {mode: 'open', delegatesFocus: true}; mode: 'open',
delegatesFocus: true,
};
/** /**
* Whether or not the button is disabled. * Whether or not the button is disabled.
@ -48,7 +57,7 @@ export abstract class Button extends LitElement implements FormSubmitter {
* Where to display the linked `href` URL for a link button. Common options * Where to display the linked `href` URL for a link button. Common options
* include `_blank` to open in a new tab. * include `_blank` to open in a new tab.
*/ */
@property() target: '_blank'|'_parent'|'_self'|'_top'|'' = ''; @property() target: '_blank' | '_parent' | '_self' | '_top' | '' = '';
/** /**
* Whether to render the icon at the inline end of the label rather than the * Whether to render the icon at the inline end of the label rather than the
@ -81,14 +90,14 @@ export abstract class Button extends LitElement implements FormSubmitter {
return this[internals].form; return this[internals].form;
} }
@query('.button') private readonly buttonElement!: HTMLElement|null; @query('.button') private readonly buttonElement!: HTMLElement | null;
@queryAssignedElements({slot: 'icon', flatten: true}) @queryAssignedElements({slot: 'icon', flatten: true})
private readonly assignedIcons!: HTMLElement[]; private readonly assignedIcons!: HTMLElement[];
/** @private */ /** @private */
[internals] = [internals] = (this as HTMLElement) /* needed for closure */
(this as HTMLElement /* needed for closure */).attachInternals(); .attachInternals();
constructor() { constructor() {
super(); super();
@ -138,12 +147,12 @@ export abstract class Button extends LitElement implements FormSubmitter {
private renderContent() { private renderContent() {
// Link buttons may not be disabled // Link buttons may not be disabled
const isDisabled = this.disabled && !this.href; const isDisabled = this.disabled && !this.href;
const icon = const icon = html`<slot
html`<slot name="icon" @slotchange="${this.handleSlotChange}"></slot>`; name="icon"
@slotchange="${this.handleSlotChange}"></slot>`;
return html` return html`
${this.renderElevation?.()} ${this.renderElevation?.()} ${this.renderOutline?.()}
${this.renderOutline?.()}
<md-focus-ring part="focus-ring"></md-focus-ring> <md-focus-ring part="focus-ring"></md-focus-ring>
<md-ripple class="button__ripple" ?disabled="${isDisabled}"></md-ripple> <md-ripple class="button__ripple" ?disabled="${isDisabled}"></md-ripple>
<span class="touch"></span> <span class="touch"></span>
@ -154,7 +163,7 @@ export abstract class Button extends LitElement implements FormSubmitter {
} }
private readonly handleActivationClick = (event: MouseEvent) => { private readonly handleActivationClick = (event: MouseEvent) => {
if (!isActivationClick((event)) || !this.buttonElement) { if (!isActivationClick(event) || !this.buttonElement) {
return; return;
} }
this.focus(); this.focus();

View File

@ -57,4 +57,4 @@ export class CatalogComponentHeaderTitle extends LitElement {
} }
} }
`; `;
} }

View File

@ -17,13 +17,10 @@ import {customElement} from 'lit/decorators.js';
@customElement('catalog-component-header') @customElement('catalog-component-header')
export class CatalogComponentHeader extends LitElement { export class CatalogComponentHeader extends LitElement {
render() { render() {
return html` return html` <div>
<div> <slot class="title" name="title"></slot>
<slot class="title" name="title"></slot> <slot class="image"></slot>
<slot </div>`;
class="image"
></slot>
</div>`;
} }
static styles = css` static styles = css`

View File

@ -4,12 +4,18 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/iconbutton/icon-button.js';
import '@material/web/icon/icon.js'; import '@material/web/icon/icon.js';
import '@material/web/iconbutton/icon-button.js';
import {MdIconButton} from '@material/web/iconbutton/icon-button.js'; import {MdIconButton} from '@material/web/iconbutton/icon-button.js';
import {css, html, LitElement} from 'lit'; import {css, html, LitElement} from 'lit';
import {customElement, property, query, queryAssignedElements, state,} from 'lit/decorators.js'; import {
customElement,
property,
query,
queryAssignedElements,
state,
} from 'lit/decorators.js';
/** /**
* A custom element that places a copy button at the top right corner of a * A custom element that places a copy button at the top right corner of a
@ -37,7 +43,7 @@ export class CopyCodeButton extends LitElement {
} }
`; `;
private timeoutId: number|undefined; private timeoutId: number | undefined;
@state() private showCheckmark = false; @state() private showCheckmark = false;
@ -72,8 +78,7 @@ export class CopyCodeButton extends LitElement {
title=${this.buttonTitle} title=${this.buttonTitle}
.selected=${this.showCheckmark} .selected=${this.showCheckmark}
aria-label=${this.label} aria-label=${this.label}
aria-label-selected=${this.successLabel} aria-label-selected=${this.successLabel}>
>
<md-icon>content_copy</md-icon> <md-icon>content_copy</md-icon>
<md-icon slot="selected">checkmark</md-icon> <md-icon slot="selected">checkmark</md-icon>
</md-icon-button> </md-icon-button>

View File

@ -48,7 +48,7 @@ export class HCTSlider extends LitElement {
/** /**
* The type of HCT slider to display * The type of HCT slider to display
*/ */
@property({type: String}) type: 'hue'|'chroma'|'tone' = 'hue'; @property({type: String}) type: 'hue' | 'chroma' | 'tone' = 'hue';
override render() { override render() {
let range = HUE_RANGE; let range = HUE_RANGE;
@ -62,21 +62,19 @@ export class HCTSlider extends LitElement {
return html`<section> return html`<section>
<span id="label" class="color-on-surface-text">${this.label}</span> <span id="label" class="color-on-surface-text">${this.label}</span>
<md-slider <md-slider
id="source" id="source"
labeled labeled
aria-label=${this.label} aria-label=${this.label}
.min=${range[0]} .min=${range[0]}
.max=${range[1]} .max=${range[1]}
.value=${this.value} .value=${this.value}
@input=${this.onInput} @input=${this.onInput}></md-slider>
></md-slider>
<div <div
id="gradient" id="gradient"
class=${this.type} class=${this.type}
style=${styleMap({ style=${styleMap({
background: this.buildGradient(), background: this.buildGradient(),
})} })}></div>
></div>
</section>`; </section>`;
} }
@ -100,7 +98,7 @@ export class HCTSlider extends LitElement {
if (this.type === 'hue') { if (this.type === 'hue') {
for (let i = 0; i < numStops; i++) { for (let i = 0; i < numStops; i++) {
const hue = HUE_RANGE[1] / numStops * i; const hue = (HUE_RANGE[1] / numStops) * i;
// Set chroma to something fairly saturated + tone in the middle of // Set chroma to something fairly saturated + tone in the middle of
// black and white so it's not too dark or too bright and vary the hue // black and white so it's not too dark or too bright and vary the hue
const hex = hexFromHct(hue, 100, 50); const hex = hexFromHct(hue, 100, 50);
@ -111,7 +109,7 @@ export class HCTSlider extends LitElement {
const hue = hct.hue; const hue = hct.hue;
for (let i = 0; i < numStops; i++) { for (let i = 0; i < numStops; i++) {
const chroma = CHROMA_RANGE[1] / numStops * i; const chroma = (CHROMA_RANGE[1] / numStops) * i;
// Change the color of the bar to the current hue and set the tone to // Change the color of the bar to the current hue and set the tone to
// mid so we it's not too dark or too bright and vary the chroma // mid so we it's not too dark or too bright and vary the chroma
const hex = hexFromHct(hue, chroma, 50); const hex = hexFromHct(hue, chroma, 50);
@ -119,7 +117,7 @@ export class HCTSlider extends LitElement {
} }
} else if (this.type === 'tone') { } else if (this.type === 'tone') {
for (let i = 0; i < numStops; i++) { for (let i = 0; i < numStops; i++) {
const tone = TONE_RANGE[1] / numStops * i; const tone = (TONE_RANGE[1] / numStops) * i;
// Set tone color to black (0 chroma means that hue doesn't matter) and // Set tone color to black (0 chroma means that hue doesn't matter) and
// vary the tone // vary the tone
const hex = hexFromHct(0, 0, tone); const hex = hexFromHct(0, 0, tone);

View File

@ -6,7 +6,7 @@
import {animate, fadeIn, fadeOut} from '@lit-labs/motion'; import {animate, fadeIn, fadeOut} from '@lit-labs/motion';
import {EASING} from '@material/web/internal/motion/animation.js'; import {EASING} from '@material/web/internal/motion/animation.js';
import {css, html, LitElement, nothing, PropertyValues} from 'lit'; import {LitElement, PropertyValues, css, html, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js'; import {customElement, property, state} from 'lit/decorators.js';
import {drawerOpenSignal} from '../signals/drawer-open-state.js'; import {drawerOpenSignal} from '../signals/drawer-open-state.js';
@ -21,8 +21,8 @@ import {SignalElement} from '../signals/signal-element.js';
* widths, and position itself inline with the page at wider page widths. Most * widths, and position itself inline with the page at wider page widths. Most
* importantly, this sidebar is SSR compatible. * importantly, this sidebar is SSR compatible.
*/ */
@customElement('nav-drawer') export class NavDrawer extends SignalElement @customElement('nav-drawer')
(LitElement) { export class NavDrawer extends SignalElement(LitElement) {
/** /**
* Whether or not the side drawer is collapsible or inline. * Whether or not the side drawer is collapsible or inline.
*/ */
@ -45,8 +45,9 @@ import {SignalElement} from '../signals/signal-element.js';
const drawerContentOpacityDuration = showModal ? 300 : 150; const drawerContentOpacityDuration = showModal ? 300 : 150;
const scrimOpacityDuration = 150; const scrimOpacityDuration = 150;
const drawerSlideAnimationEasing = const drawerSlideAnimationEasing = showModal
showModal ? EASING.EMPHASIZED : EASING.EMPHASIZED_ACCELERATE; ? EASING.EMPHASIZED
: EASING.EMPHASIZED_ACCELERATE;
return html` return html`
<div class="root"> <div class="root">
@ -65,8 +66,7 @@ import {SignalElement} from '../signals/signal-element.js';
}, },
in: fadeIn, in: fadeIn,
out: fadeOut, out: fadeOut,
})} })}></div>`
></div>`
: nothing} : nothing}
<aside <aside
?inert=${this.isCollapsible && !drawerOpenSignal.value} ?inert=${this.isCollapsible && !drawerOpenSignal.value}
@ -76,18 +76,16 @@ import {SignalElement} from '../signals/signal-element.js';
duration: drawerSlideAnimationDuration, duration: drawerSlideAnimationDuration,
easing: drawerSlideAnimationEasing, easing: drawerSlideAnimationEasing,
}, },
})} })}>
> <div class="scroll-wrapper">
<div class="scroll-wrapper"> <slot
<slot ${animate({
${animate({ properties: ['opacity'],
properties: ['opacity'], keyframeOptions: {
keyframeOptions: { duration: drawerContentOpacityDuration,
duration: drawerContentOpacityDuration, easing: 'linear',
easing: 'linear', },
}, })}></slot>
})}
></slot>
</div> </div>
</aside> </aside>
</div> </div>
@ -102,8 +100,7 @@ import {SignalElement} from '../signals/signal-element.js';
private renderContent(showModal: boolean) { private renderContent(showModal: boolean) {
return html` <div return html` <div
class="pane content-pane" class="pane content-pane"
?inert=${showModal || inertContentSignal.value} ?inert=${showModal || inertContentSignal.value}>
>
<div class="scroll-wrapper"> <div class="scroll-wrapper">
<div class="content"> <div class="content">
<slot name="app-content"></slot> <slot name="app-content"></slot>
@ -119,8 +116,7 @@ import {SignalElement} from '../signals/signal-element.js';
return html` <div return html` <div
class="pane toc" class="pane toc"
?inert=${showModal || inertContentSignal.value} ?inert=${showModal || inertContentSignal.value}>
>
<div class="scroll-wrapper"> <div class="scroll-wrapper">
<p>On this page:</p> <p>On this page:</p>
<h2>${this.pageTitle}</h2> <h2>${this.pageTitle}</h2>
@ -148,11 +144,16 @@ import {SignalElement} from '../signals/signal-element.js';
updated(changed: PropertyValues<this>) { updated(changed: PropertyValues<this>) {
super.updated(changed); super.updated(changed);
if (this.lastDrawerOpen !== drawerOpenSignal.value && if (
drawerOpenSignal.value && this.isCollapsible) { this.lastDrawerOpen !== drawerOpenSignal.value &&
(this.querySelector('md-list.nav md-list-item[tabindex="0"]') as drawerOpenSignal.value &&
HTMLElement) this.isCollapsible
?.focus(); ) {
(
this.querySelector(
'md-list.nav md-list-item[tabindex="0"]',
) as HTMLElement
)?.focus();
} }
} }

View File

@ -4,12 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/labs/segmentedbuttonset/outlined-segmented-button-set.js';
import '@material/web/labs/segmentedbutton/outlined-segmented-button.js';
import '@material/web/icon/icon.js';
import './hct-slider.js';
import './copy-code-button.js';
import '@material/web/focus/md-focus-ring.js'; import '@material/web/focus/md-focus-ring.js';
import '@material/web/icon/icon.js';
import '@material/web/labs/segmentedbutton/outlined-segmented-button.js';
import '@material/web/labs/segmentedbuttonset/outlined-segmented-button-set.js';
import './copy-code-button.js';
import './hct-slider.js';
import type {MdOutlinedSegmentedButton} from '@material/web/labs/segmentedbutton/outlined-segmented-button.js'; import type {MdOutlinedSegmentedButton} from '@material/web/labs/segmentedbutton/outlined-segmented-button.js';
import {css, html, LitElement} from 'lit'; import {css, html, LitElement} from 'lit';
@ -18,8 +18,12 @@ import {live} from 'lit/directives/live.js';
import {ChangeColorEvent, ChangeDarkModeEvent} from '../types/color-events.js'; import {ChangeColorEvent, ChangeDarkModeEvent} from '../types/color-events.js';
import {hctFromHex, hexFromHct} from '../utils/material-color-helpers.js'; import {hctFromHex, hexFromHct} from '../utils/material-color-helpers.js';
import {getCurrentMode, getCurrentSeedColor, getCurrentThemeString} from '../utils/theme.js';
import type {ColorMode} from '../utils/theme.js'; import type {ColorMode} from '../utils/theme.js';
import {
getCurrentMode,
getCurrentSeedColor,
getCurrentThemeString,
} from '../utils/theme.js';
import type {HCTSlider} from './hct-slider.js'; import type {HCTSlider} from './hct-slider.js';
@ -37,7 +41,7 @@ export class ThemeChanger extends LitElement {
/** /**
* The currently selected color mode. * The currently selected color mode.
*/ */
@state() selectedColorMode: ColorMode|null = null; @state() selectedColorMode: ColorMode | null = null;
/** /**
* The currently selected hex color. * The currently selected hex color.
@ -68,17 +72,14 @@ export class ThemeChanger extends LitElement {
render() { render() {
return html` return html`
<div id="head-wrapper"> <div id="head-wrapper">
<h2> <h2> Theme Controls </h2>
Theme Controls
</h2>
<copy-code-button <copy-code-button
button-title="Copy current theme to clipboard" button-title="Copy current theme to clipboard"
label="Copy current theme" label="Copy current theme"
.getCopyText=${getCurrentThemeString}> .getCopyText=${getCurrentThemeString}>
</copy-code-button> </copy-code-button>
</div> </div>
${this.renderHexPicker()} ${this.renderHexPicker()} ${this.renderHctPicker()}
${this.renderHctPicker()}
${this.renderColorModePicker()} ${this.renderColorModePicker()}
`; `;
} }
@ -96,8 +97,7 @@ export class ThemeChanger extends LitElement {
id="color-input" id="color-input"
@input=${this.onHexPickerInput} @input=${this.onHexPickerInput}
type="color" type="color"
.value=${live(this.hexColor)} .value=${live(this.hexColor)} />
/>
</div> </div>
<md-focus-ring for="color-input"></md-focus-ring> <md-focus-ring for="color-input"></md-focus-ring>
</span> </span>
@ -111,27 +111,24 @@ export class ThemeChanger extends LitElement {
private renderHctPicker() { private renderHctPicker() {
return html`<div class="sliders"> return html`<div class="sliders">
<hct-slider <hct-slider
.value=${live(this.hue)} .value=${live(this.hue)}
type="hue" type="hue"
label="Hue" label="Hue"
max="360" max="360"
@input=${this.onSliderInput} @input=${this.onSliderInput}></hct-slider>
></hct-slider>
<hct-slider <hct-slider
.value=${live(this.chroma)} .value=${live(this.chroma)}
.color=${this.hexColor} .color=${this.hexColor}
type="chroma" type="chroma"
label="Chroma" label="Chroma"
max="150" max="150"
@input=${this.onSliderInput} @input=${this.onSliderInput}></hct-slider>
></hct-slider>
<hct-slider <hct-slider
.value=${live(this.tone)} .value=${live(this.tone)}
type="tone" type="tone"
label="Tone" label="Tone"
max="100" max="100"
@input=${this.onSliderInput} @input=${this.onSliderInput}></hct-slider>
></hct-slider>
</div>`; </div>`;
} }
@ -140,9 +137,8 @@ export class ThemeChanger extends LitElement {
*/ */
private renderColorModePicker() { private renderColorModePicker() {
return html`<md-outlined-segmented-button-set return html`<md-outlined-segmented-button-set
@segmented-button-set-selection=${this.onColorModeSelection} @segmented-button-set-selection=${this.onColorModeSelection}
aria-label="Color mode" aria-label="Color mode">
>
${this.renderModeButton('dark', 'dark_mode')} ${this.renderModeButton('dark', 'dark_mode')}
${this.renderModeButton('auto', 'brightness_medium')} ${this.renderModeButton('auto', 'brightness_medium')}
${this.renderModeButton('light', 'light_mode')} ${this.renderModeButton('light', 'light_mode')}
@ -161,8 +157,7 @@ export class ThemeChanger extends LitElement {
data-value=${mode} data-value=${mode}
title=${mode} title=${mode}
aria-label="${mode} color scheme" aria-label="${mode} color scheme"
.selected=${this.selectedColorMode === mode} .selected=${this.selectedColorMode === mode}>
>
<md-icon slot="icon">${icon}</md-icon> <md-icon slot="icon">${icon}</md-icon>
</md-outlined-segmented-button>`; </md-outlined-segmented-button>`;
} }
@ -208,9 +203,13 @@ export class ThemeChanger extends LitElement {
this.updateHctFromHex(this.hexColor); this.updateHctFromHex(this.hexColor);
} }
private onColorModeSelection(e: CustomEvent<{ private onColorModeSelection(
button: MdOutlinedSegmentedButton; selected: boolean; index: number; e: CustomEvent<{
}>) { button: MdOutlinedSegmentedButton;
selected: boolean;
index: number;
}>,
) {
const {button} = e.detail; const {button} = e.detail;
const value = button.dataset.value as ColorMode; const value = button.dataset.value as ColorMode;
this.selectedColorMode = value; this.selectedColorMode = value;

View File

@ -5,8 +5,8 @@
*/ */
import '@material/web/focus/md-focus-ring.js'; import '@material/web/focus/md-focus-ring.js';
import '@material/web/iconbutton/icon-button.js';
import '@material/web/icon/icon.js'; import '@material/web/icon/icon.js';
import '@material/web/iconbutton/icon-button.js';
import type {MdIconButton} from '@material/web/iconbutton/icon-button.js'; import type {MdIconButton} from '@material/web/iconbutton/icon-button.js';
import {css, html, LitElement} from 'lit'; import {css, html, LitElement} from 'lit';
@ -21,8 +21,8 @@ import {materialDesign} from '../svg/material-design-logo.js';
/** /**
* Top app bar of the catalog. * Top app bar of the catalog.
*/ */
@customElement('top-app-bar') export class TopAppBar extends SignalElement @customElement('top-app-bar')
(LitElement) { export class TopAppBar extends SignalElement(LitElement) {
/** /**
* Whether or not the color picker menu is open. * Whether or not the color picker menu is open.
*/ */
@ -40,11 +40,11 @@ import {materialDesign} from '../svg/material-design-logo.js';
aria-label-selected="open navigation menu" aria-label-selected="open navigation menu"
aria-label="close navigation menu" aria-label="close navigation menu"
aria-expanded=${drawerOpenSignal.value ? 'false' : 'true'} aria-expanded=${drawerOpenSignal.value ? 'false' : 'true'}
title="${ title="${!drawerOpenSignal.value
!drawerOpenSignal.value ? 'Open' : 'Close'} navigation menu" ? 'Open'
: 'Close'} navigation menu"
.selected=${live(!drawerOpenSignal.value)} .selected=${live(!drawerOpenSignal.value)}
@input=${this.onMenuIconToggle} @input=${this.onMenuIconToggle}>
>
<md-icon slot="selected">menu</md-icon> <md-icon slot="selected">menu</md-icon>
<md-icon>menu_open</md-icon> <md-icon>menu_open</md-icon>
</md-icon-button> </md-icon-button>
@ -52,8 +52,7 @@ import {materialDesign} from '../svg/material-design-logo.js';
href="/" href="/"
class="home-button" class="home-button"
title="Home" title="Home"
aria-label="Home" aria-label="Home">
>
${materialDesign} ${materialDesign}
</md-icon-button> </md-icon-button>
</section> </section>
@ -71,16 +70,14 @@ import {materialDesign} from '../svg/material-design-logo.js';
<lit-island <lit-island
on:interaction="pointerenter,focusin,pointerdown" on:interaction="pointerenter,focusin,pointerdown"
import="/js/hydration-entrypoints/menu.js" import="/js/hydration-entrypoints/menu.js"
id="menu-island" id="menu-island">
>
<md-icon-button <md-icon-button
id="theme-button" id="theme-button"
@click="${this.onPaletteClick}" @click="${this.onPaletteClick}"
title="Page theme controls" title="Page theme controls"
aria-label="Page theme controls" aria-label="Page theme controls"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded=${this.menuOpen ? 'true' : 'false'} aria-expanded=${this.menuOpen ? 'true' : 'false'}>
>
<md-icon>palette</md-icon> <md-icon>palette</md-icon>
</md-icon-button> </md-icon-button>
<md-menu <md-menu
@ -93,8 +90,7 @@ import {materialDesign} from '../svg/material-design-logo.js';
.open=${this.menuOpen} .open=${this.menuOpen}
@opened=${this.onMenuOpened} @opened=${this.onMenuOpened}
@closed=${this.onMenuClosed} @closed=${this.onMenuClosed}
@keydown=${this.onKeydown} @keydown=${this.onKeydown}>
>
<theme-changer></theme-changer> <theme-changer></theme-changer>
</md-menu> </md-menu>
</lit-island> </lit-island>

View File

@ -8,4 +8,4 @@ import '@material/web/button/elevated-button.js';
import '@material/web/button/filled-button.js'; import '@material/web/button/filled-button.js';
import '@material/web/button/filled-tonal-button.js'; import '@material/web/button/filled-tonal-button.js';
import '@material/web/button/outlined-button.js'; import '@material/web/button/outlined-button.js';
import '@material/web/button/text-button.js'; import '@material/web/button/text-button.js';

View File

@ -4,4 +4,4 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/checkbox/checkbox.js'; import '@material/web/checkbox/checkbox.js';

View File

@ -4,5 +4,5 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/fab/branded-fab.js';
import '@material/web/fab/fab.js'; import '@material/web/fab/fab.js';
import '@material/web/fab/branded-fab.js';

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/iconbutton/icon-button.js';
import '@material/web/iconbutton/filled-icon-button.js'; import '@material/web/iconbutton/filled-icon-button.js';
import '@material/web/iconbutton/filled-tonal-icon-button.js'; import '@material/web/iconbutton/filled-tonal-icon-button.js';
import '@material/web/iconbutton/icon-button.js';
import '@material/web/iconbutton/outlined-icon-button.js'; import '@material/web/iconbutton/outlined-icon-button.js';

View File

@ -7,4 +7,4 @@
import '@material/web/button/filled-button.js'; import '@material/web/button/filled-button.js';
import '@material/web/menu/menu.js'; import '@material/web/menu/menu.js';
import '@material/web/menu/menu-item.js'; import '@material/web/menu/menu-item.js';
import '@material/web/menu/sub-menu.js'; import '@material/web/menu/sub-menu.js';

View File

@ -4,6 +4,6 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/select/select-option.js';
import '@material/web/select/outlined-select.js';
import '@material/web/select/filled-select.js'; import '@material/web/select/filled-select.js';
import '@material/web/select/outlined-select.js';
import '@material/web/select/select-option.js';

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/textfield/filled-text-field.js';
import '@material/web/textfield/outlined-text-field.js';
import '@material/web/icon/icon.js'; import '@material/web/icon/icon.js';
import '@material/web/iconbutton/icon-button.js'; import '@material/web/iconbutton/icon-button.js';
import '@material/web/textfield/filled-text-field.js';
import '@material/web/textfield/outlined-text-field.js';

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/list/list.js';
import '@material/web/list/list-item.js';
import '../components/nav-drawer.js'; import '../components/nav-drawer.js';
import '../components/top-app-bar.js'; import '../components/top-app-bar.js';
import '@material/web/list/list-item.js';
import '@material/web/list/list.js';

View File

@ -4,6 +4,6 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import 'playground-elements/playground-project.js'; import 'playground-elements/playground-file-editor.js';
import 'playground-elements/playground-preview.js'; import 'playground-elements/playground-preview.js';
import 'playground-elements/playground-file-editor.js'; import 'playground-elements/playground-project.js';

View File

@ -20,7 +20,9 @@ import {getCurrentThemeString} from '../utils/theme.js';
* @param previewEl An element reference to the playground preview element. * @param previewEl An element reference to the playground preview element.
*/ */
async function updateMessageTargetOnIframeLoad( async function updateMessageTargetOnIframeLoad(
postdoc: PostDoc, previewEl: PlaygroundPreview) { postdoc: PostDoc,
previewEl: PlaygroundPreview,
) {
await previewEl.updateComplete; await previewEl.updateComplete;
const iframe = previewEl.iframe!; const iframe = previewEl.iframe!;
@ -76,9 +78,9 @@ function demoDropdown() {
// tslint:disable:no-unnecessary-type-assertion TSC externally seems to differ // tslint:disable:no-unnecessary-type-assertion TSC externally seems to differ
// from internal here and needs these type assertions // from internal here and needs these type assertions
const expandButton = const expandButton = detailsEl?.querySelector(
detailsEl?.querySelector('summary md-outlined-icon-button') as 'summary md-outlined-icon-button',
MdOutlinedIconButton; ) as MdOutlinedIconButton;
// tslint:enable:no-unnecessary-type-assertion // tslint:enable:no-unnecessary-type-assertion
// Synchronize details open state with toggle button // Synchronize details open state with toggle button

View File

@ -12,7 +12,16 @@
* and global scroll listeners. * and global scroll listeners.
*/ */
import {changeColor, changeColorAndMode, changeColorMode, getCurrentMode, getCurrentSeedColor, getCurrentThemeString, getLastSavedAutoColorMode, isModeDark} from '../utils/theme.js'; import {
changeColor,
changeColorAndMode,
changeColorMode,
getCurrentMode,
getCurrentSeedColor,
getCurrentThemeString,
getLastSavedAutoColorMode,
isModeDark,
} from '../utils/theme.js';
/** /**
* Applies theme-based event listeners such as changing color, mode, and * Applies theme-based event listeners such as changing color, mode, and
@ -29,14 +38,15 @@ function applyColorThemeListeners() {
// Listen for system color change and applies the new theme if the current // Listen for system color change and applies the new theme if the current
// color mode is 'auto'. // color mode is 'auto'.
window.matchMedia('(prefers-color-scheme: dark)') window
.addEventListener('change', () => { .matchMedia('(prefers-color-scheme: dark)')
if (getCurrentMode() !== 'auto') { .addEventListener('change', () => {
return; if (getCurrentMode() !== 'auto') {
} return;
}
changeColor(getCurrentSeedColor()!); changeColor(getCurrentSeedColor()!);
}); });
} }
/** /**

View File

@ -9,4 +9,4 @@ import {signal} from './signal-element.js';
/** /**
* Whether or not the sidebar drawer should be open. * Whether or not the sidebar drawer should be open.
*/ */
export const drawerOpenSignal = signal(false); export const drawerOpenSignal = signal(false);

View File

@ -15,4 +15,4 @@ export const inertContentSignal = signal(false);
/** /**
* Whether or not the sidebar should be inert. * Whether or not the sidebar should be inert.
*/ */
export const inertSidebarSignal = signal(false); export const inertSidebarSignal = signal(false);

View File

@ -18,8 +18,9 @@ type ReactiveElementConstructor = new (...args: any[]) => ReactiveElement;
* *
* @param Base The class to mix-in and listen to Preact signal changes. * @param Base The class to mix-in and listen to Preact signal changes.
*/ */
export function SignalElement<T extends ReactiveElementConstructor>(Base: T): export function SignalElement<T extends ReactiveElementConstructor>(
T { Base: T,
): T {
return class SignalElement extends Base { return class SignalElement extends Base {
private _disposeEffect?: () => void; private _disposeEffect?: () => void;
@ -39,4 +40,4 @@ export function SignalElement<T extends ReactiveElementConstructor>(Base: T):
}); });
} }
}; };
} }

View File

@ -10,4 +10,4 @@ import {hydrateShadowRoots} from '@webcomponents/template-shadowroot/template-sh
if (!HTMLTemplateElement.prototype.hasOwnProperty('shadowRoot')) { if (!HTMLTemplateElement.prototype.hasOwnProperty('shadowRoot')) {
hydrateShadowRoots(document.body); hydrateShadowRoots(document.body);
} }
document.body.removeAttribute('dsd-pending'); document.body.removeAttribute('dsd-pending');

View File

@ -6,4 +6,4 @@
// Must be loaded before any lit element see /site/_includes/default.html for // Must be loaded before any lit element see /site/_includes/default.html for
// usage. Allows hydrating SSRd lit elements. // usage. Allows hydrating SSRd lit elements.
import '@lit-labs/ssr-client/lit-element-hydrate-support.js'; import '@lit-labs/ssr-client/lit-element-hydrate-support.js';

View File

@ -6,7 +6,10 @@
import {Island} from '@11ty/is-land'; import {Island} from '@11ty/is-land';
customElements.define('lit-island', class extends Island { customElements.define(
// Removes the feature in which 11ty island removes DOM to render a fallback. 'lit-island',
override forceFallback() {} class extends Island {
}); // Removes the feature in which 11ty island removes DOM to render a fallback.
override forceFallback() {}
},
);

View File

@ -6,11 +6,11 @@
// This file imports only files that will be SSRd e.g. if you can't SSR a // This file imports only files that will be SSRd e.g. if you can't SSR a
// component, don't import it here. // component, don't import it here.
import '@material/web/all.js';
import './components/catalog-component-header.js'; import './components/catalog-component-header.js';
import './components/catalog-component-header-title.js'; import './components/catalog-component-header-title.js';
import './components/top-app-bar.js';
import './components/nav-drawer.js'; import './components/nav-drawer.js';
import './components/theme-changer.js'; import './components/theme-changer.js';
import '@material/web/all.js'; import './components/top-app-bar.js';
// 🤫 // 🤫
import '@material/web/labs/item/item.js'; import '@material/web/labs/item/item.js';

View File

@ -11,8 +11,7 @@ import {html} from 'lit';
* *
* Source: Internal google symbols search. * Source: Internal google symbols search.
*/ */
export const materialDesign = html` export const materialDesign = html` <svg
<svg
viewBox="0 96 960 960" viewBox="0 96 960 960"
fill="currentColor"> fill="currentColor">
<path <path

View File

@ -32,7 +32,7 @@ export class ChangeDarkModeEvent extends Event {
/** /**
* @param mode The new color mode to apply. * @param mode The new color mode to apply.
*/ */
constructor(public mode: 'light'|'dark'|'auto') { constructor(public mode: 'light' | 'dark' | 'auto') {
super('change-mode', {bubbles: true, composed: true}); super('change-mode', {bubbles: true, composed: true});
} }
} }

View File

@ -8,4 +8,4 @@ declare module '@11ty/is-land' {
export class Island extends HTMLElement { export class Island extends HTMLElement {
forceFallback(): void; forceFallback(): void;
} }
} }

View File

@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
type WithStylesheet = type WithStylesheet = typeof globalThis & {
typeof globalThis&{[stylesheetName: string]: CSSStyleSheet | undefined}; [stylesheetName: string]: CSSStyleSheet | undefined;
};
/** /**
* Applies a stringified CSS theme to a document or shadowroot by creating or * Applies a stringified CSS theme to a document or shadowroot by creating or
@ -23,7 +24,10 @@ type WithStylesheet =
* used to generate the localstorage name. * used to generate the localstorage name.
*/ */
export function applyThemeString( export function applyThemeString(
doc: DocumentOrShadowRoot, themeString: string, ssName = 'material-theme') { doc: DocumentOrShadowRoot,
themeString: string,
ssName = 'material-theme',
) {
// Get constructable stylesheet // Get constructable stylesheet
let sheet = (globalThis as WithStylesheet)[ssName]; let sheet = (globalThis as WithStylesheet)[ssName];
// Create a new sheet if it doesn't exist already and save it globally. // Create a new sheet if it doesn't exist already and save it globally.
@ -34,11 +38,13 @@ export function applyThemeString(
} }
// Set the color of the URL bar because we are cool like that. // Set the color of the URL bar because we are cool like that.
const surfaceContainer = const surfaceContainer = themeString.match(
themeString.match(/--md-sys-color-surface-container:(.+?);/)?.[1]; /--md-sys-color-surface-container:(.+?);/,
)?.[1];
if (surfaceContainer) { if (surfaceContainer) {
document.querySelector('meta[name="theme-color"]') document
?.setAttribute('content', surfaceContainer); .querySelector('meta[name="theme-color"]')
?.setAttribute('content', surfaceContainer);
} }
sheet.replaceSync(themeString); sheet.replaceSync(themeString);

View File

@ -4,7 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import {argbFromHex, Hct, hexFromArgb, MaterialDynamicColors, SchemeContent} from '@material/material-color-utilities'; import {
argbFromHex,
Hct,
hexFromArgb,
MaterialDynamicColors,
SchemeContent,
} from '@material/material-color-utilities';
import type {Theme} from '../types/color-events.js'; import type {Theme} from '../types/color-events.js';
@ -110,7 +116,10 @@ export function themeFromSourceColor(color: string, isDark: boolean): Theme {
* used to generate the localstorage name. * used to generate the localstorage name.
*/ */
export function applyMaterialTheme( export function applyMaterialTheme(
doc: DocumentOrShadowRoot, theme: Theme, ssName = 'material-theme') { doc: DocumentOrShadowRoot,
theme: Theme,
ssName = 'material-theme',
) {
let styleString = ':root,:host{'; let styleString = ':root,:host{';
for (const [key, value] of Object.entries(theme)) { for (const [key, value] of Object.entries(theme)) {
styleString += `--md-sys-color-${key}:${value};`; styleString += `--md-sys-color-${key}:${value};`;

View File

@ -4,9 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import {applyMaterialTheme, themeFromSourceColor} from './material-color-helpers.js'; import {
applyMaterialTheme,
themeFromSourceColor,
} from './material-color-helpers.js';
export type ColorMode = 'light'|'dark'|'auto'; /** Color mode, either overriding light/dark or the user's preference. */
export type ColorMode = 'light' | 'dark' | 'auto';
/** /**
* Generates a Material Theme from a given color and dark mode boolean, and * Generates a Material Theme from a given color and dark mode boolean, and
@ -56,7 +60,7 @@ export function isModeDark(mode: ColorMode, saveAutoMode = true) {
* *
* @return The current stringified material theme css string. * @return The current stringified material theme css string.
*/ */
export function getCurrentThemeString(): string|null { export function getCurrentThemeString(): string | null {
return localStorage.getItem('material-theme'); return localStorage.getItem('material-theme');
} }
@ -65,7 +69,7 @@ export function getCurrentThemeString(): string|null {
* *
* @return The current color mode. * @return The current color mode.
*/ */
export function getCurrentMode(): ColorMode|null { export function getCurrentMode(): ColorMode | null {
return localStorage.getItem('color-mode') as ColorMode | null; return localStorage.getItem('color-mode') as ColorMode | null;
} }
@ -83,7 +87,7 @@ export function saveColorMode(mode: ColorMode) {
* *
* @return The current seed color. * @return The current seed color.
*/ */
export function getCurrentSeedColor(): string|null { export function getCurrentSeedColor(): string | null {
return localStorage.getItem('seed-color'); return localStorage.getItem('seed-color');
} }
@ -102,8 +106,10 @@ export function saveSeedColor(color: string) {
* @return The last applied color mode while in "auto". * @return The last applied color mode while in "auto".
*/ */
export function getLastSavedAutoColorMode() { export function getLastSavedAutoColorMode() {
return localStorage.getItem('last-auto-color-mode') as | 'light' | 'dark' | return localStorage.getItem('last-auto-color-mode') as
null; | 'light'
| 'dark'
| null;
} }
/** /**
@ -112,7 +118,7 @@ export function getLastSavedAutoColorMode() {
* @param mode The last applied color mode while in "auto" to be saved to local * @param mode The last applied color mode while in "auto" to be saved to local
* storage. * storage.
*/ */
export function saveLastSavedAutoColorMode(mode: 'light'|'dark') { export function saveLastSavedAutoColorMode(mode: 'light' | 'dark') {
localStorage.setItem('last-auto-color-mode', mode); localStorage.setItem('last-auto-color-mode', mode);
} }

View File

@ -8,10 +8,10 @@
import '@material/web/button/outlined-button.js'; import '@material/web/button/outlined-button.js';
import '@material/web/checkbox/checkbox.js'; import '@material/web/checkbox/checkbox.js';
import '@material/web/radio/radio.js'; import '@material/web/radio/radio.js';
import '@material/web/ripple/ripple.js';
import '@material/web/select/filled-select.js'; import '@material/web/select/filled-select.js';
import '@material/web/select/select-option.js'; import '@material/web/select/select-option.js';
import '@material/web/textfield/filled-text-field.js'; import '@material/web/textfield/filled-text-field.js';
import '@material/web/ripple/ripple.js';
import {css, html, LitElement} from 'lit'; import {css, html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js'; import {customElement, property} from 'lit/decorators.js';
@ -36,8 +36,7 @@ export function boolInput(): KnobUi<boolean> {
touch-target="none" touch-target="none"
style="margin-inline-end: 16px;" style="margin-inline-end: 16px;"
.checked=${!!knob.latestValue} .checked=${!!knob.latestValue}
@change="${valueChanged}" @change="${valueChanged}">
>
</md-checkbox> </md-checkbox>
${knob.name} ${knob.name}
</label> </label>
@ -70,11 +69,17 @@ export class KnobColorSelector extends LitElement {
box-sizing: content-box; box-sizing: content-box;
width: var(--_color-picker-size); width: var(--_color-picker-size);
height: var(--_color-picker-size); height: var(--_color-picker-size);
margin: calc((var(--_component-size) - var(--_color-picker-size) - var(--_color-picker-border-width) * 2) / 2); margin: calc(
(
var(--_component-size) - var(--_color-picker-size) -
var(--_color-picker-border-width) * 2
) / 2
);
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
border-radius: var(--_color-picker-border-width); border-radius: var(--_color-picker-border-width);
border: var(--_color-picker-border-width) solid var(--md-sys-color-outline); border: var(--_color-picker-border-width) solid
var(--md-sys-color-outline);
outline: none; outline: none;
} }
@ -126,9 +131,8 @@ export class KnobColorSelector extends LitElement {
</span> </span>
<md-outlined-button <md-outlined-button
@click=${() => { @click=${() => {
this.hasAlpha = !this.hasAlpha; this.hasAlpha = !this.hasAlpha;
}} }}>
>
${this.hasAlpha ? 'rgba' : 'rgb'} ${this.hasAlpha ? 'rgba' : 'rgb'}
</md-outlined-button> </md-outlined-button>
</span>`; </span>`;
@ -139,8 +143,7 @@ export class KnobColorSelector extends LitElement {
style=${styleMap(sharedTextFieldStyles)} style=${styleMap(sharedTextFieldStyles)}
.value=${this.value} .value=${this.value}
@change=${this.propagateEvt} @change=${this.propagateEvt}
@input=${this.onInput} @input=${this.onInput}></md-filled-text-field>`;
></md-filled-text-field>`;
} }
private renderColorInput() { private renderColorInput() {
@ -155,8 +158,7 @@ export class KnobColorSelector extends LitElement {
id="color-picker" id="color-picker"
.value=${this.value} .value=${this.value}
@change=${this.propagateEvt} @change=${this.propagateEvt}
@input=${this.onInput} @input=${this.onInput} />
/>
`; `;
} }
@ -176,14 +178,16 @@ export class KnobColorSelector extends LitElement {
override click() { override click() {
const input = this.renderRoot!.querySelector( const input = this.renderRoot!.querySelector(
'input,md-filled-text-field') as HTMLElement; 'input,md-filled-text-field',
) as HTMLElement;
input.click(); input.click();
input.focus(); input.focus();
} }
override focus() { override focus() {
const input = this.renderRoot!.querySelector( const input = this.renderRoot!.querySelector(
'input,md-filled-text-field') as HTMLElement; 'input,md-filled-text-field',
) as HTMLElement;
input.focus(); input.focus();
} }
} }
@ -222,8 +226,7 @@ export function colorPicker(opts?: ColorPickerOpts): KnobUi<string> {
<knob-color-selector <knob-color-selector
.value="${knob.latestValue ?? ''}" .value="${knob.latestValue ?? ''}"
.hasAlpha="${config.hasAlpha}" .hasAlpha="${config.hasAlpha}"
@input=${valueChanged} @input=${valueChanged}></knob-color-selector>
></knob-color-selector>
${knob.name} ${knob.name}
</label> </label>
</div> </div>
@ -269,8 +272,7 @@ export function textInput<T>(options?: TextInputOptions<T>): KnobUi<T> {
<md-filled-text-field <md-filled-text-field
style=${styleMap(sharedTextFieldStyles)} style=${styleMap(sharedTextFieldStyles)}
.value="${(knob.latestValue ?? '') as unknown as string}" .value="${(knob.latestValue ?? '') as unknown as string}"
@input="${valueChanged}" @input="${valueChanged}"></md-filled-text-field>
></md-filled-text-field>
${knob.name} ${knob.name}
</label> </label>
</div> </div>
@ -311,8 +313,7 @@ export function numberInput(opts?: NumberInputOpts): KnobUi<number> {
type="number" type="number"
step="${config.step}" step="${config.step}"
.value="${knob.latestValue ? knob.latestValue.toString() : '0'}" .value="${knob.latestValue ? knob.latestValue.toString() : '0'}"
@input="${valueChanged}" @input="${valueChanged}"></md-filled-text-field>
></md-filled-text-field>
${knob.name} ${knob.name}
</label> </label>
</div> </div>
@ -343,7 +344,7 @@ export function button(): KnobUi<number> {
} }
interface RadioSelectorConfig<T extends string> { interface RadioSelectorConfig<T extends string> {
readonly options: ReadonlyArray<{readonly value: T; readonly label: string;}>; readonly options: ReadonlyArray<{readonly value: T; readonly label: string}>;
readonly name: string; readonly name: string;
} }
@ -364,8 +365,7 @@ export function radioSelector<T extends string>({
name="${name}" name="${name}"
value="${value}" value="${value}"
@change="${valueChanged}" @change="${valueChanged}"
?checked="${knob.latestValue === option.value}" ?checked="${knob.latestValue === option.value}"></md-radio>
></md-radio>
${option.label} ${option.label}
</label>`; </label>`;
}); });
@ -375,15 +375,15 @@ export function radioSelector<T extends string>({
} }
interface SelectDropdownConfig<T extends string> { interface SelectDropdownConfig<T extends string> {
readonly options: ReadonlyArray<{readonly value: T; readonly label: string;}>; readonly options: ReadonlyArray<{readonly value: T; readonly label: string}>;
} }
/** A select dropdown Knob UI. */ /** A select dropdown Knob UI. */
export function selectDropdown<T extends string>({ export function selectDropdown<T extends string>({
options, options,
}: SelectDropdownConfig<T>): KnobUi<T|undefined> { }: SelectDropdownConfig<T>): KnobUi<T | undefined> {
return { return {
render(knob: Knob<T|undefined>, onChange: (val: T) => void) { render(knob: Knob<T | undefined>, onChange: (val: T) => void) {
const valueChanged = (e: Event) => { const valueChanged = (e: Event) => {
onChange((e.target as HTMLInputElement).value as T); onChange((e.target as HTMLInputElement).value as T);
}; };
@ -391,15 +391,14 @@ export function selectDropdown<T extends string>({
return html`<md-select-option return html`<md-select-option
?selected="${knob.latestValue === option.value}" ?selected="${knob.latestValue === option.value}"
.value="${option.value}" .value="${option.value}"
.headline=${option.label} .headline=${option.label}></md-select-option>`;
></md-select-option>`;
}); });
return html` return html`
<label> <label>
<md-filled-select <md-filled-select
@change="${valueChanged}" @change="${valueChanged}"
menu-positioning="fixed" menu-positioning="fixed"
style=${styleMap(sharedTextFieldStyles)}> style=${styleMap(sharedTextFieldStyles)}>
${listItems} ${listItems}
</md-filled-select> </md-filled-select>
${knob.name} ${knob.name}
@ -414,7 +413,10 @@ export function selectDropdown<T extends string>({
* to the updated value. * to the updated value.
*/ */
export function cssCustomProperty( export function cssCustomProperty(
knob: Knob<string>, val: string, containerOfRenderedStory: HTMLElement) { knob: Knob<string>,
val: string,
containerOfRenderedStory: HTMLElement,
) {
const value = knob.isUnset ? knob.defaultValue : val; const value = knob.isUnset ? knob.defaultValue : val;
if (value) { if (value) {
containerOfRenderedStory.style.setProperty(knob.name, value); containerOfRenderedStory.style.setProperty(knob.name, value);

View File

@ -66,10 +66,10 @@ export class StoriesRenderer extends LitElement {
@property({type: Boolean}) hideLabels = false; @property({type: Boolean}) hideLabels = false;
@property({type: Boolean, reflect: true}) hasKnobs = false; @property({type: Boolean, reflect: true}) hasKnobs = false;
@state() knobsOpen = true; @state() knobsOpen = true;
@state() knobsPanelType: 'modal'|'inline' = 'inline'; @state() knobsPanelType: 'modal' | 'inline' = 'inline';
private observedKnobs: undefined|KnobValues<PolymorphicArrayOfKnobs> = private observedKnobs: undefined | KnobValues<PolymorphicArrayOfKnobs> =
undefined; undefined;
override render() { override render() {
const collection = this.collection; const collection = this.collection;
@ -90,11 +90,12 @@ export class StoriesRenderer extends LitElement {
private renderStories(stories: Story[]): TemplateResult[] { private renderStories(stories: Story[]): TemplateResult[] {
return stories.map((story) => { return stories.map((story) => {
let label: string|TemplateResult = ''; let label: string | TemplateResult = '';
if (!this.hideLabels) { if (!this.hideLabels) {
const description = const description = story.description
story.description ? html`<small>${story.description}</small>` : ''; ? html`<small>${story.description}</small>`
: '';
label = html` label = html`
<h3 class="m-headline5">${story.name}</h3> <h3 class="m-headline5">${story.name}</h3>
${description} ${description}
@ -113,7 +114,7 @@ export class StoriesRenderer extends LitElement {
private renderKnobs(collection: Collection) { private renderKnobs(collection: Collection) {
const knobs = collection.knobs; const knobs = collection.knobs;
let knobsSection: string|TemplateResult = ''; let knobsSection: string | TemplateResult = '';
this.hasKnobs = !this.hideKnobs && !knobs.empty; this.hasKnobs = !this.hideKnobs && !knobs.empty;
@ -126,8 +127,7 @@ export class StoriesRenderer extends LitElement {
<story-knob-panel <story-knob-panel
.open=${this.knobsOpen} .open=${this.knobsOpen}
.type=${this.knobsPanelType} .type=${this.knobsPanelType}
@open-changed=${onOpenChanged} @open-changed=${onOpenChanged}>
>
${knobs.renderUI()} ${knobs.renderUI()}
</story-knob-panel> </story-knob-panel>
`; `;
@ -163,7 +163,7 @@ export class StoriesRenderer extends LitElement {
private updateObservedKnobs() { private updateObservedKnobs() {
if (this.collection?.knobs === this.observedKnobs) { if (this.collection?.knobs === this.observedKnobs) {
return; // nothing to do; return; // nothing to do;
} }
// Stop watching the knobs that we're currently observing. // Stop watching the knobs that we're currently observing.
this.unobserveKnobs(); this.unobserveKnobs();
@ -183,8 +183,9 @@ export class StoriesRenderer extends LitElement {
for (const story of this.focusStories) { for (const story of this.focusStories) {
if (!allowedStories.has(story)) { if (!allowedStories.has(story)) {
console.error( console.error(
`A stories renderer can only render stories ` + `A stories renderer can only render stories ` +
`from its collection.`); `from its collection.`,
);
} else { } else {
storiesToRender.push(story); storiesToRender.push(story);
} }
@ -199,7 +200,9 @@ export class StoriesRenderer extends LitElement {
private unobserveKnobs() { private unobserveKnobs() {
if (this.observedKnobs !== undefined) { if (this.observedKnobs !== undefined) {
this.observedKnobs.removeEventListener( this.observedKnobs.removeEventListener(
'changed', this.boundRequestUpdate); 'changed',
this.boundRequestUpdate,
);
} }
} }

View File

@ -6,9 +6,9 @@
/* Fork of Lit stories story-knob-renderer with m3 components and theming */ /* Fork of Lit stories story-knob-renderer with m3 components and theming */
import '@material/web/iconbutton/icon-button.js';
import '@material/web/icon/icon.js';
import '@material/web/elevation/elevation.js'; import '@material/web/elevation/elevation.js';
import '@material/web/icon/icon.js';
import '@material/web/iconbutton/icon-button.js';
import {css, html, LitElement, PropertyValues} from 'lit'; import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query} from 'lit/decorators.js'; import {customElement, property, query} from 'lit/decorators.js';
@ -29,13 +29,13 @@ export const DEFAULT_DIMENSIONS = {
*/ */
@customElement('story-knob-panel') @customElement('story-knob-panel')
export class StoryKnobPanel extends LitElement { export class StoryKnobPanel extends LitElement {
@query('.dragBar') dragBar!: HTMLElement|null; @query('.dragBar') dragBar!: HTMLElement | null;
@property({type: Boolean}) showCloseIcon = true; @property({type: Boolean}) showCloseIcon = true;
@property({type: Boolean, reflect: true}) open = false; @property({type: Boolean, reflect: true}) open = false;
@property({type: Boolean, reflect: true}) override draggable = false; @property({type: Boolean, reflect: true}) override draggable = false;
@property({type: Boolean}) hideDragIcon = false; @property({type: Boolean}) hideDragIcon = false;
@property({type: String, reflect: true}) type: 'modal'|'inline' = 'inline'; @property({type: String, reflect: true}) type: 'modal' | 'inline' = 'inline';
private isDragging = false; private isDragging = false;
private previousX = 0; private previousX = 0;
private currentX = 0; private currentX = 0;
@ -84,8 +84,7 @@ export class StoryKnobPanel extends LitElement {
class="dragBar" class="dragBar"
@pointerdown=${this.onDragStart} @pointerdown=${this.onDragStart}
@pointermove=${this.onDrag} @pointermove=${this.onDrag}
@pointerup=${this.onDragEnd} @pointerup=${this.onDragEnd}>
>
<md-icon>drag_handle</md-icon> <md-icon>drag_handle</md-icon>
</div> </div>
`; `;
@ -99,8 +98,7 @@ export class StoryKnobPanel extends LitElement {
<md-icon-button <md-icon-button
class="dragIconButton" class="dragIconButton"
aria-label=${iconLabel} aria-label=${iconLabel}
@click=${this.onDragIconClick} @click=${this.onDragIconClick}>
>
<md-icon>${iconSvg}</md-icon> <md-icon>${iconSvg}</md-icon>
</md-icon-button> </md-icon-button>
`; `;
@ -127,11 +125,13 @@ export class StoryKnobPanel extends LitElement {
super.updated(changed); super.updated(changed);
if (changed.has('open')) { if (changed.has('open')) {
this.dispatchEvent(new CustomEvent('open-changed', { this.dispatchEvent(
detail: { new CustomEvent('open-changed', {
open: this.open, detail: {
}, open: this.open,
})); },
}),
);
} }
} }
@ -181,11 +181,12 @@ export class StoryKnobPanel extends LitElement {
} }
const rightBound = DEFAULT_DIMENSIONS.RIGHT_OFFSET; const rightBound = DEFAULT_DIMENSIONS.RIGHT_OFFSET;
const leftBound = DEFAULT_DIMENSIONS.RIGHT_OFFSET + this.containerWidth - const leftBound =
window.innerWidth; DEFAULT_DIMENSIONS.RIGHT_OFFSET + this.containerWidth - window.innerWidth;
const topBound = -DEFAULT_DIMENSIONS.TOP_OFFSET; const topBound = -DEFAULT_DIMENSIONS.TOP_OFFSET;
const bottomBound = window.innerHeight - const bottomBound =
(DEFAULT_DIMENSIONS.DRAG_BAR_HEIGHT + DEFAULT_DIMENSIONS.TOP_OFFSET); window.innerHeight -
(DEFAULT_DIMENSIONS.DRAG_BAR_HEIGHT + DEFAULT_DIMENSIONS.TOP_OFFSET);
// do not allow drag outside right bound // do not allow drag outside right bound
if (x > rightBound) { if (x > rightBound) {

View File

@ -6,7 +6,7 @@
/* Slimmed down version of Lit stories story-renderer without IE renderer */ /* Slimmed down version of Lit stories story-renderer without IE renderer */
import {css, LitElement, PropertyValues,} from 'lit'; import {css, LitElement, PropertyValues} from 'lit';
import {customElement, property} from 'lit/decorators.js'; import {customElement, property} from 'lit/decorators.js';
import {Story} from '../story.js'; import {Story} from '../story.js';
@ -24,7 +24,7 @@ export class StoryRenderer extends LitElement {
`, `,
]; ];
@property({attribute: false}) story?: Story = undefined; @property({attribute: false}) story?: Story = undefined;
private storyRenderComplete: Promise<void>|undefined = undefined; private storyRenderComplete: Promise<void> | undefined = undefined;
override updated(propertiesChanged: PropertyValues) { override updated(propertiesChanged: PropertyValues) {
super.updated(propertiesChanged); super.updated(propertiesChanged);

View File

@ -10,4 +10,4 @@ export * from './knobs.js';
export * from './story.js'; export * from './story.js';
// This file is resolved by base.json // This file is resolved by base.json
import './components/stories-renderer.js'; import './components/stories-renderer.js';

View File

@ -53,8 +53,9 @@ export class Knob<T, Name extends string = string> extends EventTarget {
private readonly onReset = () => { private readonly onReset = () => {
this.reset(); this.reset();
}; };
private readonly renderedStoryContainers = private readonly renderedStoryContainers = new Set<
new Set<HTMLElement|DocumentFragment>(); HTMLElement | DocumentFragment
>();
constructor(readonly name: Name, init: KnobInit<T>) { constructor(readonly name: Name, init: KnobInit<T>) {
super(); super();
@ -80,25 +81,28 @@ export class Knob<T, Name extends string = string> extends EventTarget {
* Connect the knob's wiring, if any, up to a container of a rendered story. * Connect the knob's wiring, if any, up to a container of a rendered story.
* This is fast and idempotent, so it's fine to call frequently. * This is fast and idempotent, so it's fine to call frequently.
*/ */
connectWiring(containerOfRenderedStory: HTMLElement|DocumentFragment) { connectWiring(containerOfRenderedStory: HTMLElement | DocumentFragment) {
// Fast path the common case where we have no wiring. // Fast path the common case where we have no wiring.
if (!this.wiring) { if (!this.wiring) {
return; return;
} }
const alreadyWired = const alreadyWired = this.renderedStoryContainers.has(
this.renderedStoryContainers.has(containerOfRenderedStory); containerOfRenderedStory,
);
if (!alreadyWired) { if (!alreadyWired) {
this.renderedStoryContainers.add(containerOfRenderedStory); this.renderedStoryContainers.add(containerOfRenderedStory);
// Ensure default values are wired correctly. // Ensure default values are wired correctly.
if (this.dirty || if (
(this.latestValue !== undefined && this.dirty ||
this.latestValue === this.defaultValue)) { (this.latestValue !== undefined &&
this.latestValue === this.defaultValue)
) {
this.wiring?.(this, this.latestValue!, containerOfRenderedStory); this.wiring?.(this, this.latestValue!, containerOfRenderedStory);
} }
} }
} }
disconnectWiring(containerOfRenderedStory: HTMLElement|DocumentFragment) { disconnectWiring(containerOfRenderedStory: HTMLElement | DocumentFragment) {
return this.renderedStoryContainers.delete(containerOfRenderedStory); return this.renderedStoryContainers.delete(containerOfRenderedStory);
} }
@ -139,15 +143,17 @@ type KnobKeys<Knobs extends PolymorphicArrayOfKnobs> = Knobs[number]['name'];
* *
* This type operator would return `number`. * This type operator would return `number`.
*/ */
type TypeOfKnobWithName<Knobs extends PolymorphicArrayOfKnobs, type TypeOfKnobWithName<
SearchName extends string> = Knobs extends PolymorphicArrayOfKnobs,
Extract<Knobs[number], {name: SearchName}> extends Knob<infer U>? SearchName extends string,
U|undefined : > = Extract<Knobs[number], {name: SearchName}> extends Knob<infer U>
never; ? U | undefined
: never;
/** A helper class for getting the latest value for a knob by name. */ /** A helper class for getting the latest value for a knob by name. */
export class KnobValues<Knobs extends PolymorphicArrayOfKnobs> extends export class KnobValues<
EventTarget { Knobs extends PolymorphicArrayOfKnobs,
> extends EventTarget {
private readonly byName: ReadonlyMap<string, Knob<unknown>>; private readonly byName: ReadonlyMap<string, Knob<unknown>>;
constructor(knobsArray: PolymorphicArrayOfKnobs) { constructor(knobsArray: PolymorphicArrayOfKnobs) {
@ -156,25 +162,32 @@ export class KnobValues<Knobs extends PolymorphicArrayOfKnobs> extends
for (const knob of knobsArray) { for (const knob of knobsArray) {
if (byName.has(knob.name)) { if (byName.has(knob.name)) {
throw new Error( throw new Error(
`More than one knob with name '${knob.name}' given to a story.`); `More than one knob with name '${knob.name}' given to a story.`,
);
} }
byName.set(knob.name, knob); byName.set(knob.name, knob);
knob.addEventListener('changed', (e) => { knob.addEventListener('changed', (e) => {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('changed', {detail: {knobName: knob.name}})); new CustomEvent('changed', {detail: {knobName: knob.name}}),
);
}); });
} }
this.byName = byName; this.byName = byName;
} }
get<SearchName extends KnobKeys<Knobs>>(knobName: SearchName): get<SearchName extends KnobKeys<Knobs>>(
TypeOfKnobWithName<Knobs, SearchName> { knobName: SearchName,
return this.byName.get(knobName)?.latestValue as ): TypeOfKnobWithName<Knobs, SearchName> {
TypeOfKnobWithName<Knobs, SearchName>; return this.byName.get(knobName)?.latestValue as TypeOfKnobWithName<
Knobs,
SearchName
>;
} }
set<SearchName extends KnobKeys<Knobs>>( set<SearchName extends KnobKeys<Knobs>>(
knobName: SearchName, newValue: TypeOfKnobWithName<Knobs, SearchName>) { knobName: SearchName,
newValue: TypeOfKnobWithName<Knobs, SearchName>,
) {
const knob = this.byName.get(knobName); const knob = this.byName.get(knobName);
if (knob === undefined) { if (knob === undefined) {
throw new Error(`No knob with name ${knobName}`); throw new Error(`No knob with name ${knobName}`);
@ -210,7 +223,7 @@ export class KnobValues<Knobs extends PolymorphicArrayOfKnobs> extends
* Unlikely that any code outside of the stories system internals would * Unlikely that any code outside of the stories system internals would
* call this. * call this.
*/ */
connectWiring(container: HTMLElement|DocumentFragment) { connectWiring(container: HTMLElement | DocumentFragment) {
for (const knob of this.byName.values()) { for (const knob of this.byName.values()) {
if (container instanceof DocumentFragment) { if (container instanceof DocumentFragment) {
container = container.firstElementChild as HTMLElement; container = container.firstElementChild as HTMLElement;
@ -225,7 +238,7 @@ export class KnobValues<Knobs extends PolymorphicArrayOfKnobs> extends
* *
* Returns false if the container wasn't actually connected. * Returns false if the container wasn't actually connected.
*/ */
disconnectWiring(container: HTMLElement|DocumentFragment) { disconnectWiring(container: HTMLElement | DocumentFragment) {
let disconnected = false; let disconnected = false;
for (const knob of this.byName.values()) { for (const knob of this.byName.values()) {
disconnected = knob.disconnectWiring(container) || disconnected; disconnected = knob.disconnectWiring(container) || disconnected;
@ -261,8 +274,11 @@ export interface KnobUi<T> {
* and wiring may treat this case differently (e.g. restoring a value * and wiring may treat this case differently (e.g. restoring a value
* to the value it had before the wiring set it the first time). * to the value it had before the wiring set it the first time).
*/ */
render(knob: Knob<T>, onChange: (val: T) => void, onReset: () => void): render(
TemplateResult; knob: Knob<T>,
onChange: (val: T) => void,
onReset: () => void,
): TemplateResult;
} }
/** /**
@ -276,6 +292,9 @@ export interface KnobUi<T> {
* wired up by just applying styles to the containerOfRenderedStory. * wired up by just applying styles to the containerOfRenderedStory.
*/ */
export interface KnobWiring<T> { export interface KnobWiring<T> {
(knob: Knob<T>, val: T, (
containerOfRenderedStory: HTMLElement|DocumentFragment): void; knob: Knob<T>,
val: T,
containerOfRenderedStory: HTMLElement | DocumentFragment,
): void;
} }

View File

@ -52,11 +52,11 @@ export function title(): KnobUi<void> {
* ``` * ```
*/ */
export type KnobTypesToKnobs< export type KnobTypesToKnobs<
// tslint:disable-next-line:no-any No way to represent this type clearly. // tslint:disable-next-line:no-any No way to represent this type clearly.
T extends {[name: string]: any}, T extends {[name: string]: any},
Names extends Extract<keyof T, string> = Extract<keyof T, string>, Names extends Extract<keyof T, string> = Extract<keyof T, string>,
// tslint:disable-next-line:no-any We need to "map" the union type to knobs. // tslint:disable-next-line:no-any We need to "map" the union type to knobs.
> = ReadonlyArray<Names extends any ? Knob<T[Names], Names>: never>; > = ReadonlyArray<Names extends any ? Knob<T[Names], Names> : never>;
/** /**
* An init object for Material Stories. This should be exposed to the user. * An init object for Material Stories. This should be exposed to the user.
@ -86,7 +86,7 @@ export type KnobTypesToKnobs<
export interface MaterialStoryInit<T extends {[name: string]: any}> { export interface MaterialStoryInit<T extends {[name: string]: any}> {
name: string; name: string;
render: (knobs: T) => TemplateResult | Promise<TemplateResult>; render: (knobs: T) => TemplateResult | Promise<TemplateResult>;
styles?: CSSResult|CSSResult[]; styles?: CSSResult | CSSResult[];
} }
/** /**
@ -94,8 +94,8 @@ export interface MaterialStoryInit<T extends {[name: string]: any}> {
*/ */
// tslint:disable-next-line:no-any No way to represent this type clearly. // tslint:disable-next-line:no-any No way to represent this type clearly.
export function materialInitsToStoryInits<T extends {[name: string]: any}>( export function materialInitsToStoryInits<T extends {[name: string]: any}>(
inits: Array<MaterialStoryInit<T>>): inits: Array<MaterialStoryInit<T>>,
Array<LitStoryInit<KnobValues<KnobTypesToKnobs<T>>>> { ): Array<LitStoryInit<KnobValues<KnobTypesToKnobs<T>>>> {
return inits.map((init) => { return inits.map((init) => {
return { return {
name: init.name, name: init.name,
@ -119,4 +119,4 @@ export function setUpDemo(collection: LitCollection): void {
const renderer = document.createElement('stories-renderer'); const renderer = document.createElement('stories-renderer');
renderer.collection = collection; renderer.collection = collection;
document.body.appendChild(renderer); document.body.appendChild(renderer);
} }

View File

@ -24,22 +24,23 @@ export interface BaseStoryInit {
type GenericKnobValues = KnobValues<PolymorphicArrayOfKnobs>; type GenericKnobValues = KnobValues<PolymorphicArrayOfKnobs>;
/** A story with an arbitrary render function. */ /** A story with an arbitrary render function. */
export interface StoryInit< export interface StoryInit<KV extends GenericKnobValues = GenericKnobValues>
KV extends GenericKnobValues = GenericKnobValues> extends BaseStoryInit { extends BaseStoryInit {
render(container: HTMLElement|DocumentFragment, knobs: KV): Promise<void>; render(container: HTMLElement | DocumentFragment, knobs: KV): Promise<void>;
styles?: CSSStyleSheet[]; styles?: CSSStyleSheet[];
} }
class StoryImpl<Knobs extends PolymorphicArrayOfKnobs = class StoryImpl<
PolymorphicArrayOfKnobs> { Knobs extends PolymorphicArrayOfKnobs = PolymorphicArrayOfKnobs,
> {
readonly name: string; readonly name: string;
readonly id: string; readonly id: string;
readonly description: string|undefined; readonly description: string | undefined;
readonly render: (container: HTMLElement|DocumentFragment) => Promise<void>; readonly render: (container: HTMLElement | DocumentFragment) => Promise<void>;
readonly dispose: (container: HTMLElement|DocumentFragment) => void; readonly dispose: (container: HTMLElement | DocumentFragment) => void;
readonly knobs: KnobValues<Knobs>; readonly knobs: KnobValues<Knobs>;
private readonly initStyles: CSSStyleSheet[]|undefined; private readonly initStyles: CSSStyleSheet[] | undefined;
get styles() { get styles() {
let styles = [...this.collection.customStyles]; let styles = [...this.collection.customStyles];
@ -54,19 +55,22 @@ class StoryImpl<Knobs extends PolymorphicArrayOfKnobs =
this.description = init.description; this.description = init.description;
this.id = init.id || this.name.replace(/ /g, '_').replace(/,/g, ''); this.id = init.id || this.name.replace(/ /g, '_').replace(/,/g, '');
if (this.id.includes('/')) { if (this.id.includes('/')) {
const message = `A story id can't contain a '/' character. ` + const message =
`The name can, but if so you have to give an ` + `A story id can't contain a '/' character. ` +
`explicit id that doesn't.`; `The name can, but if so you have to give an ` +
`explicit id that doesn't.`;
throw new Error(message); throw new Error(message);
} }
const wrapperDivMap = const wrapperDivMap = new WeakMap<
new WeakMap<HTMLElement|DocumentFragment, HTMLDivElement>(); HTMLElement | DocumentFragment,
HTMLDivElement
>();
this.initStyles = init.styles; this.initStyles = init.styles;
this.knobs = collection.knobs; this.knobs = collection.knobs;
this.render = async (container: HTMLElement|DocumentFragment) => { this.render = async (container: HTMLElement | DocumentFragment) => {
let wrapperDiv = wrapperDivMap.get(container); let wrapperDiv = wrapperDivMap.get(container);
if (wrapperDiv === undefined) { if (wrapperDiv === undefined) {
wrapperDiv = document.createElement('div'); wrapperDiv = document.createElement('div');
@ -77,7 +81,7 @@ class StoryImpl<Knobs extends PolymorphicArrayOfKnobs =
await init.render(wrapperDiv, this.knobs); await init.render(wrapperDiv, this.knobs);
}; };
this.dispose = (container: HTMLElement|DocumentFragment) => { this.dispose = (container: HTMLElement | DocumentFragment) => {
wrapperDivMap.delete(container); wrapperDivMap.delete(container);
render(nothing, container); render(nothing, container);
}; };
@ -93,8 +97,9 @@ class StoryImpl<Knobs extends PolymorphicArrayOfKnobs =
* The type is exposed here, but the implementation isn't, because a Story * The type is exposed here, but the implementation isn't, because a Story
* must be constructed via a Collection. * must be constructed via a Collection.
*/ */
export type Story<Knobs extends PolymorphicArrayOfKnobs = export type Story<
PolymorphicArrayOfKnobs> = StoryImpl<Knobs>; Knobs extends PolymorphicArrayOfKnobs = PolymorphicArrayOfKnobs,
> = StoryImpl<Knobs>;
/** /**
* A tree of related stories and sub-collections. * A tree of related stories and sub-collections.
@ -106,9 +111,10 @@ export type Story<Knobs extends PolymorphicArrayOfKnobs =
* either a toplevel collection for a named component, or it is a member of * either a toplevel collection for a named component, or it is a member of
* exactly one collection. * exactly one collection.
*/ */
export class Collection<T extends PolymorphicArrayOfKnobs = export class Collection<
ReadonlyArray<Knob<unknown>>> { T extends PolymorphicArrayOfKnobs = ReadonlyArray<Knob<unknown>>,
private readonly children = new Map<string, Story|Collection>(); > {
private readonly children = new Map<string, Story | Collection>();
readonly customStyles: CSSStyleSheet[] = []; readonly customStyles: CSSStyleSheet[] = [];
private static readonly collectionsByName = new Map<string, Collection>(); private static readonly collectionsByName = new Map<string, Collection>();
readonly knobs: KnobValues<T>; readonly knobs: KnobValues<T>;
@ -136,7 +142,7 @@ export class Collection<T extends PolymorphicArrayOfKnobs =
return stories; return stories;
} }
get tree(): ReadonlyMap<string, Story<T>|Collection<T>> { get tree(): ReadonlyMap<string, Story<T> | Collection<T>> {
return this.children; return this.children;
} }
@ -148,8 +154,9 @@ export class Collection<T extends PolymorphicArrayOfKnobs =
for (const init of inits) { for (const init of inits) {
const story = new StoryImpl(init, this); const story = new StoryImpl(init, this);
if (this.children.has(story.id)) { if (this.children.has(story.id)) {
const message = `A story or subcollection already exists with the id ${ const message = `A story or subcollection already exists with the id ${JSON.stringify(
JSON.stringify(story.id)}`; story.id,
)}`;
// Don't throw an error, as that will disrupt live_reload / hot reload, // Don't throw an error, as that will disrupt live_reload / hot reload,
// by halting this module's initialization. // by halting this module's initialization.
console.error(message); console.error(message);
@ -168,10 +175,10 @@ export class Collection<T extends PolymorphicArrayOfKnobs =
* Describes a single configuration of a specific web UI component. * Describes a single configuration of a specific web UI component.
*/ */
export interface LitStoryInit< export interface LitStoryInit<
KV extends KnobValues<PolymorphicArrayOfKnobs> = KV extends KnobValues<PolymorphicArrayOfKnobs> = KnobValues<PolymorphicArrayOfKnobs>,
KnobValues<PolymorphicArrayOfKnobs>> extends BaseStoryInit { > extends BaseStoryInit {
renderLit(knobs: KV): TemplateResult|Promise<TemplateResult>; renderLit(knobs: KV): TemplateResult | Promise<TemplateResult>;
litStyles?: CSSResult|CSSResult[]; litStyles?: CSSResult | CSSResult[];
} }
function isLitStoryInit(init: Partial<LitStoryInit>): init is LitStoryInit { function isLitStoryInit(init: Partial<LitStoryInit>): init is LitStoryInit {
@ -181,19 +188,21 @@ function isLitStoryInit(init: Partial<LitStoryInit>): init is LitStoryInit {
/** /**
* A collection with convenience methods for rendering lit-html templates. * A collection with convenience methods for rendering lit-html templates.
*/ */
export class LitCollection<T extends PolymorphicArrayOfKnobs = export class LitCollection<
ReadonlyArray<Knob<unknown>>> extends T extends PolymorphicArrayOfKnobs = ReadonlyArray<Knob<unknown>>,
Collection<T> { > extends Collection<T> {
override addStories( override addStories(
...inits: Array<StoryInit<KnobValues<T>>|LitStoryInit<KnobValues<T>>>) { ...inits: Array<StoryInit<KnobValues<T>> | LitStoryInit<KnobValues<T>>>
) {
const simpleInits: StoryInit[] = []; const simpleInits: StoryInit[] = [];
for (const init of inits) { for (const init of inits) {
if (isLitStoryInit(init)) { if (isLitStoryInit(init)) {
let styles: CSSStyleSheet[] = []; let styles: CSSStyleSheet[] = [];
if (init.litStyles) { if (init.litStyles) {
styles = init.litStyles instanceof Array ? styles =
init.litStyles.map((s) => s.styleSheet!) : init.litStyles instanceof Array
[init.litStyles.styleSheet!]; ? init.litStyles.map((s) => s.styleSheet!)
: [init.litStyles.styleSheet!];
} }
simpleInits.push({ simpleInits.push({
...init, ...init,

View File

@ -44,7 +44,6 @@ const postdoc = new PostDoc({
onMessage, onMessage,
}); });
await postdoc.handshake; await postdoc.handshake;
// Request the initial theme. // Request the initial theme.

View File

@ -20,63 +20,68 @@ describe('<md-checkbox>', () => {
describe('forms', () => { describe('forms', () => {
createFormTests({ createFormTests({
queryControl: root => root.querySelector('md-checkbox'), queryControl: (root) => root.querySelector('md-checkbox'),
valueTests: [ valueTests: [
{ {
name: 'unnamed', name: 'unnamed',
render: () => html`<md-checkbox checked></md-checkbox>`, render: () => html`<md-checkbox checked></md-checkbox>`,
assertValue(formData) { assertValue(formData) {
expect(formData) expect(formData)
.withContext('should not add anything to form without a name') .withContext('should not add anything to form without a name')
.toHaveSize(0); .toHaveSize(0);
} },
}, },
{ {
name: 'unchecked', name: 'unchecked',
render: () => html`<md-checkbox name="checkbox"></md-checkbox>`, render: () => html`<md-checkbox name="checkbox"></md-checkbox>`,
assertValue(formData) { assertValue(formData) {
expect(formData) expect(formData)
.withContext('should not add anything to form when unchecked') .withContext('should not add anything to form when unchecked')
.toHaveSize(0); .toHaveSize(0);
} },
}, },
{ {
name: 'checked default value', name: 'checked default value',
render: () => render: () =>
html`<md-checkbox name="checkbox" checked></md-checkbox>`, html`<md-checkbox name="checkbox" checked></md-checkbox>`,
assertValue(formData) { assertValue(formData) {
expect(formData.get('checkbox')).toBe('on'); expect(formData.get('checkbox')).toBe('on');
} },
}, },
{ {
name: 'checked custom value', name: 'checked custom value',
render: () => render: () =>
html`<md-checkbox name="checkbox" checked value="Custom value"></md-checkbox>`, html`<md-checkbox
name="checkbox"
checked
value="Custom value"></md-checkbox>`,
assertValue(formData) { assertValue(formData) {
expect(formData.get('checkbox')).toBe('Custom value'); expect(formData.get('checkbox')).toBe('Custom value');
} },
}, },
{ {
name: 'indeterminate', name: 'indeterminate',
render: () => render: () =>
html`<md-checkbox name="checkbox" checked indeterminate></md-checkbox>`, html`<md-checkbox
name="checkbox"
checked
indeterminate></md-checkbox>`,
assertValue(formData) { assertValue(formData) {
expect(formData) expect(formData)
.withContext( .withContext('should not add anything to form when indeterminate')
'should not add anything to form when indeterminate') .toHaveSize(0);
.toHaveSize(0); },
}
}, },
{ {
name: 'disabled', name: 'disabled',
render: () => render: () =>
html`<md-checkbox name="checkbox" checked disabled></md-checkbox>`, html`<md-checkbox name="checkbox" checked disabled></md-checkbox>`,
assertValue(formData) { assertValue(formData) {
expect(formData) expect(formData)
.withContext('should not add anything to form when disabled') .withContext('should not add anything to form when disabled')
.toHaveSize(0); .toHaveSize(0);
} },
} },
], ],
resetTests: [ resetTests: [
{ {
@ -87,36 +92,36 @@ describe('<md-checkbox>', () => {
}, },
assertReset(checkbox) { assertReset(checkbox) {
expect(checkbox.checked) expect(checkbox.checked)
.withContext('checkbox.checked after reset') .withContext('checkbox.checked after reset')
.toBeFalse(); .toBeFalse();
} },
}, },
{ {
name: 'reset to checked', name: 'reset to checked',
render: () => render: () =>
html`<md-checkbox name="checkbox" checked></md-checkbox>`, html`<md-checkbox name="checkbox" checked></md-checkbox>`,
change(checkbox) { change(checkbox) {
checkbox.checked = false; checkbox.checked = false;
}, },
assertReset(checkbox) { assertReset(checkbox) {
expect(checkbox.checked) expect(checkbox.checked)
.withContext('checkbox.checked after reset') .withContext('checkbox.checked after reset')
.toBeTrue(); .toBeTrue();
} },
}, },
{ {
name: 'reset to indeterminate', name: 'reset to indeterminate',
render: () => render: () =>
html`<md-checkbox name="checkbox" indeterminate></md-checkbox>`, html`<md-checkbox name="checkbox" indeterminate></md-checkbox>`,
change(checkbox) { change(checkbox) {
checkbox.indeterminate = false; checkbox.indeterminate = false;
}, },
assertReset(checkbox) { assertReset(checkbox) {
expect(checkbox.indeterminate) expect(checkbox.indeterminate)
.withContext('checkbox.indeterminate should not be reset') .withContext('checkbox.indeterminate should not be reset')
.toBeFalse(); .toBeFalse();
} },
} },
], ],
restoreTests: [ restoreTests: [
{ {
@ -124,31 +129,31 @@ describe('<md-checkbox>', () => {
render: () => html`<md-checkbox name="checkbox"></md-checkbox>`, render: () => html`<md-checkbox name="checkbox"></md-checkbox>`,
assertRestored(checkbox) { assertRestored(checkbox) {
expect(checkbox.checked) expect(checkbox.checked)
.withContext('checkbox.checked after restore') .withContext('checkbox.checked after restore')
.toBeFalse(); .toBeFalse();
} },
}, },
{ {
name: 'restore checked', name: 'restore checked',
render: () => render: () =>
html`<md-checkbox name="checkbox" checked></md-checkbox>`, html`<md-checkbox name="checkbox" checked></md-checkbox>`,
assertRestored(checkbox) { assertRestored(checkbox) {
expect(checkbox.checked) expect(checkbox.checked)
.withContext('checkbox.checked after restore') .withContext('checkbox.checked after restore')
.toBeTrue(); .toBeTrue();
} },
}, },
{ {
name: 'restore indeterminate', name: 'restore indeterminate',
render: () => render: () =>
html`<md-checkbox name="checkbox" indeterminate></md-checkbox>`, html`<md-checkbox name="checkbox" indeterminate></md-checkbox>`,
assertRestored(checkbox) { assertRestored(checkbox) {
expect(checkbox.indeterminate) expect(checkbox.indeterminate)
.withContext('checkbox.indeterminate should not be restored') .withContext('checkbox.indeterminate should not be restored')
.toBeFalse(); .toBeFalse();
} },
} },
] ],
}); });
}); });
}); });

View File

@ -4,20 +4,27 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {boolInput, Knob} from './index.js'; import {boolInput, Knob} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Checkbox', [ 'Checkbox',
new Knob('checked', {defaultValue: false, ui: boolInput()}), [
new Knob('indeterminate', {defaultValue: false, ui: boolInput()}), new Knob('checked', {defaultValue: false, ui: boolInput()}),
new Knob('disabled', {defaultValue: false, ui: boolInput()}), new Knob('indeterminate', {defaultValue: false, ui: boolInput()}),
]); new Knob('disabled', {defaultValue: false, ui: boolInput()}),
],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -6,7 +6,10 @@
import '@material/web/checkbox/checkbox.js'; import '@material/web/checkbox/checkbox.js';
import {labelStyles, MaterialStoryInit} from './material-collection.js'; import {
labelStyles,
MaterialStoryInit,
} from './material-collection.js';
import {css, html} from 'lit'; import {css, html} from 'lit';
/** Knob types for checkbox stories. */ /** Knob types for checkbox stories. */
@ -25,8 +28,7 @@ const checkbox: MaterialStoryInit<StoryKnobs> = {
?checked=${checked} ?checked=${checked}
?disabled=${disabled} ?disabled=${disabled}
?indeterminate=${indeterminate} ?indeterminate=${indeterminate}
touch-target="wrapper" touch-target="wrapper"></md-checkbox>
></md-checkbox>
`; `;
}, },
}; };
@ -53,8 +55,7 @@ const withLabels: MaterialStoryInit<StoryKnobs> = {
<md-checkbox <md-checkbox
?disabled=${disabled} ?disabled=${disabled}
aria-label="Cats" aria-label="Cats"
touch-target="wrapper" touch-target="wrapper"></md-checkbox>
></md-checkbox>
Cats Cats
</label> </label>
<label> <label>
@ -62,8 +63,7 @@ const withLabels: MaterialStoryInit<StoryKnobs> = {
checked checked
?disabled=${disabled} ?disabled=${disabled}
aria-label="dogs" aria-label="dogs"
touch-target="wrapper" touch-target="wrapper"></md-checkbox>
></md-checkbox>
Dogs Dogs
</label> </label>
<label> <label>
@ -71,8 +71,7 @@ const withLabels: MaterialStoryInit<StoryKnobs> = {
indeterminate indeterminate
?disabled=${disabled} ?disabled=${disabled}
aria-label="Birds" aria-label="Birds"
touch-target="wrapper" touch-target="wrapper"></md-checkbox>
></md-checkbox>
Birds Birds
</label> </label>
</div> </div>

View File

@ -13,7 +13,11 @@ import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js'; import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../internal/controller/events.js'; import {
dispatchActivationClick,
isActivationClick,
redispatchEvent,
} from '../../internal/controller/events.js';
/** /**
* A checkbox component. * A checkbox component.
@ -26,7 +30,7 @@ export class Checkbox extends LitElement {
/** @nocollapse */ /** @nocollapse */
static override shadowRootOptions = { static override shadowRootOptions = {
...LitElement.shadowRootOptions, ...LitElement.shadowRootOptions,
delegatesFocus: true delegatesFocus: true,
}; };
/** @nocollapse */ /** @nocollapse */
@ -126,12 +130,12 @@ export class Checkbox extends LitElement {
@state() private prevChecked = false; @state() private prevChecked = false;
@state() private prevDisabled = false; @state() private prevDisabled = false;
@state() private prevIndeterminate = false; @state() private prevIndeterminate = false;
@query('input') private readonly input!: HTMLInputElement|null; @query('input') private readonly input!: HTMLInputElement | null;
// Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432 // Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432
// Replace with this.internals.validity.customError when resolved. // Replace with this.internals.validity.customError when resolved.
private hasCustomValidityError = false; private hasCustomValidityError = false;
private readonly internals = // Cast needed for closure
(this as HTMLElement /* needed for closure */).attachInternals(); private readonly internals = (this as HTMLElement).attachInternals();
constructor() { constructor() {
super(); super();
@ -196,12 +200,15 @@ export class Checkbox extends LitElement {
} }
protected override update(changed: PropertyValues<Checkbox>) { protected override update(changed: PropertyValues<Checkbox>) {
if (changed.has('checked') || changed.has('disabled') || if (
changed.has('indeterminate')) { changed.has('checked') ||
changed.has('disabled') ||
changed.has('indeterminate')
) {
this.prevChecked = changed.get('checked') ?? this.checked; this.prevChecked = changed.get('checked') ?? this.checked;
this.prevDisabled = changed.get('disabled') ?? this.disabled; this.prevDisabled = changed.get('disabled') ?? this.disabled;
this.prevIndeterminate = this.prevIndeterminate =
changed.get('indeterminate') ?? this.indeterminate; changed.get('indeterminate') ?? this.indeterminate;
} }
const shouldAddFormValue = this.checked && !this.indeterminate; const shouldAddFormValue = this.checked && !this.indeterminate;
@ -235,7 +242,8 @@ export class Checkbox extends LitElement {
// form.reportValidity() to work in Chrome. // form.reportValidity() to work in Chrome.
return html` return html`
<div class="container ${containerClasses}"> <div class="container ${containerClasses}">
<input type="checkbox" <input
type="checkbox"
id="input" id="input"
aria-checked=${isIndeterminate ? 'mixed' : nothing} aria-checked=${isIndeterminate ? 'mixed' : nothing}
aria-label=${ariaLabel || nothing} aria-label=${ariaLabel || nothing}
@ -244,8 +252,7 @@ export class Checkbox extends LitElement {
?required=${this.required} ?required=${this.required}
.indeterminate=${this.indeterminate} .indeterminate=${this.indeterminate}
.checked=${this.checked} .checked=${this.checked}
@change=${this.handleChange} @change=${this.handleChange} />
>
<div class="outline"></div> <div class="outline"></div>
<div class="background"></div> <div class="background"></div>
@ -284,7 +291,10 @@ export class Checkbox extends LitElement {
} }
this.internals.setValidity( this.internals.setValidity(
input.validity, input.validationMessage, this.getInput()); input.validity,
input.validationMessage,
this.getInput(),
);
} }
private getInput() { private getInput() {

View File

@ -23,7 +23,8 @@ describe('checkbox', () => {
const env = new Environment(); const env = new Environment();
async function setupTest( async function setupTest(
template = html`<md-test-checkbox></md-test-checkbox>`) { template = html`<md-test-checkbox></md-test-checkbox>`,
) {
const element = env.render(template).querySelector('md-test-checkbox'); const element = env.render(template).querySelector('md-test-checkbox');
if (!element) { if (!element) {
throw new Error('Could not query rendered <md-test-checkbox>.'); throw new Error('Could not query rendered <md-test-checkbox>.');
@ -84,84 +85,78 @@ describe('checkbox', () => {
}); });
describe('checked', () => { describe('checked', () => {
it('get/set updates the checked property on the native checkbox element', it('get/set updates the checked property on the native checkbox element', async () => {
async () => { const {harness, input} = await setupTest();
const {harness, input} = await setupTest(); harness.element.checked = true;
harness.element.checked = true; await env.waitForStability();
await env.waitForStability(); expect(input.checked).toEqual(true);
expect(input.checked).toEqual(true); harness.element.checked = false;
harness.element.checked = false; await env.waitForStability();
await env.waitForStability(); expect(input.checked).toEqual(false);
expect(input.checked).toEqual(false); });
});
it('get/set updates the checked property after user updates checked state', it('get/set updates the checked property after user updates checked state', async () => {
async () => { const {harness, input} = await setupTest();
const {harness, input} = await setupTest();
// Simulate user interaction setting checked to true. // Simulate user interaction setting checked to true.
await harness.clickWithMouse(); await harness.clickWithMouse();
await env.waitForStability(); await env.waitForStability();
expect(input.checked).toEqual(true); expect(input.checked).toEqual(true);
expect(harness.element.checked).toEqual(true); expect(harness.element.checked).toEqual(true);
// Set custom element checked to false. // Set custom element checked to false.
harness.element.checked = false; harness.element.checked = false;
await env.waitForStability(); await env.waitForStability();
expect(input.checked).toEqual(false); expect(input.checked).toEqual(false);
expect(harness.element.checked).toEqual(false); expect(harness.element.checked).toEqual(false);
// Set custom element checked to true. // Set custom element checked to true.
harness.element.checked = true; harness.element.checked = true;
await env.waitForStability(); await env.waitForStability();
expect(input.checked).toEqual(true); expect(input.checked).toEqual(true);
expect(harness.element.checked).toEqual(true); expect(harness.element.checked).toEqual(true);
}); });
}); });
describe('indeterminate', () => { describe('indeterminate', () => {
it('get/set updates the indeterminate property on the native checkbox element', it('get/set updates the indeterminate property on the native checkbox element', async () => {
async () => { const {harness, input} = await setupTest();
const {harness, input} = await setupTest(); harness.element.indeterminate = true;
harness.element.indeterminate = true; await env.waitForStability();
await env.waitForStability();
expect(input.indeterminate).toEqual(true); expect(input.indeterminate).toEqual(true);
expect(input.getAttribute('aria-checked')).toEqual('mixed'); expect(input.getAttribute('aria-checked')).toEqual('mixed');
harness.element.indeterminate = false; harness.element.indeterminate = false;
await env.waitForStability(); await env.waitForStability();
expect(input.indeterminate).toEqual(false); expect(input.indeterminate).toEqual(false);
expect(input.getAttribute('aria-checked')).not.toEqual('mixed'); expect(input.getAttribute('aria-checked')).not.toEqual('mixed');
}); });
}); });
describe('disabled', () => { describe('disabled', () => {
it('get/set updates the disabled property on the native checkbox element', it('get/set updates the disabled property on the native checkbox element', async () => {
async () => { const {harness, input} = await setupTest();
const {harness, input} = await setupTest(); harness.element.disabled = true;
harness.element.disabled = true; await env.waitForStability();
await env.waitForStability();
expect(input.disabled).toEqual(true); expect(input.disabled).toEqual(true);
harness.element.disabled = false; harness.element.disabled = false;
await env.waitForStability(); await env.waitForStability();
expect(input.disabled).toEqual(false); expect(input.disabled).toEqual(false);
}); });
}); });
describe('form submission', () => { describe('form submission', () => {
async function setupFormTest(propsInit: Partial<Checkbox> = {}) { async function setupFormTest(propsInit: Partial<Checkbox> = {}) {
return await setupTest(html` return await setupTest(html` <form>
<form> <md-test-checkbox
<md-test-checkbox .checked=${propsInit.checked === true}
.checked=${propsInit.checked === true} .disabled=${propsInit.disabled === true}
.disabled=${propsInit.disabled === true} .name=${propsInit.name ?? ''}
.name=${propsInit.name ?? ''} .value=${propsInit.value ?? ''}></md-test-checkbox>
.value=${propsInit.value ?? ''} </form>`);
></md-test-checkbox>
</form>`);
} }
it('does not submit if not checked', async () => { it('does not submit if not checked', async () => {
@ -171,8 +166,11 @@ describe('checkbox', () => {
}); });
it('does not submit if disabled', async () => { it('does not submit if disabled', async () => {
const {harness} = const {harness} = await setupFormTest({
await setupFormTest({name: 'foo', checked: true, disabled: true}); name: 'foo',
checked: true,
disabled: true,
});
const formData = await harness.submitForm(); const formData = await harness.submitForm();
expect(formData.get('foo')).toBeNull(); expect(formData.get('foo')).toBeNull();
}); });
@ -185,8 +183,11 @@ describe('checkbox', () => {
}); });
it('submits under correct conditions', async () => { it('submits under correct conditions', async () => {
const {harness} = const {harness} = await setupFormTest({
await setupFormTest({name: 'foo', checked: true, value: 'bar'}); name: 'foo',
checked: true,
value: 'bar',
});
const formData = await harness.submitForm(); const formData = await harness.submitForm();
expect(formData.get('foo')).toEqual('bar'); expect(formData.get('foo')).toEqual('bar');
}); });
@ -195,17 +196,21 @@ describe('checkbox', () => {
describe('label activation', () => { describe('label activation', () => {
async function setupLabelTest() { async function setupLabelTest() {
const test = await setupTest(html` const test = await setupTest(html`
<label> <label>
<md-test-checkbox></md-test-checkbox> <md-test-checkbox></md-test-checkbox>
</label> </label>
`); `);
const label = (test.harness.element.getRootNode() as HTMLElement) const label = (
.querySelector<HTMLLabelElement>('label')!; test.harness.element.getRootNode() as HTMLElement
).querySelector<HTMLLabelElement>('label')!;
return {...test, label}; return {...test, label};
} }
it('toggles when label is clicked', async () => { it('toggles when label is clicked', async () => {
const {harness: {element}, label} = await setupLabelTest(); const {
harness: {element},
label,
} = await setupLabelTest();
label.click(); label.click();
await env.waitForStability(); await env.waitForStability();
expect(element.checked).toBeTrue(); expect(element.checked).toBeTrue();
@ -221,8 +226,8 @@ describe('checkbox', () => {
harness.element.required = true; harness.element.required = true;
expect(harness.element.validity.valueMissing) expect(harness.element.validity.valueMissing)
.withContext('checkbox.validity.valueMissing') .withContext('checkbox.validity.valueMissing')
.toBeTrue(); .toBeTrue();
}); });
it('should not set valueMissing when required and checked', async () => { it('should not set valueMissing when required and checked', async () => {
@ -231,8 +236,8 @@ describe('checkbox', () => {
harness.element.checked = true; harness.element.checked = true;
expect(harness.element.validity.valueMissing) expect(harness.element.validity.valueMissing)
.withContext('checkbox.validity.valueMissing') .withContext('checkbox.validity.valueMissing')
.toBeFalse(); .toBeFalse();
}); });
it('should set valueMissing when required and indeterminate', async () => { it('should set valueMissing when required and indeterminate', async () => {
@ -241,8 +246,8 @@ describe('checkbox', () => {
harness.element.indeterminate = true; harness.element.indeterminate = true;
expect(harness.element.validity.valueMissing) expect(harness.element.validity.valueMissing)
.withContext('checkbox.validity.valueMissing') .withContext('checkbox.validity.valueMissing')
.toBeTrue(); .toBeTrue();
}); });
}); });
}); });

View File

@ -4,21 +4,28 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {boolInput, Knob, textInput} from './index.js'; import {boolInput, Knob, textInput} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Chips', [ 'Chips',
new Knob('label', {defaultValue: '', ui: textInput()}), [
new Knob('elevated', {defaultValue: false, ui: boolInput()}), new Knob('label', {defaultValue: '', ui: textInput()}),
new Knob('disabled', {defaultValue: false, ui: boolInput()}), new Knob('elevated', {defaultValue: false, ui: boolInput()}),
new Knob('scrolling', {defaultValue: false, ui: boolInput()}), new Knob('disabled', {defaultValue: false, ui: boolInput()}),
]); new Knob('scrolling', {defaultValue: false, ui: boolInput()}),
],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -4,12 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/icon/icon.js';
import '@material/web/chips/chip-set.js';
import '@material/web/chips/assist-chip.js'; import '@material/web/chips/assist-chip.js';
import '@material/web/chips/chip-set.js';
import '@material/web/chips/filter-chip.js'; import '@material/web/chips/filter-chip.js';
import '@material/web/chips/input-chip.js'; import '@material/web/chips/input-chip.js';
import '@material/web/chips/suggestion-chip.js'; import '@material/web/chips/suggestion-chip.js';
import '@material/web/icon/icon.js';
import {MaterialStoryInit} from './material-collection.js'; import {MaterialStoryInit} from './material-collection.js';
import {css, html, svg} from 'lit'; import {css, html, svg} from 'lit';
@ -53,13 +53,11 @@ const assist: MaterialStoryInit<StoryKnobs> = {
<md-assist-chip <md-assist-chip
label=${label || 'Assist chip'} label=${label || 'Assist chip'}
?disabled=${disabled} ?disabled=${disabled}
?elevated=${elevated} ?elevated=${elevated}></md-assist-chip>
></md-assist-chip>
<md-assist-chip <md-assist-chip
label=${label || 'Assist chip with icon'} label=${label || 'Assist chip with icon'}
?disabled=${disabled} ?disabled=${disabled}
?elevated=${elevated} ?elevated=${elevated}>
>
<md-icon slot="icon">local_laundry_service</md-icon> <md-icon slot="icon">local_laundry_service</md-icon>
</md-assist-chip> </md-assist-chip>
<md-assist-chip <md-assist-chip
@ -67,16 +65,16 @@ const assist: MaterialStoryInit<StoryKnobs> = {
?elevated=${elevated} ?elevated=${elevated}
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
>${GOOGLE_LOGO}</md-assist-chip> >${GOOGLE_LOGO}</md-assist-chip
>
<md-assist-chip <md-assist-chip
label=${label || 'Disabled assist chip (focusable)'} label=${label || 'Disabled assist chip (focusable)'}
disabled disabled
always-focusable always-focusable
?elevated=${elevated} ?elevated=${elevated}></md-assist-chip>
></md-assist-chip>
</md-chip-set> </md-chip-set>
`; `;
} },
}; };
const filters: MaterialStoryInit<StoryKnobs> = { const filters: MaterialStoryInit<StoryKnobs> = {
@ -85,36 +83,31 @@ const filters: MaterialStoryInit<StoryKnobs> = {
render({label, elevated, disabled, scrolling}) { render({label, elevated, disabled, scrolling}) {
const classes = {'scrolling': scrolling}; const classes = {'scrolling': scrolling};
return html` return html`
<md-chip-set class=${classMap(classes)} <md-chip-set class=${classMap(classes)} aria-label="Filter chips">
aria-label="Filter chips">
<md-filter-chip <md-filter-chip
label=${label || 'Filter chip'} label=${label || 'Filter chip'}
?disabled=${disabled} ?disabled=${disabled}
?elevated=${elevated} ?elevated=${elevated}></md-filter-chip>
></md-filter-chip>
<md-filter-chip <md-filter-chip
label=${label || 'Filter chip with icon'} label=${label || 'Filter chip with icon'}
?disabled=${disabled} ?disabled=${disabled}
?elevated=${elevated} ?elevated=${elevated}>
>
<md-icon slot="icon">local_laundry_service</md-icon> <md-icon slot="icon">local_laundry_service</md-icon>
</md-filter-chip> </md-filter-chip>
<md-filter-chip <md-filter-chip
label=${label || 'Removable filter chip'} label=${label || 'Removable filter chip'}
?disabled=${disabled} ?disabled=${disabled}
?elevated=${elevated} ?elevated=${elevated}
removable removable></md-filter-chip>
></md-filter-chip>
<md-filter-chip <md-filter-chip
label=${label || 'Disabled filter chip (focusable)'} label=${label || 'Disabled filter chip (focusable)'}
disabled disabled
always-focusable always-focusable
?elevated=${elevated} ?elevated=${elevated}
removable removable></md-filter-chip>
></md-filter-chip>
</md-chip-set> </md-chip-set>
`; `;
} },
}; };
const inputs: MaterialStoryInit<StoryKnobs> = { const inputs: MaterialStoryInit<StoryKnobs> = {
@ -126,39 +119,37 @@ const inputs: MaterialStoryInit<StoryKnobs> = {
<md-chip-set class=${classMap(classes)} aria-label="Input chips"> <md-chip-set class=${classMap(classes)} aria-label="Input chips">
<md-input-chip <md-input-chip
label=${label || 'Input chip'} label=${label || 'Input chip'}
?disabled=${disabled} ?disabled=${disabled}></md-input-chip>
></md-input-chip>
<md-input-chip <md-input-chip
label=${label || 'Input chip with icon'} label=${label || 'Input chip with icon'}
?disabled=${disabled} ?disabled=${disabled}>
>
<md-icon slot="icon">local_laundry_service</md-icon> <md-icon slot="icon">local_laundry_service</md-icon>
</md-input-chip> </md-input-chip>
<md-input-chip <md-input-chip
label=${label || 'Input chip with avatar'} label=${label || 'Input chip with avatar'}
?disabled=${disabled} ?disabled=${disabled}
avatar avatar>
> <img
<img slot="icon" src="https://lh3.googleusercontent.com/a/default-user=s48"> slot="icon"
src="https://lh3.googleusercontent.com/a/default-user=s48" />
</md-input-chip> </md-input-chip>
<md-input-chip <md-input-chip
label=${label || 'Input link chip'} label=${label || 'Input link chip'}
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
>${GOOGLE_LOGO}</md-input-chip> >${GOOGLE_LOGO}</md-input-chip
>
<md-input-chip <md-input-chip
label=${label || 'Remove-only input chip'} label=${label || 'Remove-only input chip'}
?disabled=${disabled} ?disabled=${disabled}
remove-only remove-only></md-input-chip>
></md-input-chip>
<md-input-chip <md-input-chip
label=${label || 'Disabled input chip (focusable)'} label=${label || 'Disabled input chip (focusable)'}
disabled disabled
always-focusable always-focusable></md-input-chip>
></md-input-chip>
</md-chip-set> </md-chip-set>
`; `;
} },
}; };
const suggestions: MaterialStoryInit<StoryKnobs> = { const suggestions: MaterialStoryInit<StoryKnobs> = {
@ -171,13 +162,11 @@ const suggestions: MaterialStoryInit<StoryKnobs> = {
<md-suggestion-chip <md-suggestion-chip
label=${label || 'Suggestion chip'} label=${label || 'Suggestion chip'}
?disabled=${disabled} ?disabled=${disabled}
?elevated=${elevated} ?elevated=${elevated}></md-suggestion-chip>
></md-suggestion-chip>
<md-suggestion-chip <md-suggestion-chip
label=${label || 'Suggestion chip with icon'} label=${label || 'Suggestion chip with icon'}
?disabled=${disabled} ?disabled=${disabled}
?elevated=${elevated} ?elevated=${elevated}>
>
<md-icon slot="icon">local_laundry_service</md-icon> <md-icon slot="icon">local_laundry_service</md-icon>
</md-suggestion-chip> </md-suggestion-chip>
<md-suggestion-chip <md-suggestion-chip
@ -185,16 +174,16 @@ const suggestions: MaterialStoryInit<StoryKnobs> = {
?elevated=${elevated} ?elevated=${elevated}
href="https://google.com" href="https://google.com"
target="_blank" target="_blank"
>${GOOGLE_LOGO}</md-suggestion-chip> >${GOOGLE_LOGO}</md-suggestion-chip
>
<md-suggestion-chip <md-suggestion-chip
label=${label || 'Disabled suggestion chip (focusable)'} label=${label || 'Disabled suggestion chip (focusable)'}
disabled disabled
always-focusable always-focusable
?elevated=${elevated} ?elevated=${elevated}></md-suggestion-chip>
></md-suggestion-chip>
</md-chip-set> </md-chip-set>
`; `;
} },
}; };
/** Chips stories. */ /** Chips stories. */

View File

@ -28,6 +28,10 @@ declare global {
@customElement('md-filter-chip') @customElement('md-filter-chip')
export class MdFilterChip extends FilterChip { export class MdFilterChip extends FilterChip {
static override styles = [ static override styles = [
sharedStyles, elevatedStyles, trailingIconStyles, selectableStyles, styles sharedStyles,
elevatedStyles,
trailingIconStyles,
selectableStyles,
styles,
]; ];
} }

View File

@ -12,16 +12,18 @@ import {Chip} from './internal/chip.js';
* Test harness for chips. * Test harness for chips.
*/ */
export class ChipHarness extends Harness<Chip> { export class ChipHarness extends Harness<Chip> {
action: 'primary'|'trailing' = 'primary'; action: 'primary' | 'trailing' = 'primary';
protected override async getInteractiveElement() { protected override async getInteractiveElement() {
await this.element.updateComplete; await this.element.updateComplete;
const {primaryId} = this.element as unknown as {primaryId: string}; const {primaryId} = this.element as unknown as {primaryId: string};
const primaryAction = primaryId && const primaryAction =
this.element.renderRoot.querySelector<HTMLElement>(`#${primaryId}`); primaryId &&
this.element.renderRoot.querySelector<HTMLElement>(`#${primaryId}`);
// Retrieve MultiActionChip's trailingAction // Retrieve MultiActionChip's trailingAction
const {trailingAction} = const {trailingAction} = this.element as {
this.element as {trailingAction?: HTMLElement | null}; trailingAction?: HTMLElement | null;
};
// Default to trailing action if there isn't a primary action and the user // Default to trailing action if there isn't a primary action and the user
// didn't explicitly set `harness.action = 'trailing'` (remove-only input // didn't explicitly set `harness.action = 'trailing'` (remove-only input
@ -29,7 +31,8 @@ export class ChipHarness extends Harness<Chip> {
if (this.action === 'trailing' || !primaryAction) { if (this.action === 'trailing' || !primaryAction) {
if (!trailingAction) { if (!trailingAction) {
throw new Error( throw new Error(
'`ChipHarness.action` is "trailing", but the chip does not have a trailing action.'); '`ChipHarness.action` is "trailing", but the chip does not have a trailing action.',
);
} }
return trailingAction; return trailingAction;
@ -37,7 +40,8 @@ export class ChipHarness extends Harness<Chip> {
if (!primaryAction) { if (!primaryAction) {
throw new Error( throw new Error(
'`ChipHarness.action` is "primary", but the chip does not have a primary action.'); '`ChipHarness.action` is "primary", but the chip does not have a primary action.',
);
} }
return primaryAction; return primaryAction;

View File

@ -26,6 +26,10 @@ declare global {
*/ */
@customElement('md-input-chip') @customElement('md-input-chip')
export class MdInputChip extends InputChip { export class MdInputChip extends InputChip {
static override styles = static override styles = [
[sharedStyles, trailingIconStyles, selectableStyles, styles]; sharedStyles,
trailingIconStyles,
selectableStyles,
styles,
];
} }

View File

@ -19,7 +19,7 @@ import {Chip} from './chip.js';
export class AssistChip extends Chip { export class AssistChip extends Chip {
@property({type: Boolean}) elevated = false; @property({type: Boolean}) elevated = false;
@property() href = ''; @property() href = '';
@property() target: '_blank'|'_parent'|'_self'|'_top'|'' = ''; @property() target: '_blank' | '_parent' | '_self' | '_top' | '' = '';
protected get primaryId() { protected get primaryId() {
return this.href ? 'link' : 'button'; return this.href ? 'link' : 'button';
@ -44,22 +44,26 @@ export class AssistChip extends Chip {
const {ariaLabel} = this as ARIAMixinStrict; const {ariaLabel} = this as ARIAMixinStrict;
if (this.href) { if (this.href) {
return html` return html`
<a class="primary action" <a
class="primary action"
id="link" id="link"
aria-label=${ariaLabel || nothing} aria-label=${ariaLabel || nothing}
href=${this.href} href=${this.href}
target=${this.target || nothing} target=${this.target || nothing}
>${content}</a> >${content}</a
>
`; `;
} }
return html` return html`
<button class="primary action" <button
class="primary action"
id="button" id="button"
aria-label=${ariaLabel || nothing} aria-label=${ariaLabel || nothing}
?disabled=${this.disabled && !this.alwaysFocusable} ?disabled=${this.disabled && !this.alwaysFocusable}
type="button" type="button"
>${content}</button> >${content}</button
>
`; `;
} }

View File

@ -31,8 +31,8 @@ describe('Assist chip', () => {
await chip.updateComplete; await chip.updateComplete;
expect(chip.renderRoot.querySelector('a')) expect(chip.renderRoot.querySelector('a'))
.withContext('should have a rendered <a> link') .withContext('should have a rendered <a> link')
.toBeTruthy(); .toBeTruthy();
}); });
it('should not allow link chips to be disabled', async () => { it('should not allow link chips to be disabled', async () => {
@ -42,8 +42,8 @@ describe('Assist chip', () => {
await chip.updateComplete; await chip.updateComplete;
expect(chip.renderRoot.querySelector('.disabled,:disabled')) expect(chip.renderRoot.querySelector('.disabled,:disabled'))
.withContext('should not have any disabled styling or behavior') .withContext('should not have any disabled styling or behavior')
.toBeNull(); .toBeNull();
}); });
}); });
}); });

View File

@ -7,7 +7,10 @@
import {html, isServer, LitElement} from 'lit'; import {html, isServer, LitElement} from 'lit';
import {queryAssignedElements} from 'lit/decorators.js'; import {queryAssignedElements} from 'lit/decorators.js';
import {polyfillElementInternalsAria, setupHostAria} from '../../internal/aria/aria.js'; import {
polyfillElementInternalsAria,
setupHostAria,
} from '../../internal/aria/aria.js';
import {Chip} from './chip.js'; import {Chip} from './chip.js';
@ -21,12 +24,16 @@ export class ChipSet extends LitElement {
get chips() { get chips() {
return this.childElements.filter( return this.childElements.filter(
(child): child is Chip => child instanceof Chip); (child): child is Chip => child instanceof Chip,
);
} }
@queryAssignedElements() private readonly childElements!: HTMLElement[]; @queryAssignedElements() private readonly childElements!: HTMLElement[];
private readonly internals = polyfillElementInternalsAria( private readonly internals = polyfillElementInternalsAria(
this, (this as HTMLElement /* needed for closure */).attachInternals()); this,
// Cast needed for closure
(this as HTMLElement).attachInternals(),
);
constructor() { constructor() {
super(); super();
@ -71,7 +78,7 @@ export class ChipSet extends LitElement {
// Check if moving forwards or backwards // Check if moving forwards or backwards
const isRtl = getComputedStyle(this).direction === 'rtl'; const isRtl = getComputedStyle(this).direction === 'rtl';
const forwards = isRtl ? isLeft : isRight; const forwards = isRtl ? isLeft : isRight;
const focusedChip = chips.find(chip => chip.matches(':focus-within')); const focusedChip = chips.find((chip) => chip.matches(':focus-within'));
if (!focusedChip) { if (!focusedChip) {
// If there is not already a chip focused, select the first or last chip // If there is not already a chip focused, select the first or last chip
// based on the direction we're traveling. // based on the direction we're traveling.
@ -120,7 +127,7 @@ export class ChipSet extends LitElement {
// The chip that should be focusable is either the chip that currently has // The chip that should be focusable is either the chip that currently has
// focus or the first chip that can be focused. // focus or the first chip that can be focused.
const {chips} = this; const {chips} = this;
let chipToFocus: Chip|undefined; let chipToFocus: Chip | undefined;
for (const chip of chips) { for (const chip of chips) {
const isChipFocusable = chip.alwaysFocusable || !chip.disabled; const isChipFocusable = chip.alwaysFocusable || !chip.disabled;
const chipIsFocused = chip.matches(':focus-within'); const chipIsFocused = chip.matches(':focus-within');
@ -147,5 +154,5 @@ export class ChipSet extends LitElement {
} }
interface MaybeMultiActionChip extends Chip { interface MaybeMultiActionChip extends Chip {
focus(options?: FocusOptions&{trailing?: boolean}): void; focus(options?: FocusOptions & {trailing?: boolean}): void;
} }

View File

@ -19,14 +19,11 @@ import {ChipSet} from './chip-set.js';
import {InputChip} from './input-chip.js'; import {InputChip} from './input-chip.js';
@customElement('test-chip-set') @customElement('test-chip-set')
class TestChipSet extends ChipSet { class TestChipSet extends ChipSet {}
}
@customElement('test-chip-set-assist-chip') @customElement('test-chip-set-assist-chip')
class TestAssistChip extends AssistChip { class TestAssistChip extends AssistChip {}
}
@customElement('test-chip-set-input-chip') @customElement('test-chip-set-input-chip')
class TestInputChip extends InputChip { class TestInputChip extends InputChip {}
}
describe('Chip set', () => { describe('Chip set', () => {
const env = new Environment(); const env = new Environment();
@ -61,42 +58,51 @@ describe('Chip set', () => {
describe('navigation', () => { describe('navigation', () => {
it('should add tabindex="-1" to all chips except the first', async () => { it('should add tabindex="-1" to all chips except the first', async () => {
const chipSet = await setupTest( const chipSet = await setupTest([
[new TestAssistChip(), new TestAssistChip(), new TestAssistChip()]); new TestAssistChip(),
new TestAssistChip(),
new TestAssistChip(),
]);
expect(chipSet.chips[0].getAttribute('tabindex')) expect(chipSet.chips[0].getAttribute('tabindex'))
.withContext('first tabindex') .withContext('first tabindex')
.toBe('0'); .toBe('0');
expect(chipSet.chips[1].getAttribute('tabindex')) expect(chipSet.chips[1].getAttribute('tabindex'))
.withContext('second tabindex') .withContext('second tabindex')
.toBe('-1'); .toBe('-1');
expect(chipSet.chips[2].getAttribute('tabindex')) expect(chipSet.chips[2].getAttribute('tabindex'))
.withContext('third tabindex') .withContext('third tabindex')
.toBe('-1'); .toBe('-1');
}); });
async function testNavigation({chipSet, ltrKey, rtlKey, current, next}: { async function testNavigation({
chipSet: ChipSet, chipSet,
ltrKey: string, ltrKey,
rtlKey: string, rtlKey,
current: Chip|null, current,
next: Chip, next,
}: {
chipSet: ChipSet;
ltrKey: string;
rtlKey: string;
current: Chip | null;
next: Chip;
}) { }) {
const harness = current ? new ChipHarness(current) : new Harness(chipSet); const harness = current ? new ChipHarness(current) : new Harness(chipSet);
// Don't use harness focusing since we need to test real focus states // Don't use harness focusing since we need to test real focus states
current?.focus(); current?.focus();
await harness.keypress(ltrKey); await harness.keypress(ltrKey);
expect(next.matches(':focus-within')) expect(next.matches(':focus-within'))
.withContext(`next chip is focused in LTR after ${ltrKey}`) .withContext(`next chip is focused in LTR after ${ltrKey}`)
.toBeTrue(); .toBeTrue();
next.blur(); next.blur();
chipSet.style.direction = 'rtl'; chipSet.style.direction = 'rtl';
current?.focus(); current?.focus();
await harness.keypress(rtlKey); await harness.keypress(rtlKey);
expect(next.matches(':focus-within')) expect(next.matches(':focus-within'))
.withContext(`next chip is focused in RTL after ${rtlKey}`) .withContext(`next chip is focused in RTL after ${rtlKey}`)
.toBeTrue(); .toBeTrue();
} }
it('should navigate forward on horizontal arrow keys', async () => { it('should navigate forward on horizontal arrow keys', async () => {
@ -109,7 +115,7 @@ describe('Chip set', () => {
ltrKey: 'ArrowRight', ltrKey: 'ArrowRight',
rtlKey: 'ArrowLeft', rtlKey: 'ArrowLeft',
current: first, current: first,
next: second next: second,
}); });
}); });
@ -123,7 +129,7 @@ describe('Chip set', () => {
ltrKey: 'ArrowLeft', ltrKey: 'ArrowLeft',
rtlKey: 'ArrowRight', rtlKey: 'ArrowRight',
current: second, current: second,
next: first next: first,
}); });
}); });
@ -137,7 +143,7 @@ describe('Chip set', () => {
ltrKey: 'Home', ltrKey: 'Home',
rtlKey: 'Home', rtlKey: 'Home',
current: second, current: second,
next: first next: first,
}); });
}); });
@ -151,39 +157,37 @@ describe('Chip set', () => {
ltrKey: 'End', ltrKey: 'End',
rtlKey: 'End', rtlKey: 'End',
current: second, current: second,
next: third next: third,
}); });
}); });
it('should navigate to first chip on forward when none focused', it('should navigate to first chip on forward when none focused', async () => {
async () => { const first = new TestAssistChip();
const first = new TestAssistChip(); const second = new TestAssistChip();
const second = new TestAssistChip(); const third = new TestAssistChip();
const third = new TestAssistChip(); const chipSet = await setupTest([first, second, third]);
const chipSet = await setupTest([first, second, third]); await testNavigation({
await testNavigation({ chipSet,
chipSet, ltrKey: 'ArrowRight',
ltrKey: 'ArrowRight', rtlKey: 'ArrowLeft',
rtlKey: 'ArrowLeft', current: null,
current: null, next: first,
next: first });
}); });
});
it('should navigate to last chip on backward when none focused', it('should navigate to last chip on backward when none focused', async () => {
async () => { const first = new TestAssistChip();
const first = new TestAssistChip(); const second = new TestAssistChip();
const second = new TestAssistChip(); const third = new TestAssistChip();
const third = new TestAssistChip(); const chipSet = await setupTest([first, second, third]);
const chipSet = await setupTest([first, second, third]); await testNavigation({
await testNavigation({ chipSet,
chipSet, ltrKey: 'ArrowLeft',
ltrKey: 'ArrowLeft', rtlKey: 'ArrowRight',
rtlKey: 'ArrowRight', current: null,
current: null, next: third,
next: third });
}); });
});
it('should skip over disabled chips', async () => { it('should skip over disabled chips', async () => {
const first = new TestAssistChip(); const first = new TestAssistChip();
@ -196,7 +200,7 @@ describe('Chip set', () => {
ltrKey: 'ArrowRight', ltrKey: 'ArrowRight',
rtlKey: 'ArrowLeft', rtlKey: 'ArrowLeft',
current: first, current: first,
next: third next: third,
}); });
}); });
@ -212,7 +216,7 @@ describe('Chip set', () => {
ltrKey: 'ArrowRight', ltrKey: 'ArrowRight',
rtlKey: 'ArrowLeft', rtlKey: 'ArrowLeft',
current: first, current: first,
next: second next: second,
}); });
}); });
@ -226,11 +230,12 @@ describe('Chip set', () => {
// Don't use harness focusing since we need to test real focus states // Don't use harness focusing since we need to test real focus states
second.focus(); second.focus();
await harness.keypress('ArrowLeft'); await harness.keypress('ArrowLeft');
const {trailingAction} = const {trailingAction} = first as unknown as {
first as unknown as {trailingAction: HTMLElement}; trailingAction: HTMLElement;
};
expect(trailingAction.matches(':focus-within')) expect(trailingAction.matches(':focus-within'))
.withContext('trailing action of first chip is focused') .withContext('trailing action of first chip is focused')
.toBeTrue(); .toBeTrue();
}); });
it('should ignore other keyboard events', async () => { it('should ignore other keyboard events', async () => {
@ -244,8 +249,8 @@ describe('Chip set', () => {
first.focus(); first.focus();
await harness.keypress('Enter'); await harness.keypress('Enter');
expect(first.matches(':focus-within')) expect(first.matches(':focus-within'))
.withContext('first chip is still focused') .withContext('first chip is still focused')
.toBeTrue(); .toBeTrue();
}); });
it('should do nothing if there are not at least two chips', async () => { it('should do nothing if there are not at least two chips', async () => {
@ -257,8 +262,8 @@ describe('Chip set', () => {
single.focus(); single.focus();
await harness.keypress('ArrowRight'); await harness.keypress('ArrowRight');
expect(single.matches(':focus-within')) expect(single.matches(':focus-within'))
.withContext('single chip is still focused') .withContext('single chip is still focused')
.toBeTrue(); .toBeTrue();
}); });
}); });
}); });

View File

@ -24,7 +24,7 @@ export abstract class Chip extends LitElement {
/** @nocollapse */ /** @nocollapse */
static override shadowRootOptions = { static override shadowRootOptions = {
...LitElement.shadowRootOptions, ...LitElement.shadowRootOptions,
delegatesFocus: true delegatesFocus: true,
}; };
/** /**
@ -93,10 +93,10 @@ export abstract class Chip extends LitElement {
protected renderContainerContent() { protected renderContainerContent() {
return html` return html`
${this.renderOutline()} ${this.renderOutline()}
<md-focus-ring part="focus-ring" <md-focus-ring part="focus-ring" for=${this.primaryId}></md-focus-ring>
for=${this.primaryId}></md-focus-ring> <md-ripple
<md-ripple for=${this.primaryId} for=${this.primaryId}
?disabled=${this.rippleDisabled}></md-ripple> ?disabled=${this.rippleDisabled}></md-ripple>
${this.renderPrimaryAction(this.renderPrimaryContent())} ${this.renderPrimaryAction(this.renderPrimaryContent())}
`; `;
} }

View File

@ -33,14 +33,13 @@ describe('Chip', () => {
return {chip, harness: new ChipHarness(chip)}; return {chip, harness: new ChipHarness(chip)};
} }
it('should dispatch `update-focus` for chip set when disabled changes', it('should dispatch `update-focus` for chip set when disabled changes', async () => {
async () => { const {chip} = await setupTest();
const {chip} = await setupTest(); const updateFocusListener = jasmine.createSpy('updateFocusListener');
const updateFocusListener = jasmine.createSpy('updateFocusListener'); chip.addEventListener('update-focus', updateFocusListener);
chip.addEventListener('update-focus', updateFocusListener);
chip.disabled = true; chip.disabled = true;
await env.waitForStability(); await env.waitForStability();
expect(updateFocusListener).toHaveBeenCalled(); expect(updateFocusListener).toHaveBeenCalled();
}); });
}); });

View File

@ -27,9 +27,10 @@ export class FilterChip extends MultiActionChip {
return 'button'; return 'button';
} }
@query('.primary.action') protected readonly primaryAction!: HTMLElement|null; @query('.primary.action')
protected readonly primaryAction!: HTMLElement | null;
@query('.trailing.action') @query('.trailing.action')
protected readonly trailingAction!: HTMLElement|null; protected readonly trailingAction!: HTMLElement | null;
protected override getContainerClasses() { protected override getContainerClasses() {
return { return {
@ -43,13 +44,15 @@ export class FilterChip extends MultiActionChip {
protected override renderPrimaryAction(content: unknown) { protected override renderPrimaryAction(content: unknown) {
const {ariaLabel} = this as ARIAMixinStrict; const {ariaLabel} = this as ARIAMixinStrict;
return html` return html`
<button class="primary action" <button
class="primary action"
id="button" id="button"
aria-label=${ariaLabel || nothing} aria-label=${ariaLabel || nothing}
aria-pressed=${this.selected} aria-pressed=${this.selected}
?disabled=${this.disabled && !this.alwaysFocusable} ?disabled=${this.disabled && !this.alwaysFocusable}
@click=${this.handleClick} @click=${this.handleClick}
>${content}</button> >${content}</button
>
`; `;
} }
@ -60,7 +63,8 @@ export class FilterChip extends MultiActionChip {
return html` return html`
<svg class="checkmark" viewBox="0 0 18 18" aria-hidden="true"> <svg class="checkmark" viewBox="0 0 18 18" aria-hidden="true">
<path d="M6.75012 12.1274L3.62262 8.99988L2.55762 10.0574L6.75012 14.2499L15.7501 5.24988L14.6926 4.19238L6.75012 12.1274Z" /> <path
d="M6.75012 12.1274L3.62262 8.99988L2.55762 10.0574L6.75012 14.2499L15.7501 5.24988L14.6926 4.19238L6.75012 12.1274Z" />
</svg> </svg>
`; `;
} }
@ -70,7 +74,7 @@ export class FilterChip extends MultiActionChip {
return renderRemoveButton({ return renderRemoveButton({
focusListener, focusListener,
ariaLabel: this.ariaLabelRemove, ariaLabel: this.ariaLabelRemove,
disabled: this.disabled disabled: this.disabled,
}); });
} }

View File

@ -18,7 +18,7 @@ import {renderRemoveButton} from './trailing-icons.js';
export class InputChip extends MultiActionChip { export class InputChip extends MultiActionChip {
@property({type: Boolean}) avatar = false; @property({type: Boolean}) avatar = false;
@property() href = ''; @property() href = '';
@property() target: '_blank'|'_parent'|'_self'|'_top'|'' = ''; @property() target: '_blank' | '_parent' | '_self' | '_top' | '' = '';
@property({type: Boolean, attribute: 'remove-only'}) removeOnly = false; @property({type: Boolean, attribute: 'remove-only'}) removeOnly = false;
@property({type: Boolean, reflect: true}) selected = false; @property({type: Boolean, reflect: true}) selected = false;
@ -50,7 +50,7 @@ export class InputChip extends MultiActionChip {
} }
@query('.trailing.action') @query('.trailing.action')
protected readonly trailingAction!: HTMLElement|null; protected readonly trailingAction!: HTMLElement | null;
protected override getContainerClasses() { protected override getContainerClasses() {
return { return {
@ -68,12 +68,14 @@ export class InputChip extends MultiActionChip {
const {ariaLabel} = this as ARIAMixinStrict; const {ariaLabel} = this as ARIAMixinStrict;
if (this.href) { if (this.href) {
return html` return html`
<a class="primary action" <a
class="primary action"
id="link" id="link"
aria-label=${ariaLabel || nothing} aria-label=${ariaLabel || nothing}
href=${this.href} href=${this.href}
target=${this.target || nothing} target=${this.target || nothing}
>${content}</a> >${content}</a
>
`; `;
} }
@ -86,12 +88,14 @@ export class InputChip extends MultiActionChip {
} }
return html` return html`
<button class="primary action" <button
class="primary action"
id="button" id="button"
aria-label=${ariaLabel || nothing} aria-label=${ariaLabel || nothing}
?disabled=${this.disabled && !this.alwaysFocusable} ?disabled=${this.disabled && !this.alwaysFocusable}
type="button" type="button"
>${content}</button> >${content}</button
>
`; `;
} }

View File

@ -31,8 +31,8 @@ describe('Input chip', () => {
await chip.updateComplete; await chip.updateComplete;
expect(chip.renderRoot.querySelector('a')) expect(chip.renderRoot.querySelector('a'))
.withContext('should have a rendered <a> link') .withContext('should have a rendered <a> link')
.toBeTruthy(); .toBeTruthy();
}); });
it('should not allow link chips to be disabled', async () => { it('should not allow link chips to be disabled', async () => {
@ -42,8 +42,8 @@ describe('Input chip', () => {
await chip.updateComplete; await chip.updateComplete;
expect(chip.renderRoot.querySelector('.disabled,:disabled')) expect(chip.renderRoot.querySelector('.disabled,:disabled'))
.withContext('should not have any disabled styling or behavior') .withContext('should not have any disabled styling or behavior')
.toBeNull(); .toBeNull();
}); });
}); });
}); });

View File

@ -24,7 +24,7 @@ export abstract class MultiActionChip extends Chip {
const {ariaLabel} = this as ARIAMixinStrict; const {ariaLabel} = this as ARIAMixinStrict;
return `Remove ${ariaLabel || this.label}`; return `Remove ${ariaLabel || this.label}`;
} }
set ariaLabelRemove(ariaLabel: string|null) { set ariaLabelRemove(ariaLabel: string | null) {
const prev = this.ariaLabelRemove; const prev = this.ariaLabelRemove;
if (ariaLabel === prev) { if (ariaLabel === prev) {
return; return;
@ -39,8 +39,8 @@ export abstract class MultiActionChip extends Chip {
this.requestUpdate(); this.requestUpdate();
} }
protected abstract readonly primaryAction: HTMLElement|null; protected abstract readonly primaryAction: HTMLElement | null;
protected abstract readonly trailingAction: HTMLElement|null; protected abstract readonly trailingAction: HTMLElement | null;
constructor() { constructor() {
super(); super();
@ -50,7 +50,7 @@ export abstract class MultiActionChip extends Chip {
} }
} }
override focus(options?: FocusOptions&{trailing?: boolean}) { override focus(options?: FocusOptions & {trailing?: boolean}) {
const isFocusable = this.alwaysFocusable || !this.disabled; const isFocusable = this.alwaysFocusable || !this.disabled;
if (isFocusable && options?.trailing && this.trailingAction) { if (isFocusable && options?.trailing && this.trailingAction) {
this.trailingAction.focus(options); this.trailingAction.focus(options);
@ -67,8 +67,9 @@ export abstract class MultiActionChip extends Chip {
`; `;
} }
protected abstract renderTrailingAction(focusListener: EventListener): protected abstract renderTrailingAction(
unknown; focusListener: EventListener,
): unknown;
private handleKeyDown(event: KeyboardEvent) { private handleKeyDown(event: KeyboardEvent) {
const isLeft = event.key === 'ArrowLeft'; const isLeft = event.key === 'ArrowLeft';
@ -112,8 +113,12 @@ export abstract class MultiActionChip extends Chip {
// shift+tab from the trailing action to move to the previous chip rather // shift+tab from the trailing action to move to the previous chip rather
// than the primary action in the same chip. // than the primary action in the same chip.
primaryAction.tabIndex = -1; primaryAction.tabIndex = -1;
trailingAction.addEventListener('focusout', () => { trailingAction.addEventListener(
primaryAction.tabIndex = 0; 'focusout',
}, {once: true}); () => {
primaryAction.tabIndex = 0;
},
{once: true},
);
} }
} }

View File

@ -17,7 +17,11 @@ import {renderRemoveButton} from './trailing-icons.js';
@customElement('test-multi-action-chip') @customElement('test-multi-action-chip')
class TestMultiActionChip extends MultiActionChip { class TestMultiActionChip extends MultiActionChip {
static override styles = css`:host { position: relative; }`; static override styles = css`
:host {
position: relative;
}
`;
@query('#primary') primaryAction!: HTMLElement; @query('#primary') primaryAction!: HTMLElement;
@query('.trailing.action') trailingAction!: HTMLElement; @query('.trailing.action') trailingAction!: HTMLElement;
@ -37,7 +41,7 @@ class TestMultiActionChip extends MultiActionChip {
return renderRemoveButton({ return renderRemoveButton({
focusListener, focusListener,
ariaLabel: this.ariaLabelRemove, ariaLabel: this.ariaLabelRemove,
disabled: this.disabled disabled: this.disabled,
}); });
} }
} }
@ -59,13 +63,13 @@ describe('Multi-action chips', () => {
await primaryHarness.focusWithKeyboard(); await primaryHarness.focusWithKeyboard();
expect(chip.primaryAction.matches(':focus-within')) expect(chip.primaryAction.matches(':focus-within'))
.withContext('primary action is focused') .withContext('primary action is focused')
.toBeTrue(); .toBeTrue();
await primaryHarness.keypress('ArrowRight'); await primaryHarness.keypress('ArrowRight');
expect(chip.trailingAction.matches(':focus-within')) expect(chip.trailingAction.matches(':focus-within'))
.withContext('trailing action is focused') .withContext('trailing action is focused')
.toBeTrue(); .toBeTrue();
}); });
it('should move internal focus forwards in rtl', async () => { it('should move internal focus forwards in rtl', async () => {
@ -75,13 +79,13 @@ describe('Multi-action chips', () => {
await primaryHarness.focusWithKeyboard(); await primaryHarness.focusWithKeyboard();
expect(chip.primaryAction.matches(':focus-within')) expect(chip.primaryAction.matches(':focus-within'))
.withContext('primary action is focused') .withContext('primary action is focused')
.toBeTrue(); .toBeTrue();
await primaryHarness.keypress('ArrowLeft'); await primaryHarness.keypress('ArrowLeft');
expect(chip.trailingAction.matches(':focus-within')) expect(chip.trailingAction.matches(':focus-within'))
.withContext('trailing action is focused') .withContext('trailing action is focused')
.toBeTrue(); .toBeTrue();
}); });
it('should move internal focus backwards', async () => { it('should move internal focus backwards', async () => {
@ -91,13 +95,13 @@ describe('Multi-action chips', () => {
await trailingHarness.focusWithKeyboard(); await trailingHarness.focusWithKeyboard();
expect(chip.trailingAction.matches(':focus-within')) expect(chip.trailingAction.matches(':focus-within'))
.withContext('trailing action is focused') .withContext('trailing action is focused')
.toBeTrue(); .toBeTrue();
await trailingHarness.keypress('ArrowLeft'); await trailingHarness.keypress('ArrowLeft');
expect(chip.primaryAction.matches(':focus-within')) expect(chip.primaryAction.matches(':focus-within'))
.withContext('primary action is focused') .withContext('primary action is focused')
.toBeTrue(); .toBeTrue();
}); });
it('should move internal focus backwards in rtl', async () => { it('should move internal focus backwards in rtl', async () => {
@ -108,13 +112,13 @@ describe('Multi-action chips', () => {
await trailingHarness.focusWithKeyboard(); await trailingHarness.focusWithKeyboard();
expect(chip.trailingAction.matches(':focus-within')) expect(chip.trailingAction.matches(':focus-within'))
.withContext('trailing action is focused') .withContext('trailing action is focused')
.toBeTrue(); .toBeTrue();
await trailingHarness.keypress('ArrowRight'); await trailingHarness.keypress('ArrowRight');
expect(chip.primaryAction.matches(':focus-within')) expect(chip.primaryAction.matches(':focus-within'))
.withContext('primary action is focused') .withContext('primary action is focused')
.toBeTrue(); .toBeTrue();
}); });
it('should not bubble when navigating internally', async () => { it('should not bubble when navigating internally', async () => {
@ -132,38 +136,36 @@ describe('Multi-action chips', () => {
expect(keydownHandler).not.toHaveBeenCalled(); expect(keydownHandler).not.toHaveBeenCalled();
}); });
it('should bubble event when navigating forward past trailing action', it('should bubble event when navigating forward past trailing action', async () => {
async () => { const chip = await setupTest();
const chip = await setupTest(); const trailingHarness = new ChipHarness(chip);
const trailingHarness = new ChipHarness(chip); trailingHarness.action = 'trailing';
trailingHarness.action = 'trailing'; const keydownHandler = jasmine.createSpy();
const keydownHandler = jasmine.createSpy(); if (!chip.parentElement) {
if (!chip.parentElement) { throw new Error('Expected chip to have a parentElement for test.');
throw new Error('Expected chip to have a parentElement for test.'); }
}
chip.parentElement.addEventListener('keydown', keydownHandler); chip.parentElement.addEventListener('keydown', keydownHandler);
await trailingHarness.focusWithKeyboard(); await trailingHarness.focusWithKeyboard();
await trailingHarness.keypress('ArrowRight'); await trailingHarness.keypress('ArrowRight');
expect(keydownHandler).toHaveBeenCalledTimes(1); expect(keydownHandler).toHaveBeenCalledTimes(1);
}); });
it('should bubble event when navigating backward before primary action', it('should bubble event when navigating backward before primary action', async () => {
async () => { const chip = await setupTest();
const chip = await setupTest(); const primaryHarness = new ChipHarness(chip);
const primaryHarness = new ChipHarness(chip); const keydownHandler = jasmine.createSpy();
const keydownHandler = jasmine.createSpy(); if (!chip.parentElement) {
if (!chip.parentElement) { throw new Error('Expected chip to have a parentElement for test.');
throw new Error('Expected chip to have a parentElement for test.'); }
}
chip.parentElement.addEventListener('keydown', keydownHandler); chip.parentElement.addEventListener('keydown', keydownHandler);
await primaryHarness.focusWithKeyboard(); await primaryHarness.focusWithKeyboard();
await primaryHarness.keypress('ArrowLeft'); await primaryHarness.keypress('ArrowLeft');
expect(keydownHandler).toHaveBeenCalledTimes(1); expect(keydownHandler).toHaveBeenCalledTimes(1);
}); });
it('should do nothing if it does not have multiple actions', async () => { it('should do nothing if it does not have multiple actions', async () => {
const chip = await setupTest(); const chip = await setupTest();
@ -174,8 +176,8 @@ describe('Multi-action chips', () => {
await primaryHarness.focusWithKeyboard(); await primaryHarness.focusWithKeyboard();
await primaryHarness.keypress('ArrowLeft'); await primaryHarness.keypress('ArrowLeft');
expect(chip.primaryAction.matches(':focus-within')) expect(chip.primaryAction.matches(':focus-within'))
.withContext('primary action is still focused') .withContext('primary action is still focused')
.toBeTrue(); .toBeTrue();
}); });
}); });
@ -186,12 +188,12 @@ describe('Multi-action chips', () => {
harness.action = 'trailing'; harness.action = 'trailing';
expect(chip.parentElement) expect(chip.parentElement)
.withContext('chip should be attached before removing') .withContext('chip should be attached before removing')
.not.toBeNull(); .not.toBeNull();
await harness.clickWithMouse(); await harness.clickWithMouse();
expect(chip.parentElement) expect(chip.parentElement)
.withContext('chip should be detached after removing') .withContext('chip should be detached after removing')
.toBeNull(); .toBeNull();
}); });
it('should dispatch a "remove" event when removed', async () => { it('should dispatch a "remove" event when removed', async () => {
@ -205,20 +207,19 @@ describe('Multi-action chips', () => {
expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledTimes(1);
}); });
it('should not remove chip if "remove" event is default prevented', it('should not remove chip if "remove" event is default prevented', async () => {
async () => { const chip = await setupTest();
const chip = await setupTest(); const harness = new ChipHarness(chip);
const harness = new ChipHarness(chip); harness.action = 'trailing';
harness.action = 'trailing'; chip.addEventListener('remove', (event) => {
chip.addEventListener('remove', event => { event.preventDefault();
event.preventDefault(); });
});
await harness.clickWithMouse(); await harness.clickWithMouse();
expect(chip.parentElement) expect(chip.parentElement)
.withContext('chip should still be attached') .withContext('chip should still be attached')
.not.toBeNull(); .not.toBeNull();
}); });
it('should provide a default "ariaLabelRemove" value', async () => { it('should provide a default "ariaLabelRemove" value', async () => {
const chip = await setupTest(); const chip = await setupTest();
@ -227,14 +228,13 @@ describe('Multi-action chips', () => {
expect(chip.ariaLabelRemove).toEqual(`Remove ${chip.label}`); expect(chip.ariaLabelRemove).toEqual(`Remove ${chip.label}`);
}); });
it('should provide a default "ariaLabelRemove" when "ariaLabel" is provided', it('should provide a default "ariaLabelRemove" when "ariaLabel" is provided', async () => {
async () => { const chip = await setupTest();
const chip = await setupTest(); chip.label = 'Label';
chip.label = 'Label'; chip.ariaLabel = 'Descriptive label';
chip.ariaLabel = 'Descriptive label';
expect(chip.ariaLabelRemove).toEqual(`Remove ${chip.ariaLabel}`); expect(chip.ariaLabelRemove).toEqual(`Remove ${chip.ariaLabel}`);
}); });
it('should allow setting a custom "ariaLabelRemove"', async () => { it('should allow setting a custom "ariaLabelRemove"', async () => {
const chip = await setupTest(); const chip = await setupTest();

View File

@ -19,20 +19,24 @@ interface RemoveButtonProperties {
} }
/** @protected */ /** @protected */
export function renderRemoveButton( export function renderRemoveButton({
{ariaLabel, disabled, focusListener, tabbable = false}: ariaLabel,
RemoveButtonProperties) { disabled,
focusListener,
tabbable = false,
}: RemoveButtonProperties) {
return html` return html`
<button class="trailing action" <button
class="trailing action"
aria-label=${ariaLabel} aria-label=${ariaLabel}
tabindex=${!tabbable ? -1 : nothing} tabindex=${!tabbable ? -1 : nothing}
@click=${handleRemoveClick} @click=${handleRemoveClick}
@focus=${focusListener} @focus=${focusListener}>
>
<md-focus-ring part="trailing-focus-ring"></md-focus-ring> <md-focus-ring part="trailing-focus-ring"></md-focus-ring>
<md-ripple ?disabled=${disabled}></md-ripple> <md-ripple ?disabled=${disabled}></md-ripple>
<svg class="trailing icon" viewBox="0 96 960 960" aria-hidden="true"> <svg class="trailing icon" viewBox="0 96 960 960" aria-hidden="true">
<path d="m249 849-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" /> <path
d="m249 849-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" />
</svg> </svg>
<span class="touch"></span> <span class="touch"></span>
</button> </button>
@ -45,8 +49,9 @@ function handleRemoveClick(this: Chip, event: Event) {
} }
event.stopPropagation(); event.stopPropagation();
const preventDefault = const preventDefault = !this.dispatchEvent(
!this.dispatchEvent(new Event('remove', {cancelable: true})); new Event('remove', {cancelable: true}),
);
if (preventDefault) { if (preventDefault) {
return; return;
} }

View File

@ -4,22 +4,30 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {Knob, textInput} from './index.js'; import {Knob, textInput} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Dialog', [ 'Dialog',
new Knob('icon', {defaultValue: '', ui: textInput()}), [
new Knob('headline', {defaultValue: 'Dialog', ui: textInput()}), new Knob('icon', {defaultValue: '', ui: textInput()}),
new Knob( new Knob('headline', {defaultValue: 'Dialog', ui: textInput()}),
'supportingText', new Knob('supportingText', {
{defaultValue: 'Just a simple dialog.', ui: textInput()}), defaultValue: 'Just a simple dialog.',
]); ui: textInput(),
}),
],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/iconbutton/icon-button.js';
import '@material/web/textfield/filled-text-field.js';
import '@material/web/radio/radio.js';
import '@material/web/icon/icon.js';
import '@material/web/button/filled-tonal-button.js';
import '@material/web/button/filled-button.js'; import '@material/web/button/filled-button.js';
import '@material/web/button/filled-tonal-button.js';
import '@material/web/button/text-button.js'; import '@material/web/button/text-button.js';
import '@material/web/dialog/dialog.js'; import '@material/web/dialog/dialog.js';
import '@material/web/icon/icon.js';
import '@material/web/iconbutton/icon-button.js';
import '@material/web/radio/radio.js';
import '@material/web/textfield/filled-text-field.js';
import {MdDialog} from '@material/web/dialog/dialog.js'; import {MdDialog} from '@material/web/dialog/dialog.js';
import {MaterialStoryInit} from './material-collection.js'; import {MaterialStoryInit} from './material-collection.js';
@ -32,8 +32,9 @@ const standard: MaterialStoryInit<StoryKnobs> = {
name: 'Dialog', name: 'Dialog',
render({icon, headline, supportingText}) { render({icon, headline, supportingText}) {
return html` return html`
<md-filled-button @click=${showDialog} <md-filled-button @click=${showDialog} aria-label="Open a dialog"
aria-label="Open a dialog">Open</md-filled-button> >Open</md-filled-button
>
<md-dialog aria-label=${headline ? nothing : 'A simple dialog'}> <md-dialog aria-label=${headline ? nothing : 'A simple dialog'}>
${icon ? html`<md-icon slot="icon">${icon}</md-icon>` : nothing} ${icon ? html`<md-icon slot="icon">${icon}</md-icon>` : nothing}
@ -47,15 +48,16 @@ const standard: MaterialStoryInit<StoryKnobs> = {
</div> </div>
</md-dialog> </md-dialog>
`; `;
} },
}; };
const alert: MaterialStoryInit<StoryKnobs> = { const alert: MaterialStoryInit<StoryKnobs> = {
name: 'Alert', name: 'Alert',
render() { render() {
return html` return html`
<md-filled-button @click=${showDialog} <md-filled-button @click=${showDialog} aria-label="Open an alert dialog"
aria-label="Open an alert dialog">Alert</md-filled-button> >Alert</md-filled-button
>
<md-dialog type="alert"> <md-dialog type="alert">
<div slot="headline">Alert dialog</div> <div slot="headline">Alert dialog</div>
@ -68,15 +70,18 @@ const alert: MaterialStoryInit<StoryKnobs> = {
</div> </div>
</md-dialog> </md-dialog>
`; `;
} },
}; };
const confirm: MaterialStoryInit<StoryKnobs> = { const confirm: MaterialStoryInit<StoryKnobs> = {
name: 'Confirm', name: 'Confirm',
render() { render() {
return html` return html`
<md-filled-button @click=${showDialog} <md-filled-button
aria-label="Open a confirmation dialog">Confirm</md-filled-button> @click=${showDialog}
aria-label="Open a confirmation dialog"
>Confirm</md-filled-button
>
<md-dialog style="max-width: 320px;"> <md-dialog style="max-width: 320px;">
<div slot="headline">Permanently delete?</div> <div slot="headline">Permanently delete?</div>
@ -87,12 +92,13 @@ const confirm: MaterialStoryInit<StoryKnobs> = {
</form> </form>
<div slot="actions"> <div slot="actions">
<md-text-button form="form" value="delete">Delete</md-text-button> <md-text-button form="form" value="delete">Delete</md-text-button>
<md-filled-tonal-button form="form" value="cancel" <md-filled-tonal-button form="form" value="cancel" autofocus
autofocus>Cancel</md-filled-tonal-button> >Cancel</md-filled-tonal-button
>
</div> </div>
</md-dialog> </md-dialog>
`; `;
} },
}; };
const choose: MaterialStoryInit<StoryKnobs> = { const choose: MaterialStoryInit<StoryKnobs> = {
@ -105,22 +111,36 @@ const choose: MaterialStoryInit<StoryKnobs> = {
`, `,
render() { render() {
return html` return html`
<md-filled-button @click=${showDialog} <md-filled-button @click=${showDialog} aria-label="Open a choice dialog"
aria-label="Open a choice dialog">Choice</md-filled-button> >Choice</md-filled-button
>
<md-dialog> <md-dialog>
<div slot="headline">Choose your favorite pet</div> <div slot="headline">Choose your favorite pet</div>
<form id="form" slot="content" method="dialog"> <form id="form" slot="content" method="dialog">
<label> <label>
<md-radio name="pet" value="cats" aria-label="Cats" touch-target="wrapper" checked></md-radio> <md-radio
name="pet"
value="cats"
aria-label="Cats"
touch-target="wrapper"
checked></md-radio>
<span aria-hidden="true">Cats</span> <span aria-hidden="true">Cats</span>
</label> </label>
<label> <label>
<md-radio name="pet" value="dogs" aria-label="Dogs" touch-target="wrapper"></md-radio> <md-radio
name="pet"
value="dogs"
aria-label="Dogs"
touch-target="wrapper"></md-radio>
<span aria-hidden="true">Dogs</span> <span aria-hidden="true">Dogs</span>
</label> </label>
<label> <label>
<md-radio name="pet" value="birds" aria-label="Birds" touch-target="wrapper"></md-radio> <md-radio
name="pet"
value="birds"
aria-label="Birds"
touch-target="wrapper"></md-radio>
<span aria-hidden="true">Birds</span> <span aria-hidden="true">Birds</span>
</label> </label>
</form> </form>
@ -130,7 +150,7 @@ const choose: MaterialStoryInit<StoryKnobs> = {
</div> </div>
</md-dialog> </md-dialog>
`; `;
} },
}; };
const contacts: MaterialStoryInit<StoryKnobs> = { const contacts: MaterialStoryInit<StoryKnobs> = {
@ -140,7 +160,7 @@ const contacts: MaterialStoryInit<StoryKnobs> = {
min-width: calc(100vw - 212px); min-width: calc(100vw - 212px);
} }
.contacts [slot="header"] { .contacts [slot='header'] {
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row-reverse;
align-items: center; align-items: center;
@ -150,7 +170,8 @@ const contacts: MaterialStoryInit<StoryKnobs> = {
flex: 1; flex: 1;
} }
.contact-content, .contact-row { .contact-content,
.contact-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
@ -165,8 +186,9 @@ const contacts: MaterialStoryInit<StoryKnobs> = {
`, `,
render() { render() {
return html` return html`
<md-filled-button @click=${showDialog} <md-filled-button @click=${showDialog} aria-label="Open a form dialog"
aria-label="Open a form dialog">Form</md-filled-button> >Form</md-filled-button
>
<md-dialog class="contacts"> <md-dialog class="contacts">
<span slot="headline"> <span slot="headline">
@ -177,7 +199,9 @@ const contacts: MaterialStoryInit<StoryKnobs> = {
</span> </span>
<form id="form" slot="content" method="dialog" class="contact-content"> <form id="form" slot="content" method="dialog" class="contact-content">
<div class="contact-row"> <div class="contact-row">
<md-filled-text-field autofocus label="First Name"></md-filled-text-field> <md-filled-text-field
autofocus
label="First Name"></md-filled-text-field>
<md-filled-text-field label="Last Name"></md-filled-text-field> <md-filled-text-field label="Last Name"></md-filled-text-field>
</div> </div>
<div class="contact-row"> <div class="contact-row">
@ -188,24 +212,23 @@ const contacts: MaterialStoryInit<StoryKnobs> = {
<md-filled-text-field label="Phone"></md-filled-text-field> <md-filled-text-field label="Phone"></md-filled-text-field>
</form> </form>
<div slot="actions"> <div slot="actions">
<md-text-button form="form" value="reset" type="reset">Reset</md-text-button> <md-text-button form="form" value="reset" type="reset"
>Reset</md-text-button
>
<div style="flex: 1"></div> <div style="flex: 1"></div>
<md-text-button form="form" value="cancel">Cancel</md-text-button> <md-text-button form="form" value="cancel">Cancel</md-text-button>
<md-text-button form="form" value="save">Save</md-text-button> <md-text-button form="form" value="save">Save</md-text-button>
</div> </div>
</md-dialog> </md-dialog>
`; `;
} },
}; };
const floatingSheet: MaterialStoryInit<StoryKnobs> = { const floatingSheet: MaterialStoryInit<StoryKnobs> = {
name: 'Floating sheet', name: 'Floating sheet',
render() { render() {
return html` return html`
<md-filled-button <md-filled-button @click=${showDialog} aria-label="Open a floating sheet">
@click=${showDialog}
aria-label="Open a floating sheet"
>
Floating sheet Floating sheet
</md-filled-button> </md-filled-button>
@ -217,16 +240,21 @@ const floatingSheet: MaterialStoryInit<StoryKnobs> = {
</md-icon-button> </md-icon-button>
</span> </span>
<form id="form" slot="content" method="dialog"> <form id="form" slot="content" method="dialog">
This is a floating sheet with title. This is a floating sheet with title. Floating sheets offer no action
Floating sheets offer no action buttons at the bottom, buttons at the bottom, but there's a close icon button at the top
but there's a close icon button at the top right. right. They accept any HTML content.
They accept any HTML content.
</form> </form>
</md-dialog> </md-dialog>
`; `;
} },
}; };
/** Dialog stories. */ /** Dialog stories. */
export const stories = export const stories = [
[standard, alert, confirm, choose, contacts, floatingSheet]; standard,
alert,
confirm,
choose,
contacts,
floatingSheet,
];

View File

@ -20,7 +20,7 @@ describe('<md-dialog>', () => {
<md-dialog> <md-dialog>
<form id="form" method="dialog" slot="content"> <form id="form" method="dialog" slot="content">
Content Content
<input autofocus> <input autofocus />
</form> </form>
<div slot="actions"> <div slot="actions">
<button form="form" value="button">Close</button> <button form="form" value="button">Close</button>
@ -107,14 +107,19 @@ describe('<md-dialog>', () => {
it('closes when element with action is clicked', async () => { it('closes when element with action is clicked', async () => {
const {harness} = await setupTest(); const {harness} = await setupTest();
await harness.element.show(); await harness.element.show();
const closedPromise = new Promise<void>(resolve => { const closedPromise = new Promise<void>((resolve) => {
harness.element.addEventListener('closed', () => { harness.element.addEventListener(
resolve(); 'closed',
}, {once: true}); () => {
resolve();
},
{once: true},
);
}); });
harness.element.querySelector<HTMLButtonElement>( harness.element
'[value="button"]')!.click(); .querySelector<HTMLButtonElement>('[value="button"]')!
.click();
await closedPromise; await closedPromise;
expect(harness.element.open).toBeFalse(); expect(harness.element.open).toBeFalse();
expect(harness.element.returnValue).toBe('button'); expect(harness.element.returnValue).toBe('button');
@ -132,19 +137,18 @@ describe('<md-dialog>', () => {
expect(isClosing).toHaveBeenCalled(); expect(isClosing).toHaveBeenCalled();
}); });
it('focuses element with autofocus when shown and previously focused element when closed', it('focuses element with autofocus when shown and previously focused element when closed', async () => {
async () => { const {harness, focusElement} = await setupTest();
const {harness, focusElement} = await setupTest(); const button = document.createElement('button');
const button = document.createElement('button'); document.body.append(button);
document.body.append(button); button.focus();
button.focus(); expect(document.activeElement).toBe(button);
expect(document.activeElement).toBe(button); await harness.element.show();
await harness.element.show(); expect(document.activeElement).toBe(focusElement);
expect(document.activeElement).toBe(focusElement); await harness.element.close();
await harness.element.close(); expect(document.activeElement).toBe(button);
expect(document.activeElement).toBe(button); button.remove();
button.remove(); });
});
}); });
it('should set returnValue during the close event', async () => { it('should set returnValue during the close event', async () => {
@ -159,14 +163,14 @@ describe('<md-dialog>', () => {
const returnValue = 'foo'; const returnValue = 'foo';
await harness.element.close(returnValue); await harness.element.close(returnValue);
expect(returnValueDuringClose) expect(returnValueDuringClose)
.withContext('dialog.returnValue during close event') .withContext('dialog.returnValue during close event')
.toBe(returnValue); .toBe(returnValue);
}); });
it('should not change returnValue if close event is canceled', async () => { it('should not change returnValue if close event is canceled', async () => {
const {harness} = await setupTest(); const {harness} = await setupTest();
harness.element.addEventListener('close', event => { harness.element.addEventListener('close', (event) => {
event.preventDefault(); event.preventDefault();
}); });
@ -174,8 +178,8 @@ describe('<md-dialog>', () => {
const prevReturnValue = harness.element.returnValue; const prevReturnValue = harness.element.returnValue;
await harness.element.close('new return value'); await harness.element.close('new return value');
expect(harness.element.returnValue) expect(harness.element.returnValue)
.withContext('dialog.returnValue after close event canceled') .withContext('dialog.returnValue after close event canceled')
.toBe(prevReturnValue); .toBe(prevReturnValue);
}); });
it('should open on connected if opened before connected to DOM', async () => { it('should open on connected if opened before connected to DOM', async () => {
@ -185,50 +189,49 @@ describe('<md-dialog>', () => {
dialog.addEventListener('open', openListener); dialog.addEventListener('open', openListener);
dialog.open = true; dialog.open = true;
expect(openListener) expect(openListener)
.withContext('should not trigger open before connected') .withContext('should not trigger open before connected')
.not.toHaveBeenCalled(); .not.toHaveBeenCalled();
const root = env.render(html``); const root = env.render(html``);
root.appendChild(dialog); root.appendChild(dialog);
await env.waitForStability(); await env.waitForStability();
expect(openListener) expect(openListener)
.withContext('opens after connecting') .withContext('opens after connecting')
.toHaveBeenCalled(); .toHaveBeenCalled();
}); });
it('should not open on connected if opened, but closed before connected to DOM', it('should not open on connected if opened, but closed before connected to DOM', async () => {
async () => { const openListener = jasmine.createSpy('openListener');
const openListener = jasmine.createSpy('openListener'); const dialog = document.createElement('md-dialog');
const dialog = document.createElement('md-dialog'); disableDialogAnimations(dialog);
disableDialogAnimations(dialog); dialog.addEventListener('open', openListener);
dialog.addEventListener('open', openListener); dialog.open = true;
dialog.open = true; await env.waitForStability();
await env.waitForStability(); dialog.open = false;
dialog.open = false; const root = env.render(html``);
const root = env.render(html``); root.appendChild(dialog);
root.appendChild(dialog); await env.waitForStability();
await env.waitForStability(); expect(openListener)
expect(openListener) .withContext('should not open on connected since close was called')
.withContext('should not open on connected since close was called') .not.toHaveBeenCalled();
.not.toHaveBeenCalled(); });
});
it('should not open on connected if opened before connection but closed after', it('should not open on connected if opened before connection but closed after', async () => {
async () => { const openListener = jasmine.createSpy('openListener');
const openListener = jasmine.createSpy('openListener'); const dialog = document.createElement('md-dialog');
const dialog = document.createElement('md-dialog'); disableDialogAnimations(dialog);
disableDialogAnimations(dialog); dialog.addEventListener('open', openListener);
dialog.addEventListener('open', openListener); dialog.open = true;
dialog.open = true; const root = env.render(html``);
const root = env.render(html``); root.appendChild(dialog);
root.appendChild(dialog); dialog.open = false;
dialog.open = false; await env.waitForStability();
await env.waitForStability(); expect(openListener)
expect(openListener) .withContext(
.withContext( 'should not open on connected since close was called before open could complete',
'should not open on connected since close was called before open could complete') )
.not.toHaveBeenCalled(); .not.toHaveBeenCalled();
}); });
it('should not dispatch close if closed while disconnected', async () => { it('should not dispatch close if closed while disconnected', async () => {
const {harness, root} = await setupTest(); const {harness, root} = await setupTest();
@ -240,19 +243,19 @@ describe('<md-dialog>', () => {
await env.waitForStability(); await env.waitForStability();
expect(closeListener) expect(closeListener)
.withContext('should not trigger close when disconnected') .withContext('should not trigger close when disconnected')
.not.toHaveBeenCalled(); .not.toHaveBeenCalled();
await harness.element.close(); await harness.element.close();
expect(closeListener) expect(closeListener)
.withContext('should not trigger close when disconnected') .withContext('should not trigger close when disconnected')
.not.toHaveBeenCalled(); .not.toHaveBeenCalled();
root.appendChild(harness.element); root.appendChild(harness.element);
await env.waitForStability(); await env.waitForStability();
expect(closeListener) expect(closeListener)
.withContext('should not trigger close when disconnected') .withContext('should not trigger close when disconnected')
.not.toHaveBeenCalled(); .not.toHaveBeenCalled();
}); });
}); });

View File

@ -14,7 +14,8 @@ import {Dialog} from './internal/dialog.js';
export class DialogHarness extends Harness<Dialog> { export class DialogHarness extends Harness<Dialog> {
override async getInteractiveElement() { override async getInteractiveElement() {
await this.element.updateComplete; await this.element.updateComplete;
return this.element.querySelector<HTMLElement>('[autocomplete]') ?? return (
this.element; this.element.querySelector<HTMLElement>('[autocomplete]') ?? this.element
);
} }
} }

View File

@ -54,20 +54,21 @@ export const DIALOG_DEFAULT_OPEN_ANIMATION: DialogAnimation = {
[ [
// Dialog slide down // Dialog slide down
[{'transform': 'translateY(-50px)'}, {'transform': 'translateY(0)'}], [{'transform': 'translateY(-50px)'}, {'transform': 'translateY(0)'}],
{duration: 500, easing: EASING.EMPHASIZED} {duration: 500, easing: EASING.EMPHASIZED},
], ],
], ],
scrim: [ scrim: [
[ [
// Scrim fade in // Scrim fade in
[{'opacity': 0}, {'opacity': 0.32}], {duration: 500, easing: 'linear'} [{'opacity': 0}, {'opacity': 0.32}],
{duration: 500, easing: 'linear'},
], ],
], ],
container: [ container: [
[ [
// Container fade in // Container fade in
[{'opacity': 0}, {'opacity': 1}], [{'opacity': 0}, {'opacity': 1}],
{duration: 50, easing: 'linear', pseudoElement: '::before'} {duration: 50, easing: 'linear', pseudoElement: '::before'},
], ],
[ [
// Container grow // Container grow
@ -83,21 +84,21 @@ export const DIALOG_DEFAULT_OPEN_ANIMATION: DialogAnimation = {
[ [
// Headline fade in // Headline fade in
[{'opacity': 0}, {'opacity': 0, offset: 0.2}, {'opacity': 1}], [{'opacity': 0}, {'opacity': 0, offset: 0.2}, {'opacity': 1}],
{duration: 250, easing: 'linear', fill: 'forwards'} {duration: 250, easing: 'linear', fill: 'forwards'},
], ],
], ],
content: [ content: [
[ [
// Content fade in // Content fade in
[{'opacity': 0}, {'opacity': 0, offset: 0.2}, {'opacity': 1}], [{'opacity': 0}, {'opacity': 0, offset: 0.2}, {'opacity': 1}],
{duration: 250, easing: 'linear', fill: 'forwards'} {duration: 250, easing: 'linear', fill: 'forwards'},
], ],
], ],
actions: [ actions: [
[ [
// Actions fade in // Actions fade in
[{'opacity': 0}, {'opacity': 0, offset: 0.5}, {'opacity': 1}], [{'opacity': 0}, {'opacity': 0, offset: 0.5}, {'opacity': 1}],
{duration: 300, easing: 'linear', fill: 'forwards'} {duration: 300, easing: 'linear', fill: 'forwards'},
], ],
], ],
}; };
@ -110,13 +111,14 @@ export const DIALOG_DEFAULT_CLOSE_ANIMATION: DialogAnimation = {
[ [
// Dialog slide up // Dialog slide up
[{'transform': 'translateY(0)'}, {'transform': 'translateY(-50px)'}], [{'transform': 'translateY(0)'}, {'transform': 'translateY(-50px)'}],
{duration: 150, easing: EASING.EMPHASIZED_ACCELERATE} {duration: 150, easing: EASING.EMPHASIZED_ACCELERATE},
], ],
], ],
scrim: [ scrim: [
[ [
// Scrim fade out // Scrim fade out
[{'opacity': 0.32}, {'opacity': 0}], {duration: 150, easing: 'linear'} [{'opacity': 0.32}, {'opacity': 0}],
{duration: 150, easing: 'linear'},
], ],
], ],
container: [ container: [
@ -133,27 +135,27 @@ export const DIALOG_DEFAULT_CLOSE_ANIMATION: DialogAnimation = {
// Container fade out // Container fade out
[{'opacity': '1'}, {'opacity': '0'}], [{'opacity': '1'}, {'opacity': '0'}],
{delay: 100, duration: 50, easing: 'linear', pseudoElement: '::before'}, {delay: 100, duration: 50, easing: 'linear', pseudoElement: '::before'},
] ],
], ],
headline: [ headline: [
[ [
// Headline fade out // Headline fade out
[{'opacity': 1}, {'opacity': 0}], [{'opacity': 1}, {'opacity': 0}],
{duration: 100, easing: 'linear', fill: 'forwards'} {duration: 100, easing: 'linear', fill: 'forwards'},
], ],
], ],
content: [ content: [
[ [
// Content fade out // Content fade out
[{'opacity': 1}, {'opacity': 0}], [{'opacity': 1}, {'opacity': 0}],
{duration: 100, easing: 'linear', fill: 'forwards'} {duration: 100, easing: 'linear', fill: 'forwards'},
], ],
], ],
actions: [ actions: [
[ [
// Actions fade out // Actions fade out
[{'opacity': 1}, {'opacity': 0}], [{'opacity': 1}, {'opacity': 0}],
{duration: 100, easing: 'linear', fill: 'forwards'} {duration: 100, easing: 'linear', fill: 'forwards'},
], ],
], ],
}; };

View File

@ -14,7 +14,12 @@ import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {redispatchEvent} from '../../internal/controller/events.js'; import {redispatchEvent} from '../../internal/controller/events.js';
import {DIALOG_DEFAULT_CLOSE_ANIMATION, DIALOG_DEFAULT_OPEN_ANIMATION, DialogAnimation, DialogAnimationArgs} from './animations.js'; import {
DIALOG_DEFAULT_CLOSE_ANIMATION,
DIALOG_DEFAULT_OPEN_ANIMATION,
DialogAnimation,
DialogAnimationArgs,
} from './animations.js';
/** /**
* A dialog component. * A dialog component.
@ -34,7 +39,7 @@ export class Dialog extends LitElement {
/** @nocollapse */ /** @nocollapse */
static override shadowRootOptions = { static override shadowRootOptions = {
...LitElement.shadowRootOptions, ...LitElement.shadowRootOptions,
delegatesFocus: true delegatesFocus: true,
}; };
/** /**
@ -91,17 +96,17 @@ export class Dialog extends LitElement {
// getIsConnectedPromise() immediately sets the resolve property. // getIsConnectedPromise() immediately sets the resolve property.
private isConnectedPromiseResolve!: () => void; private isConnectedPromiseResolve!: () => void;
private isConnectedPromise = this.getIsConnectedPromise(); private isConnectedPromise = this.getIsConnectedPromise();
@query('dialog') private readonly dialog!: HTMLDialogElement|null; @query('dialog') private readonly dialog!: HTMLDialogElement | null;
@query('.scrim') private readonly scrim!: HTMLDialogElement|null; @query('.scrim') private readonly scrim!: HTMLDialogElement | null;
@query('.container') private readonly container!: HTMLDialogElement|null; @query('.container') private readonly container!: HTMLDialogElement | null;
@query('.headline') private readonly headline!: HTMLDialogElement|null; @query('.headline') private readonly headline!: HTMLDialogElement | null;
@query('.content') private readonly content!: HTMLDialogElement|null; @query('.content') private readonly content!: HTMLDialogElement | null;
@query('.actions') private readonly actions!: HTMLDialogElement|null; @query('.actions') private readonly actions!: HTMLDialogElement | null;
@state() private isAtScrollTop = false; @state() private isAtScrollTop = false;
@state() private isAtScrollBottom = false; @state() private isAtScrollBottom = false;
@query('.scroller') private readonly scroller!: HTMLElement|null; @query('.scroller') private readonly scroller!: HTMLElement | null;
@query('.top.anchor') private readonly topAnchor!: HTMLElement|null; @query('.top.anchor') private readonly topAnchor!: HTMLElement | null;
@query('.bottom.anchor') private readonly bottomAnchor!: HTMLElement|null; @query('.bottom.anchor') private readonly bottomAnchor!: HTMLElement | null;
private nextClickIsFromContent = false; private nextClickIsFromContent = false;
private intersectionObserver?: IntersectionObserver; private intersectionObserver?: IntersectionObserver;
// Dialogs should not be SSR'd while open, so we can just use runtime checks. // Dialogs should not be SSR'd while open, so we can just use runtime checks.
@ -139,8 +144,9 @@ export class Dialog extends LitElement {
return; return;
} }
const preventOpen = const preventOpen = !this.dispatchEvent(
!this.dispatchEvent(new Event('open', {cancelable: true})); new Event('open', {cancelable: true}),
);
if (preventOpen) { if (preventOpen) {
this.open = false; this.open = false;
return; return;
@ -191,8 +197,9 @@ export class Dialog extends LitElement {
const prevReturnValue = this.returnValue; const prevReturnValue = this.returnValue;
this.returnValue = returnValue; this.returnValue = returnValue;
const preventClose = const preventClose = !this.dispatchEvent(
!this.dispatchEvent(new Event('close', {cancelable: true})); new Event('close', {cancelable: true}),
);
if (preventClose) { if (preventClose) {
this.returnValue = prevReturnValue; this.returnValue = prevReturnValue;
return; return;
@ -216,7 +223,7 @@ export class Dialog extends LitElement {
protected override render() { protected override render() {
const scrollable = const scrollable =
this.open && !(this.isAtScrollTop && this.isAtScrollBottom); this.open && !(this.isAtScrollTop && this.isAtScrollBottom);
const classes = { const classes = {
'has-headline': this.hasHeadline, 'has-headline': this.hasHeadline,
'has-actions': this.hasActions, 'has-actions': this.hasActions,
@ -236,18 +243,16 @@ export class Dialog extends LitElement {
role=${this.type === 'alert' ? 'alertdialog' : nothing} role=${this.type === 'alert' ? 'alertdialog' : nothing}
@cancel=${this.handleCancel} @cancel=${this.handleCancel}
@click=${this.handleDialogClick} @click=${this.handleDialogClick}
.returnValue=${this.returnValue || nothing} .returnValue=${this.returnValue || nothing}>
> <div class="container" @click=${this.handleContentClick}>
<div class="container"
@click=${this.handleContentClick}
>
<div class="headline"> <div class="headline">
<div class="icon" aria-hidden="true"> <div class="icon" aria-hidden="true">
<slot name="icon" @slotchange=${this.handleIconChange}></slot> <slot name="icon" @slotchange=${this.handleIconChange}></slot>
</div> </div>
<h2 id="headline" aria-hidden=${!this.hasHeadline || nothing}> <h2 id="headline" aria-hidden=${!this.hasHeadline || nothing}>
<slot name="headline" <slot
@slotchange=${this.handleHeadlineChange}></slot> name="headline"
@slotchange=${this.handleHeadlineChange}></slot>
</h2> </h2>
<md-divider></md-divider> <md-divider></md-divider>
</div> </div>
@ -260,8 +265,7 @@ export class Dialog extends LitElement {
</div> </div>
<div class="actions"> <div class="actions">
<md-divider></md-divider> <md-divider></md-divider>
<slot name="actions" <slot name="actions" @slotchange=${this.handleActionsChange}></slot>
@slotchange=${this.handleActionsChange}></slot>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -269,11 +273,14 @@ export class Dialog extends LitElement {
} }
protected override firstUpdated() { protected override firstUpdated() {
this.intersectionObserver = new IntersectionObserver(entries => { this.intersectionObserver = new IntersectionObserver(
for (const entry of entries) { (entries) => {
this.handleAnchorIntersection(entry); for (const entry of entries) {
} this.handleAnchorIntersection(entry);
}, {root: this.scroller!}); }
},
{root: this.scroller!},
);
this.intersectionObserver.observe(this.topAnchor!); this.intersectionObserver.observe(this.topAnchor!);
this.intersectionObserver.observe(this.bottomAnchor!); this.intersectionObserver.observe(this.bottomAnchor!);
@ -289,8 +296,9 @@ export class Dialog extends LitElement {
// Click originated on the backdrop. Native `<dialog>`s will not cancel, // Click originated on the backdrop. Native `<dialog>`s will not cancel,
// but Material dialogs do. // but Material dialogs do.
const preventDefault = const preventDefault = !this.dispatchEvent(
!this.dispatchEvent(new Event('cancel', {cancelable: true})); new Event('cancel', {cancelable: true}),
);
if (preventDefault) { if (preventDefault) {
return; return;
} }
@ -343,13 +351,16 @@ export class Dialog extends LitElement {
scrim: scrimAnimate, scrim: scrimAnimate,
headline: headlineAnimate, headline: headlineAnimate,
content: contentAnimate, content: contentAnimate,
actions: actionsAnimate actions: actionsAnimate,
} = animation; } = animation;
const elementAndAnimation: Array<[Element, DialogAnimationArgs[]]> = [ const elementAndAnimation: Array<[Element, DialogAnimationArgs[]]> = [
[dialog, dialogAnimate ?? []], [scrim, scrimAnimate ?? []], [dialog, dialogAnimate ?? []],
[container, containerAnimate ?? []], [headline, headlineAnimate ?? []], [scrim, scrimAnimate ?? []],
[content, contentAnimate ?? []], [actions, actionsAnimate ?? []] [container, containerAnimate ?? []],
[headline, headlineAnimate ?? []],
[content, contentAnimate ?? []],
[actions, actionsAnimate ?? []],
]; ];
const animations: Animation[] = []; const animations: Animation[] = [];
@ -359,7 +370,7 @@ export class Dialog extends LitElement {
} }
} }
await Promise.all(animations.map(animation => animation.finished)); await Promise.all(animations.map((animation) => animation.finished));
} }
private handleHeadlineChange(event: Event) { private handleHeadlineChange(event: Event) {
@ -389,7 +400,7 @@ export class Dialog extends LitElement {
} }
private getIsConnectedPromise() { private getIsConnectedPromise() {
return new Promise<void>(resolve => { return new Promise<void>((resolve) => {
this.isConnectedPromiseResolve = resolve; this.isConnectedPromiseResolve = resolve;
}); });
} }

View File

@ -4,20 +4,27 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {boolInput, Knob} from './index.js'; import {boolInput, Knob} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Divider', [ 'Divider',
new Knob('inset', {defaultValue: true, ui: boolInput()}), [
new Knob('inset (start)', {defaultValue: false, ui: boolInput()}), new Knob('inset', {defaultValue: true, ui: boolInput()}),
new Knob('inset (end)', {defaultValue: false, ui: boolInput()}), new Knob('inset (start)', {defaultValue: false, ui: boolInput()}),
]); new Knob('inset (end)', {defaultValue: false, ui: boolInput()}),
],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -35,25 +35,24 @@ const standard: MaterialStoryInit<StoryKnobs> = {
`, `,
render(knobs) { render(knobs) {
return html` return html`
<ul aria-label="A list of items with decorative and non-decorative separators"> <ul
aria-label="A list of items with decorative and non-decorative separators">
<li>List item one</li> <li>List item one</li>
<md-divider <md-divider
?inset=${knobs.inset} ?inset=${knobs.inset}
?inset-start=${knobs['inset (start)']} ?inset-start=${knobs['inset (start)']}
?inset-end=${knobs['inset (end)']} ?inset-end=${knobs['inset (end)']}></md-divider>
></md-divider>
<li>List item two</li> <li>List item two</li>
<md-divider role="separator"></md-divider> <md-divider role="separator"></md-divider>
<li>List item three</li> <li>List item three</li>
<md-divider <md-divider
?inset=${knobs.inset} ?inset=${knobs.inset}
?inset-start=${knobs['inset (start)']} ?inset-start=${knobs['inset (start)']}
?inset-end=${knobs['inset (end)']} ?inset-end=${knobs['inset (end)']}></md-divider>
></md-divider>
<li>List item four</li> <li>List item four</li>
</ul> </ul>
`; `;
} },
}; };
/** Divider stories. */ /** Divider stories. */

View File

@ -4,18 +4,23 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {Knob, numberInput} from './index.js'; import {Knob, numberInput} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Elevation', [ 'Elevation',
new Knob('level', {defaultValue: 1, ui: numberInput()}), [new Knob('level', {defaultValue: 1, ui: numberInput()})],
]); );
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -4,12 +4,22 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {FabSize} from '@material/web/fab/fab.js'; import {FabSize} from '@material/web/fab/fab.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
import {boolInput, Knob, selectDropdown, textInput} from './index.js'; KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {
boolInput,
Knob,
selectDropdown,
textInput,
} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
@ -24,8 +34,8 @@ const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('FAB', [
{label: 'medium', value: 'medium'}, {label: 'medium', value: 'medium'},
{label: 'small', value: 'small'}, {label: 'small', value: 'small'},
{label: 'large', value: 'large'}, {label: 'large', value: 'large'},
] ],
}) }),
}), }),
]); ]);

View File

@ -4,12 +4,15 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/fab/branded-fab.js';
import '@material/web/fab/fab.js'; import '@material/web/fab/fab.js';
import '@material/web/icon/icon.js'; import '@material/web/icon/icon.js';
import '@material/web/fab/branded-fab.js';
import {FabSize} from '@material/web/fab/fab.js'; import {FabSize} from '@material/web/fab/fab.js';
import {labelStyles, MaterialStoryInit} from './material-collection.js'; import {
labelStyles,
MaterialStoryInit,
} from './material-collection.js';
import {css, html, nothing} from 'lit'; import {css, html, nothing} from 'lit';
/** Knob types for fab stories. */ /** Knob types for fab stories. */
@ -17,7 +20,7 @@ export interface StoryKnobs {
icon: string; icon: string;
label: string; label: string;
lowered: boolean; lowered: boolean;
size: FabSize|undefined; size: FabSize | undefined;
} }
const styles = css` const styles = css`
@ -46,8 +49,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
variant="surface" variant="surface"
.lowered=${lowered} .lowered=${lowered}
.label=${label} .label=${label}
.size=${size!} .size=${size!}>
>
<md-icon slot="icon">${icon}</md-icon> <md-icon slot="icon">${icon}</md-icon>
</md-fab> </md-fab>
</label> </label>
@ -59,8 +61,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
variant="primary" variant="primary"
.lowered=${lowered} .lowered=${lowered}
.label=${label} .label=${label}
.size=${size!} .size=${size!}>
>
<md-icon slot="icon">${icon}</md-icon> <md-icon slot="icon">${icon}</md-icon>
</md-fab> </md-fab>
</label> </label>
@ -72,8 +73,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
variant="secondary" variant="secondary"
.lowered=${lowered} .lowered=${lowered}
.label=${label} .label=${label}
.size=${size!} .size=${size!}>
>
<md-icon slot="icon">${icon}</md-icon> <md-icon slot="icon">${icon}</md-icon>
</md-fab> </md-fab>
</label> </label>
@ -85,8 +85,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
variant="tertiary" variant="tertiary"
.lowered=${lowered} .lowered=${lowered}
.label=${label} .label=${label}
.size=${size!} .size=${size!}>
>
<md-icon slot="icon">${icon}</md-icon> <md-icon slot="icon">${icon}</md-icon>
</md-fab> </md-fab>
</label> </label>
@ -97,8 +96,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
aria-label=${label ? nothing : 'An example branded FAB'} aria-label=${label ? nothing : 'An example branded FAB'}
.lowered=${lowered} .lowered=${lowered}
.label=${label} .label=${label}
.size=${size!} .size=${size!}>
>
<svg slot="icon" width="36" height="36" viewBox="0 0 36 36"> <svg slot="icon" width="36" height="36" viewBox="0 0 36 36">
<path fill="#34A853" d="M16 16v14h4V20z"></path> <path fill="#34A853" d="M16 16v14h4V20z"></path>
<path fill="#4285F4" d="M30 16H20l-4 4h14z"></path> <path fill="#4285F4" d="M30 16H20l-4 4h14z"></path>
@ -110,7 +108,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
</label> </label>
</div> </div>
`; `;
} },
}; };
/** Checkbox stories. */ /** Checkbox stories. */

View File

@ -156,8 +156,9 @@ describe('<md-branded-fab>', () => {
const env = new Environment(); const env = new Environment();
async function setupTest() { async function setupTest() {
const element = env.render(html`<md-branded-fab></md-branded-fab>`) const element = env
.querySelector('md-branded-fab'); .render(html`<md-branded-fab></md-branded-fab>`)
.querySelector('md-branded-fab');
if (!element) { if (!element) {
throw new Error('Could not query rendered <md-branded-fab>.'); throw new Error('Could not query rendered <md-branded-fab>.');
} }

View File

@ -14,7 +14,6 @@ import {Fab} from './internal/fab.js';
export class FabHarness extends Harness<Fab> { export class FabHarness extends Harness<Fab> {
override async getInteractiveElement() { override async getInteractiveElement() {
await this.element.updateComplete; await this.element.updateComplete;
return this.element.renderRoot.querySelector('.fab') as return this.element.renderRoot.querySelector('.fab') as HTMLButtonElement;
HTMLButtonElement;
} }
} }

View File

@ -11,7 +11,7 @@ import {SharedFab} from './shared.js';
/** /**
* The variants available to non-branded FABs. * The variants available to non-branded FABs.
*/ */
export type FabVariant = 'surface'|'primary'|'secondary'|'tertiary'; export type FabVariant = 'surface' | 'primary' | 'secondary' | 'tertiary';
// tslint:disable-next-line:enforce-comments-on-exported-symbols // tslint:disable-next-line:enforce-comments-on-exported-symbols
export class Fab extends SharedFab { export class Fab extends SharedFab {

View File

@ -18,7 +18,7 @@ import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
/** /**
* Sizes variants available to non-extended FABs. * Sizes variants available to non-extended FABs.
*/ */
export type FabSize = 'medium'|'small'|'large'; export type FabSize = 'medium' | 'small' | 'large';
// tslint:disable-next-line:enforce-comments-on-exported-symbols // tslint:disable-next-line:enforce-comments-on-exported-symbols
export abstract class SharedFab extends LitElement { export abstract class SharedFab extends LitElement {
@ -45,7 +45,6 @@ export abstract class SharedFab extends LitElement {
*/ */
@property() label = ''; @property() label = '';
/** /**
* Lowers the FAB's elevation. * Lowers the FAB's elevation.
*/ */
@ -57,14 +56,11 @@ export abstract class SharedFab extends LitElement {
return html` return html`
<button <button
class="fab ${classMap(this.getRenderClasses())}" class="fab ${classMap(this.getRenderClasses())}"
aria-label=${ariaLabel || nothing} aria-label=${ariaLabel || nothing}>
>
<md-elevation></md-elevation> <md-elevation></md-elevation>
<md-focus-ring part="focus-ring"></md-focus-ring> <md-focus-ring part="focus-ring"></md-focus-ring>
<md-ripple class="ripple"></md-ripple> <md-ripple class="ripple"></md-ripple>
${this.renderTouchTarget()} ${this.renderTouchTarget()} ${this.renderIcon()} ${this.renderLabel()}
${this.renderIcon()}
${this.renderLabel()}
</button> </button>
`; `;
} }
@ -90,12 +86,13 @@ export abstract class SharedFab extends LitElement {
private renderIcon() { private renderIcon() {
const {ariaLabel} = this as ARIAMixinStrict; const {ariaLabel} = this as ARIAMixinStrict;
return html`<span class="icon"> return html`<span class="icon">
<slot <slot
name="icon" name="icon"
aria-hidden=${ aria-hidden=${ariaLabel || this.label
ariaLabel || this.label ? 'true' : nothing as unknown as 'false'}> ? 'true'
<span></span> : (nothing as unknown as 'false')}>
</slot> <span></span>
</span>`; </slot>
</span>`;
} }
} }

View File

@ -4,32 +4,45 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
import {boolInput, Knob, numberInput, textInput} from './index.js'; KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {
boolInput,
Knob,
numberInput,
textInput,
} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Field', [ 'Field',
new Knob('label', {ui: textInput(), defaultValue: 'Label'}), [
new Knob( new Knob('label', {ui: textInput(), defaultValue: 'Label'}),
'Supporting text', new Knob('Supporting text', {
{ui: textInput(), defaultValue: 'Supporting text'}), ui: textInput(),
new Knob('Error text', {ui: textInput(), defaultValue: 'Error text'}), defaultValue: 'Supporting text',
new Knob('count', {ui: numberInput(), defaultValue: 0}), }),
new Knob('max', {ui: numberInput(), defaultValue: 0}), new Knob('Error text', {ui: textInput(), defaultValue: 'Error text'}),
new Knob('disabled', {ui: boolInput(), defaultValue: false}), new Knob('count', {ui: numberInput(), defaultValue: 0}),
new Knob('error', {ui: boolInput(), defaultValue: false}), new Knob('max', {ui: numberInput(), defaultValue: 0}),
new Knob('focused', {ui: boolInput(), defaultValue: false}), new Knob('disabled', {ui: boolInput(), defaultValue: false}),
new Knob('populated', {ui: boolInput(), defaultValue: false}), new Knob('error', {ui: boolInput(), defaultValue: false}),
new Knob('required', {ui: boolInput(), defaultValue: false}), new Knob('focused', {ui: boolInput(), defaultValue: false}),
new Knob('Leading icon', {ui: boolInput(), defaultValue: false}), new Knob('populated', {ui: boolInput(), defaultValue: false}),
new Knob('Trailing icon', {ui: boolInput(), defaultValue: false}), new Knob('required', {ui: boolInput(), defaultValue: false}),
new Knob('resizable', {ui: boolInput(), defaultValue: false}), new Knob('Leading icon', {ui: boolInput(), defaultValue: false}),
]); new Knob('Trailing icon', {ui: boolInput(), defaultValue: false}),
new Knob('resizable', {ui: boolInput(), defaultValue: false}),
],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/icon/icon.js';
import '@material/web/field/filled-field.js'; import '@material/web/field/filled-field.js';
import '@material/web/field/outlined-field.js'; import '@material/web/field/outlined-field.js';
import '@material/web/icon/icon.js';
import {MaterialStoryInit} from './material-collection.js'; import {MaterialStoryInit} from './material-collection.js';
import {css, html, nothing} from 'lit'; import {css, html, nothing} from 'lit';
@ -52,16 +52,16 @@ const filled: MaterialStoryInit<StoryKnobs> = {
required, required,
resizable, resizable,
count, count,
max max,
} = knobs; } = knobs;
const supportingText = knobs['Supporting text']; const supportingText = knobs['Supporting text'];
const errorText = knobs['Error text']; const errorText = knobs['Error text'];
const hasStart = knobs['Leading icon']; const hasStart = knobs['Leading icon'];
const hasEnd = knobs['Trailing icon']; const hasEnd = knobs['Trailing icon'];
const content = resizable ? const content = resizable
html`<textarea rows="1" ?disabled=${disabled}></textarea>` : ? html`<textarea rows="1" ?disabled=${disabled}></textarea>`
html`<input ?disabled=${disabled}>`; : html`<input ?disabled=${disabled} />`;
const styles = {resize: resizable ? 'both' : null}; const styles = {resize: resizable ? 'both' : null};
return html` return html`
@ -78,14 +78,12 @@ const filled: MaterialStoryInit<StoryKnobs> = {
supporting-text=${supportingText} supporting-text=${supportingText}
error-text=${errorText} error-text=${errorText}
count=${count} count=${count}
max=${max} max=${max}>
> ${hasStart ? START_CONTENT : nothing} ${content}
${hasStart ? START_CONTENT : nothing}
${content}
${hasEnd ? END_CONTENT : nothing} ${hasEnd ? END_CONTENT : nothing}
</md-filled-field> </md-filled-field>
`; `;
} },
}; };
const outlined: MaterialStoryInit<StoryKnobs> = { const outlined: MaterialStoryInit<StoryKnobs> = {
@ -101,16 +99,18 @@ const outlined: MaterialStoryInit<StoryKnobs> = {
required, required,
resizable, resizable,
count, count,
max max,
} = knobs; } = knobs;
const supportingText = knobs['Supporting text']; const supportingText = knobs['Supporting text'];
const errorText = knobs['Error text']; const errorText = knobs['Error text'];
const hasStart = knobs['Leading icon']; const hasStart = knobs['Leading icon'];
const hasEnd = knobs['Trailing icon']; const hasEnd = knobs['Trailing icon'];
const content = resizable ? const content = resizable
html`<textarea rows="1" ?disabled=${ ? html`<textarea
disabled} aria-describedby="description"></textarea>` : rows="1"
html`<input ?disabled=${disabled} aria-describedby="description">`; ?disabled=${disabled}
aria-describedby="description"></textarea>`
: html`<input ?disabled=${disabled} aria-describedby="description" />`;
const styles = {resize: resizable ? 'both' : null}; const styles = {resize: resizable ? 'both' : null};
return html` return html`
@ -127,15 +127,13 @@ const outlined: MaterialStoryInit<StoryKnobs> = {
supporting-text=${supportingText} supporting-text=${supportingText}
error-text=${errorText} error-text=${errorText}
count=${count} count=${count}
max=${max} max=${max}>
>
<div id="description" slot="aria-describedby" hidden></div> <div id="description" slot="aria-describedby" hidden></div>
${hasStart ? START_CONTENT : nothing} ${hasStart ? START_CONTENT : nothing} ${content}
${content}
${hasEnd ? END_CONTENT : nothing} ${hasEnd ? END_CONTENT : nothing}
</md-outlined-field> </md-outlined-field>
`; `;
} },
}; };
/** Field stories. */ /** Field stories. */

View File

@ -30,6 +30,6 @@ export class FieldHarness extends Harness<Field> {
protected override async getInteractiveElement() { protected override async getInteractiveElement() {
await this.element.updateComplete; await this.element.updateComplete;
return (this.element.querySelector(':not([slot])') || return (this.element.querySelector(':not([slot])') ||
this.element.renderRoot.querySelector('.field')) as HTMLElement; this.element.renderRoot.querySelector('.field')) as HTMLElement;
} }
} }

View File

@ -4,7 +4,14 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import {html, LitElement, nothing, PropertyValues, render, TemplateResult} from 'lit'; import {
html,
LitElement,
nothing,
PropertyValues,
render,
TemplateResult,
} from 'lit';
import {property, query, queryAssignedElements, state} from 'lit/decorators.js'; import {property, query, queryAssignedElements, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js'; import {classMap} from 'lit/directives/class-map.js';
@ -60,9 +67,10 @@ export class Field extends LitElement {
*/ */
@state() private refreshErrorAlert = false; @state() private refreshErrorAlert = false;
@state() private disableTransitions = false; @state() private disableTransitions = false;
@query('.label.floating') private readonly floatingLabelEl!: HTMLElement|null; @query('.label.floating')
@query('.label.resting') private readonly restingLabelEl!: HTMLElement|null; private readonly floatingLabelEl!: HTMLElement | null;
@query('.container') private readonly containerEl!: HTMLElement|null; @query('.label.resting') private readonly restingLabelEl!: HTMLElement | null;
@query('.container') private readonly containerEl!: HTMLElement | null;
/** /**
* Re-announces the field's error supporting text to screen readers. * Re-announces the field's error supporting text to screen readers.
@ -78,7 +86,7 @@ export class Field extends LitElement {
protected override update(props: PropertyValues<Field>) { protected override update(props: PropertyValues<Field>) {
// Client-side property updates // Client-side property updates
const isDisabledChanging = const isDisabledChanging =
props.has('disabled') && props.get('disabled') !== undefined; props.has('disabled') && props.get('disabled') !== undefined;
if (isDisabledChanging) { if (isDisabledChanging) {
this.disableTransitions = true; this.disableTransitions = true;
} }
@ -92,7 +100,7 @@ export class Field extends LitElement {
// Animate if focused or populated change. // Animate if focused or populated change.
this.animateLabelIfNeeded({ this.animateLabelIfNeeded({
wasFocused: props.get('focused'), wasFocused: props.get('focused'),
wasPopulated: props.get('populated') wasPopulated: props.get('populated'),
}); });
super.update(props); super.update(props);
@ -118,17 +126,14 @@ export class Field extends LitElement {
return html` return html`
<div class="field ${classMap(classes)}"> <div class="field ${classMap(classes)}">
<div class="container-overflow"> <div class="container-overflow">
${this.renderBackground?.()} ${this.renderBackground?.()} ${this.renderIndicator?.()} ${outline}
${this.renderIndicator?.()}
${outline}
<div class="container"> <div class="container">
<div class="start"> <div class="start">
<slot name="start"></slot> <slot name="start"></slot>
</div> </div>
<div class="middle"> <div class="middle">
<div class="label-wrapper"> <div class="label-wrapper">
${restingLabel} ${restingLabel} ${outline ? nothing : floatingLabel}
${outline ? nothing : floatingLabel}
</div> </div>
<div class="content"> <div class="content">
<slot></slot> <slot></slot>
@ -145,8 +150,12 @@ export class Field extends LitElement {
} }
protected override updated(changed: PropertyValues<Field>) { protected override updated(changed: PropertyValues<Field>) {
if (changed.has('supportingText') || changed.has('errorText') || if (
changed.has('count') || changed.has('max')) { changed.has('supportingText') ||
changed.has('errorText') ||
changed.has('count') ||
changed.has('max')
) {
this.updateSlottedAriaDescribedBy(); this.updateSlottedAriaDescribedBy();
} }
@ -180,21 +189,22 @@ export class Field extends LitElement {
const start = html`<span>${supportingOrErrorText}</span>`; const start = html`<span>${supportingOrErrorText}</span>`;
// Conditionally render counter so we don't render the extra `gap`. // Conditionally render counter so we don't render the extra `gap`.
// TODO(b/244473435): add aria-label and announcements // TODO(b/244473435): add aria-label and announcements
const end = counterText ? const end = counterText
html`<span class="counter">${counterText}</span>` : ? html`<span class="counter">${counterText}</span>`
nothing; : nothing;
// Announce if there is an error and error text visible. // Announce if there is an error and error text visible.
// If refreshErrorAlert is true, do not announce. This will remove the // If refreshErrorAlert is true, do not announce. This will remove the
// role="alert" attribute. Another render cycle will happen after an // role="alert" attribute. Another render cycle will happen after an
// animation frame to re-add the role. // animation frame to re-add the role.
const shouldErrorAnnounce = const shouldErrorAnnounce =
this.error && this.errorText && !this.refreshErrorAlert; this.error && this.errorText && !this.refreshErrorAlert;
const role = shouldErrorAnnounce ? 'alert' : nothing; const role = shouldErrorAnnounce ? 'alert' : nothing;
return html` return html`
<div class="supporting-text" role=${role}>${start}${end}</div> <div class="supporting-text" role=${role}>${start}${end}</div>
<slot name="aria-describedby" @slotchange=${ <slot
this.updateSlottedAriaDescribedBy}></slot> name="aria-describedby"
@slotchange=${this.updateSlottedAriaDescribedBy}></slot>
`; `;
} }
@ -230,15 +240,18 @@ export class Field extends LitElement {
const labelText = `${this.label}${this.required ? '*' : ''}`; const labelText = `${this.label}${this.required ? '*' : ''}`;
return html` return html`
<span class="label ${classMap(classes)}" <span class="label ${classMap(classes)}" aria-hidden=${!visible}
aria-hidden=${!visible} >${labelText}</span
>${labelText}</span> >
`; `;
} }
private animateLabelIfNeeded({wasFocused, wasPopulated}: { private animateLabelIfNeeded({
wasFocused?: boolean, wasFocused,
wasPopulated?: boolean wasPopulated,
}: {
wasFocused?: boolean;
wasPopulated?: boolean;
}) { }) {
if (!this.label) { if (!this.label) {
return; return;
@ -268,7 +281,9 @@ export class Field extends LitElement {
// from appearing. // from appearing.
// TODO(b/241113345): use animation tokens // TODO(b/241113345): use animation tokens
this.labelAnimation = this.floatingLabelEl?.animate( this.labelAnimation = this.floatingLabelEl?.animate(
this.getLabelKeyframes(), {duration: 150, easing: EASING.STANDARD}); this.getLabelKeyframes(),
{duration: 150, easing: EASING.STANDARD},
);
this.labelAnimation?.addEventListener('finish', () => { this.labelAnimation?.addEventListener('finish', () => {
// At the end of the animation, update the visible label. // At the end of the animation, update the visible label.
@ -282,10 +297,16 @@ export class Field extends LitElement {
return []; return [];
} }
const {x: floatingX, y: floatingY, height: floatingHeight} = const {
floatingLabelEl.getBoundingClientRect(); x: floatingX,
const {x: restingX, y: restingY, height: restingHeight} = y: floatingY,
restingLabelEl.getBoundingClientRect(); height: floatingHeight,
} = floatingLabelEl.getBoundingClientRect();
const {
x: restingX,
y: restingY,
height: restingHeight,
} = restingLabelEl.getBoundingClientRect();
const floatingScrollWidth = floatingLabelEl.scrollWidth; const floatingScrollWidth = floatingLabelEl.scrollWidth;
const restingScrollWidth = restingLabelEl.scrollWidth; const restingScrollWidth = restingLabelEl.scrollWidth;
// Scale by width ratio instead of font size since letter-spacing will scale // Scale by width ratio instead of font size since letter-spacing will scale
@ -298,14 +319,15 @@ export class Field extends LitElement {
// we move the floating label down to the resting label's position, it won't // we move the floating label down to the resting label's position, it won't
// exactly match because of this. We need to adjust by half of what the // exactly match because of this. We need to adjust by half of what the
// final scaled floating label's height will be. // final scaled floating label's height will be.
const yDelta = restingY - floatingY + const yDelta =
Math.round((restingHeight - floatingHeight * scale) / 2); restingY -
floatingY +
Math.round((restingHeight - floatingHeight * scale) / 2);
// Create the two transforms: floating to resting (using the calculations // Create the two transforms: floating to resting (using the calculations
// above), and resting to floating (re-setting the transform to initial // above), and resting to floating (re-setting the transform to initial
// values). // values).
const restTransform = const restTransform = `translateX(${xDelta}px) translateY(${yDelta}px) scale(${scale})`;
`translateX(${xDelta}px) translateY(${yDelta}px) scale(${scale})`;
const floatTransform = `translateX(0) translateY(0) scale(1)`; const floatTransform = `translateX(0) translateY(0) scale(1)`;
// Constrain the floating labels width to a scaled percentage of the // Constrain the floating labels width to a scaled percentage of the
@ -316,12 +338,14 @@ export class Field extends LitElement {
const width = isRestingClipped ? `${restingClientWidth / scale}px` : ''; const width = isRestingClipped ? `${restingClientWidth / scale}px` : '';
if (this.focused || this.populated) { if (this.focused || this.populated) {
return [ return [
{transform: restTransform, width}, {transform: floatTransform, width} {transform: restTransform, width},
{transform: floatTransform, width},
]; ];
} }
return [ return [
{transform: floatTransform, width}, {transform: restTransform, width} {transform: floatTransform, width},
{transform: restTransform, width},
]; ];
} }

View File

@ -31,8 +31,11 @@ class TestField extends Field {
} }
didErrorAnnounce() { didErrorAnnounce() {
return this.renderRoot.querySelector('.supporting-text') return (
?.getAttribute('role') === 'alert'; this.renderRoot
.querySelector('.supporting-text')
?.getAttribute('role') === 'alert'
);
} }
// Ensure floating/resting labels are both rendered // Ensure floating/resting labels are both rendered
@ -54,9 +57,8 @@ describe('Field', () => {
.populated=${props.populated ?? false} .populated=${props.populated ?? false}
.required=${props.required ?? false} .required=${props.required ?? false}
.supportingText=${props.supportingText ?? ''} .supportingText=${props.supportingText ?? ''}
.errorText=${props.errorText ?? ''} .errorText=${props.errorText ?? ''}>
> <input />
<input>
</md-test-field> </md-test-field>
`; `;
const root = env.render(template); const root = env.render(template);
@ -82,8 +84,8 @@ describe('Field', () => {
await env.waitForStability(); await env.waitForStability();
// Assertion. // Assertion.
expect(instance.focused) expect(instance.focused)
.withContext('focused is false after disabled is set to true') .withContext('focused is false after disabled is set to true')
.toBe(false); .toBe(false);
}); });
it('should not allow focus when disabled', async () => { it('should not allow focus when disabled', async () => {
@ -94,8 +96,8 @@ describe('Field', () => {
await env.waitForStability(); await env.waitForStability();
// Assertion. // Assertion.
expect(instance.focused) expect(instance.focused)
.withContext('focused set back to false when disabled') .withContext('focused set back to false when disabled')
.toBe(false); .toBe(false);
}); });
/* /*
@ -291,9 +293,10 @@ describe('Field', () => {
const {instance} = await setupTest({label: undefined}); const {instance} = await setupTest({label: undefined});
// Assertion. // Assertion.
expect(instance.labelText) expect(instance.labelText)
.withContext( .withContext(
'label text should be empty string if label is not provided') 'label text should be empty string if label is not provided',
.toBe(''); )
.toBe('');
}); });
it('should render label', async () => { it('should render label', async () => {
@ -303,8 +306,8 @@ describe('Field', () => {
const {instance} = await setupTest({label: labelValue}); const {instance} = await setupTest({label: labelValue});
// Assertion. // Assertion.
expect(instance.labelText) expect(instance.labelText)
.withContext('label text should equal label when not required') .withContext('label text should equal label when not required')
.toBe(labelValue); .toBe(labelValue);
}); });
it('should adds asterisk if required', async () => { it('should adds asterisk if required', async () => {
@ -314,44 +317,49 @@ describe('Field', () => {
const {instance} = await setupTest({required: true, label: labelValue}); const {instance} = await setupTest({required: true, label: labelValue});
// Assertion. // Assertion.
expect(instance.labelText) expect(instance.labelText)
.withContext( .withContext(
'label text should equal label with asterisk when required') 'label text should equal label with asterisk when required',
.toBe(`${labelValue}*`); )
.toBe(`${labelValue}*`);
}); });
it('should not render asterisk if required when there is no label', it('should not render asterisk if required when there is no label', async () => {
async () => { // Setup.
// Setup. // Test case.
// Test case. const {instance} = await setupTest({required: true, label: undefined});
const {instance} = await setupTest({required: true, label: undefined}); // Assertion.
// Assertion. expect(instance.labelText)
expect(instance.labelText) .withContext(
.withContext( 'label text should be empty string if label is not provided, even when required',
'label text should be empty string if label is not provided, even when required') )
.toBe(''); .toBe('');
}); });
}); });
describe('supporting text', () => { describe('supporting text', () => {
it('should update to errorText when error is true', async () => { it('should update to errorText when error is true', async () => {
const errorText = 'Error message'; const errorText = 'Error message';
const {instance} = await setupTest( const {instance} = await setupTest({
{error: true, supportingText: 'Supporting text', errorText}); error: true,
supportingText: 'Supporting text',
errorText,
});
expect(instance.supportingTextContent).toEqual(errorText); expect(instance.supportingTextContent).toEqual(errorText);
}); });
}); });
describe('error announcement', () => { describe('error announcement', () => {
it('should announce errors when both error and errorText are set', it('should announce errors when both error and errorText are set', async () => {
async () => { const {instance} = await setupTest({
const {instance} = error: true,
await setupTest({error: true, errorText: 'Error message'}); errorText: 'Error message',
});
expect(instance.didErrorAnnounce()) expect(instance.didErrorAnnounce())
.withContext('instance.didErrorAnnounce()') .withContext('instance.didErrorAnnounce()')
.toBeTrue(); .toBeTrue();
}); });
it('should not announce supporting text', async () => { it('should not announce supporting text', async () => {
const {instance} = await setupTest(); const {instance} = await setupTest();
@ -360,26 +368,28 @@ describe('Field', () => {
await env.waitForStability(); await env.waitForStability();
expect(instance.didErrorAnnounce()) expect(instance.didErrorAnnounce())
.withContext('instance.didErrorAnnounce()') .withContext('instance.didErrorAnnounce()')
.toBeFalse(); .toBeFalse();
}); });
it('should re-announce when reannounceError() is called', async () => { it('should re-announce when reannounceError() is called', async () => {
const {instance} = const {instance} = await setupTest({
await setupTest({error: true, errorText: 'Error message'}); error: true,
errorText: 'Error message',
});
instance.reannounceError(); instance.reannounceError();
await env.waitForStability(); await env.waitForStability();
// After lit update, but before re-render refresh // After lit update, but before re-render refresh
expect(instance.didErrorAnnounce()) expect(instance.didErrorAnnounce())
.withContext('didErrorAnnounce() before refresh') .withContext('didErrorAnnounce() before refresh')
.toBeFalse(); .toBeFalse();
// After the second lit update render refresh // After the second lit update render refresh
await env.waitForStability(); await env.waitForStability();
expect(instance.didErrorAnnounce()) expect(instance.didErrorAnnounce())
.withContext('didErrorAnnounce() after refresh') .withContext('didErrorAnnounce() after refresh')
.toBeTrue(); .toBeTrue();
}); });
}); });
}); });

View File

@ -4,18 +4,23 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {boolInput, Knob} from './index.js'; import {boolInput, Knob} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Focus', [ 'Focus',
new Knob('inward', {ui: boolInput(), defaultValue: false}), [new Knob('inward', {ui: boolInput(), defaultValue: false})],
]); );
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -55,13 +55,13 @@ const standard: MaterialStoryInit<StoryKnobs> = {
<md-focus-ring ?inward=${inward}></md-focus-ring> <md-focus-ring ?inward=${inward}></md-focus-ring>
</button> </button>
`; `;
} },
}; };
const multiAction: MaterialStoryInit<StoryKnobs> = { const multiAction: MaterialStoryInit<StoryKnobs> = {
name: 'Multi-action components', name: 'Multi-action components',
styles: css` styles: css`
[role="list"] { [role='list'] {
align-items: center; align-items: center;
appearance: none; appearance: none;
background: var(--md-sys-color-surface); background: var(--md-sys-color-surface);
@ -76,11 +76,11 @@ const multiAction: MaterialStoryInit<StoryKnobs> = {
position: relative; position: relative;
} }
[role="list"]:focus-within { [role='list']:focus-within {
background: var(--md-sys-color-surface-variant); background: var(--md-sys-color-surface-variant);
} }
[role="listitem"] { [role='listitem'] {
display: flex; display: flex;
flex: 1; flex: 1;
} }
@ -105,7 +105,7 @@ const multiAction: MaterialStoryInit<StoryKnobs> = {
--md-focus-ring-shape: 32px; --md-focus-ring-shape: 32px;
} }
[role="list"]::before, [role='list']::before,
#secondary::before { #secondary::before {
border: 1px solid var(--md-sys-color-outline); border: 1px solid var(--md-sys-color-outline);
border-radius: inherit; border-radius: inherit;
@ -120,20 +120,24 @@ const multiAction: MaterialStoryInit<StoryKnobs> = {
<md-focus-ring for="primary" ?inward=${inward}></md-focus-ring> <md-focus-ring for="primary" ?inward=${inward}></md-focus-ring>
<div role="listitem"> <div role="listitem">
<button id="primary" aria-label="The primary action for a multi-action component"> <button
id="primary"
aria-label="The primary action for a multi-action component">
Action Action
</button> </button>
</div> </div>
<div role="listitem"> <div role="listitem">
<button id="secondary" aria-label="The secondary action for a multi-action component"> <button
id="secondary"
aria-label="The secondary action for a multi-action component">
X X
<md-focus-ring ?inward=${inward}></md-focus-ring> <md-focus-ring ?inward=${inward}></md-focus-ring>
</button> </button>
</div> </div>
</div> </div>
`; `;
} },
}; };
/** Focus ring stories. */ /** Focus ring stories. */

View File

@ -7,7 +7,10 @@
import {isServer, LitElement, PropertyValues} from 'lit'; import {isServer, LitElement, PropertyValues} from 'lit';
import {property} from 'lit/decorators.js'; import {property} from 'lit/decorators.js';
import {Attachable, AttachableController} from '../../internal/controller/attachable-controller.js'; import {
Attachable,
AttachableController,
} from '../../internal/controller/attachable-controller.js';
/** /**
* Events that the focus ring listens to. * Events that the focus ring listens to.
@ -34,19 +37,21 @@ export class FocusRing extends LitElement implements Attachable {
return this.attachableController.htmlFor; return this.attachableController.htmlFor;
} }
set htmlFor(htmlFor: string|null) { set htmlFor(htmlFor: string | null) {
this.attachableController.htmlFor = htmlFor; this.attachableController.htmlFor = htmlFor;
} }
get control() { get control() {
return this.attachableController.control; return this.attachableController.control;
} }
set control(control: HTMLElement|null) { set control(control: HTMLElement | null) {
this.attachableController.control = control; this.attachableController.control = control;
} }
private readonly attachableController = private readonly attachableController = new AttachableController(
new AttachableController(this, this.onControlChange.bind(this)); this,
this.onControlChange.bind(this),
);
attach(control: HTMLElement) { attach(control: HTMLElement) {
this.attachableController.attach(control); this.attachableController.attach(control);
@ -86,7 +91,7 @@ export class FocusRing extends LitElement implements Attachable {
event[HANDLED_BY_FOCUS_RING] = true; event[HANDLED_BY_FOCUS_RING] = true;
} }
private onControlChange(prev: HTMLElement|null, next: HTMLElement|null) { private onControlChange(prev: HTMLElement | null, next: HTMLElement | null) {
if (isServer) return; if (isServer) return;
for (const event of EVENTS) { for (const event of EVENTS) {

View File

@ -64,27 +64,26 @@ describe('focus ring', () => {
expect(focusRing.control).withContext('focusRing.control').toBe(button); expect(focusRing.control).withContext('focusRing.control').toBe(button);
}); });
it('should update a referenced element when for attribute changes', it('should update a referenced element when for attribute changes', async () => {
async () => { const {root, focusRing} = setupTest(html`
const {root, focusRing} = setupTest(html` <button id="first"></button>
<button id="first"></button> <button id="second"></button>
<button id="second"></button> <test-focus-ring for="first"></test-focus-ring>
<test-focus-ring for="first"></test-focus-ring> `);
`);
const secondButton = root.querySelector<HTMLElement>('#second'); const secondButton = root.querySelector<HTMLElement>('#second');
if (!secondButton) { if (!secondButton) {
throw new Error('Could not query rendered <button id="second">'); throw new Error('Could not query rendered <button id="second">');
} }
focusRing.setAttribute('for', 'second'); focusRing.setAttribute('for', 'second');
expect(focusRing.control) expect(focusRing.control)
.withContext('focusRing.control') .withContext('focusRing.control')
.toBe(secondButton); .toBe(secondButton);
await new Harness(secondButton).focusWithKeyboard(); await new Harness(secondButton).focusWithKeyboard();
expect(focusRing.visible).withContext('focusRing.visible').toBeTrue(); expect(focusRing.visible).withContext('focusRing.visible').toBeTrue();
}); });
it('should be able to be imperatively attached', () => { it('should be able to be imperatively attached', () => {
const {button, focusRing} = setupTest(html` const {button, focusRing} = setupTest(html`
@ -104,12 +103,12 @@ describe('focus ring', () => {
`); `);
expect(focusRing.control) expect(focusRing.control)
.withContext('focusRing.control before attach') .withContext('focusRing.control before attach')
.toBe(button); .toBe(button);
focusRing.attach(button); focusRing.attach(button);
expect(focusRing.control) expect(focusRing.control)
.withContext('focusRing.control after attach') .withContext('focusRing.control after attach')
.toBe(button); .toBe(button);
}); });
it('should detach previous control when attaching a new one', async () => { it('should detach previous control when attaching a new one', async () => {
@ -170,8 +169,8 @@ describe('focus ring', () => {
await harness.clickWithMouse(); await harness.clickWithMouse();
expect(focusRing.visible) expect(focusRing.visible)
.withContext('focusRing.visible after clickWithMouse') .withContext('focusRing.visible after clickWithMouse')
.toBeFalse(); .toBeFalse();
}); });
it('should be visible on keyboard focus', async () => { it('should be visible on keyboard focus', async () => {
@ -183,8 +182,8 @@ describe('focus ring', () => {
await harness.focusWithKeyboard(); await harness.focusWithKeyboard();
expect(focusRing.visible) expect(focusRing.visible)
.withContext('focusRing.visible after focusWithKeyboard') .withContext('focusRing.visible after focusWithKeyboard')
.toBeTrue(); .toBeTrue();
}); });
it('should hide on blur', async () => { it('should hide on blur', async () => {
@ -197,7 +196,7 @@ describe('focus ring', () => {
focusRing.visible = true; focusRing.visible = true;
await harness.blur(); await harness.blur();
expect(focusRing.visible) expect(focusRing.visible)
.withContext('focusRing.visible after blur') .withContext('focusRing.visible after blur')
.toBeFalse(); .toBeFalse();
}); });
}); });

View File

@ -23,8 +23,7 @@ describe('<md-icon>', () => {
describe('accessiblity', () => { describe('accessiblity', () => {
it('sets aria-hidden to true by default', async () => { it('sets aria-hidden to true by default', async () => {
const root = env.render(html` const root = env.render(html` <md-icon>check</md-icon>`);
<md-icon>check</md-icon>`);
const icon = root.querySelector('md-icon')!; const icon = root.querySelector('md-icon')!;
await env.waitForStability(); await env.waitForStability();
@ -33,8 +32,9 @@ describe('<md-icon>', () => {
}); });
it('sets aria-hidden is removed when initalized as false', async () => { it('sets aria-hidden is removed when initalized as false', async () => {
const root = env.render(html` const root = env.render(html` <md-icon aria-hidden="false"
<md-icon aria-hidden="false">check</md-icon>`); >check</md-icon
>`);
const icon = root.querySelector('md-icon')!; const icon = root.querySelector('md-icon')!;
await env.waitForStability(); await env.waitForStability();
@ -43,8 +43,7 @@ describe('<md-icon>', () => {
}); });
it('allows overriding aria-hidden after first render', async () => { it('allows overriding aria-hidden after first render', async () => {
const root = env.render(html` const root = env.render(html` <md-icon>check</md-icon>`);
<md-icon>check</md-icon>`);
const icon = root.querySelector('md-icon')!; const icon = root.querySelector('md-icon')!;
await env.waitForStability(); await env.waitForStability();
@ -58,9 +57,9 @@ describe('<md-icon>', () => {
}); });
it('overrides invalid aria-hidden values to true', async () => { it('overrides invalid aria-hidden values to true', async () => {
const root = env.render(html` const root =
<!-- @ts-ignore:disable-next-line:no-incompatible-type-binding --> env.render(html` <!-- @ts-ignore:disable-next-line:no-incompatible-type-binding -->
<md-icon aria-hidden="foo">check</md-icon>`); <md-icon aria-hidden="foo">check</md-icon>`);
const icon = root.querySelector('md-icon')!; const icon = root.querySelector('md-icon')!;
await env.waitForStability(); await env.waitForStability();

View File

@ -4,20 +4,27 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import './index.js';
import './material-collection.js'; import './material-collection.js';
import './index.js';
import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js'; import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {boolInput, Knob, textInput} from './index.js'; import {boolInput, Knob, textInput} from './index.js';
import {stories, StoryKnobs} from './stories.js'; import {stories, StoryKnobs} from './stories.js';
const collection = const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Icon Button', [ 'Icon Button',
new Knob('icon', {ui: textInput(), defaultValue: 'check'}), [
new Knob('selectedIcon', {ui: textInput(), defaultValue: 'close'}), new Knob('icon', {ui: textInput(), defaultValue: 'check'}),
new Knob('disabled', {ui: boolInput(), defaultValue: false}), new Knob('selectedIcon', {ui: textInput(), defaultValue: 'close'}),
]); new Knob('disabled', {ui: boolInput(), defaultValue: false}),
],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -7,8 +7,8 @@
import '@material/web/icon/icon.js'; import '@material/web/icon/icon.js';
import '@material/web/iconbutton/filled-icon-button.js'; import '@material/web/iconbutton/filled-icon-button.js';
import '@material/web/iconbutton/filled-tonal-icon-button.js'; import '@material/web/iconbutton/filled-tonal-icon-button.js';
import '@material/web/iconbutton/outlined-icon-button.js';
import '@material/web/iconbutton/icon-button.js'; import '@material/web/iconbutton/icon-button.js';
import '@material/web/iconbutton/outlined-icon-button.js';
import {MaterialStoryInit} from './material-collection.js'; import {MaterialStoryInit} from './material-collection.js';
import {css, html} from 'lit'; import {css, html} from 'lit';
@ -40,10 +40,7 @@ const buttons: MaterialStoryInit<StoryKnobs> = {
return html` return html`
<div class="column"> <div class="column">
<div class="row"> <div class="row">
<md-icon-button <md-icon-button aria-label="Standard icon" ?disabled=${disabled}>
aria-label="Standard icon"
?disabled=${disabled}
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
</md-icon-button> </md-icon-button>
@ -53,10 +50,7 @@ const buttons: MaterialStoryInit<StoryKnobs> = {
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
</md-outlined-icon-button> </md-outlined-icon-button>
<md-filled-icon-button <md-filled-icon-button aria-label="Filled icon" ?disabled=${disabled}>
aria-label="Filled icon"
?disabled=${disabled}
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
</md-filled-icon-button> </md-filled-icon-button>
@ -68,7 +62,7 @@ const buttons: MaterialStoryInit<StoryKnobs> = {
</div> </div>
</div> </div>
`; `;
} },
}; };
const toggles: MaterialStoryInit<StoryKnobs> = { const toggles: MaterialStoryInit<StoryKnobs> = {
@ -81,8 +75,7 @@ const toggles: MaterialStoryInit<StoryKnobs> = {
<md-icon-button <md-icon-button
aria-label="Standard icon" aria-label="Standard icon"
toggle toggle
?disabled=${disabled} ?disabled=${disabled}>
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
<md-icon slot="selected">${selectedIcon}</md-icon> <md-icon slot="selected">${selectedIcon}</md-icon>
</md-icon-button> </md-icon-button>
@ -90,8 +83,7 @@ const toggles: MaterialStoryInit<StoryKnobs> = {
<md-outlined-icon-button <md-outlined-icon-button
aria-label="Outlined icon" aria-label="Outlined icon"
toggle toggle
?disabled=${disabled} ?disabled=${disabled}>
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
<md-icon slot="selected">${selectedIcon}</md-icon> <md-icon slot="selected">${selectedIcon}</md-icon>
</md-outlined-icon-button> </md-outlined-icon-button>
@ -99,8 +91,7 @@ const toggles: MaterialStoryInit<StoryKnobs> = {
<md-filled-icon-button <md-filled-icon-button
aria-label="Filled icon" aria-label="Filled icon"
toggle toggle
?disabled=${disabled} ?disabled=${disabled}>
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
<md-icon slot="selected">${selectedIcon}</md-icon> <md-icon slot="selected">${selectedIcon}</md-icon>
</md-filled-icon-button> </md-filled-icon-button>
@ -108,15 +99,14 @@ const toggles: MaterialStoryInit<StoryKnobs> = {
<md-filled-tonal-icon-button <md-filled-tonal-icon-button
aria-label="Filled tonal icon" aria-label="Filled tonal icon"
toggle toggle
?disabled=${disabled} ?disabled=${disabled}>
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
<md-icon slot="selected">${selectedIcon}</md-icon> <md-icon slot="selected">${selectedIcon}</md-icon>
</md-filled-tonal-icon-button> </md-filled-tonal-icon-button>
</div> </div>
</div> </div>
`; `;
} },
}; };
const links: MaterialStoryInit<StoryKnobs> = { const links: MaterialStoryInit<StoryKnobs> = {
@ -129,38 +119,34 @@ const links: MaterialStoryInit<StoryKnobs> = {
<md-icon-button <md-icon-button
aria-label="Standard icon" aria-label="Standard icon"
href="https://google.com" href="https://google.com"
target="_blank" target="_blank">
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
</md-icon-button> </md-icon-button>
<md-outlined-icon-button <md-outlined-icon-button
aria-label="Outlined icon" aria-label="Outlined icon"
href="https://google.com" href="https://google.com"
target="_blank" target="_blank">
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
</md-outlined-icon-button> </md-outlined-icon-button>
<md-filled-icon-button <md-filled-icon-button
aria-label="Filled icon" aria-label="Filled icon"
href="https://google.com" href="https://google.com"
target="_blank" target="_blank">
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
</md-filled-icon-button> </md-filled-icon-button>
<md-filled-tonal-icon-button <md-filled-tonal-icon-button
aria-label="Filled tonal icon" aria-label="Filled tonal icon"
href="https://google.com" href="https://google.com"
target="_blank" target="_blank">
>
<md-icon>${icon}</md-icon> <md-icon>${icon}</md-icon>
</md-filled-tonal-icon-button> </md-filled-tonal-icon-button>
</div> </div>
</div> </div>
`; `;
} },
}; };
/** Icon button stories. */ /** Icon button stories. */

View File

@ -14,4 +14,4 @@ describe('<md-filled-icon-button>', () => {
describe('.styles', () => { describe('.styles', () => {
createTokenTests(MdFilledIconButton.styles); createTokenTests(MdFilledIconButton.styles);
}); });
}); });

View File

@ -14,4 +14,4 @@ describe('<md-filled-tonal-icon-button', () => {
describe('.styles', () => { describe('.styles', () => {
createTokenTests(MdFilledTonalIconButton.styles); createTokenTests(MdFilledTonalIconButton.styles);
}); });
}); });

View File

@ -44,73 +44,83 @@ describe('icon button tests', () => {
}); });
describe('md-icon-button', () => { describe('md-icon-button', () => {
it('setting `disabled` updates the disabled attribute on the native ' + it(
'button element', 'setting `disabled` updates the disabled attribute on the native ' +
async () => { 'button element',
const {element} = await setUpTest('button'); async () => {
const button = element.shadowRoot!.querySelector('button')!; const {element} = await setUpTest('button');
const button = element.shadowRoot!.querySelector('button')!;
element.disabled = true; element.disabled = true;
await element.updateComplete; await element.updateComplete;
expect(button.hasAttribute('disabled')).toBeTrue(); expect(button.hasAttribute('disabled')).toBeTrue();
element.disabled = false; element.disabled = false;
await element.updateComplete; await element.updateComplete;
expect(button.hasAttribute('disabled')).toBeFalse(); expect(button.hasAttribute('disabled')).toBeFalse();
}); },
);
it('setting `ariaLabel` updates the aria-label attribute on the native ' + it(
'button element', 'setting `ariaLabel` updates the aria-label attribute on the native ' +
async () => { 'button element',
const {element} = await setUpTest('button'); async () => {
const button = element.shadowRoot!.querySelector('button')!; const {element} = await setUpTest('button');
const button = element.shadowRoot!.querySelector('button')!;
element.ariaLabel = 'test'; element.ariaLabel = 'test';
await element.updateComplete; await element.updateComplete;
expect(button.getAttribute('aria-label')).toBe('test'); expect(button.getAttribute('aria-label')).toBe('test');
}); },
);
}); });
describe('md-icon-button link', () => { describe('md-icon-button link', () => {
it('setting `ariaLabel` updates the aria-label attribute on the anchor' + it(
'tag', 'setting `ariaLabel` updates the aria-label attribute on the anchor' +
async () => { 'tag',
const {element} = await setUpTest('link'); async () => {
const anchor = element.shadowRoot!.querySelector('a')!; const {element} = await setUpTest('link');
expect(anchor).not.toBeNull(); const anchor = element.shadowRoot!.querySelector('a')!;
expect(anchor).not.toBeNull();
element.ariaLabel = 'test'; element.ariaLabel = 'test';
await element.updateComplete; await element.updateComplete;
expect(anchor.getAttribute('aria-label')).toBe('test'); expect(anchor.getAttribute('aria-label')).toBe('test');
}); },
);
}); });
describe('md-icon-button toggle', () => { describe('md-icon-button toggle', () => {
it('setting `disabled` updates the disabled attribute on the native ' + it(
'button element', 'setting `disabled` updates the disabled attribute on the native ' +
async () => { 'button element',
const {element} = await setUpTest('toggle'); async () => {
const button = element.shadowRoot!.querySelector('button')!; const {element} = await setUpTest('toggle');
const button = element.shadowRoot!.querySelector('button')!;
element.disabled = true; element.disabled = true;
await element.updateComplete; await element.updateComplete;
expect(button.hasAttribute('disabled')).toBeTrue(); expect(button.hasAttribute('disabled')).toBeTrue();
element.disabled = false; element.disabled = false;
await element.updateComplete; await element.updateComplete;
expect(button.hasAttribute('disabled')).toBeFalse(); expect(button.hasAttribute('disabled')).toBeFalse();
}); },
);
it('setting `ariaLabel` updates the aria-label attribute on the native ' + it(
'button element', 'setting `ariaLabel` updates the aria-label attribute on the native ' +
async () => { 'button element',
const {element} = await setUpTest('toggle'); async () => {
const button = element.shadowRoot!.querySelector('button')!; const {element} = await setUpTest('toggle');
const button = element.shadowRoot!.querySelector('button')!;
element.ariaLabel = 'test'; element.ariaLabel = 'test';
await element.updateComplete; await element.updateComplete;
expect(button.getAttribute('aria-label')).toBe('test'); expect(button.getAttribute('aria-label')).toBe('test');
}); },
);
it('toggles the `selected` state when button is clicked', async () => { it('toggles the `selected` state when button is clicked', async () => {
const {element, harness} = await setUpTest('toggle'); const {element, harness} = await setUpTest('toggle');
@ -126,8 +136,8 @@ describe('icon button tests', () => {
const {element, harness} = await setUpTest('toggle'); const {element, harness} = await setUpTest('toggle');
let changeEvent = false; let changeEvent = false;
let inputEvent = false; let inputEvent = false;
element.addEventListener('input', () => inputEvent = true); element.addEventListener('input', () => (inputEvent = true));
element.addEventListener('change', () => changeEvent = true); element.addEventListener('change', () => (changeEvent = true));
expect(element.selected).toBeFalse(); expect(element.selected).toBeFalse();
await harness.clickWithMouse(); await harness.clickWithMouse();
expect(element.selected).toBeTrue(); expect(element.selected).toBeTrue();
@ -135,19 +145,18 @@ describe('icon button tests', () => {
expect(changeEvent).toBeTrue(); expect(changeEvent).toBeTrue();
}); });
it('setting `selected` updates the aria-pressed attribute on the native button element', it('setting `selected` updates the aria-pressed attribute on the native button element', async () => {
async () => { const {element} = await setUpTest('toggle');
const {element} = await setUpTest('toggle');
element.selected = true; element.selected = true;
await element.updateComplete; await element.updateComplete;
const button = element.shadowRoot!.querySelector('button')!; const button = element.shadowRoot!.querySelector('button')!;
expect(button.getAttribute('aria-pressed')).toEqual('true'); expect(button.getAttribute('aria-pressed')).toEqual('true');
element.selected = false; element.selected = false;
await element.updateComplete; await element.updateComplete;
expect(button.getAttribute('aria-pressed')).toEqual('false'); expect(button.getAttribute('aria-pressed')).toEqual('false');
}); });
it('button with toggled aria label toggles aria label', async () => { it('button with toggled aria label toggles aria label', async () => {
const {element, harness} = await setUpTest('toggle'); const {element, harness} = await setUpTest('toggle');
@ -167,45 +176,41 @@ describe('icon button tests', () => {
}); });
it('if `flipsIconInRtl=true`, flips icon in an RTL context', async () => { it('if `flipsIconInRtl=true`, flips icon in an RTL context', async () => {
const template = html` const template = html` <div dir="rtl">
<div dir="rtl"> <md-icon-button aria-label="Star" .flipIconInRtl="${true}">
<md-icon-button aria-label="Star" .flipIconInRtl="${true}"> star
star </md-icon-button>
</md-icon-button> </div>`;
</div>`;
const element = env.render(template).querySelector('md-icon-button')!; const element = env.render(template).querySelector('md-icon-button')!;
await env.waitForStability(); await env.waitForStability();
expect((element as unknown as IconButtonInternals).flipIcon).toBeTrue(); expect((element as unknown as IconButtonInternals).flipIcon).toBeTrue();
}); });
it('if `flipsIconInRtl=true`, does not flip icon in an LTR context', it('if `flipsIconInRtl=true`, does not flip icon in an LTR context', async () => {
async () => { const template = html` <div dir="ltr">
const template = html` <md-icon-button aria-label="Star" .flipIconInRtl="${true}">
<div dir="ltr"> star
<md-icon-button aria-label="Star" .flipIconInRtl="${true}"> </md-icon-button>
star </div>`;
</md-icon-button> const element = env.render(template).querySelector('md-icon-button')!;
</div>`; await env.waitForStability();
const element = env.render(template).querySelector('md-icon-button')!;
await env.waitForStability();
expect((element as unknown as IconButtonInternals).flipIcon) expect((element as unknown as IconButtonInternals).flipIcon).toBeFalse();
.toBeFalse(); });
});
it('should allow preventing toggle click event', async () => { it('should allow preventing toggle click event', async () => {
const {element, harness} = await setUpTest('toggle'); const {element, harness} = await setUpTest('toggle');
element.addEventListener('click', event => { element.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
}); });
expect(element.selected).withContext('selected before click').toBeFalse(); expect(element.selected).withContext('selected before click').toBeFalse();
await harness.clickWithMouse(); await harness.clickWithMouse();
expect(element.selected) expect(element.selected)
.withContext('selected after prevent default click') .withContext('selected after prevent default click')
.toBeFalse(); .toBeFalse();
}); });
}); });

View File

@ -10,15 +10,19 @@ import '../../ripple/ripple.js';
import {html, LitElement, nothing} from 'lit'; import {html, LitElement, nothing} from 'lit';
import {property, state} from 'lit/decorators.js'; import {property, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js'; import {classMap} from 'lit/directives/class-map.js';
import {html as staticHtml, literal} from 'lit/static-html.js'; import {literal, html as staticHtml} from 'lit/static-html.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js'; import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {internals} from '../../internal/controller/element-internals.js'; import {internals} from '../../internal/controller/element-internals.js';
import {FormSubmitter, FormSubmitterType, setupFormSubmitter} from '../../internal/controller/form-submitter.js'; import {
FormSubmitter,
FormSubmitterType,
setupFormSubmitter,
} from '../../internal/controller/form-submitter.js';
import {isRtl} from '../../internal/controller/is-rtl.js'; import {isRtl} from '../../internal/controller/is-rtl.js';
type LinkTarget = '_blank'|'_parent'|'_self'|'_top'; type LinkTarget = '_blank' | '_parent' | '_self' | '_top';
// tslint:disable-next-line:enforce-comments-on-exported-symbols // tslint:disable-next-line:enforce-comments-on-exported-symbols
export class IconButton extends LitElement implements FormSubmitter { export class IconButton extends LitElement implements FormSubmitter {
@ -31,8 +35,10 @@ export class IconButton extends LitElement implements FormSubmitter {
static readonly formAssociated = true; static readonly formAssociated = true;
/** @nocollapse */ /** @nocollapse */
static override shadowRootOptions: static override shadowRootOptions: ShadowRootInit = {
ShadowRootInit = {mode: 'open', delegatesFocus: true}; mode: 'open',
delegatesFocus: true,
};
/** /**
* Disables the icon button and makes it non-interactive. * Disables the icon button and makes it non-interactive.
@ -53,7 +59,7 @@ export class IconButton extends LitElement implements FormSubmitter {
/** /**
* Sets the underlying `HTMLAnchorElement`'s `target` attribute. * Sets the underlying `HTMLAnchorElement`'s `target` attribute.
*/ */
@property() target: LinkTarget|'' = ''; @property() target: LinkTarget | '' = '';
/** /**
* The `aria-label` of the button when the button is toggleable and selected. * The `aria-label` of the button when the button is toggleable and selected.
@ -101,8 +107,8 @@ export class IconButton extends LitElement implements FormSubmitter {
@state() private flipIcon = isRtl(this, this.flipIconInRtl); @state() private flipIcon = isRtl(this, this.flipIconInRtl);
/** @private */ /** @private */
[internals] = [internals] = (this as HTMLElement) /* needed for closure */
(this as HTMLElement /* needed for closure */).attachInternals(); .attachInternals();
/** /**
* Link buttons cannot be disabled. * Link buttons cannot be disabled.
@ -119,18 +125,19 @@ export class IconButton extends LitElement implements FormSubmitter {
const {ariaLabel, ariaHasPopup, ariaExpanded} = this as ARIAMixinStrict; const {ariaLabel, ariaHasPopup, ariaExpanded} = this as ARIAMixinStrict;
const hasToggledAriaLabel = ariaLabel && this.ariaLabelSelected; const hasToggledAriaLabel = ariaLabel && this.ariaLabelSelected;
const ariaPressedValue = !this.toggle ? nothing : this.selected; const ariaPressedValue = !this.toggle ? nothing : this.selected;
let ariaLabelValue: string|null|typeof nothing = nothing; let ariaLabelValue: string | null | typeof nothing = nothing;
if (!this.href) { if (!this.href) {
ariaLabelValue = (hasToggledAriaLabel && this.selected) ? ariaLabelValue =
this.ariaLabelSelected : hasToggledAriaLabel && this.selected
ariaLabel; ? this.ariaLabelSelected
: ariaLabel;
} }
return staticHtml`<${tag} return staticHtml`<${tag}
class="icon-button ${classMap(this.getRenderClasses())}" class="icon-button ${classMap(this.getRenderClasses())}"
id="button" id="button"
aria-label="${ariaLabelValue || nothing}" aria-label="${ariaLabelValue || nothing}"
aria-haspopup="${!this.href && ariaHasPopup || nothing}" aria-haspopup="${(!this.href && ariaHasPopup) || nothing}"
aria-expanded="${!this.href && ariaExpanded || nothing}" aria-expanded="${(!this.href && ariaExpanded) || nothing}"
aria-pressed="${ariaPressedValue}" aria-pressed="${ariaPressedValue}"
?disabled="${!this.href && this.disabled}" ?disabled="${!this.href && this.disabled}"
@click="${this.handleClick}"> @click="${this.handleClick}">
@ -147,12 +154,12 @@ export class IconButton extends LitElement implements FormSubmitter {
// Needed for closure conformance // Needed for closure conformance
const {ariaLabel} = this as ARIAMixinStrict; const {ariaLabel} = this as ARIAMixinStrict;
return html` return html`
<a class="link" <a
class="link"
id="link" id="link"
href="${this.href}" href="${this.href}"
target="${this.target || nothing}" target="${this.target || nothing}"
aria-label="${ariaLabel || nothing}" aria-label="${ariaLabel || nothing}"></a>
></a>
`; `;
} }
@ -169,7 +176,9 @@ export class IconButton extends LitElement implements FormSubmitter {
private renderSelectedIcon() { private renderSelectedIcon() {
// Use default slot as fallback to not require specifying multiple icons // Use default slot as fallback to not require specifying multiple icons
return html`<span class="icon icon--selected"><slot name="selected"><slot></slot></slot></span>`; return html`<span class="icon icon--selected"
><slot name="selected"><slot></slot></slot
></span>`;
} }
private renderTouchTarget() { private renderTouchTarget() {
@ -177,15 +186,15 @@ export class IconButton extends LitElement implements FormSubmitter {
} }
private renderFocusRing() { private renderFocusRing() {
return html`<md-focus-ring part="focus-ring" for=${ return html`<md-focus-ring
this.href ? 'link' : 'button'}></md-focus-ring>`; part="focus-ring"
for=${this.href ? 'link' : 'button'}></md-focus-ring>`;
} }
private renderRipple() { private renderRipple() {
return html`<md-ripple return html`<md-ripple
for=${this.href ? 'link' : nothing} for=${this.href ? 'link' : nothing}
?disabled="${!this.href && this.disabled}" ?disabled="${!this.href && this.disabled}"></md-ripple>`;
></md-ripple>`;
} }
override connectedCallback() { override connectedCallback() {
@ -202,7 +211,8 @@ export class IconButton extends LitElement implements FormSubmitter {
this.selected = !this.selected; this.selected = !this.selected;
this.dispatchEvent( this.dispatchEvent(
new InputEvent('input', {bubbles: true, composed: true})); new InputEvent('input', {bubbles: true, composed: true}),
);
// Bubbles but does not compose to mimic native browser <input> & <select> // Bubbles but does not compose to mimic native browser <input> & <select>
// Additionally, native change event is not an InputEvent. // Additionally, native change event is not an InputEvent.
this.dispatchEvent(new Event('change', {bubbles: true})); this.dispatchEvent(new Event('change', {bubbles: true}));

View File

@ -14,4 +14,4 @@ describe('<md-outlined-icon-button>', () => {
describe('.styles', () => { describe('.styles', () => {
createTokenTests(MdOutlinedIconButton.styles); createTokenTests(MdOutlinedIconButton.styles);
}); });
}); });

View File

@ -86,20 +86,25 @@ export function isAriaAttribute(attribute: string): attribute is ARIAAttribute {
* @param property The aria property. * @param property The aria property.
* @return The aria attribute. * @return The aria attribute.
*/ */
export function ariaPropertyToAttribute<K extends ARIAProperty|'role'>( export function ariaPropertyToAttribute<K extends ARIAProperty | 'role'>(
property: K) { property: K,
return property ) {
.replace('aria', 'aria-') return (
// IDREF attributes also include an "Element" or "Elements" suffix property
.replace(/Elements?/g, '') .replace('aria', 'aria-')
.toLowerCase() as ARIAPropertyToAttribute<K>; // IDREF attributes also include an "Element" or "Elements" suffix
.replace(/Elements?/g, '')
.toLowerCase() as ARIAPropertyToAttribute<K>
);
} }
// Converts an `ariaFoo` string type to an `aria-foo` string type. // Converts an `ariaFoo` string type to an `aria-foo` string type.
type ARIAPropertyToAttribute<K extends string> = type ARIAPropertyToAttribute<K extends string> =
K extends `aria${infer Suffix}Element${infer OptS}` ? K extends `aria${infer Suffix}Element${infer OptS}`
`aria-${Lowercase < Suffix >}` : ? `aria-${Lowercase<Suffix>}`
K extends `aria${infer Suffix}` ? `aria-${Lowercase < Suffix >}` : K; : K extends `aria${infer Suffix}`
? `aria-${Lowercase<Suffix>}`
: K;
/** /**
* An extension of `ARIAMixin` that enforces strict value types for aria * An extension of `ARIAMixin` that enforces strict value types for aria
@ -118,68 +123,176 @@ type ARIAPropertyToAttribute<K extends string> =
* } * }
*/ */
export interface ARIAMixinStrict extends ARIAMixin { export interface ARIAMixinStrict extends ARIAMixin {
ariaAtomic: 'true'|'false'|null; ariaAtomic: 'true' | 'false' | null;
ariaAutoComplete: 'none'|'inline'|'list'|'both'|null; ariaAutoComplete: 'none' | 'inline' | 'list' | 'both' | null;
ariaBusy: 'true'|'false'|null; ariaBusy: 'true' | 'false' | null;
ariaChecked: 'true'|'false'|null; ariaChecked: 'true' | 'false' | null;
ariaColCount: `${number}`|null; ariaColCount: `${number}` | null;
ariaColIndex: `${number}`|null; ariaColIndex: `${number}` | null;
ariaColSpan: `${number}`|null; ariaColSpan: `${number}` | null;
ariaCurrent: 'page'|'step'|'location'|'date'|'time'|'true'|'false'|null; ariaCurrent:
ariaDisabled: 'true'|'false'|null; | 'page'
ariaExpanded: 'true'|'false'|null; | 'step'
ariaHasPopup: 'false'|'true'|'menu'|'listbox'|'tree'|'grid'|'dialog'|null; | 'location'
ariaHidden: 'true'|'false'|null; | 'date'
ariaInvalid: 'true'|'false'|null; | 'time'
ariaKeyShortcuts: string|null; | 'true'
ariaLabel: string|null; | 'false'
ariaLevel: `${number}`|null; | null;
ariaLive: 'assertive'|'off'|'polite'|null; ariaDisabled: 'true' | 'false' | null;
ariaModal: 'true'|'false'|null; ariaExpanded: 'true' | 'false' | null;
ariaMultiLine: 'true'|'false'|null; ariaHasPopup:
ariaMultiSelectable: 'true'|'false'|null; | 'false'
ariaOrientation: 'horizontal'|'vertical'|'undefined'|null; | 'true'
ariaPlaceholder: string|null; | 'menu'
ariaPosInSet: `${number}`|null; | 'listbox'
ariaPressed: 'true'|'false'|null; | 'tree'
ariaReadOnly: 'true'|'false'|null; | 'grid'
ariaRequired: 'true'|'false'|null; | 'dialog'
ariaRoleDescription: string|null; | null;
ariaRowCount: `${number}`|null; ariaHidden: 'true' | 'false' | null;
ariaRowIndex: `${number}`|null; ariaInvalid: 'true' | 'false' | null;
ariaRowSpan: `${number}`|null; ariaKeyShortcuts: string | null;
ariaSelected: 'true'|'false'|null; ariaLabel: string | null;
ariaSetSize: `${number}`|null; ariaLevel: `${number}` | null;
ariaSort: 'ascending'|'descending'|'none'|'other'|null; ariaLive: 'assertive' | 'off' | 'polite' | null;
ariaValueMax: `${number}`|null; ariaModal: 'true' | 'false' | null;
ariaValueMin: `${number}`|null; ariaMultiLine: 'true' | 'false' | null;
ariaValueNow: `${number}`|null; ariaMultiSelectable: 'true' | 'false' | null;
ariaValueText: string|null; ariaOrientation: 'horizontal' | 'vertical' | 'undefined' | null;
role: ARIARole|null; ariaPlaceholder: string | null;
ariaPosInSet: `${number}` | null;
ariaPressed: 'true' | 'false' | null;
ariaReadOnly: 'true' | 'false' | null;
ariaRequired: 'true' | 'false' | null;
ariaRoleDescription: string | null;
ariaRowCount: `${number}` | null;
ariaRowIndex: `${number}` | null;
ariaRowSpan: `${number}` | null;
ariaSelected: 'true' | 'false' | null;
ariaSetSize: `${number}` | null;
ariaSort: 'ascending' | 'descending' | 'none' | 'other' | null;
ariaValueMax: `${number}` | null;
ariaValueMin: `${number}` | null;
ariaValueNow: `${number}` | null;
ariaValueText: string | null;
role: ARIARole | null;
} }
/** /**
* Valid values for `role`. * Valid values for `role`.
*/ */
export type ARIARole = export type ARIARole =
'alert'|'alertdialog'|'button'|'checkbox'|'dialog'|'gridcell'|'link'|'log'| | 'alert'
'marquee'|'menuitem'|'menuitemcheckbox'|'menuitemradio'|'option'| | 'alertdialog'
'progressbar'|'radio'|'scrollbar'|'searchbox'|'slider'|'spinbutton'| | 'button'
'status'|'switch'|'tab'|'tabpanel'|'textbox'|'timer'|'tooltip'|'treeitem'| | 'checkbox'
'combobox'|'grid'|'listbox'|'menu'|'menubar'|'radiogroup'|'tablist'|'tree'| | 'dialog'
'treegrid'|'application'|'article'|'cell'|'columnheader'|'definition'| | 'gridcell'
'directory'|'document'|'feed'|'figure'|'group'|'heading'|'img'|'list'| | 'link'
'listitem'|'math'|'none'|'note'|'presentation'|'region'|'row'|'rowgroup'| | 'log'
'rowheader'|'separator'|'table'|'term'|'text'|'toolbar'|'banner'| | 'marquee'
'complementary'|'contentinfo'|'form'|'main'|'navigation'|'region'|'search'| | 'menuitem'
'doc-abstract'|'doc-acknowledgments'|'doc-afterword'|'doc-appendix'| | 'menuitemcheckbox'
'doc-backlink'|'doc-biblioentry'|'doc-bibliography'|'doc-biblioref'| | 'menuitemradio'
'doc-chapter'|'doc-colophon'|'doc-conclusion'|'doc-cover'|'doc-credit'| | 'option'
'doc-credits'|'doc-dedication'|'doc-endnote'|'doc-endnotes'|'doc-epigraph'| | 'progressbar'
'doc-epilogue'|'doc-errata'|'doc-example'|'doc-footnote'|'doc-foreword'| | 'radio'
'doc-glossary'|'doc-glossref'|'doc-index'|'doc-introduction'|'doc-noteref'| | 'scrollbar'
'doc-notice'|'doc-pagebreak'|'doc-pagelist'|'doc-part'|'doc-preface'| | 'searchbox'
'doc-prologue'|'doc-pullquote'|'doc-qna'|'doc-subtitle'|'doc-tip'|'doc-toc'; | 'slider'
| 'spinbutton'
| 'status'
| 'switch'
| 'tab'
| 'tabpanel'
| 'textbox'
| 'timer'
| 'tooltip'
| 'treeitem'
| 'combobox'
| 'grid'
| 'listbox'
| 'menu'
| 'menubar'
| 'radiogroup'
| 'tablist'
| 'tree'
| 'treegrid'
| 'application'
| 'article'
| 'cell'
| 'columnheader'
| 'definition'
| 'directory'
| 'document'
| 'feed'
| 'figure'
| 'group'
| 'heading'
| 'img'
| 'list'
| 'listitem'
| 'math'
| 'none'
| 'note'
| 'presentation'
| 'region'
| 'row'
| 'rowgroup'
| 'rowheader'
| 'separator'
| 'table'
| 'term'
| 'text'
| 'toolbar'
| 'banner'
| 'complementary'
| 'contentinfo'
| 'form'
| 'main'
| 'navigation'
| 'region'
| 'search'
| 'doc-abstract'
| 'doc-acknowledgments'
| 'doc-afterword'
| 'doc-appendix'
| 'doc-backlink'
| 'doc-biblioentry'
| 'doc-bibliography'
| 'doc-biblioref'
| 'doc-chapter'
| 'doc-colophon'
| 'doc-conclusion'
| 'doc-cover'
| 'doc-credit'
| 'doc-credits'
| 'doc-dedication'
| 'doc-endnote'
| 'doc-endnotes'
| 'doc-epigraph'
| 'doc-epilogue'
| 'doc-errata'
| 'doc-example'
| 'doc-footnote'
| 'doc-foreword'
| 'doc-glossary'
| 'doc-glossref'
| 'doc-index'
| 'doc-introduction'
| 'doc-noteref'
| 'doc-notice'
| 'doc-pagebreak'
| 'doc-pagelist'
| 'doc-part'
| 'doc-preface'
| 'doc-prologue'
| 'doc-pullquote'
| 'doc-qna'
| 'doc-subtitle'
| 'doc-tip'
| 'doc-toc';
/** /**
* Enables a host custom element to be the target for aria roles and attributes. * Enables a host custom element to be the target for aria roles and attributes.
@ -196,9 +309,11 @@ export type ARIARole =
* @param options Options to configure the element's host aria. * @param options Options to configure the element's host aria.
*/ */
export function setupHostAria( export function setupHostAria(
ctor: typeof ReactiveElement, {focusable}: SetupHostAriaOptions = {}) { ctor: typeof ReactiveElement,
{focusable}: SetupHostAriaOptions = {},
) {
if (focusable !== false) { if (focusable !== false) {
ctor.addInitializer(host => { ctor.addInitializer((host) => {
host.addController({ host.addController({
hostConnected() { hostConnected() {
if (host.hasAttribute('tabindex')) { if (host.hasAttribute('tabindex')) {
@ -206,7 +321,7 @@ export function setupHostAria(
} }
host.tabIndex = 0; host.tabIndex = 0;
} },
}); });
}); });
} }
@ -262,7 +377,9 @@ export interface SetupHostAriaOptions {
* } * }
*/ */
export function polyfillElementInternalsAria( export function polyfillElementInternalsAria(
host: ReactiveElement, internals: ElementInternals) { host: ReactiveElement,
internals: ElementInternals,
) {
if (checkIfElementInternalsSupportsAria(internals)) { if (checkIfElementInternalsSupportsAria(internals)) {
return internals; return internals;
} }
@ -271,25 +388,29 @@ export function polyfillElementInternalsAria(
throw new Error('Missing setupHostAria()'); throw new Error('Missing setupHostAria()');
} }
let firstConnectedCallbacks: let firstConnectedCallbacks: Array<{
Array<{property: ARIAProperty | 'role', callback: () => void}> = []; property: ARIAProperty | 'role';
callback: () => void;
}> = [];
let hasBeenConnected = false; let hasBeenConnected = false;
// Add support for Firefox, which has not yet implement ElementInternals aria // Add support for Firefox, which has not yet implement ElementInternals aria
for (const ariaProperty of ARIA_PROPERTIES) { for (const ariaProperty of ARIA_PROPERTIES) {
let internalAriaValue: string|null = null; let internalAriaValue: string | null = null;
Object.defineProperty(internals, ariaProperty, { Object.defineProperty(internals, ariaProperty, {
enumerable: true, enumerable: true,
configurable: true, configurable: true,
get() { get() {
return internalAriaValue; return internalAriaValue;
}, },
set(value: string|null) { set(value: string | null) {
const setValue = () => { const setValue = () => {
internalAriaValue = value; internalAriaValue = value;
if (!hasBeenConnected) { if (!hasBeenConnected) {
firstConnectedCallbacks.push( firstConnectedCallbacks.push({
{property: ariaProperty, callback: setValue}); property: ariaProperty,
callback: setValue,
});
return; return;
} }
@ -303,14 +424,14 @@ export function polyfillElementInternalsAria(
}); });
} }
let internalRoleValue: string|null = null; let internalRoleValue: string | null = null;
Object.defineProperty(internals, 'role', { Object.defineProperty(internals, 'role', {
enumerable: true, enumerable: true,
configurable: true, configurable: true,
get() { get() {
return internalRoleValue; return internalRoleValue;
}, },
set(value: string|null) { set(value: string | null) {
const setRole = () => { const setRole = () => {
internalRoleValue = value; internalRoleValue = value;
@ -341,17 +462,17 @@ export function polyfillElementInternalsAria(
hasBeenConnected = true; hasBeenConnected = true;
const propertiesSetByUser = new Set<ARIAProperty|'role'>(); const propertiesSetByUser = new Set<ARIAProperty | 'role'>();
// See which properties were set by the user on host before we apply // See which properties were set by the user on host before we apply
// internals values as attributes to host. Needs to be done in another // internals values as attributes to host. Needs to be done in another
// for loop because the callbacks set these attributes on host. // for loop because the callbacks set these attributes on host.
for (const {property} of firstConnectedCallbacks) { for (const {property} of firstConnectedCallbacks) {
const wasSetByUser = const wasSetByUser =
host.getAttribute(ariaPropertyToAttribute(property)) !== null || host.getAttribute(ariaPropertyToAttribute(property)) !== null ||
// Dynamic lookup rather than hardcoding all properties. // Dynamic lookup rather than hardcoding all properties.
// tslint:disable-next-line:no-dict-access-on-struct-type // tslint:disable-next-line:no-dict-access-on-struct-type
host[property] !== undefined; host[property] !== undefined;
if (wasSetByUser) { if (wasSetByUser) {
propertiesSetByUser.add(property); propertiesSetByUser.add(property);
@ -370,13 +491,12 @@ export function polyfillElementInternalsAria(
// Remove strong callback references // Remove strong callback references
firstConnectedCallbacks = []; firstConnectedCallbacks = [];
} },
}); });
return internals; return internals;
} }
// Separate function so that typescript doesn't complain about internals being // Separate function so that typescript doesn't complain about internals being
// "never". // "never".
function checkIfElementInternalsSupportsAria(internals: ElementInternals) { function checkIfElementInternalsSupportsAria(internals: ElementInternals) {

View File

@ -9,20 +9,26 @@
import {html, LitElement} from 'lit'; import {html, LitElement} from 'lit';
import {customElement} from 'lit/decorators.js'; import {customElement} from 'lit/decorators.js';
import {ARIAProperty, ariaPropertyToAttribute, isAriaAttribute, polyfillElementInternalsAria, setupHostAria} from './aria.js'; import {
ARIAProperty,
ariaPropertyToAttribute,
isAriaAttribute,
polyfillElementInternalsAria,
setupHostAria,
} from './aria.js';
describe('aria', () => { describe('aria', () => {
describe('isAriaAttribute()', () => { describe('isAriaAttribute()', () => {
it('should return true for aria value attributes', () => { it('should return true for aria value attributes', () => {
expect(isAriaAttribute('aria-label')) expect(isAriaAttribute('aria-label'))
.withContext('aria-label input') .withContext('aria-label input')
.toBeTrue(); .toBeTrue();
}); });
it('should return true for aria idref attributes', () => { it('should return true for aria idref attributes', () => {
expect(isAriaAttribute('aria-labelledby')) expect(isAriaAttribute('aria-labelledby'))
.withContext('aria-labelledby input') .withContext('aria-labelledby input')
.toBeTrue(); .toBeTrue();
}); });
it('should return false for role', () => { it('should return false for role', () => {
@ -40,8 +46,9 @@ describe('aria', () => {
}); });
it('should convert aria idref properties', () => { it('should convert aria idref properties', () => {
expect(ariaPropertyToAttribute('ariaLabelledByElements' as ARIAProperty)) expect(
.toBe('aria-labelledby'); ariaPropertyToAttribute('ariaLabelledByElements' as ARIAProperty),
).toBe('aria-labelledby');
}); });
}); });
@ -60,16 +67,16 @@ describe('aria', () => {
it('should not hydrate tabindex attribute on creation', () => { it('should not hydrate tabindex attribute on creation', () => {
const element = new TestElement(); const element = new TestElement();
expect(element.hasAttribute('tabindex')) expect(element.hasAttribute('tabindex'))
.withContext('has tabindex attribute') .withContext('has tabindex attribute')
.toBeFalse(); .toBeFalse();
}); });
it('should set tabindex="0" on element once connected', () => { it('should set tabindex="0" on element once connected', () => {
const element = new TestElement(); const element = new TestElement();
document.body.appendChild(element); document.body.appendChild(element);
expect(element.getAttribute('tabindex')) expect(element.getAttribute('tabindex'))
.withContext('tabindex attribute value') .withContext('tabindex attribute value')
.toEqual('0'); .toEqual('0');
element.remove(); element.remove();
}); });
@ -79,8 +86,8 @@ describe('aria', () => {
element.tabIndex = -1; element.tabIndex = -1;
document.body.appendChild(element); document.body.appendChild(element);
expect(element.getAttribute('tabindex')) expect(element.getAttribute('tabindex'))
.withContext('tabindex attribute value') .withContext('tabindex attribute value')
.toEqual('-1'); .toEqual('-1');
element.remove(); element.remove();
}); });
@ -92,24 +99,23 @@ describe('aria', () => {
element.remove(); element.remove();
document.body.appendChild(element); document.body.appendChild(element);
expect(element.getAttribute('tabindex')) expect(element.getAttribute('tabindex'))
.withContext('tabindex attribute value') .withContext('tabindex attribute value')
.toEqual('-1'); .toEqual('-1');
}); });
if (!('role' in Element.prototype)) { if (!('role' in Element.prototype)) {
describe('polyfill', () => { describe('polyfill', () => {
it('should hydrate aria attributes when ARIAMixin is not supported', it('should hydrate aria attributes when ARIAMixin is not supported', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); document.body.appendChild(element);
document.body.appendChild(element); element.role = 'button';
element.role = 'button'; await element.updateComplete;
await element.updateComplete; expect(element.getAttribute('role'))
expect(element.getAttribute('role')) .withContext('role attribute value')
.withContext('role attribute value') .toEqual('button');
.toEqual('button');
element.remove(); element.remove();
}); });
}); });
} }
}); });
@ -138,8 +144,8 @@ describe('aria', () => {
const element = new TestElement(); const element = new TestElement();
document.body.appendChild(element); document.body.appendChild(element);
expect(element.hasAttribute('role')) expect(element.hasAttribute('role'))
.withContext('has role attribute') .withContext('has role attribute')
.toBeFalse(); .toBeFalse();
element.remove(); element.remove();
}); });
@ -148,43 +154,41 @@ describe('aria', () => {
const element = new TestElement(); const element = new TestElement();
// TestElement() sets role in constructor // TestElement() sets role in constructor
expect(element.internals.role) expect(element.internals.role)
.withContext('ElementInternals.role') .withContext('ElementInternals.role')
.toEqual('button'); .toEqual('button');
}); });
it('should preserve aria values when set before connected', () => { it('should preserve aria values when set before connected', () => {
const element = new TestElement(); const element = new TestElement();
element.internals.ariaLabel = 'Foo'; element.internals.ariaLabel = 'Foo';
expect(element.internals.ariaLabel) expect(element.internals.ariaLabel)
.withContext('ElementInternals.ariaLabel') .withContext('ElementInternals.ariaLabel')
.toEqual('Foo'); .toEqual('Foo');
}); });
it('should hydrate role attributes when set before connection', it('should hydrate role attributes when set before connection', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); // TestElement() sets role in constructor
// TestElement() sets role in constructor document.body.appendChild(element);
document.body.appendChild(element); await element.updateComplete;
await element.updateComplete; expect(element.getAttribute('role'))
expect(element.getAttribute('role')) .withContext('role attribute value')
.withContext('role attribute value') .toEqual('button');
.toEqual('button');
element.remove(); element.remove();
}); });
it('should hydrate aria attributes when set before connection', it('should hydrate aria attributes when set before connection', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.internals.ariaLabel = 'Foo';
element.internals.ariaLabel = 'Foo'; document.body.appendChild(element);
document.body.appendChild(element); await element.updateComplete;
await element.updateComplete; expect(element.getAttribute('aria-label'))
expect(element.getAttribute('aria-label')) .withContext('aria-label attribute value')
.withContext('aria-label attribute value') .toEqual('Foo');
.toEqual('Foo');
element.remove(); element.remove();
}); });
it('should set aria attributes when set after connection', async () => { it('should set aria attributes when set after connection', async () => {
const element = new TestElement(); const element = new TestElement();
@ -192,180 +196,171 @@ describe('aria', () => {
element.internals.ariaLabel = 'Value after construction'; element.internals.ariaLabel = 'Value after construction';
await element.updateComplete; await element.updateComplete;
expect(element.getAttribute('aria-label')) expect(element.getAttribute('aria-label'))
.withContext('aria-label attribute value') .withContext('aria-label attribute value')
.toEqual('Value after construction'); .toEqual('Value after construction');
element.remove(); element.remove();
}); });
it('should not override aria attributes on host when set before connection', it('should not override aria attributes on host when set before connection', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.setAttribute('aria-label', 'Value set by user');
element.setAttribute('aria-label', 'Value set by user'); element.internals.ariaLabel = 'Value set on internals';
element.internals.ariaLabel = 'Value set on internals'; document.body.appendChild(element);
document.body.appendChild(element); await element.updateComplete;
await element.updateComplete; expect(element.getAttribute('aria-label'))
expect(element.getAttribute('aria-label')) .withContext('aria-label attribute value on host')
.withContext('aria-label attribute value on host') .toEqual('Value set by user');
.toEqual('Value set by user'); expect(element.internals.ariaLabel)
expect(element.internals.ariaLabel) .withContext('ariaLabel internals property still the same')
.withContext('ariaLabel internals property still the same') .toEqual('Value set on internals');
.toEqual('Value set on internals');
element.remove(); element.remove();
}); });
it('should not override aria properties on host when set before connection', it('should not override aria properties on host when set before connection', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.ariaLabel = 'Value set by user';
element.ariaLabel = 'Value set by user'; element.internals.ariaLabel = 'Value set on internals';
element.internals.ariaLabel = 'Value set on internals'; document.body.appendChild(element);
document.body.appendChild(element); await element.updateComplete;
await element.updateComplete; expect(element.getAttribute('aria-label'))
expect(element.getAttribute('aria-label')) .withContext('aria-label attribute value on host')
.withContext('aria-label attribute value on host') .toEqual('Value set by user');
.toEqual('Value set by user'); expect(element.ariaLabel)
expect(element.ariaLabel) .withContext('ariaLabel property value on host')
.withContext('ariaLabel property value on host') .toEqual('Value set by user');
.toEqual('Value set by user'); expect(element.internals.ariaLabel)
expect(element.internals.ariaLabel) .withContext('ariaLabel internals property still the same')
.withContext('ariaLabel internals property still the same') .toEqual('Value set on internals');
.toEqual('Value set on internals');
element.remove(); element.remove();
}); });
it('should not override role attribute on host when set before connection', it('should not override role attribute on host when set before connection', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.setAttribute('role', 'Value set by user');
element.setAttribute('role', 'Value set by user'); element.internals.role = 'Value set on internals';
element.internals.role = 'Value set on internals'; document.body.appendChild(element);
document.body.appendChild(element); await element.updateComplete;
await element.updateComplete; expect(element.getAttribute('role'))
expect(element.getAttribute('role')) .withContext('role attribute value on host')
.withContext('role attribute value on host') .toEqual('Value set by user');
.toEqual('Value set by user'); expect(element.internals.role)
expect(element.internals.role) .withContext('role internals property still the same')
.withContext('role internals property still the same') .toEqual('Value set on internals');
.toEqual('Value set on internals');
element.remove(); element.remove();
}); });
it('should not override role property on host when set before connection', it('should not override role property on host when set before connection', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.role = 'Value set by user';
element.role = 'Value set by user'; element.internals.role = 'Value set on internals';
element.internals.role = 'Value set on internals'; document.body.appendChild(element);
document.body.appendChild(element); await element.updateComplete;
await element.updateComplete; expect(element.getAttribute('role'))
expect(element.getAttribute('role')) .withContext('role attribute value on host')
.withContext('role attribute value on host') .toEqual('Value set by user');
.toEqual('Value set by user'); expect(element.role)
expect(element.role) .withContext('role property value on host')
.withContext('role property value on host') .toEqual('Value set by user');
.toEqual('Value set by user'); expect(element.internals.role)
expect(element.internals.role) .withContext('role internals property still the same')
.withContext('role internals property still the same') .toEqual('Value set on internals');
.toEqual('Value set on internals');
element.remove(); element.remove();
}); });
it('should handle setting role multiple times before connection', it('should handle setting role multiple times before connection', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.internals.role = 'button';
element.internals.role = 'button'; element.internals.role = 'checkbox';
element.internals.role = 'checkbox';
expect(element.internals.role) expect(element.internals.role)
.withContext('internals.role before connection') .withContext('internals.role before connection')
.toEqual('checkbox'); .toEqual('checkbox');
document.body.appendChild(element); document.body.appendChild(element);
await element.updateComplete; await element.updateComplete;
expect(element.internals.role) expect(element.internals.role)
.withContext('internals.role after connection') .withContext('internals.role after connection')
.toEqual('checkbox'); .toEqual('checkbox');
element.remove(); element.remove();
}); });
it('should handle setting role multiple times before connection when property is set on host', it('should handle setting role multiple times before connection when property is set on host', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.role = 'radio';
element.role = 'radio'; element.internals.role = 'button';
element.internals.role = 'button'; element.internals.role = 'checkbox';
element.internals.role = 'checkbox';
expect(element.internals.role) expect(element.internals.role)
.withContext('internals.role before connection') .withContext('internals.role before connection')
.toEqual('checkbox'); .toEqual('checkbox');
document.body.appendChild(element); document.body.appendChild(element);
await element.updateComplete; await element.updateComplete;
expect(element.internals.role) expect(element.internals.role)
.withContext('internals.role after connection') .withContext('internals.role after connection')
.toEqual('checkbox'); .toEqual('checkbox');
element.remove(); element.remove();
}); });
it('should handle setting aria properties multiple times before connection', it('should handle setting aria properties multiple times before connection', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.internals.ariaLabel = 'First';
element.internals.ariaLabel = 'First'; element.internals.ariaLabel = 'Second';
element.internals.ariaLabel = 'Second';
expect(element.internals.ariaLabel) expect(element.internals.ariaLabel)
.withContext('internals.ariaLabel before connection') .withContext('internals.ariaLabel before connection')
.toEqual('Second'); .toEqual('Second');
document.body.appendChild(element); document.body.appendChild(element);
await element.updateComplete; await element.updateComplete;
expect(element.internals.ariaLabel) expect(element.internals.ariaLabel)
.withContext('internals.ariaLabel after connection') .withContext('internals.ariaLabel after connection')
.toEqual('Second'); .toEqual('Second');
element.remove(); element.remove();
}); });
it('should handle setting aria properties multiple times before connection when property is set on host', it('should handle setting aria properties multiple times before connection when property is set on host', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.ariaLabel = 'First';
element.ariaLabel = 'First'; element.internals.ariaLabel = 'First';
element.internals.ariaLabel = 'First'; element.internals.ariaLabel = 'Second';
element.internals.ariaLabel = 'Second';
expect(element.internals.ariaLabel) expect(element.internals.ariaLabel)
.withContext('internals.ariaLabel before connection') .withContext('internals.ariaLabel before connection')
.toEqual('Second'); .toEqual('Second');
document.body.appendChild(element); document.body.appendChild(element);
await element.updateComplete; await element.updateComplete;
expect(element.internals.ariaLabel) expect(element.internals.ariaLabel)
.withContext('internals.ariaLabel after connection') .withContext('internals.ariaLabel after connection')
.toEqual('Second'); .toEqual('Second');
element.remove(); element.remove();
}); });
it('should handle setting role after first connection while disconnected', it('should handle setting role after first connection while disconnected', async () => {
async () => { const element = new TestElement();
const element = new TestElement(); element.internals.role = 'button';
element.internals.role = 'button'; document.body.appendChild(element);
document.body.appendChild(element); await element.updateComplete;
await element.updateComplete;
element.remove(); element.remove();
element.internals.role = 'checkbox'; element.internals.role = 'checkbox';
expect(element.internals.role) expect(element.internals.role)
.withContext('internals.role after connected and disconnected') .withContext('internals.role after connected and disconnected')
.toEqual('checkbox'); .toEqual('checkbox');
document.body.appendChild(element); document.body.appendChild(element);
await element.updateComplete; await element.updateComplete;
expect(element.internals.role) expect(element.internals.role)
.withContext('internals.role after reconnected') .withContext('internals.role after reconnected')
.toEqual('checkbox'); .toEqual('checkbox');
element.remove(); element.remove();
}); });
} }
}); });
}); });

Some files were not shown because too many files have changed in this diff Show More