Skip to content

Commit

Permalink
feat(focus): improve usability
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 527611914
  • Loading branch information
asyncLiz authored and Copybara-Service committed Apr 27, 2023
1 parent f6d72f9 commit 34d8db0
Show file tree
Hide file tree
Showing 5 changed files with 332 additions and 23 deletions.
2 changes: 2 additions & 0 deletions focus/focus-ring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ declare global {
}

/**
* TODO(b/267336424): add docs
*
* @final
* @suppress {visibility}
*/
Expand Down
19 changes: 9 additions & 10 deletions focus/lib/_focus-ring.scss
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,17 @@ $_md-sys-motion: tokens.md-sys-motion-values();

:host([visible]) {
display: flex;
animation-name: focus-ring;
}

@keyframes focus-ring {
from {
outline-width: 0px;
}
25% {
box-shadow: inset 0 0 0 calc(var(--_active-width) / 2) currentColor;
outline-width: calc(var(--_active-width) / 2);
}
@keyframes focus-ring {
from {
outline-width: 0px;
}
25% {
box-shadow: inset 0 0 0 calc(var(--_active-width) / 2) currentColor;
outline-width: calc(var(--_active-width) / 2);
}

animation-name: focus-ring;
}

@media (prefers-reduced-motion) {
Expand Down
146 changes: 134 additions & 12 deletions focus/lib/focus-ring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,147 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {LitElement} from 'lit';
import {LitElement, PropertyValues} from 'lit';
import {property} from 'lit/decorators.js';

/**
* @summary An accessible, themable ring designed to be shown on
* `:focus-visible`.
*
* @description
* An accessible, themable ring designed to be shown on focus-visible.
* Focus ring is designed to be controlled by the `strong-focus` module in the
* same package.
*
* In most cases, `visible` should be set to
* `shouldShowStrongFocus()` on `focus` and `pointerdown` (see `pointerPress()`
* documentation in the `strong-focus` module), and `false` on `blur`.
* A focus ring component.
*/
export class FocusRing extends LitElement {
/**
* Makes the focus ring visible.
*/
@property({type: Boolean, reflect: true}) visible = false;

/**
* Reflects the value of the `for` attribute, which is the ID of the focus
* ring's associated control element.
*
* Use this when the focus ring's associated element is not a parent element.
*
* To manually control a focus ring, set its `for` attribute to `""`.
*
* @example
* ```html
* <div class="container">
* <md-focus-ring for="interactive"></md-focus-ring>
* <button id="interactive">Action</button>
* </div>
* ```
*
* @example
* ```html
* <button class="manually-controlled">
* <md-focus-ring visible for=""></md-focus-ring>
* </button>
* ```
*/
@property({attribute: 'for', reflect: true}) htmlFor = '';

/**
* The element that controls the visibility of the focus ring. It is one of:
*
* - The element referenced by the `for` attribute.
* - The element provided to `.attach(element)`
* - The parent element.
* - `null` if the focus ring is not controlled.
*/
get control() {
if (this.hasAttribute('for')) {
if (!this.htmlFor) {
return null;
}

return (this.getRootNode() as Document | ShadowRoot)
.querySelector<HTMLElement>(`#${this.htmlFor}`);
}

return this.currentControl || this.parentElement;
}

private currentControl: HTMLElement|null = null;

/**
* Attaches the focus ring to an interactive element.
*
* @param control The element that controls the focus ring.
*/
attach(control: HTMLElement) {
if (control === this.currentControl) {
return;
}

this.detach();
for (const event of ['focusin', 'focusout', 'pointerdown']) {
control.addEventListener(event, this);
}

this.currentControl = control;
this.removeAttribute('for');
}

/**
* Detaches the focus ring from its current interactive element.
*/
detach() {
for (const event of ['focusin', 'focusout', 'pointerdown']) {
this.currentControl?.removeEventListener(event, this);
}

this.currentControl = null;
this.setAttribute('for', '');
}

override connectedCallback() {
super.connectedCallback();
const {control} = this;
if (control) {
this.attach(control);
}
}

override disconnectedCallback() {
super.disconnectedCallback();
this.detach();
}

protected override updated(changedProperties: PropertyValues<FocusRing>) {
if (changedProperties.has('htmlFor')) {
const {control} = this;
if (control) {
this.attach(control);
}
}
}

/**
* @private
*/
handleEvent(event: FocusRingEvent) {
if (event[HANDLED_BY_FOCUS_RING]) {
// This ensures the focus ring does not activate when multiple focus rings
// are used within a single component.
return;
}

switch (event.type) {
default:
return;
case 'focusin':
this.visible = this.control?.matches(':focus-visible') ?? false;
break;
case 'focusout':
case 'pointerdown':
this.visible = false;
break;
}

event[HANDLED_BY_FOCUS_RING] = true;
}
}

const HANDLED_BY_FOCUS_RING = Symbol('handledByFocusRing');

interface FocusRingEvent extends Event {
[HANDLED_BY_FOCUS_RING]: true;
}
180 changes: 180 additions & 0 deletions focus/lib/focus-ring_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

// import 'jasmine'; (google3-only)

import {html, TemplateResult} from 'lit';

import {Environment} from '../../testing/environment.js';
import {Harness} from '../../testing/harness.js';

import {FocusRing} from './focus-ring.js';

customElements.define('test-focus-ring', FocusRing);

declare global {
interface HTMLElementTagNameMap {
'test-focus-ring': FocusRing;
}
}

describe('focus ring', () => {
const env = new Environment();

function setupTest(template: TemplateResult) {
const root = env.render(template);
const button = root.querySelector('button');
if (!button) {
throw new Error('Could not query rendered <button>.');
}

const focusRing = root.querySelector('test-focus-ring');
if (!focusRing) {
throw new Error('Could not query rendered <test-focus-ring>.');
}

return {
button,
focusRing,
harness: new Harness(button),
};
}

describe('control', () => {
it('should be the parentElement by default', () => {
const {button, focusRing} = setupTest(html`
<button>
<test-focus-ring></test-focus-ring>
</button>
`);

expect(focusRing.control).withContext('focusRing.control').toBe(button);
});

it('should be a referenced element when using a for attribute', () => {
const {button, focusRing} = setupTest(html`
<button id="button"></button>
<test-focus-ring for="button"></test-focus-ring>
`);

expect(focusRing.control).withContext('focusRing.control').toBe(button);
});

it('should be able to be imperatively attached', () => {
const {button, focusRing} = setupTest(html`
<button></button>
<test-focus-ring></test-focus-ring>
`);

focusRing.attach(button);
expect(focusRing.control).withContext('focusRing.control').toBe(button);
});

it('should do nothing if attaching the same control', () => {
const {button, focusRing} = setupTest(html`
<button>
<test-focus-ring></test-focus-ring>
</button>
`);

expect(focusRing.control)
.withContext('focusRing.control before attach')
.toBe(button);
focusRing.attach(button);
expect(focusRing.control)
.withContext('focusRing.control after attach')
.toBe(button);
});

it('should detach previous control when attaching a new one', async () => {
const {harness, focusRing} = setupTest(html`
<button>
<test-focus-ring></test-focus-ring>
</button>
`);

const newControl = document.createElement('div');
focusRing.attach(newControl);
// Focus the button. It should not trigger focus ring visible anymore.
await harness.focusWithKeyboard();
expect(focusRing.visible).withContext('focusRing.visible').toBeFalse();
});

it('should detach when removed from the DOM', async () => {
const {harness, focusRing} = setupTest(html`
<button>
<test-focus-ring></test-focus-ring>
</button>
`);

focusRing.remove();
// Focus the button. It should not trigger focus ring visible anymore.
await harness.focusWithKeyboard();
expect(focusRing.visible).withContext('focusRing.visible').toBeFalse();
});

it('should be able to be imperatively detached', () => {
const {focusRing} = setupTest(html`
<button>
<test-focus-ring></test-focus-ring>
</button>
`);

focusRing.detach();
expect(focusRing.control).withContext('focusRing.control').toBeNull();
});

it('should not be controlled with an empty for attribute', () => {
const {focusRing} = setupTest(html`
<button>
<test-focus-ring for=""></test-focus-ring>
</button>
`);

expect(focusRing.control).withContext('focusRing.control').toBeNull();
});
});

it('should be hidden on non-keyboard focus', async () => {
const {harness, focusRing} = setupTest(html`
<button>
<test-focus-ring></test-focus-ring>
</button>
`);

await harness.clickWithMouse();
expect(focusRing.visible)
.withContext('focusRing.visible after clickWithMouse')
.toBeFalse();
});

it('should be visible on keyboard focus', async () => {
const {harness, focusRing} = setupTest(html`
<button>
<test-focus-ring></test-focus-ring>
</button>
`);

await harness.focusWithKeyboard();
expect(focusRing.visible)
.withContext('focusRing.visible after focusWithKeyboard')
.toBeTrue();
});

it('should hide on blur', async () => {
const {harness, focusRing} = setupTest(html`
<button>
<test-focus-ring></test-focus-ring>
</button>
`);

focusRing.visible = true;
await harness.blur();
expect(focusRing.visible)
.withContext('focusRing.visible after blur')
.toBeFalse();
});
});

0 comments on commit 34d8db0

Please sign in to comment.