refactor: refactor focus management (#1662)
This commit is contained in:
parent
26e90d23de
commit
c071ac1174
|
@ -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 { PAGE_HISTORY_SIZE } from '../../_static/pages'
|
||||
|
||||
class ListStore extends RealmStore {
|
||||
constructor (state) {
|
||||
super(state, /* maxSize */ 10)
|
||||
super(state, /* maxSize */ PAGE_HISTORY_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
.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 New Issue