feat: left/right keys can change columns or focus (#1516)

* feat: left/right keys can change columns or focus

* fixup

* fixup, add tests
This commit is contained in:
Nolan Lawson 2019-09-22 23:53:29 -07:00 committed by GitHub
parent 8f3b0ac80a
commit 3d58c86963
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 265 additions and 12 deletions

View file

@ -8,6 +8,10 @@
<Shortcut key="s" on:pressed="goto('/search')"/> <Shortcut key="s" on:pressed="goto('/search')"/>
<Shortcut key="h|?" on:pressed="showShortcutHelpDialog()"/> <Shortcut key="h|?" on:pressed="showShortcutHelpDialog()"/>
<Shortcut key="c|7" on:pressed="showComposeDialog()"/> <Shortcut key="c|7" on:pressed="showComposeDialog()"/>
{#if !$leftRightChangesFocus}
<Shortcut key="ArrowLeft" on:pressed="goLeftOrRight(true)" />
<Shortcut key="ArrowRight" on:pressed="goLeftOrRight(false)" />
{/if}
{#each $navPages as navPage, i} {#each $navPages as navPage, i}
<Shortcut key={(i + 1).toString()} on:pressed="goto(navPage.href)" /> <Shortcut key={(i + 1).toString()} on:pressed="goto(navPage.href)" />
{/each} {/each}
@ -35,6 +39,21 @@
async showComposeDialog () { async showComposeDialog () {
const showComposeDialog = await importShowComposeDialog() const showComposeDialog = await importShowComposeDialog()
showComposeDialog() showComposeDialog()
},
goLeftOrRight (left) {
let { currentPage, navPages } = this.store.get()
if (currentPage === 'notifications/mentions') { // special case
currentPage = 'notifications'
}
const idx = navPages.findIndex(_ => _.name === currentPage)
if (idx === -1) {
return
}
if (left && idx > 0) {
goto(navPages[idx - 1].href)
} else if (!left && idx < navPages.length - 1) {
goto(navPages[idx + 1].href)
}
} }
} }
} }

View file

