Skip to content
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

Board switcher keyboard shortcut navigation #3242

Merged
merged 16 commits into from
Jul 28, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions webapp/src/components/boardsSwitcher/boardsSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,21 @@ const BoardsSwitcher = (props: Props): JSX.Element => {
}
}

const handleEscKeyPress = (e: KeyboardEvent) => {
if (Utils.isKeyPressed(e, Constants.keyCodes.ESC)) {
e.preventDefault()
setShowSwitcher(false)
}
}

useEffect(() => {
document.addEventListener('keydown', handleQuickSwitchKeyPress)
document.addEventListener('keydown', handleEscKeyPress)
Pinjasaur marked this conversation as resolved.
Show resolved Hide resolved

// cleanup function
return () => {
document.removeEventListener('keydown', handleQuickSwitchKeyPress)
document.removeEventListener('keydown', handleEscKeyPress)
}
}, [])

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode} from 'react'
import React, {ReactNode, useRef, createRef} from 'react'

import './boardSwitcherDialog.scss'
import {useIntl} from 'react-intl'
Expand Down Expand Up @@ -50,21 +50,25 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
const teamsById:Record<string, Team> = {}
useAppSelector(getAllTeams).forEach((t) => teamsById[t.id] = t)

const refs = useRef([])

const searchHandler = async (query: string): Promise<Array<ReactNode>> => {
if (query.trim().length === 0 || !team) {
return []
}

const items = await octoClient.search(team.id, query)
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
return items.map((item) => {
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled Board'})
refs.current = items.map((_, i) => refs.current[i] ?? createRef())
return items.map((item, i) => {
const resultTitle = item.title || untitledBoardTitle
const teamTitle = teamsById[item.teamId].title
return (
<div
key={item.id}
className='blockSearchResult'
onClick={() => selectBoard(item.teamId, item.id)}
ref={refs.current[i]}
>
{item.type === BoardTypeOpen && <Globe/>}
{item.type === BoardTypePrivate && <LockOutline/>}
Expand All @@ -81,6 +85,7 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
title={title}
subTitle={subTitle}
searchHandler={searchHandler}
refs={refs}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this refs passings looks a bit convoluted to me. Couldn't it be something like an state at this level with the selected id? and an onSelect callback or something like that?

I always find passing refs down the components really hard to track.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, it wasn't as straightforward as I was expecting it to be. My reasoning for using refs was it let me use the native .focus() and .click() methods on the underlying native elements.

Since the parent component (this) defines the search handler which returns the JSX components and passes that definition into the child component which calls it and does the rendering, it seemed to make sense to define the refs in the parent so the child could use the native methods I mentioned above. onSelect did come to mind, but it's not available for all elements, only <input> and <textarea>.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, what I meant is. move the selected state to the BoardSwitcherDialog component, update the selected state using a callback that is passed to the SearchDialog component, and then, you can use a useEffect call to update the focus on every selected change. The syntethic click, is not going to be needed, because you are already there, and you have access to the selectBoard function. You are going to still need the refs, but you are not passing them around that is what makes it more confusing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jespino Refactored it as requested and while I agree passing refs as props is confusing, I'm not positive trading passing the selected state is less confusing. Additionally, needed to move the keyboard event listeners around. I believe the functionality is the same, but I still prefer my original approach after doing the refactor.

Let me know your thoughts—take care not to merge as the cherrypick label(s) will need to be updated.

/>
)
}
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/components/searchDialog/searchDialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
cursor: pointer;
overflow: hidden;

&:hover {
&:hover,
&:focus {
background: rgba(var(--center-channel-color-rgb), 0.08);
}
}
Expand Down
43 changes: 42 additions & 1 deletion webapp/src/components/searchDialog/searchDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode, useMemo, useState} from 'react'
import React, {MutableRefObject, ReactNode, useEffect, useMemo, useState} from 'react'

import './searchDialog.scss'
import {FormattedMessage} from 'react-intl'
Expand All @@ -10,22 +10,27 @@ import {debounce} from 'lodash'
import Dialog from '../dialog'
import {Utils} from '../../utils'
import Search from '../../widgets/icons/search'
import { Constants } from '../../constants'

type Props = {
onClose: () => void
title: string
subTitle?: string | ReactNode
searchHandler: (query: string) => Promise<Array<ReactNode>>
initialData?: Array<ReactNode>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
refs: MutableRefObject<any>
}

const SearchDialog = (props: Props): JSX.Element => {
const [results, setResults] = useState<Array<ReactNode>>(props.initialData || [])
const [isSearching, setIsSearching] = useState<boolean>(false)
const [searchQuery, setSearchQuery] = useState<string>('')
const [selected, setSelected] = useState<number>(-1)

const searchHandler = async (query: string): Promise<void> => {
setIsSearching(true)
setSelected(-1)
setSearchQuery(query)
const searchResults = await props.searchHandler(query)
setResults(searchResults)
Expand All @@ -36,6 +41,41 @@ const SearchDialog = (props: Props): JSX.Element => {

const emptyResult = results.length === 0 && !isSearching && searchQuery

const handleUpDownKeyPress = (e: KeyboardEvent) => {
if (Utils.isKeyPressed(e, Constants.keyCodes.DOWN)) {
e.preventDefault()
if (results.length > 0)
setSelected(Utils.clamp(selected + 1, 0, results.length - 1))
Pinjasaur marked this conversation as resolved.
Show resolved Hide resolved
}

if (Utils.isKeyPressed(e, Constants.keyCodes.UP)) {
e.preventDefault()
if (results.length > 0)
setSelected(Utils.clamp(selected - 1, 0, results.length - 1))
}
}

const handleEnterKeyPress = (e: KeyboardEvent) => {
if (Utils.isKeyPressed(e, Constants.keyCodes.ENTER) && selected >= 0) {
e.preventDefault()
props.refs.current[selected].current.click()
}
}

useEffect(() => {
if (selected >= 0)
props.refs.current[selected].current.parentElement.focus()

document.addEventListener('keydown', handleUpDownKeyPress)
Pinjasaur marked this conversation as resolved.
Show resolved Hide resolved
document.addEventListener('keydown', handleEnterKeyPress)

// cleanup function
return () => {
document.removeEventListener('keydown', handleUpDownKeyPress)
document.removeEventListener('keydown', handleEnterKeyPress)
}
}, [selected, results])

return (
<Dialog
className='BoardSwitcherDialog'
Expand Down Expand Up @@ -64,6 +104,7 @@ const SearchDialog = (props: Props): JSX.Element => {
<div
key={Utils.uuid()}
className='searchResult'
tabIndex={-1}
>
{result}
</div>
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ class Constants {

static readonly keyCodes: {[key: string]: [string, number]} = {
COMPOSING: ['Composing', 229],
ESC: ['Esc', 27],
UP: ['Up', 38],
DOWN: ['Down', 40],
ENTER: ['Enter', 13],
A: ['a', 65],
B: ['b', 66],
C: ['c', 67],
Expand Down
5 changes: 5 additions & 0 deletions webapp/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,11 @@ class Utils {

return bytes.toFixed(dp) + ' ' + units[u]
}

// Clamps a number between a ceiling and a floor, inclusive. Optionally absolute value, too.
static clamp(number: number, min: number, max: number, absolute?: boolean) : number {
return absolute ? Math.abs(Math.max(min, Math.min(number, max))) : Math.max(min, Math.min(number, max))
}
}

export {Utils, IDType}