Skip to content
Merged
2 changes: 2 additions & 0 deletions .changeset/mosaic-sheet-keyboard-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
165 changes: 165 additions & 0 deletions packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { guardKeyboardFocus } from '../keyboard-focus';

class FakeViewport extends EventTarget {
height = 768;
offsetTop = 0;
scale = 1;
}

const initialScrollY = Object.getOwnPropertyDescriptor(window, 'scrollY');
let viewport: FakeViewport;
let container: HTMLDivElement;
let release: () => void;

function rect(top: number, height: number): DOMRect {
return new DOMRect(0, top, 320, height);
}

function openKeyboard() {
viewport.height = 400;
viewport.dispatchEvent(new Event('resize'));
}

function scroller() {
const element = document.createElement('div');
element.style.overflowY = 'auto';
Object.defineProperties(element, {
clientHeight: { value: 768 },
scrollHeight: { value: 1400 },
});
element.getBoundingClientRect = () => rect(0, 768);
element.scrollTo = vi.fn();
return element;
}

function field(top: number) {
const input = document.createElement('input');
input.getBoundingClientRect = () => rect(top, 40);
return input;
}

beforeEach(() => {
viewport = new FakeViewport();
Object.defineProperty(window, 'visualViewport', { configurable: true, value: viewport });
Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 768 });
container = document.createElement('div');
document.body.appendChild(container);
release = guardKeyboardFocus(container);
});

afterEach(() => {
release();
vi.restoreAllMocks();
if (initialScrollY) {
Object.defineProperty(window, 'scrollY', initialScrollY);
}
container.remove();
Reflect.deleteProperty(window, 'visualViewport');
Reflect.deleteProperty(document.documentElement, 'clientHeight');
});

describe('guardKeyboardFocus', () => {
it('centers the focused field in the band above the keyboard once it opens', () => {
const scroll = scroller();
const input = field(600);
scroll.append(input);
container.append(scroll);
input.focus();

openKeyboard();

expect(scroll.scrollTo).toHaveBeenCalledWith({ top: 420, behavior: 'smooth' });
});

it('leaves a field that is already visible above the keyboard where it is', () => {
const scroll = scroller();
const input = field(100);
scroll.append(input);
container.append(scroll);
input.focus();

openKeyboard();

expect(scroll.scrollTo).not.toHaveBeenCalled();
});

it('reveals a field that sits above the visible band when the page is panned', () => {
const scroll = scroller();
scroll.scrollTop = 500;
const input = field(100);
scroll.append(input);
container.append(scroll);
input.focus();
viewport.offsetTop = 200;

openKeyboard();

expect(scroll.scrollTo).toHaveBeenCalledWith({ top: 220, behavior: 'smooth' });
});

it('centers the next field when focus moves with the keyboard up', () => {
const scroll = scroller();
const first = field(100);
const second = field(700);
scroll.append(first, second);
container.append(scroll);
first.focus();
openKeyboard();

second.focus();

expect(scroll.scrollTo).toHaveBeenCalledWith({ top: 520, behavior: 'smooth' });
});

it('moves focus between fields without the browser panning while the keyboard is up', () => {
const scroll = scroller();
const first = field(100);
const second = field(200);
scroll.append(first, second);
container.append(scroll);
first.focus();
openKeyboard();
const focus = vi.spyOn(second, 'focus');

second.focus();

expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(document.activeElement).toBe(second);
});

it('leaves focus to the browser while the keyboard is down', () => {
const scroll = scroller();
const first = field(100);
const second = field(200);
scroll.append(first, second);
container.append(scroll);
first.focus();
const focus = vi.spyOn(second, 'focus');

second.focus();

expect(focus).toHaveBeenCalledTimes(1);
expect(focus).toHaveBeenCalledWith();
});

it('puts the page back when the browser scrolls it to reveal a field with the keyboard up', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
openKeyboard();
Object.defineProperty(window, 'scrollY', { configurable: true, value: 120 });

window.dispatchEvent(new Event('scroll'));

expect(scrollTo).toHaveBeenCalledWith({ left: 0, top: 0, behavior: 'instant' });
});

it('leaves page scrolling alone while the keyboard is down', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
Object.defineProperty(window, 'scrollY', { configurable: true, value: 120 });

window.dispatchEvent(new Event('scroll'));

expect(scrollTo).not.toHaveBeenCalled();
});
});
157 changes: 157 additions & 0 deletions packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { guardKeyboardTouch } from '../keyboard-touch';

