chore(focus): Add Strong Focus manager

StrongFocus allows components to show an accessible "strong" focus visual when focused via keyboard, and to not show that when focused via a pointing device.

PiperOrigin-RevId: 403230844
This commit is contained in:
Daniel Freedman 2021-10-14 17:34:39 -07:00 committed by Copybara-Service
parent ef0f2f7a96
commit 210cc7b59f
2 changed files with 224 additions and 0 deletions

View File

@ -0,0 +1,97 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
interface StrongFocus {
visible: boolean;
setVisible(visible: boolean): void;
}
class FocusGlobal implements StrongFocus {
visible = false;
setVisible(visible: boolean) {
this.visible = visible;
}
}
/**
* This object can be overwritten by the `setup()` function to use a different
* focus coordination object.
*/
let focusObject: StrongFocus = new FocusGlobal();
/**
* Set of keyboard event codes that correspond to keyboard navigation
*/
const KEYBOARD_NAVIGATION_CODES =
new Set(['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
const KEYDOWN_HANDLER = (e: KeyboardEvent) => {
if (KEYBOARD_NAVIGATION_CODES.has(e.code)) {
focusObject.setVisible(true);
}
};
/**
* Set up integration with alternate global focus tracking object
*
* @param focusGlobal A global focus object to coordinate between multiple
* systems
* @param enableKeydownHandler Set to true to let StrongFocusService listen for
* keyboard navigation
*/
export function setup(focusGlobal: StrongFocus, enableKeydownHandler = false) {
focusObject = focusGlobal;
if (enableKeydownHandler) {
window.addEventListener('keydown', KEYDOWN_HANDLER);
} else {
window.removeEventListener('keydown', KEYDOWN_HANDLER);
}
}
/**
* Setting for always showing strong focus
*
* Defaults to false, controlled by `setForceStrongFocus`
*/
let alwaysStrong = false;
/**
* Returns `true` if the component should show strong focus.
*
* By default, strong focus is shown only on keyboard navigation, and not on
* pointer interaction.
*/
export function shouldShowStrongFocus() {
return alwaysStrong || focusObject.visible;
}
/**
* Control if strong focus should always be shown on component focus
*
* Defaults to `false`
*/
export function setForceStrongFocus(force: boolean) {
alwaysStrong = force;
}
/**
* If `true`, strong focus is always shown
*/
export function isStrongFocusForced() {
return alwaysStrong;
}
/**
* Components should call this when a user interacts with a component with a
* pointing device.
*
* By default, this will prevent the strong focus from being shown.
*/
export function pointerPress() {
focusObject.setVisible(false);
}
setup(focusObject, true);

View File

@ -0,0 +1,127 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import 'jasmine';
import * as strongFocus from '../strong-focus';
class MockFocus {
constructor(public visible = false) {}
setVisible(visible: boolean) {
this.visible = visible;
}
}
function simulateKeydown(code: string) {
const ev = new KeyboardEvent('keydown', {code, bubbles: true});
window.dispatchEvent(ev);
}
describe('Strong Focus', () => {
describe('standalone operation', () => {
beforeEach(() => {
strongFocus.setup(new MockFocus(), true);
});
it('does not show strong focus by default', () => {
expect(strongFocus.shouldShowStrongFocus()).toBeFalse();
});
it('does not force strong focus by default', () => {
expect(strongFocus.isStrongFocusForced()).toBeFalse();
});
describe('keyboard navigation', () => {
it('shows strong focus on Tab', () => {
simulateKeydown('Tab');
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
it('shows strong focus on ArrowLeft', () => {
simulateKeydown('ArrowLeft');
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
it('shows strong focus on ArrowLeft', () => {
simulateKeydown('ArrowLeft');
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
it('shows strong focus on ArrowRight', () => {
simulateKeydown('ArrowRight');
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
it('shows strong focus on ArrowUp', () => {
simulateKeydown('ArrowUp');
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
it('shows strong focus on ArrowDown', () => {
simulateKeydown('ArrowDown');
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
});
describe('pointer interaction', () => {
it('does not show strong focus', () => {
simulateKeydown('Tab');
strongFocus.pointerPress();
expect(strongFocus.shouldShowStrongFocus()).toBeFalse();
});
});
});
describe('force strong focus', () => {
beforeAll(() => {
strongFocus.setForceStrongFocus(true);
});
afterAll(() => {
strongFocus.setForceStrongFocus(false);
});
beforeEach(() => {
strongFocus.setup(new MockFocus(), true);
});
it('shows strong focus when forced', () => {
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
it('reports that strong focus is forced', () => {
expect(strongFocus.isStrongFocusForced()).toBeTrue();
});
it('shows strong focus after pointer interaction', () => {
strongFocus.pointerPress();
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
});
describe('shared focus state', () => {
let focus!: MockFocus;
beforeEach(() => {
focus = new MockFocus();
strongFocus.setup(focus);
});
it('reads from shared state', () => {
focus.visible = true;
expect(strongFocus.shouldShowStrongFocus()).toBeTrue();
});
it('writes to shared state', () => {
focus.visible = true;
strongFocus.pointerPress();
expect(focus.visible).toBeFalse();
});
});
describe('setup function', () => {
it('removes keydown listener when not wanted', () => {
const focus = new MockFocus();
strongFocus.setup(focus);
simulateKeydown('Tab');
expect(focus.visible).toBeFalse();
});
});
});