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('label', {ui: textInput(), defaultValue: ''}),
new Knob('disabled', {ui: boolInput(), defaultValue: false}), 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.
@ -87,8 +96,8 @@ export abstract class Button extends LitElement implements FormSubmitter {
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

@ -17,12 +17,9 @@ 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 <slot class="image"></slot>
class="image"
></slot>
</div>`; </div>`;
} }

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
@ -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

@ -68,15 +68,13 @@ export class HCTSlider extends LitElement {
.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,8 +76,7 @@ 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({
@ -86,8 +85,7 @@ import {SignalElement} from '../signals/signal-element.js';
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';
@ -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>
@ -115,23 +115,20 @@ export class ThemeChanger extends LitElement {
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>`;
} }
@ -141,8 +138,7 @@ 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

@ -4,5 +4,5 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import '@material/web/fab/fab.js';
import '@material/web/fab/branded-fab.js'; import '@material/web/fab/branded-fab.js';
import '@material/web/fab/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

@ -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-preview.js';
import 'playground-elements/playground-file-editor.js'; import 'playground-elements/playground-file-editor.js';
import 'playground-elements/playground-preview.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,7 +38,8 @@ 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
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => { .addEventListener('change', () => {
if (getCurrentMode() !== 'auto') { if (getCurrentMode() !== 'auto') {
return; return;

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;

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(
'lit-island',
class extends Island {
// Removes the feature in which 11ty island removes DOM to render a fallback. // Removes the feature in which 11ty island removes DOM to render a fallback.
override forceFallback() {} 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

@ -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,10 +38,12 @@ 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
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', surfaceContainer); ?.setAttribute('content', surfaceContainer);
} }

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,8 +4,12 @@
* 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';
/** Color mode, either overriding light/dark or the user's preference. */
export type ColorMode = 'light' | 'dark' | 'auto'; export type ColorMode = 'light' | 'dark' | 'auto';
/** /**
@ -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;
} }
/** /**

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;
} }
@ -127,8 +132,7 @@ export class KnobColorSelector extends LitElement {
<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,7 +375,7 @@ 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. */
@ -391,8 +391,7 @@ 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>
@ -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

@ -93,8 +93,9 @@ export class StoriesRenderer extends LitElement {
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}
@ -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>
`; `;
@ -184,7 +184,8 @@ export class StoriesRenderer extends LitElement {
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';
@ -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(
new CustomEvent('open-changed', {
detail: { detail: {
open: this.open, open: this.open,
}, },
})); }),
);
} }
} }
@ -181,10 +181,11 @@ 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 =
window.innerHeight -
(DEFAULT_DIMENSIONS.DRAG_BAR_HEIGHT + DEFAULT_DIMENSIONS.TOP_OFFSET); (DEFAULT_DIMENSIONS.DRAG_BAR_HEIGHT + DEFAULT_DIMENSIONS.TOP_OFFSET);
// do not allow drag outside right bound // do not allow drag outside right bound

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';

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();
@ -85,14 +86,17 @@ export class Knob<T, Name extends string = string> extends EventTarget {
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.dirty ||
(this.latestValue !== undefined && (this.latestValue !== undefined &&
this.latestValue === this.defaultValue)) { this.latestValue === this.defaultValue)
) {
this.wiring?.(this, this.latestValue!, containerOfRenderedStory); this.wiring?.(this, this.latestValue!, 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}`);
@ -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

@ -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,

View File

@ -24,14 +24,15 @@ 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;
@ -54,13 +55,16 @@ 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 =
`A story id can't contain a '/' character. ` +
`The name can, but if so you have to give an ` + `The name can, but if so you have to give an ` +
`explicit id that doesn't.`; `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;
@ -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,8 +111,9 @@ 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>();
@ -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,8 +175,8 @@ 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[];
} }
@ -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,7 +20,7 @@ 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',
@ -29,7 +29,7 @@ describe('<md-checkbox>', () => {
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',
@ -38,7 +38,7 @@ describe('<md-checkbox>', () => {
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',
@ -46,26 +46,31 @@ describe('<md-checkbox>', () => {
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',
@ -75,8 +80,8 @@ describe('<md-checkbox>', () => {
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: [
{ {
@ -89,7 +94,7 @@ describe('<md-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',
@ -102,7 +107,7 @@ describe('<md-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',
@ -115,8 +120,8 @@ describe('<md-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: [
{ {
@ -126,7 +131,7 @@ describe('<md-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',
@ -136,7 +141,7 @@ describe('<md-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',
@ -146,9 +151,9 @@ describe('<md-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('checked', {defaultValue: false, ui: boolInput()}),
new Knob('indeterminate', {defaultValue: false, ui: boolInput()}), new Knob('indeterminate', {defaultValue: false, ui: boolInput()}),
new Knob('disabled', {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 */
@ -130,8 +134,8 @@ export class Checkbox extends LitElement {
// 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,8 +200,11 @@ 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 =
@ -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,8 +85,7 @@ 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();
@ -95,8 +95,7 @@ describe('checkbox', () => {
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.
@ -120,8 +119,7 @@ describe('checkbox', () => {
}); });
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();
@ -138,8 +136,7 @@ describe('checkbox', () => {
}); });
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();
@ -153,14 +150,12 @@ describe('checkbox', () => {
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 ?? ''} .value=${propsInit.value ?? ''}></md-test-checkbox>
></md-test-checkbox>
</form>`); </form>`);
} }
@ -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');
}); });
@ -199,13 +200,17 @@ describe('checkbox', () => {
<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();

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('label', {defaultValue: '', ui: textInput()}),
new Knob('elevated', {defaultValue: false, ui: boolInput()}), new Knob('elevated', {defaultValue: false, ui: boolInput()}),
new Knob('disabled', {defaultValue: false, ui: boolInput()}), new Knob('disabled', {defaultValue: false, ui: boolInput()}),
new Knob('scrolling', {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

@ -17,11 +17,13 @@ export class ChipHarness extends Harness<Chip> {
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 =
primaryId &&
this.element.renderRoot.querySelector<HTMLElement>(`#${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

@ -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

@ -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.

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,8 +58,11 @@ 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')
@ -75,12 +75,18 @@ describe('Chip set', () => {
.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
@ -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,12 +157,11 @@ 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();
@ -166,12 +171,11 @@ describe('Chip set', () => {
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();
@ -181,7 +185,7 @@ describe('Chip set', () => {
ltrKey: 'ArrowLeft', ltrKey: 'ArrowLeft',
rtlKey: 'ArrowRight', rtlKey: 'ArrowRight',
current: null, current: null,
next: third next: third,
}); });
}); });
@ -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,8 +230,9 @@ 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();

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,9 +93,9 @@ 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,8 +33,7 @@ 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);

