-
Notifications
You must be signed in to change notification settings - Fork 892
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(behaviors): add focusable behavior to labs
PiperOrigin-RevId: 575902929
- Loading branch information
1 parent
a5a6974
commit d1ef1fe
Showing
3 changed files
with
253 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |