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 { RealmStore } from '../../_utils/RealmStore'
import { PAGE_HISTORY_SIZE } from '../../_static/pages'
class ListStore extends RealmStore { class ListStore extends RealmStore {
constructor (state) { constructor (state) {
super(state, /* maxSize */ 10) super(state, /* maxSize */ PAGE_HISTORY_SIZE)
} }
} }

View file

@ -1,9 +1,6 @@
<h1 class="sr-only">{label}</h1> <h1 class="sr-only">{label}</h1>
<div class="timeline" <FocusRestoration realm={focusRealm}>
role="feed" <div class="timeline" role="feed">
on:focusin="saveFocus(event)"
on:focusout="clearFocus()"
>
{#if components} {#if components}
<svelte:component this={components.listComponent} <svelte:component this={components.listComponent}
component={components.listItemComponent} component={components.listItemComponent}
@ -23,7 +20,8 @@
on:noNeedToScroll="onNoNeedToScroll()" on:noNeedToScroll="onNoNeedToScroll()"
/> />
{/if} {/if}
</div> </div>
</FocusRestoration>
<Shortcut scope="global" key="." on:pressed="showMoreAndScrollToTop()" /> <Shortcut scope="global" key="." on:pressed="showMoreAndScrollToTop()" />
<ScrollListShortcuts /> <ScrollListShortcuts />
<script> <script>
@ -52,20 +50,15 @@
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
import { createMakeProps } from '../../_actions/createMakeProps' import { createMakeProps } from '../../_actions/createMakeProps'
import { showMoreAndScrollToTop } from '../../_actions/showMoreAndScrollToTop' import { showMoreAndScrollToTop } from '../../_actions/showMoreAndScrollToTop'
import FocusRestoration from '../FocusRestoration.html'
export default { export default {
oncreate () { oncreate () {
console.log('timeline oncreate()') console.log('timeline oncreate()')
this.setupFocus()
setupTimeline() setupTimeline()
this.restoreFocus()
this.setupStreaming() this.setupStreaming()
this.setupAsyncComponents() this.setupAsyncComponents()
}, },
ondestroy () {
console.log('ondestroy')
this.teardownFocus()
},
data: () => ({ data: () => ({
LoadingFooter, LoadingFooter,
MoreHeaderVirtualWrapper, MoreHeaderVirtualWrapper,
@ -133,7 +126,8 @@
count: itemIdsToAdd ? itemIdsToAdd.length : 0, count: itemIdsToAdd ? itemIdsToAdd.length : 0,
onClick: showMoreItemsForCurrentTimeline onClick: showMoreItemsForCurrentTimeline
} }
} },
focusRealm: ({ $currentInstance, timeline }) => `${$currentInstance}-${timeline}`
}, },
store: () => store, store: () => store,
methods: { methods: {
@ -216,16 +210,6 @@
scheduleIdleTask(handleItemIdsToAdd) 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 () { setupAsyncComponents () {
this.observe('componentsPromise', async componentsPromise => { this.observe('componentsPromise', async componentsPromise => {
if (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 () { onNoNeedToScroll () {
// If the timeline doesn't need to scroll, then we can safely "preinitialize," // 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 // i.e. render anything above the fold of the timeline. This avoids the affect
@ -298,7 +231,8 @@
}, },
components: { components: {
ScrollListShortcuts, ScrollListShortcuts,
Shortcut Shortcut,
FocusRestoration
} }
} }
</script> </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)) .click(getNthStatusSensitiveMediaButton(sensitiveKittenIdx + 1))
.expect(getActiveElementAriaLabel()).eql('Show sensitive media') .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')
})