From 34d8db09aa196507fca73c452b0bedc864bc2ccd Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Thu, 27 Apr 2023 10:10:13 -0700 Subject: [PATCH] feat(focus): improve usability PiperOrigin-RevId: 527611914 --- focus/focus-ring.ts | 2 + focus/lib/_focus-ring.scss | 19 ++-- focus/lib/focus-ring.ts | 146 +++++++++++++++++++++++++--- focus/lib/focus-ring_test.ts | 180 +++++++++++++++++++++++++++++++++++ focus/strong-focus.ts | 8 +- 5 files changed, 332 insertions(+), 23 deletions(-) create mode 100644 focus/lib/focus-ring_test.ts diff --git a/focus/focus-ring.ts b/focus/focus-ring.ts index 5b7f604d2d..fd92d40167 100644 --- a/focus/focus-ring.ts +++ b/focus/focus-ring.ts @@ -16,6 +16,8 @@ declare global { } /** + * TODO(b/267336424): add docs + * * @final * @suppress {visibility} */ diff --git a/focus/lib/_focus-ring.scss b/focus/lib/_focus-ring.scss index cad51d8086..3067729870 100644 --- a/focus/lib/_focus-ring.scss +++ b/focus/lib/_focus-ring.scss @@ -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) { diff --git a/focus/lib/focus-ring.ts b/focus/lib/focus-ring.ts index 484c4c2c02..7d0262a208 100644 --- a/focus/lib/focus-ring.ts +++ b/focus/lib/focus-ring.ts @@ -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 + *
+ * + * + *
+ * ``` + * + * @example + * ```html + * + * ``` + */ + @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(`#${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) { + 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; } diff --git a/focus/lib/focus-ring_test.ts b/focus/lib/focus-ring_test.ts new file mode 100644 index 0000000000..01cf35859c --- /dev/null +++ b/focus/lib/focus-ring_test.ts @@ -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 + `); + + expect(focusRing.control).withContext('focusRing.control').toBe(button); + }); + + it('should be a referenced element when using a for attribute', () => { + const {button, focusRing} = setupTest(html` + + + `); + + expect(focusRing.control).withContext('focusRing.control').toBe(button); + }); + + it('should be able to be imperatively attached', () => { + const {button, focusRing} = setupTest(html` + + + `); + + 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` + + `); + + 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` + + `); + + 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` + + `); + + 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` + + `); + + focusRing.detach(); + expect(focusRing.control).withContext('focusRing.control').toBeNull(); + }); + + it('should not be controlled with an empty for attribute', () => { + const {focusRing} = setupTest(html` + + `); + + expect(focusRing.control).withContext('focusRing.control').toBeNull(); + }); + }); + + it('should be hidden on non-keyboard focus', async () => { + const {harness, focusRing} = setupTest(html` + + `); + + 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` + + `); + + await harness.focusWithKeyboard(); + expect(focusRing.visible) + .withContext('focusRing.visible after focusWithKeyboard') + .toBeTrue(); + }); + + it('should hide on blur', async () => { + const {harness, focusRing} = setupTest(html` + + `); + + focusRing.visible = true; + await harness.blur(); + expect(focusRing.visible) + .withContext('focusRing.visible after blur') + .toBeFalse(); + }); +}); diff --git a/focus/strong-focus.ts b/focus/strong-focus.ts index 2fc143ed43..69a6e86835 100644 --- a/focus/strong-focus.ts +++ b/focus/strong-focus.ts @@ -39,6 +39,7 @@ const KEYBOARD_NAVIGATION_KEYS = * By default, this will enable the strong focus to be shown. * * @param e The native keyboard event. + * @deprecated focus-ring automatically handles focus without global state. */ export function keydownHandler(e: KeyboardEvent) { if (KEYBOARD_NAVIGATION_KEYS.has(e.key)) { @@ -53,6 +54,7 @@ export function keydownHandler(e: KeyboardEvent) { * systems * @param enableKeydownHandler Set to true to let StrongFocusService listen for * keyboard navigation + * @deprecated focus-ring automatically handles focus without global state. */ export function setup(focusGlobal: StrongFocus, enableKeydownHandler = false) { focusObject = focusGlobal; @@ -81,6 +83,7 @@ let alwaysStrong = false; * * By default, strong focus is shown only on keyboard navigation, and not on * pointer interaction. + * @deprecated focus-ring automatically handles focus without global state. */ export function shouldShowStrongFocus() { return alwaysStrong || focusObject.visible; @@ -92,6 +95,7 @@ export function shouldShowStrongFocus() { * Defaults to `false` * * @param force Forces strong focus on the page. Disables strong focus if false. + * @deprecated focus-ring automatically handles focus without global state. */ export function setForceStrongFocus(force: boolean) { alwaysStrong = force; @@ -99,6 +103,7 @@ export function setForceStrongFocus(force: boolean) { /** * If `true`, strong focus is always shown + * @deprecated focus-ring automatically handles focus without global state. */ export function isStrongFocusForced() { return alwaysStrong; @@ -109,9 +114,10 @@ export function isStrongFocusForced() { * pointing device. * * By default, this will prevent the strong focus from being shown. + * @deprecated focus-ring automatically handles focus without global state. */ export function pointerPress() { focusObject.setVisible(false); } -setup(focusObject, true); \ No newline at end of file +setup(focusObject, true);