save focus when using keyboard navigation

This commit is contained in:
Nolan Lawson 2018-02-10 13:57:04 -08:00
parent e6bf344aec
commit faac8f1a31
11 changed files with 157 additions and 39 deletions

View file

@ -1,7 +1,8 @@
<article class="status-article {{getClasses(originalStatus, timelineType, isStatusInOwnThread)}}"
tabindex="0"
delegate-click-key="{{delegateKey}}"
delegate-keydown-key="{{delegateKey}}"
delegate-click-key="{{elementKey}}"
delegate-keydown-key="{{elementKey}}"
focus-key="{{elementKey}}"
aria-posinset="{{index}}"
aria-setsize="{{length}}"
aria-label="Status by {{originalStatus.account.display_name || originalStatus.account.username}}"
@ -94,15 +95,15 @@
export default {
oncreate() {
let delegateKey = this.get('delegateKey')
let elementKey = this.get('elementKey')
let onClickOrKeydown = this.onClickOrKeydown.bind(this)
registerDelegate('click', delegateKey, onClickOrKeydown)
registerDelegate('keydown', delegateKey, onClickOrKeydown)
registerDelegate('click', elementKey, onClickOrKeydown)
registerDelegate('keydown', elementKey, onClickOrKeydown)
},
ondestroy() {
let delegateKey = this.get('delegateKey')
unregisterDelegate('click', delegateKey)
unregisterDelegate('keydown', delegateKey)
let elementKey = this.get('elementKey')
unregisterDelegate('click', elementKey)
unregisterDelegate('keydown', elementKey)
},
components: {
StatusSidebar,
@ -147,7 +148,7 @@
computed: {
originalStatus: (status) => status.reblog ? status.reblog : status,
statusId: (originalStatus) => originalStatus.id,
delegateKey: (statusId, timelineType, timelineValue) => `status-${timelineType}-${timelineValue}-${statusId}`,
elementKey: (statusId, timelineType, timelineValue) => `status-${timelineType}-${timelineValue}-${statusId}`,
contextualStatusId: ($currentInstance, timelineType, timelineValue, status, notification) => {
return `${$currentInstance}/${timelineType}/${timelineValue}/${notification ? notification.id : ''}/${status.id}`
},

View file

@ -1,5 +1,7 @@
<a class="status-author-name {{isStatusInNotification ? 'status-in-notification' : '' }} {{isStatusInOwnThread ? 'status-in-own-thread' : ''}}"
href="/accounts/{{status.account.id}}">
href="/accounts/{{status.account.id}}"
focus-key="{{focusKey}}"
>
{{status.account.display_name || status.account.username}}
</a>
<style>
@ -31,27 +33,10 @@
</style>
<script>
import IntlRelativeFormat from 'intl-relativeformat'
import ExternalLink from '../ExternalLink.html'
import { mark, stop } from '../../_utils/marks'
const relativeFormat = new IntlRelativeFormat('en-US');
export default {
helpers: {
getClass: isStatusInNotification => isStatusInNotification ? 'status-author-in-notification' : ''
},
computed: {
createdAtDate: (status) => status.created_at,
relativeDate: (createdAtDate) => {
mark('compute relativeDate')
let res = relativeFormat.format(new Date(createdAtDate))
stop('compute relativeDate')
return res
}
},
components: {
ExternalLink
statusId: (status) => status.id,
focusKey: (statusId) => `status-author-name-${statusId}`
}
}
</script>

View file

@ -72,7 +72,8 @@
}
}
return content
}
},
statusId: (status) => status.id
},
methods: {
hydrateContent() {
@ -80,6 +81,8 @@
return
}
let status = this.get('status')
let statusId = this.get('statusId')
let count = 0
mark('hydrateContent')
if (status.tags && status.tags.length) {
let anchorTags = Array.from(this.refs.node.querySelectorAll(
@ -88,6 +91,7 @@
for (let anchorTag of anchorTags) {
if (anchorTag.getAttribute('href').endsWith(`/tags/${tag.name}`)) {
anchorTag.setAttribute('href', `/tags/${tag.name}`)
anchorTag.setAttribute('focus-key', `status-content-link-${statusId}-${++count}`)
anchorTag.removeAttribute('target')
anchorTag.removeAttribute('rel')
}
@ -101,6 +105,7 @@
for (let anchorTag of anchorTags) {
if (anchorTag.getAttribute('href') === mention.url) {
anchorTag.setAttribute('href', `/accounts/${mention.id}`)
anchorTag.setAttribute('focus-key', `status-content-link-${statusId}-${++count}`)
anchorTag.removeAttribute('target')
anchorTag.removeAttribute('rel')
}

View file

@ -3,7 +3,9 @@
<use xlink:href="{{getIcon(notification, status)}}"/>
</svg>
<span>
<a href="/accounts/{{getAccount(notification, status).id}}">
<a href="/accounts/{{getAccount(notification, status).id}}"
focus-key="{{focusKey}}"
>
{{getAccount(notification, status).display_name || ('@' + getAccount(notification, status).username)}}
</a>
{{#if notification && notification.type === 'reblog'}}
@ -62,6 +64,10 @@
</style>
<script>
export default {
computed: {
statusId: (status) => status.id,
focusKey: (statusId) => `status-header-${statusId}`
},
helpers: {
getIcon(notification, status) {
if ((notification && notification.type === 'reblog') || (status && status.reblog)) {

View file

@ -1,5 +1,7 @@
<a class="status-relative-date {{isStatusInNotification ? 'status-in-notification' : '' }}"
href="/statuses/{{status.id}}">
href="/statuses/{{status.id}}"
focus-key="{{focusKey}}"
>
<time datetime={{createdAtDate}} title="{{relativeDate}}" aria-label="{{relativeDate}} click to show thread">{{relativeDate}}</time>
</a>
<style>
@ -32,12 +34,14 @@
export default {
computed: {
createdAtDate: (status) => status.created_at,
statusId: (status) => status.id,
relativeDate: (createdAtDate) => {
mark('compute relativeDate')
let res = relativeFormat.format(new Date(createdAtDate))
stop('compute relativeDate')
return res
}
},
focusKey: (statusId) => `status-relative-date-${statusId}`
}
}
</script>

View file

@ -1,4 +1,9 @@
<div class="timeline" role="feed" aria-label="{{label}}">
<div class="timeline"
role="feed"
aria-label="{{label}}"
on:focusWithCapture="saveFocus(event)"
on:blurWithCapture="clearFocus(event)"
>
{{#if !$initialized}}
<LoadingPage />
{{/if}}
@ -55,11 +60,22 @@
import { database } from '../../_database/database'
import { initializeTimeline, fetchTimelineItemsOnScrollToBottom, setupTimeline } from '../../_actions/timeline'
import LoadingPage from '../LoadingPage.html'
import { focusWithCapture, blurWithCapture } from '../../_utils/events'
export default {
async oncreate() {
oncreate() {
console.log('timeline oncreate()')
this.onPushState = this.onPushState.bind(this)
this.store.setForCurrentTimeline({ignoreBlurEvents: false})
window.addEventListener('pushState', this.onPushState)
setupTimeline()
if (this.store.get('initialized')) {
this.restoreFocus()
}
},
ondestroy() {
console.log('ondestroy')
window.removeEventListener('pushState', this.onPushState)
},
data: () => ({
StatusVirtualListItem,
@ -109,6 +125,10 @@
PseudoVirtualList,
LoadingPage
},
events: {
focusWithCapture,
blurWithCapture
},
methods: {
initialize() {
if (this.store.get('initialized') || !this.store.get('timelineItemIds')) {
@ -117,6 +137,9 @@
console.log('timeline initialize()')
initializeTimeline()
},
onPushState() {
this.store.setForCurrentTimeline({ ignoreBlurEvents: true })
},
onScrollToBottom() {
if (!this.store.get('initialized') ||
this.store.get('runningUpdate') ||
@ -124,7 +147,49 @@
return
}
fetchTimelineItemsOnScrollToBottom()
},
saveFocus(e) {
let instanceName = this.store.get('currentInstance')
let timelineName = this.get('timeline')
let lastFocusedElementSelector
let activeElement = e.target
if (activeElement) {
let focusKey = activeElement.getAttribute('focus-key')
if (focusKey) {
lastFocusedElementSelector = `[focus-key=${focusKey}]`
}
}
console.log('saving focus to ', lastFocusedElementSelector)
this.store.setForTimeline(instanceName, timelineName, {
lastFocusedElementSelector
})
},
clearFocus() {
if (this.store.get('ignoreBlurEvents')) {
return
}
console.log('clearing focus')
let instanceName = this.store.get('currentInstance')
let timelineName = this.get('timeline')
this.store.setForTimeline(instanceName, timelineName, {
lastFocusedElementSelector: null
})
},
restoreFocus() {
let lastFocusedElementSelector = this.store.get('lastFocusedElementSelector')
console.log('lastFocused', lastFocusedElementSelector)
if (lastFocusedElementSelector) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
let element = document.querySelector(lastFocusedElementSelector)
console.log('el', element)
if (element) {
element.focus()
}
})
})
}
},
}
}
</script>

View file

@ -12,6 +12,12 @@ function timelineMixins (Store) {
let timelineData = timelines[instanceName] || {}
return (timelineData[timelineName] || {})[key]
}
Store.prototype.setForCurrentTimeline = function (obj) {
let instanceName = this.get('currentInstance')
let timelineName = this.get('currentTimeline')
this.setForTimeline(instanceName, timelineName, obj)
}
}
export function mixins (Store) {

View file

@ -1,11 +1,20 @@
function computeForTimeline(store, key) {
store.compute(key, ['currentTimelineData'], (currentTimelineData) => currentTimelineData[key])
}
export function timelineComputations (store) {
store.compute('currentTimelineData', ['currentInstance', 'currentTimeline', 'timelines'],
(currentInstance, currentTimeline, timelines) => {
return ((timelines && timelines[currentInstance]) || {})[currentTimeline] || {}
})
store.compute('timelineItemIds', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.timelineItemIds)
store.compute('runningUpdate', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.runningUpdate)
store.compute('initialized', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.initialized)
computeForTimeline(store, 'timelineItemIds')
computeForTimeline(store, 'runningUpdate')
computeForTimeline(store, 'initialized')
computeForTimeline(store, 'lastFocusedElementSelector')
computeForTimeline(store, 'ignoreBlurEvents')
store.compute('lastTimelineItemId', ['timelineItemIds'], (timelineItemIds) => timelineItemIds && timelineItemIds.length && timelineItemIds[timelineItemIds.length - 1])
}

View file

@ -34,3 +34,21 @@ export function mouseover (node, callback) {
}
}
}
export function focusWithCapture (node, callback) {
node.addEventListener('focus', callback, true)
return {
teardown () {
node.removeEventListener('focus', callback, true)
}
}
}
export function blurWithCapture (node, callback) {
node.addEventListener('blur', callback, true)
return {
teardown () {
node.removeEventListener('blur', callback, true)
}
}
}

View file

@ -0,0 +1,18 @@
// hacky way to listen for pushState/replaceState changes
// per https://stackoverflow.com/a/25673911/680742
function wrapper (type) {
let orig = history[type]
return function () {
let result = orig.apply(this, arguments)
let e = new Event(type)
e.arguments = arguments
window.dispatchEvent(e)
return result
}
}
if (process.browser) {
history.pushState = wrapper('pushState')
history.replaceState = wrapper('replaceState')
}

View file

@ -2,6 +2,7 @@ import { init } from 'sapper/runtime.js'
import { loadPolyfills } from '../routes/_utils/loadPolyfills'
import '../routes/_utils/offlineNotification'
import '../routes/_utils/serviceWorkerClient'
import '../routes/_utils/historyEvents'
loadPolyfills().then(() => {
// `routes` is an array of route objects injected by Sapper