fix(behaviors): add focusable behavior to labs

PiperOrigin-RevId: 575902929
This commit is contained in:
Elizabeth Mitchell 2023-10-23 12:55:20 -07:00 committed by Copybara-Service
parent a5a6974dec
commit d1ef1febb6
3 changed files with 253 additions and 0 deletions

108
labs/behaviors/focusable.ts Normal file
View File

@ -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<T extends MixinBase<LitElement>>(base: T):
MixinReturn<T, Focusable> {
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;
}

View File

@ -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`<test-focusable></test-focusable>`);
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);
});
});

65
labs/behaviors/mixin.ts Normal file
View File

@ -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<T extends MixinBase>(base: T): MixinReturn<T, Foo> {
* // 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<ExpectedBase = object> = 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<T extends MixinBase>(base: T): MixinReturn<T, Foo> {
* 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<MixinBase, MixinClass = object> =
// Mixins must have a constructor with `...args: any[]`
// tslint:disable-next-line:no-any
(abstract new (...args: any[]) => MixinClass)&MixinBase;