save focus when using keyboard navigation
This commit is contained in:
parent
e6bf344aec
commit
faac8f1a31
|
@ -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}`
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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) {
|
||||||
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
routes/_utils/historyEvents.js
Normal file
18
routes/_utils/historyEvents.js
Normal 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')
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue