diff --git a/labs/behaviors/focusable.ts b/labs/behaviors/focusable.ts new file mode 100644 index 0000000000..f85d8b1fbd --- /dev/null +++ b/labs/behaviors/focusable.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LitElement} from 'lit'; +import {property} from 'lit/decorators.js'; + +import {MixinBase, MixinReturn} from './mixin.js'; + +/** + * An element that can enable and disable `tabindex` focusability. + */ +export interface Focusable { + /** + * Whether or not the element can be focused. Defaults to true. Set to false + * to disable focusing (unless a user has set a `tabindex`). + */ + [isFocusable]: boolean; +} + +/** + * A property symbol that indicates whether or not a `Focusable` element can be + * focused. + */ +export const isFocusable = Symbol('isFocusable'); + +const privateIsFocusable = Symbol('privateIsFocusable'); +const externalTabIndex = Symbol('externalTabIndex'); +const isUpdatingTabIndex = Symbol('isUpdatingTabIndex'); +const updateTabIndex = Symbol('updateTabIndex'); + +/** + * Mixes in focusable functionality for a class. + * + * Elements can enable and disable their focusability with the `isFocusable` + * symbol property. Changing `tabIndex` will trigger a lit render, meaning + * `this.tabIndex` can be used in template expressions. + * + * This mixin will preserve externally-set tabindices. If an element turns off + * focusability, but a user sets `tabindex="0"`, it will still be focusable. + * + * To remove user overrides and restore focusability control to the element, + * remove the `tabindex` attribute. + * + * @param base The class to mix functionality into. + * @return The provided class with `Focusable` mixed in. + */ +export function mixinFocusable>(base: T): + MixinReturn { + abstract class FocusableElement extends base implements Focusable { + @property({reflect: true}) declare tabIndex: number; + + get[isFocusable]() { + return this[privateIsFocusable]; + } + + set[isFocusable](value: boolean) { + if (this[isFocusable] === value) { + return; + } + + this[privateIsFocusable] = value; + this[updateTabIndex](); + } + + [privateIsFocusable] = false; + [externalTabIndex]: number|null = null; + [isUpdatingTabIndex] = false; + + // tslint:disable-next-line:no-any + constructor(...args: any[]) { + super(...args); + this[isFocusable] = true; + } + + override attributeChangedCallback( + name: string, old: string|null, value: string|null) { + super.attributeChangedCallback(name, old, value); + if (name !== 'tabindex' || this[isUpdatingTabIndex]) { + return; + } + + if (!this.hasAttribute('tabindex')) { + // User removed the attribute, can now use internal tabIndex + this[externalTabIndex] = null; + this[updateTabIndex](); + return; + } + + this[externalTabIndex] = this.tabIndex; + } + + async[updateTabIndex]() { + const internalTabIndex = this[isFocusable] ? 0 : -1; + const computedTabIndex = this[externalTabIndex] ?? internalTabIndex; + + this[isUpdatingTabIndex] = true; + this.tabIndex = computedTabIndex; + this.requestUpdate(); + await this.updateComplete; + this[isUpdatingTabIndex] = false; + } + } + + return FocusableElement; +} diff --git a/labs/behaviors/focusable_test.ts b/labs/behaviors/focusable_test.ts new file mode 100644 index 0000000000..6c8634fa72 --- /dev/null +++ b/labs/behaviors/focusable_test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {html, LitElement} from 'lit'; +import {customElement} from 'lit/decorators.js'; + +import {Environment} from '../../testing/environment.js'; + +import {isFocusable, mixinFocusable} from './focusable.js'; + +describe('mixinFocusable()', () => { + // tslint:disable-next-line:enforce-name-casing MixinClassCase + const FocusableLitElement = mixinFocusable(LitElement); + @customElement('test-focusable') + class TestFocusable extends FocusableLitElement { + } + + const env = new Environment(); + + async function setupTest() { + const root = env.render(html``); + const element = root.querySelector('test-focusable') as TestFocusable; + await env.waitForStability(); + return element; + } + + it('isFocusable should be true by default', async () => { + const element = await setupTest(); + expect(element[isFocusable]).withContext('isFocusable').toBeTrue(); + }); + + it('should set tabindex="0" when isFocusable is true', async () => { + const element = await setupTest(); + element[isFocusable] = true; + expect(element.tabIndex).withContext('tabIndex').toBe(0); + }); + + it('should set tabindex="-1" when isFocusable is false', async () => { + const element = await setupTest(); + element[isFocusable] = false; + expect(element.tabIndex).withContext('tabIndex').toBe(-1); + }); + + it('should re-render when tabIndex changes', async () => { + const element = await setupTest(); + spyOn(element, 'requestUpdate').and.callThrough(); + element.tabIndex = 2; + expect(element.requestUpdate).toHaveBeenCalled(); + }); + + it('should not override user-set tabindex="0" when isFocusable is false', + async () => { + const element = await setupTest(); + element[isFocusable] = false; + element.tabIndex = 0; + expect(element[isFocusable]).withContext('isFocusable').toBeFalse(); + expect(element.tabIndex).withContext('tabIndex').toBe(0); + }); + + it('should not override user-set tabindex="-1" when isFocusable is true', + async () => { + const element = await setupTest(); + element.tabIndex = -1; + expect(element[isFocusable]).withContext('isFocusable').toBeTrue(); + expect(element.tabIndex).withContext('tabIndex').toBe(-1); + }); + + it('should restore default tabindex when user-set tabindex attribute is removed', + async () => { + const element = await setupTest(); + element.tabIndex = -1; + element.removeAttribute('tabindex'); + expect(element.tabIndex).withContext('tabIndex').toBe(0); + }); +}); diff --git a/labs/behaviors/mixin.ts b/labs/behaviors/mixin.ts new file mode 100644 index 0000000000..98b144e47e --- /dev/null +++ b/labs/behaviors/mixin.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The base class for a mixin with an optional expected base class type. + * + * @template ExpectedBase Optional expected base class type, such as + * `LitElement`. + * + * @example + * ```ts + * interface Foo { + * isFoo: boolean; + * } + * + * function mixinFoo(base: T): MixinReturn { + * // Mixins must be `abstract` + * abstract class FooImpl extends base implements Foo { + * isFoo = true; + * } + * + * return FooImpl; + * } + * ``` + */ +// Mixins must have a constructor with `...args: any[]` +// tslint:disable-next-line:no-any +export type MixinBase = abstract new (...args: any[]) => + ExpectedBase; + +/** + * The return value of a mixin. + * + * @template MixinBase The generic that extends `MixinBase` used for the mixin's + * base class argument. + * @template MixinClass Optional interface of fuctionality that was mixed in. + * Omit if no additional APIs were added (such as purely overriding base + * class functionality). + * + * @example + * ```ts + * interface Foo { + * isFoo: boolean; + * } + * + * // Mixins must be `abstract` + * function mixinFoo(base: T): MixinReturn { + * abstract class FooImpl extends base implements Foo { + * isFoo = true; + * } + * + * return FooImpl; + * } + * ``` + * + */ +// Mixins must have a constructor with `...args: any[]` +// tslint:disable-next-line:no-any +export type MixinReturn = + // Mixins must have a constructor with `...args: any[]` + // tslint:disable-next-line:no-any + (abstract new (...args: any[]) => MixinClass)&MixinBase;