From faac8f1a31080212fd30607a1dc7bd1d99da84e3 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 10 Feb 2018 13:57:04 -0800 Subject: [PATCH] save focus when using keyboard navigation --- routes/_components/status/Status.html | 19 ++--- .../_components/status/StatusAuthorName.html | 25 ++----- routes/_components/status/StatusContent.html | 7 +- routes/_components/status/StatusHeader.html | 8 ++- .../status/StatusRelativeDate.html | 8 ++- routes/_components/timeline/Timeline.html | 71 ++++++++++++++++++- routes/_store/mixins.js | 6 ++ routes/_store/timelineComputations.js | 15 +++- routes/_utils/events.js | 18 +++++ routes/_utils/historyEvents.js | 18 +++++ templates/main.js | 1 + 11 files changed, 157 insertions(+), 39 deletions(-) create mode 100644 routes/_utils/historyEvents.js diff --git a/routes/_components/status/Status.html b/routes/_components/status/Status.html index 5c3262c3..7996f06b 100644 --- a/routes/_components/status/Status.html +++ b/routes/_components/status/Status.html @@ -1,7 +1,8 @@
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}` }, diff --git a/routes/_components/status/StatusAuthorName.html b/routes/_components/status/StatusAuthorName.html index b9dd738a..35033eb9 100644 --- a/routes/_components/status/StatusAuthorName.html +++ b/routes/_components/status/StatusAuthorName.html @@ -1,5 +1,7 @@ + href="/accounts/{{status.account.id}}" + focus-key="{{focusKey}}" +> {{status.account.display_name || status.account.username}} \ No newline at end of file diff --git a/routes/_components/status/StatusContent.html b/routes/_components/status/StatusContent.html index 9ab450e8..e3ebee8b 100644 --- a/routes/_components/status/StatusContent.html +++ b/routes/_components/status/StatusContent.html @@ -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') } diff --git a/routes/_components/status/StatusHeader.html b/routes/_components/status/StatusHeader.html index 5c36ba4c..8836d6d9 100644 --- a/routes/_components/status/StatusHeader.html +++ b/routes/_components/status/StatusHeader.html @@ -3,7 +3,9 @@ - + {{getAccount(notification, status).display_name || ('@' + getAccount(notification, status).username)}} {{#if notification && notification.type === 'reblog'}} @@ -62,6 +64,10 @@ \ No newline at end of file diff --git a/routes/_components/timeline/Timeline.html b/routes/_components/timeline/Timeline.html index 0fc2aef9..1afb5177 100644 --- a/routes/_components/timeline/Timeline.html +++ b/routes/_components/timeline/Timeline.html @@ -1,4 +1,9 @@ -
+
{{#if !$initialized}} {{/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() + } + }) + }) + } + }, } } \ No newline at end of file diff --git a/routes/_store/mixins.js b/routes/_store/mixins.js index efd7490b..1a247c14 100644 --- a/routes/_store/mixins.js +++ b/routes/_store/mixins.js @@ -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) { diff --git a/routes/_store/timelineComputations.js b/routes/_store/timelineComputations.js index 2cfc2c23..dd332fb8 100644 --- a/routes/_store/timelineComputations.js +++ b/routes/_store/timelineComputations.js @@ -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]) } diff --git a/routes/_utils/events.js b/routes/_utils/events.js index fc9b144f..1b02ba53 100644 --- a/routes/_utils/events.js +++ b/routes/_utils/events.js @@ -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) + } + } +} \ No newline at end of file diff --git a/routes/_utils/historyEvents.js b/routes/_utils/historyEvents.js new file mode 100644 index 00000000..6f40b28c --- /dev/null +++ b/routes/_utils/historyEvents.js @@ -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') +} \ No newline at end of file diff --git a/templates/main.js b/templates/main.js index c3b7df6a..aabe53c2 100644 --- a/templates/main.js +++ b/templates/main.js @@ -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