Repository navigation
fix(mosaic): keep sheet steady when moving between fields with the keyboard open #10349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
6b08681
fix(mosaic): keep sheet steady when moving between fields with the ke…
alexcarpenter 4044dce
chore(repo): use empty changeset
alexcarpenter 3b41144
fix(mosaic): keep the sheet inside the visible band when iOS pans for…
alexcarpenter 398b20c
fix(mosaic): measure the keyboard against the overlay box and pin pag…
alexcarpenter 7ee9ffd
fix(mosaic): reveal the focused field once the keyboard padding exist…
alexcarpenter 03f19d7
fix(mosaic): keep the sheet still when the keyboard arrows move betwe…
alexcarpenter 4c1d4ed
fix(mosaic): measure the reveal from the visible band's top and ignor…
alexcarpenter f51e876
fix(mosaic): treat a touch that gains a second finger as moved
alexcarpenter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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,2 @@ | ||
| --- | ||
| --- | ||
165 changes: 165 additions & 0 deletions
165
packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts
This file contains hidden or 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,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
157
packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts
This file contains hidden or 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,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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.