refactor: refactor focus management (#1662)
This commit is contained in:
parent
26e90d23de
commit
c071ac1174
99
src/routes/_components/FocusRestoration.html
Normal file
99
src/routes/_components/FocusRestoration.html
Normal 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>
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,29 +1,27 @@
|
||||||
<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)"
|
{#if components}
|
||||||
on:focusout="clearFocus()"
|
<svelte:component this={components.listComponent}
|
||||||
>
|
component={components.listItemComponent}
|
||||||
{#if components}
|
realm="{$currentInstance + '/' + timeline}"
|
||||||
<svelte:component this={components.listComponent}
|
{makeProps}
|
||||||
component={components.listItemComponent}
|
items={itemIds}
|
||||||
realm="{$currentInstance + '/' + timeline}"
|
showFooter={true}
|
||||||
{makeProps}
|
footerComponent={LoadingFooter}
|
||||||
items={itemIds}
|
showHeader={$showHeader}
|
||||||
showFooter={true}
|
headerComponent={MoreHeaderVirtualWrapper}
|
||||||
footerComponent={LoadingFooter}
|
{headerProps}
|
||||||
showHeader={$showHeader}
|
{scrollToItem}
|
||||||
headerComponent={MoreHeaderVirtualWrapper}
|
on:scrollToBottom="onScrollToBottom()"
|
||||||
{headerProps}
|
on:scrollToTop="onScrollToTop()"
|
||||||
{scrollToItem}
|
on:scrollTopChanged="onScrollTopChanged(event)"
|
||||||
on:scrollToBottom="onScrollToBottom()"
|
on:initialized="initialize()"
|
||||||
on:scrollToTop="onScrollToTop()"
|
on:noNeedToScroll="onNoNeedToScroll()"
|
||||||
on:scrollTopChanged="onScrollTopChanged(event)"
|
/>
|
||||||
on:initialized="initialize()"
|
{/if}
|
||||||
on:noNeedToScroll="onNoNeedToScroll()"
|
</div>
|
||||||
/>
|
</FocusRestoration>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
|
1
src/routes/_static/pages.js
Normal file
1
src/routes/_static/pages.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const PAGE_HISTORY_SIZE = 10
|
|
@ -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')
|
||||||
|
})
|
||||||
|
|
Loading…
Reference in a new issue