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)}}" <article class="status-article {{getClasses(originalStatus, timelineType, isStatusInOwnThread)}}"
tabindex="0" tabindex="0"
delegate-click-key="{{delegateKey}}" delegate-click-key="{{elementKey}}"
delegate-keydown-key="{{delegateKey}}" delegate-keydown-key="{{elementKey}}"
focus-key="{{elementKey}}"
aria-posinset="{{index}}" aria-posinset="{{index}}"
aria-setsize="{{length}}" aria-setsize="{{length}}"
aria-label="Status by {{originalStatus.account.display_name || originalStatus.account.username}}" aria-label="Status by {{originalStatus.account.display_name || originalStatus.account.username}}"
@ -94,15 +95,15 @@
export default { export default {
oncreate() { oncreate() {
let delegateKey = this.get('delegateKey') let elementKey = this.get('elementKey')
let onClickOrKeydown = this.onClickOrKeydown.bind(this) let onClickOrKeydown = this.onClickOrKeydown.bind(this)
registerDelegate('click', delegateKey, onClickOrKeydown) registerDelegate('click', elementKey, onClickOrKeydown)
registerDelegate('keydown', delegateKey, onClickOrKeydown) registerDelegate('keydown', elementKey, onClickOrKeydown)
}, },
ondestroy() { ondestroy() {
let delegateKey = this.get('delegateKey') let elementKey = this.get('elementKey')
unregisterDelegate('click', delegateKey) unregisterDelegate('click', elementKey)
unregisterDelegate('keydown', delegateKey) unregisterDelegate('keydown', elementKey)
}, },
components: { components: {
StatusSidebar, StatusSidebar,
@ -147,7 +148,7 @@
computed: { computed: {
originalStatus: (status) => status.reblog ? status.reblog : status, originalStatus: (status) => status.reblog ? status.reblog : status,
statusId: (originalStatus) => originalStatus.id, 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) => { contextualStatusId: ($currentInstance, timelineType, timelineValue, status, notification) => {
return `${$currentInstance}/${timelineType}/${timelineValue}/${notification ? notification.id : ''}/${status.id}` 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' : ''}}" <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}} {{status.account.display_name || status.account.username}}
</a> </a>
<style> <style>
@ -31,27 +33,10 @@
</style> </style>
<script> <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 { export default {
helpers: {
getClass: isStatusInNotification => isStatusInNotification ? 'status-author-in-notification' : ''
},
computed: { computed: {
createdAtDate: (status) => status.created_at, statusId: (status) => status.id,
relativeDate: (createdAtDate) => { focusKey: (statusId) => `status-author-name-${statusId}`
mark('compute relativeDate')
let res = relativeFormat.format(new Date(createdAtDate))
stop('compute relativeDate')
return res
}
},
components: {
ExternalLink
} }
} }
</script> </script>

View file

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

View file

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

View file

@ -1,5 +1,7 @@
<a class="status-relative-date {{isStatusInNotification ? 'status-in-notification' : '' }}" <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> <time datetime={{createdAtDate}} title="{{relativeDate}}" aria-label="{{relativeDate}} click to show thread">{{relativeDate}}</time>
</a> </a>
<style> <style>
@ -32,12 +34,14 @@
export default { export default {
computed: { computed: {
createdAtDate: (status) => status.created_at, createdAtDate: (status) => status.created_at,
statusId: (status) => status.id,
relativeDate: (createdAtDate) => { relativeDate: (createdAtDate) => {
mark('compute relativeDate') mark('compute relativeDate')
let res = relativeFormat.format(new Date(createdAtDate)) let res = relativeFormat.format(new Date(createdAtDate))
stop('compute relativeDate') stop('compute relativeDate')
return res return res
} },
focusKey: (statusId) => `status-relative-date-${statusId}`
} }
} }
</script> </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}} {{#if !$initialized}}
<LoadingPage /> <LoadingPage />
{{/if}} {{/if}}
@ -55,11 +60,22 @@
import { database } from '../../_database/database' import { database } from '../../_database/database'
import { initializeTimeline, fetchTimelineItemsOnScrollToBottom, setupTimeline } from '../../_actions/timeline' import { initializeTimeline, fetchTimelineItemsOnScrollToBottom, setupTimeline } from '../../_actions/timeline'
import LoadingPage from '../LoadingPage.html' import LoadingPage from '../LoadingPage.html'
import { focusWithCapture, blurWithCapture } from '../../_utils/events'
export default { export default {
async oncreate() { oncreate() {
console.log('timeline oncreate()') console.log('timeline oncreate()')
this.onPushState = this.onPushState.bind(this)
this.store.setForCurrentTimeline({ignoreBlurEvents: false})
window.addEventListener('pushState', this.onPushState)
setupTimeline() setupTimeline()
if (this.store.get('initialized')) {
this.restoreFocus()
}
},
ondestroy() {
console.log('ondestroy')
window.removeEventListener('pushState', this.onPushState)
}, },
data: () => ({ data: () => ({
StatusVirtualListItem, StatusVirtualListItem,
@ -109,6 +125,10 @@
PseudoVirtualList, PseudoVirtualList,
LoadingPage LoadingPage
}, },
events: {
focusWithCapture,
blurWithCapture
},
methods: { methods: {
initialize() { initialize() {
if (this.store.get('initialized') || !this.store.get('timelineItemIds')) { if (this.store.get('initialized') || !this.store.get('timelineItemIds')) {
@ -117,6 +137,9 @@
console.log('timeline initialize()') console.log('timeline initialize()')
initializeTimeline() initializeTimeline()
}, },
onPushState() {
this.store.setForCurrentTimeline({ ignoreBlurEvents: true })
},
onScrollToBottom() { onScrollToBottom() {
if (!this.store.get('initialized') || if (!this.store.get('initialized') ||
this.store.get('runningUpdate') || this.store.get('runningUpdate') ||
@ -124,7 +147,49 @@
return return
} }
fetchTimelineItemsOnScrollToBottom() 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> </script>

View file

@ -12,6 +12,12 @@ function timelineMixins (Store) {
let timelineData = timelines[instanceName] || {} let timelineData = timelines[instanceName] || {}
return (timelineData[timelineName] || {})[key] 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) { 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) { export function timelineComputations (store) {
store.compute('currentTimelineData', ['currentInstance', 'currentTimeline', 'timelines'], store.compute('currentTimelineData', ['currentInstance', 'currentTimeline', 'timelines'],
(currentInstance, currentTimeline, timelines) => { (currentInstance, currentTimeline, timelines) => {
return ((timelines && timelines[currentInstance]) || {})[currentTimeline] || {} return ((timelines && timelines[currentInstance]) || {})[currentTimeline] || {}
}) })
store.compute('timelineItemIds', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.timelineItemIds) computeForTimeline(store, 'timelineItemIds')
store.compute('runningUpdate', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.runningUpdate) computeForTimeline(store, 'runningUpdate')
store.compute('initialized', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.initialized) computeForTimeline(store, 'initialized')
computeForTimeline(store, 'lastFocusedElementSelector')
computeForTimeline(store, 'ignoreBlurEvents')
store.compute('lastTimelineItemId', ['timelineItemIds'], (timelineItemIds) => timelineItemIds && timelineItemIds.length && timelineItemIds[timelineItemIds.length - 1]) 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 { loadPolyfills } from '../routes/_utils/loadPolyfills'
import '../routes/_utils/offlineNotification' import '../routes/_utils/offlineNotification'
import '../routes/_utils/serviceWorkerClient' import '../routes/_utils/serviceWorkerClient'
import '../routes/_utils/historyEvents'
loadPolyfills().then(() => { loadPolyfills().then(() => {
// `routes` is an array of route objects injected by Sapper // `routes` is an array of route objects injected by Sapper