Skip to content

Commit

Permalink
fix(behaviors): add focusable behavior to labs
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 575902929
  • Loading branch information
asyncLiz authored and copybara-github committed Oct 23, 2023
1 parent a5a6974 commit d1ef1fe
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 0 deletions.
108 changes: 108 additions & 0 deletions labs/behaviors/focusable.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 80 additions & 0 deletions labs/behaviors/focusable_test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 65 additions & 0 deletions labs/behaviors/mixin.ts
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit d1ef1fe

Please sign in to comment.