refactor: refactor focus management (#1662)

This commit is contained in:
Nolan Lawson 2019-11-30 17:43:31 -08:00 committed by GitHub
parent 26e90d23de
commit c071ac1174
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 148 additions and 95 deletions

View File

@ -0,0 +1,99 @@
<div
on:focusin="saveFocus(event)"
on:focusout="clearFocus()"
>
<slot></slot>
</div>
<script>
import { PAGE_HISTORY_SIZE } from '../_static/pages'
import { QuickLRU } from '../_thirdparty/quick-lru/quick-lru'
import { doubleRAF } from '../_utils/doubleRAF'
const cache = new QuickLRU({ maxSize: PAGE_HISTORY_SIZE })
if (process.browser) {
window.__focusRestorationCache = cache
}
export default {
oncreate () {
this.setupPushState()
this.restoreFocus()
if (process.env.NODE_ENV !== 'production') {
if (!this.get().realm) {
throw new Error('FocusRestoration needs a realm')
}
}
},
ondestroy () {
this.teardownPushState()
},
methods: {
setupPushState () {
this.onPushState = this.onPushState.bind(this)
this.setInCache({ ignoreBlurEvents: false })
window.addEventListener('pushState', this.onPushState)
},
teardownPushState () {
window.removeEventListener('pushState', this.onPushState)
},
setInCache (obj) {
const { realm } = this.get()
if (!cache.has(realm)) {
cache.set(realm, {})
}
Object.assign(cache.get(realm), obj)
},
deleteInCache (key) {
const { realm } = this.get()
if (cache.has(realm)) {
delete cache.get(realm)[key]
}
},
getInCache () {
const { realm } = this.get()
return cache.get(realm) || {}
},
onPushState () {
this.setInCache({ ignoreBlurEvents: true })
},
restoreFocus () {
const { realm } = this.get()
const { elementId } = this.getInCache()
if (!elementId) {
return
}
console.log('restoreFocus', realm, elementId)
doubleRAF(() => {
const element = document.getElementById(elementId)
if (element) {
try {
element.focus({ preventScroll: true })
} catch (err) {
console.warn('failed to focus', elementId, err)
}
}
})
},
clearFocus () {
const { realm } = this.get()
const { ignoreBlurEvents } = this.getInCache()
if (!ignoreBlurEvents) {
console.log('clearFocus', realm)
this.deleteInCache('elementId')
}
},
saveFocus (e) {
const { realm } = this.get()
const element = e.target
if (element) {
const elementId = element.getAttribute('id')
if (elementId) {
console.log('saveFocus', realm, elementId)
this.setInCache({ elementId })
}
}
}
}
}
</script>

View File

@ -1,8 +1,9 @@
import { RealmStore } from '../../_utils/RealmStore'
import { PAGE_HISTORY_SIZE } from '../../_static/pages'
class ListStore extends RealmStore {
constructor (state) {
super(state, /* maxSize */ 10)
super(state, /* maxSize */ PAGE_HISTORY_SIZE)
}
}

View File

@ -1,29 +1,27 @@
<h1 class="sr-only">{label}</h1>
<div class="timeline"
role="feed"
on:focusin="saveFocus(event)"
on:focusout="clearFocus()"
>
{#if components}
<svelte:component this={components.listComponent}
component={components.listItemComponent}
realm="{$currentInstance + '/' + timeline}"
{makeProps}
items={itemIds}
showFooter={true}
footerComponent={LoadingFooter}
showHeader={$showHeader}
headerComponent={MoreHeaderVirtualWrapper}
{headerProps}
{scrollToItem}
on:scrollToBottom="onScrollToBottom()"
on:scrollToTop="onScrollToTop()"
on:scrollTopChanged="onScrollTopChanged(event)"
on:initialized="initialize()"
on:noNeedToScroll="onNoNeedToScroll()"
/>
{/if}
</div>
<FocusRestoration realm={focusRealm}>
<div class="timeline" role="feed">
{#if components}
<svelte:component this={components.listComponent}
component={components.listItemComponent}
realm="{$currentInstance + '/' + timeline}"
{makeProps}
items={itemIds}
showFooter={true}
footerComponent={LoadingFooter}
showHeader={$showHeader}
headerComponent={MoreHeaderVirtualWrapper}
{headerProps}
{scrollToItem}
on:scrollToBottom="onScrollToBottom()"
on:scrollToTop="onScrollToTop()"
on:scrollTopChanged="onScrollTopChanged(event)"
on:initialized="initialize()"
on:noNeedToScroll="onNoNeedToScroll()"
/>
{/if}
</div>
</FocusRestoration>
<Shortcut scope="global" key="." on:pressed="showMoreAndScrollToTop()" />
<ScrollListShortcuts />
<script>
@ -52,20 +50,15 @@
import { observe } from 'svelte-extras'
import { createMakeProps } from '../../_actions/createMakeProps'
import { showMoreAndScrollToTop } from '../../_actions/showMoreAndScrollToTop'
import FocusRestoration from '../FocusRestoration.html'
export default {
oncreate () {
console.log('timeline oncreate()')
this.setupFocus()
setupTimeline()
this.restoreFocus()
this.setupStreaming()
this.setupAsyncComponents()
},
ondestroy () {
console.log('ondestroy')
this.teardownFocus()
},
data: () => ({
LoadingFooter,
MoreHeaderVirtualWrapper,
@ -133,7 +126,8 @@
count: itemIdsToAdd ? itemIdsToAdd.length : 0,
onClick: showMoreItemsForCurrentTimeline
}
}
},
focusRealm: ({ $currentInstance, timeline }) => `${$currentInstance}-${timeline}`
},
store: () => store,
methods: {
@ -216,16 +210,6 @@
scheduleIdleTask(handleItemIdsToAdd)
})
},
setupFocus () {
this.onPushState = this.onPushState.bind(this)
this.store.setForCurrentTimeline({
ignoreBlurEvents: false
})
window.addEventListener('pushState', this.onPushState)
},
teardownFocus () {
window.removeEventListener('pushState', this.onPushState)
},
setupAsyncComponents () {
this.observe('componentsPromise', async componentsPromise => {
if (componentsPromise) {
@ -236,57 +220,6 @@
}
})
},
onPushState () {
this.store.setForCurrentTimeline({ ignoreBlurEvents: true })
},
saveFocus (e) {
try {
const { currentInstance } = this.store.get()
const { timeline } = this.get()
let lastFocusedElementId
const activeElement = e.target
if (activeElement) {
lastFocusedElementId = activeElement.getAttribute('id')
}
console.log('saving focus to ', lastFocusedElementId)
this.store.setForTimeline(currentInstance, timeline, {
lastFocusedElementId: lastFocusedElementId
})
} catch (err) {
console.error('unable to save focus', err)
}
},
clearFocus () {
try {
const { ignoreBlurEvents } = this.store.get()
if (ignoreBlurEvents) {
return
}
console.log('clearing focus')
const { currentInstance } = this.store.get()
const { timeline } = this.get()
this.store.setForTimeline(currentInstance, timeline, {
lastFocusedElementId: null
})
} catch (err) {
console.error('unable to clear focus', err)
}
},
restoreFocus () {
const { lastFocusedElementId } = this.store.get()
if (!lastFocusedElementId) {
return
}
console.log('restoreFocus', lastFocusedElementId)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const element = document.getElementById(lastFocusedElementId)
if (element) {
element.focus({ preventScroll: true })
}
})
})
},
onNoNeedToScroll () {
// If the timeline doesn't need to scroll, then we can safely "preinitialize,"
// i.e. render anything above the fold of the timeline. This avoids the affect
@ -298,7 +231,8 @@
},
components: {
ScrollListShortcuts,
Shortcut
Shortcut,
FocusRestoration
}
}
</script>

View File

@ -0,0 +1 @@
export const PAGE_HISTORY_SIZE = 10

View File

@ -137,3 +137,21 @@ test('clicking sensitive button returns focus to sensitive button', async t => {
.click(getNthStatusSensitiveMediaButton(sensitiveKittenIdx + 1))
.expect(getActiveElementAriaLabel()).eql('Show sensitive media')
})
test('preserves focus two levels deep', async t => {
await loginAsFoobar(t)
await t
.hover(getNthStatus(1))
.click($('.status-author-name').withText(('admin')))
.expect(getUrl()).contains('/accounts/1')
.click(getNthStatus(1))
.expect(getUrl()).contains('status')
await goBack()
await t
.expect(getUrl()).contains('/accounts/1')
.expect(getActiveElementClassList()).contains('status-article')
await goBack()
await t
.expect(getUrl()).eql('http://localhost:4002/')
.expect(getActiveElementClassList()).contains('status-author-name')
})