| // Copyright 2021 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-lit-render-outside-of-view, @devtools/enforce-custom-element-definitions-location */ |
| |
| import '../../kit/kit.js'; |
| |
| import * as Lit from '../../lit/lit.js'; |
| import * as VisualLogging from '../../visual_logging/visual_logging.js'; |
| |
| import buttonStyles from './button.css.js'; |
| |
| const {html, Directives: {ifDefined, ref, classMap}} = Lit; |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'devtools-button': Button; |
| } |
| } |
| |
| export const enum Variant { |
| PRIMARY = 'primary', |
| TONAL = 'tonal', |
| OUTLINED = 'outlined', |
| TEXT = 'text', |
| TOOLBAR = 'toolbar', |
| // Just like toolbar but has a style similar to a primary button. |
| PRIMARY_TOOLBAR = 'primary_toolbar', |
| ICON = 'icon', |
| ICON_TOGGLE = 'icon_toggle', |
| ADORNER_ICON = 'adorner_icon', |
| } |
| |
| export const enum Size { |
| MICRO = 'MICRO', |
| SMALL = 'SMALL', |
| REGULAR = 'REGULAR', |
| } |
| |
| export const enum ToggleType { |
| PRIMARY = 'primary-toggle', |
| RED = 'red-toggle', |
| } |
| |
| type ButtonType = 'button'|'submit'|'reset'; |
| |
| interface ButtonState { |
| variant: Variant; |
| size?: Size; |
| reducedFocusRing?: boolean; |
| disabled: boolean; |
| toggled?: boolean; |
| toggleOnClick?: boolean; |
| checked?: boolean; |
| active: boolean; |
| spinner?: boolean; |
| type: ButtonType; |
| value?: string; |
| iconName?: string; |
| toggledIconName?: string; |
| toggleType?: ToggleType; |
| jslogContext?: string; |
| longClickable?: boolean; |
| inverseColorTheme?: boolean; |
| } |
| |
| interface CommonButtonData { |
| variant: Variant; |
| iconName?: string; |
| toggledIconName?: string; |
| toggleType?: ToggleType; |
| toggleOnClick?: boolean; |
| size?: Size; |
| reducedFocusRing?: boolean; |
| disabled?: boolean; |
| toggled?: boolean; |
| checked?: boolean; |
| active?: boolean; |
| spinner?: boolean; |
| type?: ButtonType; |
| value?: string; |
| title?: string; |
| jslogContext?: string; |
| longClickable?: boolean; |
| inverseColorTheme?: boolean; |
| /** |
| * Sets aria-label on the internal <button> element. |
| */ |
| accessibleLabel?: string; |
| } |
| |
| export type ButtonData = CommonButtonData&(|{ |
| variant: Variant.PRIMARY_TOOLBAR | Variant.TOOLBAR | Variant.ICON, |
| iconName: string, |
| }|{ |
| variant: Variant.PRIMARY | Variant.OUTLINED | Variant.TONAL | Variant.TEXT | Variant.ADORNER_ICON, |
| }|{ |
| variant: Variant.ICON_TOGGLE, |
| iconName: string, |
| toggledIconName: string, |
| toggleType: ToggleType, |
| toggled: boolean, |
| }); |
| |
| export class Button extends HTMLElement { |
| static formAssociated = true; |
| readonly #shadow = this.attachShadow({mode: 'open', delegatesFocus: true}); |
| readonly #boundOnClick = this.#onClick.bind(this); |
| readonly #props: ButtonState = { |
| size: Size.REGULAR, |
| variant: Variant.PRIMARY, |
| toggleOnClick: true, |
| disabled: false, |
| active: false, |
| spinner: false, |
| type: 'button', |
| longClickable: false, |
| }; |
| #internals = this.attachInternals(); |
| #slotRef = Lit.Directives.createRef<HTMLSlotElement>(); |
| |
| constructor() { |
| super(); |
| this.setAttribute('role', 'presentation'); |
| this.addEventListener('click', this.#boundOnClick, true); |
| } |
| |
| override cloneNode(deep?: boolean): Node { |
| const node = document.importNode(this, deep); |
| Object.assign(node.#props, this.#props); |
| node.#render(); |
| return node; |
| } |
| |
| /** |
| * Perfer using the .data= setter instead of setting the individual properties |
| * for increased type-safety. |
| */ |
| set data(data: ButtonData) { |
| this.#props.variant = data.variant; |
| this.#props.iconName = data.iconName; |
| this.#props.toggledIconName = data.toggledIconName; |
| this.#props.toggleOnClick = data.toggleOnClick !== undefined ? data.toggleOnClick : true; |
| this.#props.size = Size.REGULAR; |
| |
| if ('size' in data && data.size) { |
| this.#props.size = data.size; |
| } |
| |
| this.#props.active = Boolean(data.active); |
| this.#props.spinner = Boolean('spinner' in data ? data.spinner : false); |
| |
| this.#props.type = 'button'; |
| if ('type' in data && data.type) { |
| this.#props.type = data.type; |
| } |
| this.#props.toggled = data.toggled; |
| this.#props.toggleType = data.toggleType; |
| this.#props.checked = data.checked; |
| this.#props.disabled = Boolean(data.disabled); |
| if (data.title) { |
| this.title = data.title; |
| } |
| |
| if (data.accessibleLabel) { |
| this.accessibleLabel = data.accessibleLabel; |
| } |
| |
| this.#props.jslogContext = data.jslogContext; |
| this.#props.longClickable = data.longClickable; |
| this.#props.inverseColorTheme = data.inverseColorTheme; |
| this.#render(); |
| } |
| |
| set iconName(iconName: string|undefined) { |
| this.#props.iconName = iconName; |
| this.#render(); |
| } |
| |
| set toggledIconName(toggledIconName: string) { |
| this.#props.toggledIconName = toggledIconName; |
| this.#render(); |
| } |
| |
| set toggleType(toggleType: ToggleType) { |
| this.#props.toggleType = toggleType; |
| this.#render(); |
| } |
| |
| set variant(variant: Variant) { |
| this.#props.variant = variant; |
| this.#render(); |
| } |
| |
| set size(size: Size) { |
| this.#props.size = size; |
| this.#render(); |
| } |
| |
| set accessibleLabel(label: string|undefined) { |
| if (label) { |
| this.setAttribute('accessibleLabel', label); |
| } else { |
| this.removeAttribute('accessibleLabel'); |
| } |
| |
| this.#render(); |
| } |
| |
| get accessibleLabel(): string|undefined { |
| return this.getAttribute('accessibleLabel') || undefined; |
| } |
| |
| set reducedFocusRing(reducedFocusRing: boolean) { |
| this.#props.reducedFocusRing = reducedFocusRing; |
| this.#render(); |
| } |
| |
| set type(type: ButtonType) { |
| this.#props.type = type; |
| this.#render(); |
| } |
| |
| override get title(): string { |
| return super.title; |
| } |
| |
| override set title(title: string) { |
| super.title = title; |
| this.#render(); |
| } |
| |
| get disabled(): boolean { |
| return this.#props.disabled; |
| } |
| |
| set disabled(disabled: boolean) { |
| this.#setDisabledProperty(disabled); |
| this.#render(); |
| } |
| |
| set toggleOnClick(toggleOnClick: boolean) { |
| this.#props.toggleOnClick = toggleOnClick; |
| this.#render(); |
| } |
| |
| set toggled(toggled: boolean) { |
| this.#props.toggled = toggled; |
| this.#render(); |
| } |
| |
| get toggled(): boolean { |
| return Boolean(this.#props.toggled); |
| } |
| |
| set checked(checked: boolean) { |
| this.#props.checked = checked; |
| this.#render(); |
| } |
| |
| set active(active: boolean) { |
| this.#props.active = active; |
| this.#render(); |
| } |
| |
| get active(): boolean { |
| return this.#props.active; |
| } |
| |
| set spinner(spinner: boolean) { |
| this.#props.spinner = spinner; |
| this.#render(); |
| } |
| |
| get jslogContext(): string|undefined { |
| return this.#props.jslogContext; |
| } |
| |
| set jslogContext(jslogContext: string|undefined) { |
| this.#props.jslogContext = jslogContext; |
| this.#render(); |
| } |
| |
| set longClickable(longClickable: boolean) { |
| this.#props.longClickable = longClickable; |
| this.#render(); |
| } |
| |
| set inverseColorTheme(inverseColorTheme: boolean) { |
| this.#props.inverseColorTheme = inverseColorTheme; |
| this.#render(); |
| } |
| |
| #setDisabledProperty(disabled: boolean): void { |
| this.#props.disabled = disabled; |
| this.#render(); |
| } |
| |
| connectedCallback(): void { |
| this.#render(); |
| } |
| |
| #onClick(event: Event): void { |
| if (this.#props.disabled) { |
| event.stopPropagation(); |
| event.preventDefault(); |
| return; |
| } |
| if (this.form && this.#props.type === 'submit') { |
| event.preventDefault(); |
| this.form.dispatchEvent(new SubmitEvent('submit', { |
| submitter: this, |
| })); |
| } |
| if (this.form && this.#props.type === 'reset') { |
| event.preventDefault(); |
| this.form.reset(); |
| } |
| if (this.#props.toggleOnClick && this.#props.variant === Variant.ICON_TOGGLE && this.#props.iconName) { |
| this.toggled = !this.#props.toggled; |
| } |
| } |
| |
| #isToolbarVariant(): boolean { |
| return this.#props.variant === Variant.TOOLBAR || this.#props.variant === Variant.PRIMARY_TOOLBAR; |
| } |
| |
| #render(): void { |
| const nodes = this.#slotRef.value?.assignedNodes(); |
| const isEmpty = !Boolean(nodes?.length); |
| if (!this.#props.variant) { |
| throw new Error('Button requires a variant to be defined'); |
| } |
| if (this.#isToolbarVariant()) { |
| if (!this.#props.iconName) { |
| throw new Error('Toolbar button requires an icon'); |
| } |
| if (!isEmpty) { |
| throw new Error('Toolbar button does not accept children'); |
| } |
| } |
| if (this.#props.variant === Variant.ICON) { |
| if (!this.#props.iconName) { |
| throw new Error('Icon button requires an icon'); |
| } |
| if (!isEmpty) { |
| throw new Error('Icon button does not accept children'); |
| } |
| } |
| const hasIcon = Boolean(this.#props.iconName); |
| const classes = { |
| primary: this.#props.variant === Variant.PRIMARY, |
| tonal: this.#props.variant === Variant.TONAL, |
| outlined: this.#props.variant === Variant.OUTLINED, |
| text: this.#props.variant === Variant.TEXT, |
| toolbar: this.#isToolbarVariant(), |
| 'primary-toolbar': this.#props.variant === Variant.PRIMARY_TOOLBAR, |
| icon: this.#props.variant === Variant.ICON || this.#props.variant === Variant.ICON_TOGGLE || |
| this.#props.variant === Variant.ADORNER_ICON, |
| 'primary-toggle': this.#props.toggleType === ToggleType.PRIMARY, |
| 'red-toggle': this.#props.toggleType === ToggleType.RED, |
| toggled: Boolean(this.#props.toggled), |
| checked: Boolean(this.#props.checked), |
| 'text-with-icon': hasIcon && !isEmpty, |
| 'only-icon': hasIcon && isEmpty, |
| micro: this.#props.size === Size.MICRO, |
| small: this.#props.size === Size.SMALL, |
| 'reduced-focus-ring': Boolean(this.#props.reducedFocusRing), |
| active: this.#props.active, |
| inverse: Boolean(this.#props.inverseColorTheme), |
| }; |
| const spinnerClasses = { |
| primary: this.#props.variant === Variant.PRIMARY, |
| outlined: this.#props.variant === Variant.OUTLINED, |
| disabled: this.#props.disabled, |
| spinner: true, |
| }; |
| const jslog = |
| this.#props.jslogContext && VisualLogging.action().track({click: true}).context(this.#props.jslogContext); |
| // clang-format off |
| Lit.render( |
| html` |
| <style>${buttonStyles}</style> |
| <button title=${ifDefined(this.title || undefined)} |
| ?disabled=${this.#props.disabled} |
| class=${classMap(classes)} |
| aria-pressed=${ifDefined(this.#props.toggled)} |
| aria-label=${ifDefined(this.accessibleLabel || this.title || undefined)} |
| jslog=${ifDefined(jslog)}> |
| ${hasIcon ? html` |
| <devtools-icon name=${ifDefined(this.#props.toggled ? this.#props.toggledIconName : this.#props.iconName)}> |
| </devtools-icon>` |
| : ''} |
| ${this.#props.longClickable ? html` |
| <devtools-icon name="triangle-bottom-right" class="long-click"> |
| </devtools-icon>` |
| : ''} |
| ${this.#props.spinner ? html`<span class=${classMap(spinnerClasses)}></span>` : ''} |
| <slot @slotchange=${this.#render} ${ref(this.#slotRef)}></slot> |
| </button> |
| `, this.#shadow, {host: this}); |
| // clang-format on |
| } |
| |
| // Based on https://web.dev/more-capable-form-controls/ to make custom elements form-friendly. |
| // Form controls usually expose a "value" property. |
| get value(): string { |
| return this.#props.value || ''; |
| } |
| set value(value: string) { |
| this.#props.value = value; |
| } |
| |
| // The following properties and methods aren't strictly required, |
| // but browser-level form controls provide them. Providing them helps |
| // ensure consistency with browser-provided controls. |
| get form(): HTMLFormElement|null { |
| return this.#internals.form; |
| } |
| get name(): string|null { |
| return this.getAttribute('name'); |
| } |
| get type(): ButtonType { |
| return this.#props.type; |
| } |
| get validity(): ValidityState { |
| return this.#internals.validity; |
| } |
| get validationMessage(): string { |
| return this.#internals.validationMessage; |
| } |
| get willValidate(): boolean { |
| return this.#internals.willValidate; |
| } |
| checkValidity(): boolean { |
| return this.#internals.checkValidity(); |
| } |
| reportValidity(): boolean { |
| return this.#internals.reportValidity(); |
| } |
| } |
| |
| customElements.define('devtools-button', Button); |