View File

@ -27,7 +27,8 @@ 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;
@ -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

@ -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

@ -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(
'focusout',
() => {
primaryAction.tabIndex = 0; primaryAction.tabIndex = 0;
}, {once: true}); },
{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,
}); });
} }
} }
@ -132,8 +136,7 @@ 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';
@ -149,8 +152,7 @@ describe('Multi-action chips', () => {
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();
@ -205,12 +207,11 @@ 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();
}); });
@ -227,8 +228,7 @@ 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';

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('icon', {defaultValue: '', ui: textInput()}),
new Knob('headline', {defaultValue: 'Dialog', ui: textInput()}), new Knob('headline', {defaultValue: 'Dialog', ui: textInput()}),
new Knob( new Knob('supportingText', {
'supportingText', defaultValue: 'Just a simple dialog.',
{defaultValue: 'Just a simple dialog.', ui: textInput()}), 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(
'closed',
() => {
resolve(); resolve();
}, {once: true}); },
{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,8 +137,7 @@ 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);
@ -166,7 +170,7 @@ describe('<md-dialog>', () => {
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();
}); });
@ -196,8 +200,7 @@ describe('<md-dialog>', () => {
.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);
@ -213,8 +216,7 @@ describe('<md-dialog>', () => {
.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);
@ -226,7 +228,8 @@ describe('<md-dialog>', () => {
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();
}); });

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,
}; };
/** /**
@ -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;
@ -236,17 +243,15 @@ 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
name="headline"
@slotchange=${this.handleHeadlineChange}></slot> @slotchange=${this.handleHeadlineChange}></slot>
</h2> </h2>
<md-divider></md-divider> <md-divider></md-divider>
@ -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(
(entries) => {
for (const entry of entries) { for (const entry of entries) {
this.handleAnchorIntersection(entry); 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', {defaultValue: true, ui: boolInput()}),
new Knob('inset (start)', {defaultValue: false, ui: boolInput()}), new Knob('inset (start)', {defaultValue: false, ui: boolInput()}),
new Knob('inset (end)', {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. */
@ -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,7 +156,8 @@ 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
.render(html`<md-branded-fab></md-branded-fab>`)
.querySelector('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

@ -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>
`; `;
} }
@ -92,8 +88,9 @@ export abstract class SharedFab extends LitElement {
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'
: (nothing as unknown as 'false')}>
<span></span> <span></span>
</slot> </slot>
</span>`; </span>`;

View File

@ -4,20 +4,32 @@
* 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('label', {ui: textInput(), defaultValue: 'Label'}),
new Knob( new Knob('Supporting text', {
'Supporting text', ui: textInput(),
{ui: textInput(), defaultValue: 'Supporting text'}), defaultValue: 'Supporting text',
}),
new Knob('Error text', {ui: textInput(), defaultValue: 'Error text'}), new Knob('Error text', {ui: textInput(), defaultValue: 'Error text'}),
new Knob('count', {ui: numberInput(), defaultValue: 0}), new Knob('count', {ui: numberInput(), defaultValue: 0}),
new Knob('max', {ui: numberInput(), defaultValue: 0}), new Knob('max', {ui: numberInput(), defaultValue: 0}),
@ -29,7 +41,8 @@ const collection =
new Knob('Leading icon', {ui: boolInput(), defaultValue: false}), new Knob('Leading icon', {ui: boolInput(), defaultValue: false}),
new Knob('Trailing icon', {ui: boolInput(), defaultValue: false}), new Knob('Trailing icon', {ui: boolInput(), defaultValue: false}),
new Knob('resizable', {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

@ -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,7 +67,8 @@ 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')
private readonly floatingLabelEl!: HTMLElement | null;
@query('.label.resting') private readonly restingLabelEl!: HTMLElement | null; @query('.label.resting') private readonly restingLabelEl!: HTMLElement | null;
@query('.container') private readonly containerEl!: HTMLElement | null; @query('.container') private readonly containerEl!: HTMLElement | null;
@ -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,9 +189,9 @@ 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
@ -193,8 +202,9 @@ export class Field extends LitElement {
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 =
restingY -
floatingY +
Math.round((restingHeight - floatingHeight * scale) / 2); 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);
@ -292,7 +294,8 @@ describe('Field', () => {
// 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('');
}); });
@ -315,19 +318,20 @@ describe('Field', () => {
// 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('');
}); });
}); });
@ -335,18 +339,22 @@ describe('Field', () => {
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()')
@ -365,8 +373,10 @@ describe('Field', () => {
}); });
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();

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.
@ -45,8 +48,10 @@ export class FocusRing extends LitElement implements Attachable {
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);

View File

@ -64,8 +64,7 @@ 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>

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,8 +57,8 @@ 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')!;

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('icon', {ui: textInput(), defaultValue: 'check'}),
new Knob('selectedIcon', {ui: textInput(), defaultValue: 'close'}), new Knob('selectedIcon', {ui: textInput(), defaultValue: 'close'}),
new Knob('disabled', {ui: boolInput(), defaultValue: false}), 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

@ -44,7 +44,8 @@ describe('icon button tests', () => {
}); });
describe('md-icon-button', () => { describe('md-icon-button', () => {
it('setting `disabled` updates the disabled attribute on the native ' + it(
'setting `disabled` updates the disabled attribute on the native ' +
'button element', 'button element',
async () => { async () => {
const {element} = await setUpTest('button'); const {element} = await setUpTest('button');
@ -57,9 +58,11 @@ describe('icon button tests', () => {
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(
'setting `ariaLabel` updates the aria-label attribute on the native ' +
'button element', 'button element',
async () => { async () => {
const {element} = await setUpTest('button'); const {element} = await setUpTest('button');
@ -68,11 +71,13 @@ describe('icon button tests', () => {
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(
'setting `ariaLabel` updates the aria-label attribute on the anchor' +
'tag', 'tag',
async () => { async () => {
const {element} = await setUpTest('link'); const {element} = await setUpTest('link');
@ -82,11 +87,13 @@ describe('icon button tests', () => {
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(
'setting `disabled` updates the disabled attribute on the native ' +
'button element', 'button element',
async () => { async () => {
const {element} = await setUpTest('toggle'); const {element} = await setUpTest('toggle');
@ -99,9 +106,11 @@ describe('icon button tests', () => {
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(
'setting `ariaLabel` updates the aria-label attribute on the native ' +
'button element', 'button element',
async () => { async () => {
const {element} = await setUpTest('toggle'); const {element} = await setUpTest('toggle');
@ -110,7 +119,8 @@ describe('icon button tests', () => {
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,8 +145,7 @@ 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;
@ -167,8 +176,7 @@ 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>
@ -179,10 +187,8 @@ describe('icon button tests', () => {
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`
<div dir="ltr">
<md-icon-button aria-label="Star" .flipIconInRtl="${true}"> <md-icon-button aria-label="Star" .flipIconInRtl="${true}">
star star
</md-icon-button> </md-icon-button>
@ -190,14 +196,13 @@ describe('icon button tests', () => {
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) 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();
}); });

View File

@ -10,12 +10,16 @@ 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';
@ -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.
@ -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.
@ -121,16 +127,17 @@ export class IconButton extends LitElement implements FormSubmitter {
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

@ -87,19 +87,24 @@ export function isAriaAttribute(attribute: string): attribute is ARIAAttribute {
* @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 ) {
return (
property
.replace('aria', 'aria-') .replace('aria', 'aria-')
// IDREF attributes also include an "Element" or "Elements" suffix // IDREF attributes also include an "Element" or "Elements" suffix
.replace(/Elements?/g, '') .replace(/Elements?/g, '')
.toLowerCase() as ARIAPropertyToAttribute<K>; .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
@ -125,10 +130,26 @@ export interface ARIAMixinStrict extends ARIAMixin {
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:
| 'page'
| 'step'
| 'location'
| 'date'
| 'time'
| 'true'
| 'false'
| null;
ariaDisabled: 'true' | 'false' | null; ariaDisabled: 'true' | 'false' | null;
ariaExpanded: 'true' | 'false' | null; ariaExpanded: 'true' | 'false' | null;
ariaHasPopup: 'false'|'true'|'menu'|'listbox'|'tree'|'grid'|'dialog'|null; ariaHasPopup:
| 'false'
| 'true'
| 'menu'
| 'listbox'
| 'tree'
| 'grid'
| 'dialog'
| null;
ariaHidden: 'true' | 'false' | null; ariaHidden: 'true' | 'false' | null;
ariaInvalid: 'true' | 'false' | null; ariaInvalid: 'true' | 'false' | null;
ariaKeyShortcuts: string | null; ariaKeyShortcuts: string | null;
@ -162,24 +183,116 @@ export interface ARIAMixinStrict extends ARIAMixin {
* 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,8 +388,10 @@ 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
@ -288,8 +407,10 @@ export function polyfillElementInternalsAria(
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;
} }
@ -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,7 +9,13 @@
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()', () => {
@ -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');
}); });
}); });
@ -98,8 +105,7 @@ describe('aria', () => {
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';
@ -160,8 +166,7 @@ describe('aria', () => {
.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);
@ -173,8 +178,7 @@ describe('aria', () => {
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);
@ -198,8 +202,7 @@ describe('aria', () => {
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';
@ -215,8 +218,7 @@ describe('aria', () => {
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';
@ -235,8 +237,7 @@ describe('aria', () => {
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';
@ -252,8 +253,7 @@ describe('aria', () => {
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';
@ -272,8 +272,7 @@ describe('aria', () => {
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';
@ -290,8 +289,7 @@ describe('aria', () => {
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';
@ -309,8 +307,7 @@ describe('aria', () => {
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';
@ -327,8 +324,7 @@ describe('aria', () => {
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';
@ -346,8 +342,7 @@ describe('aria', () => {
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);

View File

@ -46,11 +46,11 @@ export function requestUpdateOnAriaChange(ctor: typeof ReactiveElement) {
}); });
} }
ctor.addInitializer(element => { ctor.addInitializer((element) => {
const controller = { const controller = {
hostConnected() { hostConnected() {
element.setAttribute('role', 'presentation'); element.setAttribute('role', 'presentation');
} },
}; };
element.addController(controller); element.addController(controller);

View File

@ -37,8 +37,8 @@ describe('aria', () => {
async function setupTest({ariaLabel}: {ariaLabel?: string} = {}) { async function setupTest({ariaLabel}: {ariaLabel?: string} = {}) {
const root = env.render(html` const root = env.render(html`
<test-aria-delegate aria-label=${ <test-aria-delegate
ariaLabel || nothing}></test-aria-delegate> aria-label=${ariaLabel || nothing}></test-aria-delegate>
`); `);
const host = root.querySelector('test-aria-delegate'); const host = root.querySelector('test-aria-delegate');
@ -82,8 +82,7 @@ describe('aria', () => {
.toEqual(ariaLabel); .toEqual(ariaLabel);
}); });
it('should update delegated aria attributes when host attribute changes', it('should update delegated aria attributes when host attribute changes', async () => {
async () => {
const {host, child} = await setupTest({ariaLabel: 'First aria label'}); const {host, child} = await setupTest({ariaLabel: 'First aria label'});
host.setAttribute('aria-label', 'Second aria label'); host.setAttribute('aria-label', 'Second aria label');
@ -93,8 +92,7 @@ describe('aria', () => {
.toEqual('Second aria label'); .toEqual('Second aria label');
}); });
it('should remove delegated aria attributes when host attribute is removed', it('should remove delegated aria attributes when host attribute is removed', async () => {
async () => {
const {host, child} = await setupTest({ariaLabel: 'First aria label'}); const {host, child} = await setupTest({ariaLabel: 'First aria label'});
host.removeAttribute('aria-label'); host.removeAttribute('aria-label');

View File

@ -81,12 +81,13 @@ if (!isServer) {
* `Attachable` elements. If the `for` attribute changes, the controller will * `Attachable` elements. If the `for` attribute changes, the controller will
* re-attach to the new referenced element. * re-attach to the new referenced element.
*/ */
FOR_ATTRIBUTE_OBSERVER = new MutationObserver(records => { FOR_ATTRIBUTE_OBSERVER = new MutationObserver((records) => {
for (const record of records) { for (const record of records) {
// When a control's `for` attribute changes, inform its // When a control's `for` attribute changes, inform its
// `AttachableController` to update to a new control. // `AttachableController` to update to a new control.
(record.target as AttachableControllerHost)[ATTACHABLE_CONTROLLER] (record.target as AttachableControllerHost)[
?.hostConnected(); ATTACHABLE_CONTROLLER
]?.hostConnected();
} }
}); });
} }
@ -131,8 +132,9 @@ export class AttachableController implements ReactiveController, Attachable {
return null; return null;
} }
return (this.host.getRootNode() as Document | ShadowRoot) return (
.querySelector<HTMLElement>(`#${this.htmlFor}`); this.host.getRootNode() as Document | ShadowRoot
).querySelector<HTMLElement>(`#${this.htmlFor}`);
} }
return this.currentControl || this.host.parentElement; return this.currentControl || this.host.parentElement;
@ -157,8 +159,11 @@ export class AttachableController implements ReactiveController, Attachable {
*/ */
constructor( constructor(
private readonly host: AttachableControllerHost, private readonly host: AttachableControllerHost,
private readonly onControlChange: private readonly onControlChange: (
(prev: HTMLElement|null, next: HTMLElement|null) => void) { prev: HTMLElement | null,
next: HTMLElement | null,
) => void,
) {
host.addController(this); host.addController(this);
host[ATTACHABLE_CONTROLLER] = this; host[ATTACHABLE_CONTROLLER] = this;
FOR_ATTRIBUTE_OBSERVER?.observe(host, {attributeFilter: ['for']}); FOR_ATTRIBUTE_OBSERVER?.observe(host, {attributeFilter: ['for']});

View File

@ -6,14 +6,19 @@
// import 'jasmine'; (google3-only) // import 'jasmine'; (google3-only)
import {dispatchActivationClick, isActivationClick, redispatchEvent} from './events.js'; import {
dispatchActivationClick,
isActivationClick,
redispatchEvent,
} from './events.js';
describe('events', () => { describe('events', () => {
let instance: HTMLDivElement; let instance: HTMLDivElement;
beforeEach(() => { beforeEach(() => {
instance = document.createElement('div'); instance = document.createElement('div');
instance.attachShadow({mode: 'open'}) instance
.attachShadow({mode: 'open'})
.append(document.createElement('slot')); .append(document.createElement('slot'));
// To have event.target set correctly, the EventTarget instance must be // To have event.target set correctly, the EventTarget instance must be
// attached to the DOM. // attached to the DOM.
@ -38,7 +43,8 @@ describe('events', () => {
.not.toBe(event); .not.toBe(event);
expect(redispatchedEvent.target) expect(redispatchedEvent.target)
.withContext( .withContext(
'target should be the instance that redispatched the event') 'target should be the instance that redispatched the event',
)
.toBe(instance); .toBe(instance);
expect(redispatchedEvent.type) expect(redispatchedEvent.type)
.withContext('should be the same event type') .withContext('should be the same event type')
@ -77,8 +83,9 @@ describe('events', () => {
it('should preventDefault() on the original event if canceled', () => { it('should preventDefault() on the original event if canceled', () => {
const event = new Event('foo', {cancelable: true}); const event = new Event('foo', {cancelable: true});
const fooHandler = const fooHandler = jasmine
jasmine.createSpy('fooHandler').and.callFake((event: Event) => { .createSpy('fooHandler')
.and.callFake((event: Event) => {
event.preventDefault(); event.preventDefault();
}); });
instance.addEventListener('foo', fooHandler); instance.addEventListener('foo', fooHandler);
@ -119,7 +126,8 @@ describe('events', () => {
listener.and.callThrough(); listener.and.callThrough();
instance.addEventListener('click', listener); instance.addEventListener('click', listener);
instance.dispatchEvent( instance.dispatchEvent(
new MouseEvent('click', {bubbles: true, composed: true})); new MouseEvent('click', {bubbles: true, composed: true}),
);
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener.calls.mostRecent().returnValue).toBe(true); expect(listener.calls.mostRecent().returnValue).toBe(true);
}); });
@ -131,7 +139,8 @@ describe('events', () => {
const innerEl = document.createElement('div'); const innerEl = document.createElement('div');
instance.shadowRoot!.append(innerEl); instance.shadowRoot!.append(innerEl);
innerEl.dispatchEvent( innerEl.dispatchEvent(
new MouseEvent('click', {bubbles: true, composed: true})); new MouseEvent('click', {bubbles: true, composed: true}),
);
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener.calls.mostRecent().returnValue).toBe(false); expect(listener.calls.mostRecent().returnValue).toBe(false);
}); });
@ -144,7 +153,8 @@ describe('events', () => {
instance.append(slottedEl); instance.append(slottedEl);
slottedEl.dispatchEvent( slottedEl.dispatchEvent(
new MouseEvent('click', {bubbles: true, composed: true})); new MouseEvent('click', {bubbles: true, composed: true}),
);
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener.calls.mostRecent().returnValue).toBe(false); expect(listener.calls.mostRecent().returnValue).toBe(false);
}); });
@ -162,8 +172,7 @@ describe('events', () => {
expect(listener).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenCalledTimes(2);
}); });
it('dispatches an event that cannot be heard outside dispatching scope', it('dispatches an event that cannot be heard outside dispatching scope', () => {
() => {
const innerEl = document.createElement('div'); const innerEl = document.createElement('div');
instance.shadowRoot!.append(innerEl); instance.shadowRoot!.append(innerEl);
const listener = jasmine.createSpy('listener'); const listener = jasmine.createSpy('listener');

View File

@ -51,7 +51,8 @@ export interface FormSubmitter extends ReactiveElement, WithInternals {
} }
type FormSubmitterConstructor = type FormSubmitterConstructor =
(new () => FormSubmitter)|(abstract new () => FormSubmitter); | (new () => FormSubmitter)
| (abstract new () => FormSubmitter);
/** /**
* Sets up an element's constructor to enable form submission. The element * Sets up an element's constructor to enable form submission. The element
@ -82,9 +83,9 @@ export function setupFormSubmitter(ctor: FormSubmitterConstructor) {
return; return;
} }
(ctor as unknown as typeof ReactiveElement).addInitializer(instance => { (ctor as unknown as typeof ReactiveElement).addInitializer((instance) => {
const submitter = instance as FormSubmitter; const submitter = instance as FormSubmitter;
submitter.addEventListener('click', async event => { submitter.addEventListener('click', async (event) => {
const {type, [internals]: elementInternals} = submitter; const {type, [internals]: elementInternals} = submitter;
const {form} = elementInternals; const {form} = elementInternals;
if (!form || type === 'button') { if (!form || type === 'button') {
@ -92,7 +93,7 @@ export function setupFormSubmitter(ctor: FormSubmitterConstructor) {
} }
// Wait a microtask for event bubbling to complete. // Wait a microtask for event bubbling to complete.
await new Promise<void>(resolve => { await new Promise<void>((resolve) => {
resolve(); resolve();
}); });
@ -109,13 +110,17 @@ export function setupFormSubmitter(ctor: FormSubmitterConstructor) {
// elements. This patches the dispatched submit event to add the correct // elements. This patches the dispatched submit event to add the correct
// `submitter`. // `submitter`.
// See https://github.com/WICG/webcomponents/issues/814 // See https://github.com/WICG/webcomponents/issues/814
form.addEventListener('submit', submitEvent => { form.addEventListener(
'submit',
(submitEvent) => {
Object.defineProperty(submitEvent, 'submitter', { Object.defineProperty(submitEvent, 'submitter', {
configurable: true, configurable: true,
enumerable: true, enumerable: true,
get: () => submitter, get: () => submitter,
}); });
}, {capture: true, once: true}); },
{capture: true, once: true},
);
elementInternals.setFormValue(submitter.value); elementInternals.setFormValue(submitter.value);
form.requestSubmit(); form.requestSubmit();

View File

@ -36,13 +36,15 @@ class FormSubmitterButton extends LitElement {
[internals] = this.attachInternals(); [internals] = this.attachInternals();
} }
describe('setupFormSubmitter()', () => { describe('setupFormSubmitter()', () => {
const env = new Environment(); const env = new Environment();
async function setupTest() { async function setupTest() {
const root = env.render( const root = env.render(
html`<form><test-form-submitter-button></test-form-submitter-button></form>`); html`<form
><test-form-submitter-button></test-form-submitter-button
></form>`,
);
const submitter = root.querySelector('test-form-submitter-button'); const submitter = root.querySelector('test-form-submitter-button');
if (!submitter) { if (!submitter) {
throw new Error(`Could not query rendered <test-form-submitter-button>`); throw new Error(`Could not query rendered <test-form-submitter-button>`);
@ -92,9 +94,13 @@ describe('setupFormSubmitter()', () => {
spyOn(form, 'requestSubmit'); spyOn(form, 'requestSubmit');
harness.element.addEventListener('click', (event: Event) => { harness.element.addEventListener(
'click',
(event: Event) => {
event.preventDefault(); event.preventDefault();
}, {once: true}); },
{once: true},
);
await harness.clickWithMouse(); await harness.clickWithMouse();
@ -103,8 +109,9 @@ describe('setupFormSubmitter()', () => {
it('should set the button as the SubmitEvent submitter', async () => { it('should set the button as the SubmitEvent submitter', async () => {
const {harness, form} = await setupTest(); const {harness, form} = await setupTest();
const submitListener = const submitListener = jasmine
jasmine.createSpy('submitListener').and.callFake((event: Event) => { .createSpy('submitListener')
.and.callFake((event: Event) => {
event.preventDefault(); event.preventDefault();
}); });
@ -121,7 +128,7 @@ describe('setupFormSubmitter()', () => {
it('should add name/value to form data when present', async () => { it('should add name/value to form data when present', async () => {
const {harness, form} = await setupTest(); const {harness, form} = await setupTest();
form.addEventListener('submit', event => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
}); });

View File

@ -13,6 +13,8 @@
* this parameter can be used as a conditional guard. Defaults to `true`. * this parameter can be used as a conditional guard. Defaults to `true`.
*/ */
export function isRtl(el: HTMLElement, shouldCheck = true) { export function isRtl(el: HTMLElement, shouldCheck = true) {
return shouldCheck && return (
getComputedStyle(el).getPropertyValue('direction').trim() === 'rtl'; shouldCheck &&
getComputedStyle(el).getPropertyValue('direction').trim() === 'rtl'
);
} }

View File

@ -10,5 +10,5 @@ export const stringConverter = {
}, },
toAttribute(value: string): string | null { toAttribute(value: string): string | null {
return value || null; return value || null;
} },
}; };

View File

@ -98,10 +98,12 @@ export function createAnimationSignal(): AnimationSignal {
export function createThrottle() { export function createThrottle() {
const stack = new Set(); const stack = new Set();
return async ( return async (
key = '', cb: (...args: unknown[]) => unknown, key = '',
cb: (...args: unknown[]) => unknown,
timeout = async () => { timeout = async () => {
await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame);
}) => { },
) => {
if (!stack.has(key)) { if (!stack.has(key)) {
stack.add(key); stack.add(key);
await timeout(); await timeout();

View File

@ -5,7 +5,12 @@
*/ */
// import 'jasmine'; (google3-only) // import 'jasmine'; (google3-only)
import {AnimationSignal, createAnimationSignal, createThrottle, msFromTimeCSSValue} from './animation.js'; import {
AnimationSignal,
createAnimationSignal,
createThrottle,
msFromTimeCSSValue,
} from './animation.js';
describe('createAnimationSignal()', () => { describe('createAnimationSignal()', () => {
let task: AnimationSignal; let task: AnimationSignal;
@ -55,14 +60,13 @@ describe('createAnimationSignal()', () => {
}); });
}); });
describe('createThrottle()', () => { describe('createThrottle()', () => {
it('throttles calls', async () => { it('throttles calls', async () => {
const throttle = createThrottle(); const throttle = createThrottle();
const key = 'foo'; const key = 'foo';
const fn = jasmine.createSpy(); const fn = jasmine.createSpy();
let timeoutResolver: Function | null = null; let timeoutResolver: Function | null = null;
const timeout = new Promise(r => { const timeout = new Promise((r) => {
timeoutResolver = r; timeoutResolver = r;
}); });
const timeoutFn = async () => { const timeoutFn = async () => {

View File

@ -47,8 +47,9 @@ const updateTabIndex = Symbol('updateTabIndex');
* @param base The class to mix functionality into. * @param base The class to mix functionality into.
* @return The provided class with `Focusable` mixed in. * @return The provided class with `Focusable` mixed in.
*/ */
export function mixinFocusable<T extends MixinBase<LitElement>>(base: T): export function mixinFocusable<T extends MixinBase<LitElement>>(
MixinReturn<T, Focusable> { base: T,
): MixinReturn<T, Focusable> {
abstract class FocusableElement extends base implements Focusable { abstract class FocusableElement extends base implements Focusable {
@property({reflect: true}) declare tabIndex: number; @property({reflect: true}) declare tabIndex: number;
@ -76,7 +77,10 @@ export function mixinFocusable<T extends MixinBase<LitElement>>(base: T):
} }
override attributeChangedCallback( override attributeChangedCallback(
name: string, old: string|null, value: string|null) { name: string,
old: string | null,
value: string | null,
) {
super.attributeChangedCallback(name, old, value); super.attributeChangedCallback(name, old, value);
if (name !== 'tabindex' || this[isUpdatingTabIndex]) { if (name !== 'tabindex' || this[isUpdatingTabIndex]) {
return; return;

View File

@ -17,8 +17,7 @@ describe('mixinFocusable()', () => {
// tslint:disable-next-line:enforce-name-casing MixinClassCase // tslint:disable-next-line:enforce-name-casing MixinClassCase
const FocusableLitElement = mixinFocusable(LitElement); const FocusableLitElement = mixinFocusable(LitElement);
@customElement('test-focusable') @customElement('test-focusable')
class TestFocusable extends FocusableLitElement { class TestFocusable extends FocusableLitElement {}
}
const env = new Environment(); const env = new Environment();
@ -53,8 +52,7 @@ describe('mixinFocusable()', () => {
expect(element.requestUpdate).toHaveBeenCalled(); expect(element.requestUpdate).toHaveBeenCalled();
}); });
it('should not override user-set tabindex="0" when isFocusable is false', it('should not override user-set tabindex="0" when isFocusable is false', async () => {
async () => {
const element = await setupTest(); const element = await setupTest();
element[isFocusable] = false; element[isFocusable] = false;
element.tabIndex = 0; element.tabIndex = 0;
@ -62,16 +60,14 @@ describe('mixinFocusable()', () => {
expect(element.tabIndex).withContext('tabIndex').toBe(0); expect(element.tabIndex).withContext('tabIndex').toBe(0);
}); });
it('should not override user-set tabindex="-1" when isFocusable is true', it('should not override user-set tabindex="-1" when isFocusable is true', async () => {
async () => {
const element = await setupTest(); const element = await setupTest();
element.tabIndex = -1; element.tabIndex = -1;
expect(element[isFocusable]).withContext('isFocusable').toBeTrue(); expect(element[isFocusable]).withContext('isFocusable').toBeTrue();
expect(element.tabIndex).withContext('tabIndex').toBe(-1); expect(element.tabIndex).withContext('tabIndex').toBe(-1);
}); });
it('should restore default tabindex when user-set tabindex attribute is removed', it('should restore default tabindex when user-set tabindex attribute is removed', async () => {
async () => {
const element = await setupTest(); const element = await setupTest();
element.tabIndex = -1; element.tabIndex = -1;
element.removeAttribute('tabindex'); element.removeAttribute('tabindex');

View File

@ -26,10 +26,11 @@
* } * }
* ``` * ```
*/ */
export type MixinBase<ExpectedBase = object> = abstract new (
// Mixins must have a constructor with `...args: any[]` // Mixins must have a constructor with `...args: any[]`
// tslint:disable-next-line:no-any // tslint:disable-next-line:no-any
export type MixinBase<ExpectedBase = object> = abstract new (...args: any[]) => ...args: any[]
ExpectedBase; ) => ExpectedBase;
/** /**
* The return value of a mixin. * The return value of a mixin.
@ -57,8 +58,6 @@ export type MixinBase<ExpectedBase = object> = abstract new (...args: any[]) =>
* ``` * ```
* *
*/ */
// Mixins must have a constructor with `...args: any[]`
// tslint:disable-next-line:no-any
export type MixinReturn<MixinBase, MixinClass = object> = export type MixinReturn<MixinBase, MixinClass = object> =
// Mixins must have a constructor with `...args: any[]` // Mixins must have a constructor with `...args: any[]`
// tslint:disable-next-line:no-any // tslint:disable-next-line:no-any

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>>('Item', [ 'Item',
[
new Knob('overline', {ui: textInput()}), new Knob('overline', {ui: textInput()}),
new Knob('trailingSupportingText', {ui: textInput()}), new Knob('trailingSupportingText', {ui: textInput()}),
new Knob('leadingIcon', {ui: boolInput()}), new Knob('leadingIcon', {ui: boolInput()}),
new Knob('trailingIcon', {ui: boolInput()}), new Knob('trailingIcon', {ui: boolInput()}),
]); ],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -64,10 +64,7 @@ const items: MaterialStoryInit<StoryKnobs> = {
render(knobs) { render(knobs) {
return html` return html`
<div class="container"> <div class="container">
<md-item> <md-item> Single line item ${getKnobContent(knobs)} </md-item>
Single line item
${getKnobContent(knobs)}
</md-item>
<md-item> <md-item>
Two line item Two line item
@ -85,7 +82,7 @@ const items: MaterialStoryInit<StoryKnobs> = {
</md-item> </md-item>
</div> </div>
`; `;
} },
}; };
const longText: MaterialStoryInit<StoryKnobs> = { const longText: MaterialStoryInit<StoryKnobs> = {
@ -96,9 +93,7 @@ const longText: MaterialStoryInit<StoryKnobs> = {
<div class="container"> <div class="container">
<md-item class="nowrap"> <md-item class="nowrap">
Item with a truncated headline and supporting text. Item with a truncated headline and supporting text.
<div slot="supporting-text"> <div slot="supporting-text"> Supporting text. ${LOREM_IPSUM} </div>
Supporting text. ${LOREM_IPSUM}
</div>
${getKnobContent(knobs)} ${getKnobContent(knobs)}
</md-item> </md-item>
@ -112,37 +107,36 @@ const longText: MaterialStoryInit<StoryKnobs> = {
<md-item> <md-item>
Item that always shows long wrapping text. Item that always shows long wrapping text.
<div slot="supporting-text"> <div slot="supporting-text"> Supporting text. ${LOREM_IPSUM} </div>
Supporting text. ${LOREM_IPSUM}
</div>
${getKnobContent(knobs, /* threeLines */ true)} ${getKnobContent(knobs, /* threeLines */ true)}
</md-item> </md-item>
</div> </div>
`; `;
} },
}; };
function getKnobContent(knobs: StoryKnobs, threeLines = false) { function getKnobContent(knobs: StoryKnobs, threeLines = false) {
const overline = knobs.overline ? const overline = knobs.overline
html`<div slot="overline">${knobs.overline}</div>` : ? html`<div slot="overline">${knobs.overline}</div>`
nothing; : nothing;
const classes = { const classes = {
'align-start': threeLines, 'align-start': threeLines,
}; };
const trailingText = knobs.trailingSupportingText ? const trailingText = knobs.trailingSupportingText
html`<div class=${classMap(classes)} slot="trailing-supporting-text">${ ? html`<div class=${classMap(classes)} slot="trailing-supporting-text"
knobs.trailingSupportingText}</div>` : >${knobs.trailingSupportingText}</div
nothing; >`
: nothing;
const leadingIcon = knobs.leadingIcon ? const leadingIcon = knobs.leadingIcon
html`<md-icon class=${classMap(classes)} slot="start">event</md-icon>` : ? html`<md-icon class=${classMap(classes)} slot="start">event</md-icon>`
nothing; : nothing;
const trailingIcon = knobs.trailingIcon ? const trailingIcon = knobs.trailingIcon
html`<md-icon class=${classMap(classes)} slot="end">star</md-icon>` : ? html`<md-icon class=${classMap(classes)} slot="end">star</md-icon>`
nothing; : nothing;
return [overline, trailingText, leadingIcon, trailingIcon]; return [overline, trailingText, leadingIcon, trailingIcon];
} }

View File

@ -27,13 +27,13 @@ export class Item extends LitElement {
<slot name="container"></slot> <slot name="container"></slot>
<slot class="non-text" name="start"></slot> <slot class="non-text" name="start"></slot>
<div class="text"> <div class="text">
<slot name="overline" <slot name="overline" @slotchange=${this.handleTextSlotChange}></slot>
<slot
class="default-slot"
@slotchange=${this.handleTextSlotChange}></slot> @slotchange=${this.handleTextSlotChange}></slot>
<slot class="default-slot" <slot name="headline" @slotchange=${this.handleTextSlotChange}></slot>
@slotchange=${this.handleTextSlotChange}></slot> <slot
<slot name="headline" name="supporting-text"
@slotchange=${this.handleTextSlotChange}></slot>
<slot name="supporting-text"
@slotchange=${this.handleTextSlotChange}></slot> @slotchange=${this.handleTextSlotChange}></slot>
</div> </div>
<slot class="non-text" name="trailing-supporting-text"></slot> <slot class="non-text" name="trailing-supporting-text"></slot>

View File

@ -4,23 +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 {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>>('Navigation Bar', [ 'Navigation Bar',
[
new Knob('hideInactiveLabels', {ui: boolInput(), defaultValue: false}), new Knob('hideInactiveLabels', {ui: boolInput(), defaultValue: false}),
new Knob('label', {ui: textInput(), defaultValue: 'Label'}), new Knob('label', {ui: textInput(), defaultValue: 'Label'}),
new Knob('showBadge', {ui: boolInput(), defaultValue: false}), new Knob('showBadge', {ui: boolInput(), defaultValue: false}),
new Knob('badgeValue', {ui: textInput(), defaultValue: ''}), new Knob('badgeValue', {ui: textInput(), defaultValue: ''}),
new Knob('active icon', {ui: textInput(), defaultValue: 'star'}), new Knob('active icon', {ui: textInput(), defaultValue: 'star'}),
new Knob('inactive icon', {ui: textInput(), defaultValue: 'star_border'}), new Knob('inactive icon', {ui: textInput(), defaultValue: 'star_border'}),
]); ],
);
collection.addStories(...materialInitsToStoryInits(stories)); collection.addStories(...materialInitsToStoryInits(stories));

View File

@ -9,7 +9,7 @@ import '@material/web/labs/navigationbar/navigation-bar.js';
import '@material/web/labs/navigationtab/navigation-tab.js'; import '@material/web/labs/navigationtab/navigation-tab.js';
import {MaterialStoryInit} from './material-collection.js'; import {MaterialStoryInit} from './material-collection.js';
import {html, css} from 'lit'; import {css, html} from 'lit';
/** Knob types for nav bar stories. */ /** Knob types for nav bar stories. */
export interface StoryKnobs { export interface StoryKnobs {
@ -27,11 +27,11 @@ const standard: MaterialStoryInit<StoryKnobs> = {
:host { :host {
/* Material Symbols does not include filled star. */ /* Material Symbols does not include filled star. */
--md-icon-font: 'Material Icons'; --md-icon-font: 'Material Icons';
}`, }
`,
render(knobs) { render(knobs) {
const {hideInactiveLabels, label, showBadge, badgeValue} = knobs; const {hideInactiveLabels, label, showBadge, badgeValue} = knobs;
return html` return html` <div style="width:400px">
<div style="width:400px">
<md-navigation-bar <md-navigation-bar
activeIndex="1" activeIndex="1"
.hideInactiveLabels=${hideInactiveLabels}> .hideInactiveLabels=${hideInactiveLabels}>
@ -68,7 +68,7 @@ const standard: MaterialStoryInit<StoryKnobs> = {
</md-navigation-tab> </md-navigation-tab>
</md-navigation-bar> </md-navigation-bar>
</div>`; </div>`;
} },
}; };
/** Nav Bar stories. */ /** Nav Bar stories. */

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