save focus when using keyboard navigation
This commit is contained in:
		
							parent
							
								
									e6bf344aec
								
							
						
					
					
						commit
						faac8f1a31
					
				
					 11 changed files with 157 additions and 39 deletions
				
			
		| 
						 | 
				
			
			@ -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)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										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 '../routes/_utils/offlineNotification'
 | 
			
		||||
import '../routes/_utils/serviceWorkerClient'
 | 
			
		||||
import '../routes/_utils/historyEvents'
 | 
			
		||||
 | 
			
		||||
loadPolyfills().then(() => {
 | 
			
		||||
  // `routes` is an array of route objects injected by Sapper
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue