forked from cybrespace/pinafore
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)}}"
|
||||
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}`
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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) {
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 '../routes/_utils/offlineNotification'
|
||||
import '../routes/_utils/serviceWorkerClient'
|
||||
import '../routes/_utils/historyEvents'
|
||||
|
||||
loadPolyfills().then(() => {
|
||||
// `routes` is an array of route objects injected by Sapper
|
||||
|
|
Loading…
Reference in New Issue