@ -6,8 +6,19 @@
{@html ` {@html `
<h2>Global</h2> <h2>Global</h2>
<div class="hotkey-group"> <div class="hotkey-group">
${$leftRightChangesFocus ?
`
<ul>
<li><kbd></kbd> to go to the next focusable element</li>
<li><kbd></kbd> to go to the previous focusable element</li>
</ul>
` : ''}
<ul> <ul>
<li><kbd>1</kbd> - <kbd>6</kbd> to switch columns</li> <li>
<kbd>1</kbd> - <kbd>6</kbd>
${$leftRightChangesFocus ? '' : `or <kbd></kbd>/<kbd></kbd>`}
to switch columns
</li>
<li><kbd>7</kbd> or <kbd>c</kbd> to compose a new toot</li> <li><kbd>7</kbd> or <kbd>c</kbd> to compose a new toot</li>
<li><kbd>s</kbd> to search</li> <li><kbd>s</kbd> to search</li>
<li><kbd>g</kbd> + <kbd>h</kbd> to go home</li> <li><kbd>g</kbd> + <kbd>h</kbd> to go home</li>
@ -40,7 +51,7 @@
<h2>Media</h2> <h2>Media</h2>
<div class="hotkey-group"> <div class="hotkey-group">
<ul> <ul>
<li><kbd>&#8592;</kbd> - <kbd>&#8594;</kbd> to go to next or previous</li> <li><kbd></kbd> / <kbd></kbd> to go to next or previous</li>
</ul> </ul>
</div> </div>
`} `}
@ -83,7 +94,10 @@
} }
</style> </style>
<script> <script>
import { store } from '../_store/store'
export default { export default {
store: () => store,
data: () => ({ data: () => ({
inDialog: false inDialog: false
}) })

View file

@ -82,8 +82,10 @@
</div> </div>
</ModalDialog> </ModalDialog>
<Shortcut scope='modal-{id}' key="ArrowLeft" on:pressed="prev()" /> {#if !$leftRightChangesFocus }
<Shortcut scope='modal-{id}' key="ArrowRight" on:pressed="next()" /> <Shortcut scope='modal-{id}' key="ArrowLeft" on:pressed="prev()" />
<Shortcut scope='modal-{id}' key="ArrowRight" on:pressed="next()" />
{/if}
<style> <style>
:global(.media-modal-dialog) { :global(.media-modal-dialog) {
max-width: 100%; max-width: 100%;

View file

@ -3,10 +3,21 @@
<h2 class="sr-only">Preferences</h2> <h2 class="sr-only">Preferences</h2>
<form class="ui-settings" aria-label="Hotkey settings"> <form class="ui-settings" aria-label="Hotkey settings">
<label class="setting-group"> <label class="setting-group {allowChangeHotkeySetting ? '' : 'disabled-style'}">
<input type="checkbox" id="choice-disable-hotkeys" <input type="checkbox" id="choice-disable-hotkeys"
bind:checked="$disableHotkeys" on:change="onChange()"> bind:checked="$disableHotkeys"
Disable hotkeys on:change="onChange()"
disabled={!allowChangeHotkeySetting}
>
Disable all hotkeys
</label>
<label class="setting-group {allowChangeLeftRightSetting ? '' : 'disabled-style'}">
<input type="checkbox" id="choice-left-right-focus"
bind:checked="$leftRightChangesFocus"
on:change="onChange()"
disabled={!allowChangeLeftRightSetting}
>
Left/right arrow keys change focus rather than columns/media
</label> </label>
</form> </form>
@ -28,6 +39,10 @@
padding: 5px 0; padding: 5px 0;
} }
label.disabled-style {
color: var(--deemphasized-text-color);
}
@media (max-width: 240px) { @media (max-width: 240px) {
.ui-settings { .ui-settings {
padding: 20px 10px; padding: 20px 10px;
@ -38,9 +53,18 @@
import SettingsLayout from '../../_components/settings/SettingsLayout.html' import SettingsLayout from '../../_components/settings/SettingsLayout.html'
import ShortcutHelpInfo from '../../_components/ShortcutHelpInfo.html' import ShortcutHelpInfo from '../../_components/ShortcutHelpInfo.html'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { isKaiOS } from '../../_utils/userAgent'
export default { export default {
store: () => store, store: () => store,
data: () => ({
allowChangeHotkeySetting: !isKaiOS() // In general KaiOS users cannot change this because they need the hotkeys
}),
computed: {
allowChangeLeftRightSetting: ({ $disableHotkeys }) => (
!$disableHotkeys && !isKaiOS()
)
},
methods: { methods: {
onChange () { onChange () {
this.store.save() this.store.save()

View file

@ -0,0 +1,114 @@
// Makes it so the left and right arrows change focus, ala Tab/Shift+Tab. This is mostly designed
// for KaiOS devices.
export function leftRightFocusObservers (store) {
if (!process.browser) {
return
}
function getDialogParent (element) {
let parent = element.parentElement
while (parent) {
if (parent.classList.contains('modal-dialog')) {
return parent
}
parent = parent.parentElement
}
}
function getFocusableElements (activeElement) {
const query = `
a,
button,
textarea,
input[type=text],
input[type=number],
input[type=search],
input[type=radio],
input[type=checkbox],
select,
[tabindex="0"]
`
// Respect focus trap inside of dialogs
const dialogParent = getDialogParent(activeElement)
const root = dialogParent || document
return Array.from(root.querySelectorAll(query))
.filter(element => {
if (element === activeElement) {
return true
}
return !element.disabled &&
element.getAttribute('tabindex') !== '-1' &&
(element.offsetWidth > 0 || element.offsetHeight > 0)
})
}
function shouldIgnoreEvent (activeElement, key) {
const isTextarea = activeElement.tagName === 'TEXTAREA'
const isTextInput = activeElement.tagName === 'INPUT' &&
['input', 'search'].includes(activeElement.getAttribute('type'))
if (!isTextarea && !isTextInput) {
return false
}
const { selectionStart, selectionEnd } = activeElement
// if the cursor is at the start or end of the textarea and the user wants to navigate out of it,
// then do so
if (key === 'ArrowLeft' && selectionStart === selectionEnd && selectionStart === 0) {
return false
} else if (key === 'ArrowRight' && selectionStart === selectionEnd && selectionStart === activeElement.value.length) {
return false
}
return true
}
function focusNextOrPrevious (e, key) {
const { activeElement } = document
if (shouldIgnoreEvent(activeElement, key)) {
return
}
const focusable = getFocusableElements(activeElement)
const index = focusable.indexOf(activeElement)
let element
if (key === 'ArrowLeft') {
console.log('focus previous')
element = focusable[index - 1] || focusable[0]
} else { // ArrowRight
console.log('focus next')
element = focusable[index + 1] || focusable[focusable.length - 1]
}
element.focus()
e.preventDefault()
e.stopPropagation()
}
function handleEnter (e) {
const { activeElement } = document
if (activeElement.tagName === 'INPUT' && ['checkbox', 'radio'].includes(activeElement.getAttribute('type'))) {
// Explicitly override "enter" on an input and make it fire the checkbox/radio
activeElement.click()
e.preventDefault()
e.stopPropagation()
}
}
function keyListener (e) {
const { key } = e
if (key === 'ArrowRight' || key === 'ArrowLeft') {
focusNextOrPrevious(e, key)
} else if (key === 'Enter') {
handleEnter(e)
}
}
store.observe('leftRightChangesFocus', leftRightChangesFocus => {
if (leftRightChangesFocus) {
window.addEventListener('keydown', keyListener)
} else {
window.removeEventListener('keydown', keyListener)
}
})
}

View file

@ -7,6 +7,7 @@ import { setupLoggedInObservers } from './setupLoggedInObservers'
import { logOutObservers } from './logOutObservers' import { logOutObservers } from './logOutObservers'
import { touchObservers } from './touchObservers' import { touchObservers } from './touchObservers'
import { grayscaleObservers } from './grayscaleObservers' import { grayscaleObservers } from './grayscaleObservers'
import { leftRightFocusObservers } from './leftRightFocusObservers'
export function observers (store) { export function observers (store) {
onlineObservers(store) onlineObservers(store)
@ -17,5 +18,6 @@ export function observers (store) {
touchObservers(store) touchObservers(store)
logOutObservers(store) logOutObservers(store)
grayscaleObservers(store) grayscaleObservers(store)
leftRightFocusObservers(store)
setupLoggedInObservers(store) setupLoggedInObservers(store)
} }

View file

@ -3,6 +3,7 @@ import { computations } from './computations/computations'
import { mixins } from './mixins/mixins' import { mixins } from './mixins/mixins'
import { LocalStorageStore } from './LocalStorageStore' import { LocalStorageStore } from './LocalStorageStore'
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
import { isKaiOS } from '../_utils/userAgent'
const persistedState = { const persistedState = {
autoplayGifs: false, autoplayGifs: false,
@ -15,7 +16,7 @@ const persistedState = {
disableFavCounts: false, disableFavCounts: false,
disableFollowerCounts: false, disableFollowerCounts: false,
disableHotkeys: false, disableHotkeys: false,
disableInfiniteScroll: false, disableInfiniteScroll: isKaiOS(),
disableLongAriaLabels: false, disableLongAriaLabels: false,
disableNotificationBadge: false, disableNotificationBadge: false,
disableReblogCounts: false, disableReblogCounts: false,
@ -23,6 +24,7 @@ const persistedState = {
enableGrayscale: false, enableGrayscale: false,
hideCards: false, hideCards: false,
largeInlineMedia: false, largeInlineMedia: false,
leftRightChangesFocus: isKaiOS(),
instanceNameInSearch: '', instanceNameInSearch: '',
instanceThemes: {}, instanceThemes: {},
instanceSettings: {}, instanceSettings: {},

View file

@ -3,7 +3,7 @@ import { store } from '../_store/store'
// Rough guess at whether this is a "mobile" device or not, for the purposes // Rough guess at whether this is a "mobile" device or not, for the purposes
// of "device class" estimations // of "device class" estimations
const IS_MOBILE = process.browser && navigator.userAgent.match(/(?:iPhone|iPod|iPad|Android)/) const IS_MOBILE = process.browser && navigator.userAgent.match(/(?:iPhone|iPod|iPad|Android|KAIOS)/)
// Run a task that doesn't need to be processed immediately, but should // Run a task that doesn't need to be processed immediately, but should
// probably be delayed if we're on a mobile device. Also run it sooner // probably be delayed if we're on a mobile device. Also run it sooner

View file

@ -179,7 +179,8 @@ function acceptShortcutEvent (event) {
(event.shiftKey && event.key !== '?') || // '?' is a special case - it is allowed (event.shiftKey && event.key !== '?') || // '?' is a special case - it is allowed
(target && ( (target && (
target.isContentEditable || target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ['TEXTAREA', 'SELECT'].includes(target.tagName) ||
(target.tagName === 'INPUT' && !['radio', 'checkbox'].includes(target.getAttribute('type')))
)) ))
) )
} }

View file

@ -0,0 +1,3 @@
import { thunk } from './thunk'
export const isKaiOS = thunk(() => process.browser && /KAIOS/.test(navigator.userAgent))

View file

@ -1,10 +1,13 @@
import { import {
disableHotkeys,
getActiveElementAriaLabel,
getNthStatus, getNthStatus,
getUrl, isNthStatusActive, getUrl, homeNavButton, isNthStatusActive, leftRightChangesFocus, modalDialog,
modalDialogContents, modalDialogContents,
notificationsNavButton, scrollToStatus notificationsNavButton, scrollToStatus, settingsNavButton, sleep
} from '../utils' } from '../utils'
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
fixture`024-shortcuts-navigation.js` fixture`024-shortcuts-navigation.js`
.page`http://localhost:4002` .page`http://localhost:4002`
@ -122,3 +125,66 @@ test('Shortcut . scrolls to top and focuses', async t => {
.pressKey('.') .pressKey('.')
.expect(isNthStatusActive(1)).ok() .expect(isNthStatusActive(1)).ok()
}) })
test('Shortcut left and right changes columns', async t => {
await loginAsFoobar(t)
const steps = [
['right', 'notifications'],
['right', 'local'],
['right', 'community'],
['right', 'search'],
['right', 'settings'],
['right', 'settings'],
['left', 'search'],
['left', 'community'],
['left', 'local'],
['left', 'notifications'],
['left', ''],
['left', '']
]
await t
.expect(getUrl()).eql('http://localhost:4002/')
for (const [key, page] of steps) {
await t.pressKey(key)
.expect(getUrl()).eql('http://localhost:4002/' + page)
}
})
test('Shortcut left and right can change focus', async t => {
await loginAsFoobar(t)
await t
.click(settingsNavButton)
.click($('a[href="/settings/hotkeys"]'))
.click(leftRightChangesFocus)
.expect(leftRightChangesFocus.checked).ok()
.click(homeNavButton)
await sleep(1000)
await t
.pressKey('right')
.expect(getActiveElementAriaLabel()).eql('Home (current page)')
.pressKey('right')
.expect(getActiveElementAriaLabel()).eql('Notifications')
.pressKey('left')
.expect(getActiveElementAriaLabel()).eql('Home (current page)')
})
test('Shortcuts can be disabled', async t => {
await loginAsFoobar(t)
await t
.click(settingsNavButton)
.click($('a[href="/settings/hotkeys"]'))
.click(disableHotkeys)
.expect(disableHotkeys.checked).ok()
.click(homeNavButton)
.pressKey('2')
await sleep(500)
await t
.expect(getUrl()).eql('http://localhost:4002/')
.pressKey('h')
await sleep(500)
await t
.expect(modalDialog.exists).false
})

View file

@ -50,6 +50,8 @@ export const neverMarkMediaSensitiveInput = $('#choice-never-mark-media-sensitiv
export const removeEmojiFromDisplayNamesInput = $('#choice-omit-emoji-in-display-names') export const removeEmojiFromDisplayNamesInput = $('#choice-omit-emoji-in-display-names')
export const disableInfiniteScroll = $('#choice-disable-infinite-scroll') export const disableInfiniteScroll = $('#choice-disable-infinite-scroll')
export const disableUnreadNotifications = $('#choice-disable-unread-notification-counts') export const disableUnreadNotifications = $('#choice-disable-unread-notification-counts')
export const leftRightChangesFocus = $('#choice-left-right-focus')
export const disableHotkeys = $('#choice-disable-hotkeys')
export const dialogOptionsOption = $('.modal-dialog button') export const dialogOptionsOption = $('.modal-dialog button')
export const emojiSearchInput = $('.emoji-mart-search input') export const emojiSearchInput = $('.emoji-mart-search input')
export const confirmationDialogOKButton = $('.confirmation-dialog-form-flex button:nth-child(1)') export const confirmationDialogOKButton = $('.confirmation-dialog-form-flex button:nth-child(1)')
@ -136,6 +138,10 @@ export const getActiveElementAriaPosInSet = exec(() => (
(document.activeElement && document.activeElement.getAttribute('aria-posinset')) || '' (document.activeElement && document.activeElement.getAttribute('aria-posinset')) || ''
)) ))
export const getActiveElementAriaLabel = exec(() => (
(document.activeElement && document.activeElement.getAttribute('aria-label')) || ''
))
export const getActiveElementInsideNthStatus = exec(() => { export const getActiveElementInsideNthStatus = exec(() => {
let element = document.activeElement let element = document.activeElement
while (element) { while (element) {