mirror of
https://github.com/material-components/material-web.git
synced 2024-09-11 13:46:10 +03:00
fix(behaviors): add focusable behavior to labs
PiperOrigin-RevId: 575902929
This commit is contained in:
parent
a5a6974dec
commit
d1ef1febb6
108
labs/behaviors/focusable.ts
Normal file
108
labs/behaviors/focusable.ts
Normal 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;
|
||||
}
|
80
labs/behaviors/focusable_test.ts
Normal file
80
labs/behaviors/focusable_test.ts
Normal 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
65
labs/behaviors/mixin.ts
Normal 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;
|
Loading…
Reference in New Issue
Block a user