let container: HTMLDivElement;
let release: () => void;

function scroller(scrollTop: number) {
const element = document.createElement('div');
element.style.overflowY = 'auto';
Object.defineProperties(element, {
clientHeight: { value: 300 },
scrollHeight: { value: 900 },
scrollTop: { value: scrollTop },
});
return element;
}

function touch(target: Element, type: string, y: number, fingers = 1) {
const point = { clientX: 10, clientY: y };
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
touches: { value: type === 'touchend' ? [] : Array.from({ length: fingers }, () => point) },
changedTouches: { value: [point] },
});
target.dispatchEvent(event);
return event;
}

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
release = guardKeyboardTouch(container);
});

afterEach(() => {
release();
container.remove();
});

describe('guardKeyboardTouch', () => {
it('focuses a text field on tap', () => {
const input = document.createElement('input');
container.append(input);

touch(input, 'touchstart', 100);
touch(input, 'touchend', 102);

expect(input).toHaveFocus();
});

it('leaves a text field alone when a drag ends on it', () => {
const input = document.createElement('input');
container.append(input);

touch(input, 'touchstart', 100);
touch(input, 'touchmove', 300);
touch(input, 'touchend', 300);

expect(input).not.toHaveFocus();
});

it('leaves a text field alone when a drag returns to where it started', () => {
const input = document.createElement('input');
container.append(input);

touch(input, 'touchstart', 100);
touch(input, 'touchmove', 150);
touch(input, 'touchend', 102);

expect(input).not.toHaveFocus();
});

it('leaves a text field alone when a second finger joins the touch', () => {
const input = document.createElement('input');
container.append(input);

touch(input, 'touchstart', 100);
touch(input, 'touchstart', 100, 2);
touch(input, 'touchend', 101);

expect(input).not.toHaveFocus();
});

it('submits on a tap of a submit button and keeps focus on the field', () => {
const input = document.createElement('input');
const submit = document.createElement('button');
submit.type = 'submit';
const onClick = vi.fn();
submit.addEventListener('click', onClick);
container.append(input, submit);
input.focus();

touch(submit, 'touchstart', 100);
const end = touch(submit, 'touchend', 101);

expect(onClick).toHaveBeenCalledTimes(1);
expect(end.defaultPrevented).toBe(true);
expect(input).toHaveFocus();
});

it('leaves a submit tap to the browser when no field has focus', () => {
const submit = document.createElement('button');
submit.type = 'submit';
container.append(submit);

touch(submit, 'touchstart', 100);
const end = touch(submit, 'touchend', 101);

expect(end.defaultPrevented).toBe(false);
});

it('cancels a drag that nothing under it can scroll', () => {
const block = document.createElement('div');
container.append(block);

touch(block, 'touchstart', 100);
const move = touch(block, 'touchmove', 150);

expect(move.defaultPrevented).toBe(true);
});

it('lets a drag through when an ancestor can scroll that way', () => {
const scroll = scroller(200);
const block = document.createElement('div');
scroll.append(block);
container.append(scroll);

touch(block, 'touchstart', 100);
const move = touch(block, 'touchmove', 150);

expect(move.defaultPrevented).toBe(false);
});

it('cancels a drag past the end an ancestor has already reached', () => {
const scroll = scroller(0);
const block = document.createElement('div');
scroll.append(block);
container.append(scroll);

touch(block, 'touchstart', 100);
const move = touch(block, 'touchmove', 150);

expect(move.defaultPrevented).toBe(true);
});

it('lets a slider take its own drag', () => {
const slider = document.createElement('input');
slider.type = 'range';
container.append(slider);

touch(slider, 'touchstart', 100);
const move = touch(slider, 'touchmove', 150);

expect(move.defaultPrevented).toBe(false);
});
});
Loading
Loading