-
Notifications
You must be signed in to change notification settings - Fork 156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Keyboard Actions composables in Search Results #9447
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1 change: 1 addition & 0 deletions
1
packages/web-app-files/src/composables/keyboardActions/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './useKeyboardActionsSearchTable' |
168 changes: 168 additions & 0 deletions
168
packages/web-app-files/src/composables/keyboardActions/useKeyboardActionsSearchTable.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
import { computed, onBeforeUnmount, onMounted, ref, unref } from 'vue' | ||
import { useStore } from 'web-pkg/src/composables' | ||
import { useScrollTo } from 'web-app-files/src/composables/scrollTo' | ||
import { Key, ModifierKey } from 'web-pkg/src/composables/keyboardActions' | ||
import { eventBus } from 'web-pkg' | ||
import { useGettext } from 'vue3-gettext' | ||
|
||
export const useKeyboardActionsSearchTable = (keyActions, paginatedResources) => { | ||
const store = useStore() | ||
const { scrollToResource } = useScrollTo() | ||
const selectionCursor = ref(0) | ||
const latestSelectedId = computed(() => store.state.Files.latestSelectedId) | ||
const language = useGettext() | ||
|
||
let fileListClickedEvent | ||
let fileListClickedMetaEvent | ||
let fileListClickedShiftEvent | ||
|
||
keyActions.bindKeyAction({ primary: Key.ArrowUp }, () => handleNavigateAction(true)) | ||
|
||
keyActions.bindKeyAction({ primary: Key.ArrowDown }, () => handleNavigateAction()) | ||
|
||
keyActions.bindKeyAction({ primary: Key.Esc }, () => { | ||
resetSelectionCursor() | ||
store.dispatch('Files/resetFileSelection') | ||
}) | ||
|
||
keyActions.bindKeyAction({ primary: Key.Space }, () => { | ||
store.dispatch('Files/toggleFileSelection', { id: unref(latestSelectedId) }) | ||
}) | ||
|
||
keyActions.bindKeyAction({ modifier: ModifierKey.Shift, primary: Key.ArrowUp }, () => | ||
handleShiftUpAction() | ||
) | ||
|
||
keyActions.bindKeyAction({ modifier: ModifierKey.Shift, primary: Key.ArrowDown }, () => | ||
handleShiftDownAction() | ||
) | ||
|
||
keyActions.bindKeyAction({ modifier: ModifierKey.Ctrl, primary: Key.A }, () => | ||
handleSelectAllAction() | ||
) | ||
|
||
keyActions.bindKeyAction({ modifier: ModifierKey.Ctrl, primary: Key.C }, () => { | ||
store.dispatch('Files/copySelectedFiles', { | ||
...language, | ||
resources: store.getters['Files/selectedFiles'] | ||
}) | ||
}) | ||
|
||
const handleNavigateAction = async (up = false) => { | ||
const nextId = !unref(latestSelectedId) ? getFirstResourceId() : getNextResourceId(up) | ||
if (nextId === -1) { | ||
return | ||
} | ||
resetSelectionCursor() | ||
await store.dispatch('Files/resetFileSelection') | ||
store.commit('Files/ADD_FILE_SELECTION', { id: nextId }) | ||
scrollToResource({ id: nextId } as any) | ||
} | ||
|
||
const getNextResourceId = (previous = false) => { | ||
const latestSelectedResourceIndex = paginatedResources.value.findIndex( | ||
(resource) => resource.id === latestSelectedId.value | ||
) | ||
if (latestSelectedResourceIndex === -1) { | ||
return -1 | ||
} | ||
const nextResourceIndex = latestSelectedResourceIndex + (previous ? -1 : 1) | ||
if (nextResourceIndex < 0 || nextResourceIndex >= paginatedResources.value.length) { | ||
return -1 | ||
} | ||
return paginatedResources.value[nextResourceIndex].id | ||
} | ||
|
||
const getFirstResourceId = () => { | ||
return paginatedResources.value.length ? paginatedResources.value[0].id : -1 | ||
} | ||
|
||
const resetSelectionCursor = () => { | ||
selectionCursor.value = 0 | ||
} | ||
|
||
const handleSelectAllAction = () => { | ||
resetSelectionCursor() | ||
store.commit('Files/SET_FILE_SELECTION', paginatedResources.value) | ||
} | ||
|
||
const handleShiftUpAction = async () => { | ||
const nextResourceId = getNextResourceId(true) | ||
if (nextResourceId === -1) { | ||
return | ||
} | ||
if (unref(selectionCursor) > 0) { | ||
// deselect | ||
await store.dispatch('Files/toggleFileSelection', { id: unref(latestSelectedId) }) | ||
store.commit('Files/SET_LATEST_SELECTED_FILE_ID', nextResourceId) | ||
} else { | ||
// select | ||
store.commit('Files/ADD_FILE_SELECTION', { id: nextResourceId }) | ||
} | ||
scrollToResource({ id: nextResourceId } as any) | ||
selectionCursor.value = unref(selectionCursor) - 1 | ||
} | ||
const handleShiftDownAction = () => { | ||
const nextResourceId = getNextResourceId() | ||
if (nextResourceId === -1) { | ||
return | ||
} | ||
if (unref(selectionCursor) < 0) { | ||
// deselect | ||
store.dispatch('Files/toggleFileSelection', { id: unref(latestSelectedId) }) | ||
store.commit('Files/SET_LATEST_SELECTED_FILE_ID', nextResourceId) | ||
} else { | ||
// select | ||
store.commit('Files/ADD_FILE_SELECTION', { id: nextResourceId }) | ||
} | ||
scrollToResource({ id: nextResourceId } as any) | ||
selectionCursor.value = unref(selectionCursor) + 1 | ||
} | ||
|
||
const handleCtrlClickAction = async (resource) => { | ||
await store.dispatch('Files/toggleFileSelection', { id: resource.id }) | ||
} | ||
|
||
const handleShiftClickAction = ({ resource, skipTargetSelection }) => { | ||
const parent = document.querySelectorAll(`[data-item-id='${resource.id}']`)[0] | ||
const resourceNodes = Object.values(parent.parentNode.children) | ||
const latestNode = resourceNodes.find( | ||
(r) => r.getAttribute('data-item-id') === unref(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') | ||
if (skipTargetSelection && nodeId === resource.id) { | ||
continue | ||
} | ||
store.commit('Files/ADD_FILE_SELECTION', { id: nodeId }) | ||
} | ||
store.commit('Files/SET_LATEST_SELECTED_FILE_ID', resource.id) | ||
} | ||
|
||
onMounted(() => { | ||
fileListClickedEvent = eventBus.subscribe('app.files.list.clicked', resetSelectionCursor) | ||
fileListClickedMetaEvent = eventBus.subscribe( | ||
'app.files.list.clicked.meta', | ||
handleCtrlClickAction | ||
) | ||
fileListClickedShiftEvent = eventBus.subscribe( | ||
'app.files.list.clicked.shift', | ||
handleShiftClickAction | ||
) | ||
}) | ||
|
||
onBeforeUnmount(() => { | ||
eventBus.unsubscribe('app.files.list.clicked', fileListClickedEvent) | ||
eventBus.unsubscribe('app.files.list.clicked.meta', fileListClickedMetaEvent) | ||
eventBus.unsubscribe('app.files.list.clicked.shift', fileListClickedShiftEvent) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './useKeyboardActions' |
79 changes: 79 additions & 0 deletions
79
packages/web-pkg/src/composables/keyboardActions/useKeyboardActions.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { onBeforeUnmount, onMounted, ref } from 'vue' | ||
|
||
export enum Key { | ||
C = 'c', | ||
V = 'v', | ||
A = 'a', | ||
Space = ' ', | ||
ArrowUp = 'ArrowUp', | ||
ArrowDown = 'ArrowDown', | ||
Esc = 'Escape' | ||
} | ||
|
||
export enum ModifierKey { | ||
Ctrl = 'Control', | ||
Shift = 'Shift' | ||
} | ||
JammingBen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
export const useKeyboardActions = (keyBindOnElementId: string | null = null) => { | ||
const actions = ref([]) | ||
const listener = (event: KeyboardEvent): void => { | ||
event.preventDefault() | ||
const { key, ctrlKey, metaKey, shiftKey } = event | ||
let modifier = null | ||
if (metaKey || ctrlKey) { | ||
modifier = ModifierKey.Ctrl | ||
} else if (shiftKey) { | ||
modifier = ModifierKey.Shift | ||
} | ||
const action = actions.value | ||
.filter((action) => action !== null) | ||
.find((action) => { | ||
return action.primary === key && action.modifier === modifier | ||
}) | ||
if (action) { | ||
action.callback(event) | ||
} | ||
} | ||
const bindKeyAction = ( | ||
keys: { primary: Key; modifier?: ModifierKey }, | ||
callback: () => void | ||
): number => { | ||
return ( | ||
actions.value.push({ | ||
...keys, | ||
modifier: keys.modifier ?? null, | ||
callback | ||
}) - 1 | ||
) | ||
} | ||
|
||
const removeKeyAction = (index): void => { | ||
actions.value[index] = null | ||
} | ||
|
||
onMounted(() => { | ||
let element = null | ||
if (keyBindOnElementId) { | ||
element = document.getElementById(keyBindOnElementId) | ||
} | ||
element | ||
? element.addEventListener('keydown', listener) | ||
: document.addEventListener('keydown', listener) | ||
}) | ||
|
||
onBeforeUnmount(() => { | ||
let element = null | ||
if (keyBindOnElementId) { | ||
element = document.getElementById(keyBindOnElementId) | ||
} | ||
element | ||
? element.removeEventListener('keydown', listener) | ||
: document.removeEventListener('keydown', listener) | ||
}) | ||
|
||
return { | ||
bindKeyAction, | ||
removeKeyAction | ||
} | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this really SearchTable specific?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree we should make this more general, something like
useKeyboardActionsResourceTable
. Then we can use it for the regular files table as well and eventually kick theKeyboardActions.vue
component. But I'm fine with doing that in a second PR. Always a fan of keeping scopes of PRs fairly small 🙂There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So here's a thing. At this point, the goal is to provide a search table with actions in a new way.
Technically the search table is the same as a normal table, except you don't have defined space and have fewer actions you can perform.
This means we can later get rid of
KeyboardAction.vue
, and split it into 2 composables or some abstraction that would combinepaginatedResources
action only andspace
related actions only.Then it all depends on personal preference if you want to create a composable that would define each table or you want to define different combinations in the setup of component
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes 👍 The scope of this PR is fine as it is, I wouldn't change that. Let's get it merged first.
The next step after that IMO is to refactor
KeyboardAction.vue
into a separate composable like this one (it would be something likeuseKeyboardActionsGenericSpaceTable
then) and use in inGenericSpace.vue
.After that we can determine the exact differences between both composables and decide if we want to try to combine those two, or if we need to abstract some more logic.