Skip to content

Commit

Permalink
[full-ci] Add keyboard selection / navigation (#7153)
Browse files Browse the repository at this point in the history
* WIP selection via keyboard

* WIP

* Refactor keymap into own component

* Add space, escape keyboard actions

* Add select all shortcut

* Refactor/slim code

* Add latest selection checkbox outline, fix toggle multiple select issue

* Add ctrl + left click selection

* Add shift + click selection

* Add keyboard up/down navigation

* Fix navigation deselects first and last row bug

* Fix shift+click doesnt set latest selected item

* Bump ODS, Address PR issues

* Fix ODS not updated, fix row click bug undefined error

* Fix focus sidebar on selection killing keyboard shortcuts

* Fix visibility observer

* Remove wait for focus

* Move keyboard actions into views

* Add/update changelog, Resolve PR issues

* Fix broken row click, Address PR issues

* Make focus via keyboard tab work

* Fix scroll to resource

* Fix linting
  • Loading branch information
lookacat authored Jul 8, 2022
1 parent 56f508d commit f078d51
Show file tree
Hide file tree
Showing 17 changed files with 307 additions and 93 deletions.
15 changes: 15 additions & 0 deletions changelog/unreleased/enhancement-add-keyboard-navigation-selection
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Enhancement: Add Keyboard navigation/selection

We've added the possibility to navigate and select via keyboard.
- Navigation:
- via keyboard arrows up/down for moving up and down through the rows of the file list
- Selection
- via keyboard space bar: select / deselect the currently highlighted row
- via keyboard shift + arrows up/down: add a series of rows
- via keyboard cmd/ctrl + a: select all rows
- via keyboard esc: deselect all rows
- via mouse holding cmd/ctrl + left click on a row: add/remove the clicked item to/from the current selection model
- via mouse holding shift + left click on a row: add the clicked row and the series of rows towards the most recently clicked row to the current selection model.

https://github.com/owncloud/web/pull/7153
https://github.com/owncloud/web/issues/6029
18 changes: 10 additions & 8 deletions changelog/unreleased/enhancement-update-ods
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
Enhancement: Update ODS to v14.0.0-alpha.2
Enhancement: Update ODS to v14.0.0-alpha.4

We updated the ownCloud Design System to version 14.0.0-alpha.2. Please refer to the full changelog in the ODS release (linked) for more details. Summary:
We updated the ownCloud Design System to version 14.0.0-alpha.4. Please refer to the full changelog in the ODS release (linked) for more details. Summary:

- Change - Remove OcAlert component: https://github.com/owncloud/owncloud-design-system/pull/2210
- Change - Remove transition animations: https://github.com/owncloud/owncloud-design-system/pull/2210
- Change - Revamp animations: https://github.com/owncloud/owncloud-design-system/pull/2210
- Enhancement - Progress bar indeterminate state: https://github.com/owncloud/owncloud-design-system/pull/2200
- Enhancement - Redesign notifications: https://github.com/owncloud/owncloud-design-system/pull/2210
- Bugfix - Remove click event on OcIcon: https://github.com/owncloud/owncloud-design-system/pull/2216
- Bugfix - Remove click event on OcIcon: #2216
- Change - Remove OcAlert component: #2210
- Change - Remove transition animations: #2210
- Change - Revamp animations: #2210
- Change - OcTable emit event data on row click: #2218
- Enhancement - OcCheckbox add outline: #2218
- Enhancement - Progress bar indeterminate state: #2200
- Enhancement - Redesign notifications: #2210

https://github.com/owncloud/web/pull/7139
https://github.com/owncloud/owncloud-design-system/releases/tag/14.0.0-alpha.2
49 changes: 2 additions & 47 deletions packages/web-app-files/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<main id="files" class="oc-flex oc-height-1-1">
<div ref="filesListWrapper" tabindex="-1" class="files-list-wrapper oc-width-expand">
<router-view id="files-view" />
<router-view id="files-view" tabindex="0" />
</div>
<side-bar
v-if="showSidebar"
Expand All @@ -20,22 +20,15 @@
</template>
<script lang="ts">
import Mixins from './mixins'
import { mapActions, mapState, mapMutations } from 'vuex'
import { mapActions, mapState } from 'vuex'
import SideBar from './components/SideBar/SideBar.vue'
import { defineComponent } from '@vue/composition-api'
import { usePublicLinkPassword, useStore } from 'web-pkg/src/composables'
export default defineComponent({
components: {
SideBar
},
mixins: [Mixins],
setup() {
const store = useStore()
return {
publicLinkPassword: usePublicLinkPassword({ store })
}
},
computed: {
...mapState('Files/sidebar', {
sidebarClosed: 'closed',
Expand All @@ -62,50 +55,12 @@ export default defineComponent({
})
},
mounted() {
document.addEventListener('keydown', this.handleShortcut, false)
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleShortcut)
},
methods: {
...mapActions('Files', ['resetFileSelection']),
...mapActions('Files/sidebar', {
closeSidebar: 'close',
setActiveSidebarPanel: 'setActivePanel'
}),
...mapActions(['showMessage', 'createModal', 'hideModal']),
...mapActions('Files', ['copySelectedFiles', 'cutSelectedFiles', 'pasteSelectedFiles']),
...mapMutations('Files', ['UPSERT_RESOURCE']),
handleShortcut(event) {
const key = event.keyCode || event.which
const ctr = window.navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey
if (!ctr /* CTRL | CMD */) return
const isCopyAction = key === 67
const isPasteAction = key === 86
const isCutAction = key === 88
if (isCopyAction) {
this.copySelectedFiles()
} else if (isPasteAction) {
this.pasteSelectedFiles({
client: this.$client,
createModal: this.createModal,
hideModal: this.hideModal,
showMessage: this.showMessage,
$gettext: this.$gettext,
$gettextInterpolate: this.$gettextInterpolate,
$ngettext: this.$ngettext,
routeContext: this.$route.name,
publicLinkPassword: this.publicLinkPassword,
upsertResource: this.UPSERT_RESOURCE
})
} else if (isCutAction) {
this.cutSelectedFiles()
}
},
focusSideBar(component, event) {
this.focus({
Expand Down
221 changes: 221 additions & 0 deletions packages/web-app-files/src/components/FilesList/KeyboardActions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<template>
<div></div>
</template>

<script lang="ts">
import { bus } from 'web-pkg/src/instance'
import { mapActions, mapState, mapMutations } from 'vuex'
import { defineComponent } from '@vue/composition-api'
import MixinFilesListScrolling from '../../mixins/filesListScrolling'
export default defineComponent({
mixins: [MixinFilesListScrolling],
props: {
paginatedResources: {
type: Array,
required: true
},
keybindOnElementId: {
type: String,
required: false,
default: 'files-view'
}
},
data: () => {
return {
selectionCursor: 0
}
},
computed: {
...mapState('Files', ['latestSelectedId'])
},
mounted() {
const filesList = document.getElementById(this.keybindOnElementId)
if (filesList) {
filesList.addEventListener('keydown', this.handleShortcut, false)
}
const fileListClickedEvent = bus.subscribe('app.files.list.clicked', this.resetSelectionCursor)
const fileListClickedMetaEvent = bus.subscribe(
'app.files.list.clicked.meta',
this.handleCtrlClickAction
)
const fileListClickedShiftEvent = bus.subscribe(
'app.files.list.clicked.shift',
this.handleShiftClickAction
)
this.$on('beforeDestroy', () => {
bus.unsubscribe('app.files.list.clicked', fileListClickedEvent)
bus.unsubscribe('app.files.list.clicked.meta', fileListClickedMetaEvent)
bus.unsubscribe('app.files.list.clicked.shift', fileListClickedShiftEvent)
filesList.removeEventListener('keydown', this.handleShortcut)
})
},
methods: {
...mapActions(['showMessage', 'createModal', 'hideModal']),
...mapActions('Files', [
'copySelectedFiles',
'cutSelectedFiles',
'pasteSelectedFiles',
'resetFileSelection',
'toggleFileSelection'
]),
...mapMutations('Files', {
upsertResource: 'UPSERT_RESOURCE',
setLatestSelectedFile: 'SET_LATEST_SELECTED_FILE_ID',
setFileSelection: 'SET_FILE_SELECTION',
addFileSelection: 'ADD_FILE_SELECTION'
}),
handleShortcut(event) {
const key = event.keyCode || event.which
const ctrl = window.navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey
const shift = event.shiftKey
this.handleFileActionsShortcuts(key, ctrl)
this.handleFileSelectionShortcuts(key, shift, ctrl, event)
},
handleFileActionsShortcuts(key, ctrl) {
const isCopyAction = key === 67
const isPasteAction = key === 86
const isCutAction = key === 88
if (isCopyAction && ctrl) return this.copySelectedFiles()
if (isPasteAction && ctrl) return this.handlePasteAction()
if (isCutAction && ctrl) return this.cutSelectedFiles()
},
handleFileSelectionShortcuts(key, shift, ctrl, event) {
const isUpPressed = key === 38
const isDownPressed = key === 40
const isEscapePressed = key === 27
const isSpacePressed = key === 32
const isAPressed = key === 65
if (isDownPressed && !shift) return this.handleNavigateAction(event)
if (isUpPressed && !shift) return this.handleNavigateAction(event, true)
if (isSpacePressed) return this.handleSpaceAction(event)
if (isEscapePressed) return this.handleEscapeAction()
if (isDownPressed && shift) return this.handleShiftDownAction(event)
if (isUpPressed && shift) return this.handleShiftUpAction(event)
if (isAPressed && ctrl) return this.handleSelectAllAction(event)
},
handleNavigateAction(event, up = false) {
event.preventDefault()
if (!this.latestSelectedId) return
const nextId = this.getNextResourceId(up)
if (nextId === -1) return
this.resetSelectionCursor()
this.resetFileSelection()
this.addFileSelection({ id: nextId })
this.scrollToResource({ id: nextId })
},
handleShiftClickAction(resource) {
const parent = document.querySelectorAll(`[data-item-id='${resource.id}']`)[0]
const resourceNodes = Object.values(parent.parentNode.childNodes) as HTMLElement[]
const latestNode = resourceNodes.find(
(r) => r.getAttribute('data-item-id') === this.latestSelectedId
)
const clickedNode = resourceNodes.find((r) => r.getAttribute('data-item-id') === resource.id)
let latestNodeIndex = resourceNodes.indexOf(latestNode)
latestNodeIndex = latestNodeIndex === -1 ? 0 : latestNodeIndex
const clickedNodeIndex = resourceNodes.indexOf(clickedNode)
const minIndex = Math.min(latestNodeIndex, clickedNodeIndex)
const maxIndex = Math.max(latestNodeIndex, clickedNodeIndex)
for (let i = minIndex; i <= maxIndex; i++) {
const nodeId = resourceNodes[i].getAttribute('data-item-id')
this.addFileSelection({ id: nodeId })
}
this.setLatestSelectedFile(resource.id)
},
handleCtrlClickAction(resource) {
this.toggleFileSelection({ id: resource.id })
},
handleEscapeAction() {
this.resetSelectionCursor()
this.resetFileSelection()
},
handleSelectAllAction(event) {
event.preventDefault()
this.resetSelectionCursor()
this.setFileSelection(this.paginatedResources)
},
handleSpaceAction(event) {
event.preventDefault()
this.toggleFileSelection({ id: this.latestSelectedId })
},
handleShiftUpAction() {
const nextResourceId = this.getNextResourceId(true)
if (nextResourceId === -1) return
if (this.selectionCursor > 0) {
// deselect
this.toggleFileSelection({ id: this.latestSelectedId })
this.setLatestSelectedFile(nextResourceId)
} else {
// select
this.addFileSelection({ id: nextResourceId })
}
this.scrollToResource({ id: nextResourceId })
this.selectionCursor = this.selectionCursor - 1
},
handleShiftDownAction() {
const nextResourceId = this.getNextResourceId()
if (nextResourceId === -1) return
if (this.selectionCursor < 0) {
// deselect
this.toggleFileSelection({ id: this.latestSelectedId })
this.setLatestSelectedFile(nextResourceId)
} else {
// select
this.addFileSelection({ id: nextResourceId })
}
this.scrollToResource({ id: nextResourceId })
this.selectionCursor = this.selectionCursor + 1
},
handlePasteAction() {
this.pasteSelectedFiles({
client: this.$client,
createModal: this.createModal,
hideModal: this.hideModal,
showMessage: this.showMessage,
$gettext: this.$gettext,
$gettextInterpolate: this.$gettextInterpolate,
$ngettext: this.$ngettext,
upsertResource: this.upsertResource
})
},
resetSelectionCursor() {
this.selectionCursor = 0
},
getNextResourceId(previous = false) {
const latestSelectedRow = document.querySelectorAll(
`[data-item-id='${this.latestSelectedId}']`
)[0]
const nextRow = (
previous ? latestSelectedRow.previousSibling : latestSelectedRow.nextSibling
) as HTMLElement
if (nextRow === null) return -1
const nextResourceId = nextRow.getAttribute('data-item-id')
return nextResourceId
}
}
})
</script>
Loading

0 comments on commit f078d51

Please sign in to comment.