Skip to content

Commit dcf460e

Browse files
committed
feat(web-console): keyboard navigation for table listing
1 parent 53788d2 commit dcf460e

File tree

8 files changed

+255
-32
lines changed

8 files changed

+255
-32
lines changed

packages/browser-tests/questdb

Submodule questdb updated 40 files

packages/web-console/src/components/Tree/index.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const LeafWrapper = styled.div`
4545
position: absolute;
4646
height: 100%;
4747
width: 1px;
48-
left: -0.4rem;
48+
left: -0.2rem;
4949
top: 0;
5050
opacity: 0;
5151
transition: .2s;
@@ -60,12 +60,14 @@ export type TreeNodeRenderParams = {
6060
toggleOpen: ToggleOpen
6161
isOpen: boolean
6262
isLoading: boolean
63+
path: string
6364
}
6465

6566
export type TreeNodeRender = ({
6667
toggleOpen,
6768
isOpen,
6869
isLoading,
70+
path
6971
}: TreeNodeRenderParams) => React.ReactElement
7072

7173
type ToggleOpen = () => void
@@ -171,13 +173,14 @@ const Leaf = (leaf: TreeNode & { parentPath?: string }) => {
171173
return (
172174
<Li>
173175
{typeof render === "function" ? (
174-
render({ toggleOpen, isOpen: open, isLoading: loading })
176+
render({ toggleOpen, isOpen: open, isLoading: loading, path })
175177
) : (
176178
<Row
177179
kind={kind ?? "folder"}
178180
name={name}
179181
table_id={table_id}
180182
onClick={toggleOpen}
183+
path={path}
181184
/>
182185
)}
183186

packages/web-console/src/scenes/Schema/Row/index.tsx

+130-7
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*
2323
******************************************************************************/
2424

25-
import React, { MouseEvent, useContext, useState, useEffect, useRef } from "react"
25+
import React, { MouseEvent, useState, useEffect, useRef } from "react"
2626
import styled from "styled-components"
2727
import { Rocket, InfoCircle } from "@styled-icons/boxicons-regular"
2828
import { SortDown } from "@styled-icons/boxicons-regular"
@@ -38,11 +38,12 @@ import { TableIcon } from "../table-icon"
3838
import { Box } from "@questdb/react-components"
3939
import { Text, TransitionDuration, IconWithTooltip, spinAnimation } from "../../../components"
4040
import { color } from "../../../utils"
41-
import { SchemaContext } from "../SchemaContext"
41+
import { useSchema } from "../SchemaContext"
4242
import { Checkbox } from "../checkbox"
4343
import { PopperHover } from "../../../components/PopperHover"
4444
import { Tooltip } from "../../../components/Tooltip"
4545
import { mapColumnTypeToUI } from "../../../scenes/Import/ImportCSVFiles/utils"
46+
import { MATVIEWS_GROUP_KEY } from "../localStorageUtils"
4647

4748
type Props = Readonly<{
4849
className?: string
@@ -60,10 +61,11 @@ type Props = Readonly<{
6061
selectOpen?: boolean
6162
selected?: boolean
6263
onSelectToggle?: ({name, type}: {name: string, type: TreeNodeKind}) => void
63-
baseTable?: string
6464
errors?: string[]
6565
value?: string
6666
includesSymbol?: boolean
67+
path?: string
68+
tabIndex?: number
6769
}>
6870

6971
const Type = styled(Text)`
@@ -86,6 +88,8 @@ const Wrapper = styled.div<{ $isExpandable: boolean, $includesSymbol?: boolean }
8688
padding-left: 1rem;
8789
padding-right: 1rem;
8890
user-select: none;
91+
border: 1px solid transparent;
92+
border-radius: 0.4rem;
8993
${({ $isExpandable }) => $isExpandable && `
9094
cursor: pointer;
9195
`}
@@ -98,6 +102,12 @@ const Wrapper = styled.div<{ $isExpandable: boolean, $includesSymbol?: boolean }
98102
&:active {
99103
background: ${color("selection")};
100104
}
105+
106+
&:focus-visible, &.focused {
107+
outline: none;
108+
background: ${color("selection")};
109+
border: 1px solid ${color("comment")};
110+
}
101111
`
102112

103113
const StyledTitle = styled(Title)`
@@ -121,6 +131,8 @@ const StyledTitle = styled(Title)`
121131
const TableActions = styled.span`
122132
z-index: 1;
123133
position: relative;
134+
display: inline-flex;
135+
align-items: center;
124136
`
125137

126138
const FlexRow = styled.div<{ $selectOpen?: boolean }>`
@@ -165,7 +177,8 @@ const Loader = styled(Loader4)`
165177
`
166178

167179
const ErrorIconWrapper = styled.div`
168-
display: inline;
180+
display: inline-flex;
181+
align-items: center;
169182
align-self: center;
170183
171184
svg {
@@ -257,6 +270,43 @@ const ColumnIcon = ({
257270
return getIcon(type)
258271
}
259272

273+
export const isElementVisible = (element: HTMLElement | undefined, container: HTMLElement | null) => {
274+
if (!element || !container) return false
275+
const elementRect = element.getBoundingClientRect()
276+
const containerRect = container instanceof Window
277+
? { top: 0, bottom: window.innerHeight }
278+
: container.getBoundingClientRect()
279+
280+
const visibleTop = Math.max(elementRect.top, containerRect.top)
281+
const visibleBottom = Math.min(elementRect.bottom, containerRect.bottom)
282+
const visibleHeight = Math.max(0, visibleBottom - visibleTop)
283+
284+
const totalHeight = elementRect.bottom - elementRect.top
285+
286+
return visibleHeight >= totalHeight * 0.5
287+
}
288+
289+
export const computeFocusableElements = (scrollerRef: HTMLElement) => {
290+
const allElements = Array.from(document.querySelectorAll('[tabindex="100"], [tabindex="101"], [tabindex="200"], [tabindex="201"]'))
291+
292+
const focusableElements = allElements
293+
.filter(element => isElementVisible(element as HTMLElement, scrollerRef))
294+
.sort((a, b) => {
295+
const tabIndexA = parseInt(a.getAttribute('tabindex') || '0')
296+
const tabIndexB = parseInt(b.getAttribute('tabindex') || '0')
297+
298+
if (tabIndexA !== tabIndexB) {
299+
return tabIndexA - tabIndexB
300+
}
301+
302+
const positionA = allElements.indexOf(a)
303+
const positionB = allElements.indexOf(b)
304+
return positionA - positionB
305+
})
306+
307+
return focusableElements
308+
}
309+
260310
const Row = ({
261311
className,
262312
designatedTimestamp,
@@ -273,12 +323,13 @@ const Row = ({
273323
selectOpen,
274324
selected,
275325
onSelectToggle,
276-
baseTable,
277326
errors,
278327
value,
279-
includesSymbol
328+
includesSymbol,
329+
path,
330+
tabIndex,
280331
}: Props) => {
281-
const { query } = useContext(SchemaContext)
332+
const { query, scrollBy, scrollerRef } = useSchema()
282333
const [showLoader, setShowLoader] = useState(false)
283334
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
284335
const isExpandable = ["folder", "table", "matview"].includes(kind) || (kind === "column" && type === "SYMBOL")
@@ -301,19 +352,91 @@ const Row = ({
301352
}
302353
}, [isLoading])
303354

355+
const getTabIndex = () => {
356+
if (tabIndex) {
357+
return tabIndex
358+
}
359+
if (path?.startsWith(MATVIEWS_GROUP_KEY)) {
360+
return 201
361+
}
362+
return 101
363+
}
364+
304365
return (
305366
<Wrapper
306367
$isExpandable={isExpandable}
307368
$includesSymbol={includesSymbol}
308369
data-hook={dataHook ?? "schema-row"}
370+
data-kind={kind}
371+
data-path={path}
309372
className={className}
373+
tabIndex={getTabIndex()}
374+
onFocus={(e) => {
375+
;(e.target as HTMLElement).classList.add('focused')
376+
}}
377+
onBlur={(e) => {
378+
;(e.target as HTMLElement).classList.remove('focused')
379+
}}
310380
onClick={(e) => {
381+
const target = e.target as HTMLElement
382+
target.focus();
311383
if (isTableKind && selectOpen && onSelectToggle) {
312384
onSelectToggle({name, type: kind})
313385
} else {
314386
onClick?.(e)
315387
}
316388
}}
389+
onKeyDown={(e) => {
390+
if (!path) return
391+
if (!scrollerRef.current || !isElementVisible(document.activeElement as HTMLElement, scrollerRef.current)) return
392+
if (isExpandable) {
393+
if (
394+
e.key === "Enter"
395+
|| (e.key === "ArrowRight" && !expanded)
396+
|| (e.key === "ArrowLeft" && expanded)
397+
) {
398+
// @ts-ignore
399+
onClick?.()
400+
}
401+
}
402+
if (e.key === "ArrowDown") {
403+
e.preventDefault()
404+
const currentElement = document.activeElement as HTMLElement
405+
if (!currentElement || !scrollerRef.current) return
406+
let focusableElements = computeFocusableElements(scrollerRef.current)
407+
let currentIndex = focusableElements.indexOf(currentElement)
408+
409+
if (currentIndex === focusableElements.length - 1) {
410+
scrollBy(32)
411+
}
412+
focusableElements = computeFocusableElements(scrollerRef.current)
413+
currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement)
414+
415+
if (currentIndex < focusableElements.length - 1) {
416+
const nextElement = focusableElements[currentIndex + 1] as HTMLElement
417+
nextElement.focus()
418+
}
419+
}
420+
if (e.key === "ArrowUp") {
421+
e.preventDefault()
422+
if (!document.activeElement || !scrollerRef.current) return
423+
let focusableElements = computeFocusableElements(scrollerRef.current)
424+
let currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement)
425+
426+
if (currentIndex === 0) {
427+
scrollBy(-32)
428+
}
429+
setTimeout(() => {
430+
focusableElements = computeFocusableElements(scrollerRef.current!)
431+
currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement)
432+
if (currentIndex > 0) {
433+
const previousElement = focusableElements[currentIndex - 1] as HTMLElement
434+
previousElement.focus()
435+
}
436+
}, 0)
437+
438+
}
439+
}}
317440
>
318441
<Box
319442
align="center"
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,54 @@
1-
import { createContext } from "react"
1+
import React, { createContext, useContext, useRef, useState } from "react"
2+
3+
export const ScrollDefaults = {
4+
scrollerRef: { current: null },
5+
scrollBy: () => {},
6+
setScrollerRef: () => {},
7+
}
28

39
export const SchemaContext = createContext<{
410
query: string
511
setQuery: (query: string) => void
12+
scrollerRef: React.MutableRefObject<HTMLElement | null>,
13+
scrollBy: (amount: number) => void,
14+
setScrollerRef: (element: HTMLElement | null) => void,
615
}>({
716
query: "",
817
setQuery: () => {},
18+
...ScrollDefaults,
919
})
20+
21+
export const useSchema = () => {
22+
const context = useContext(SchemaContext)
23+
if (!context) {
24+
throw new Error('useSchema must be used within SchemaProvider')
25+
}
26+
return context
27+
}
28+
29+
export const SchemaProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
30+
const [query, setQuery] = useState("")
31+
const scrollerRef = useRef<HTMLElement | null>(null)
32+
33+
const scrollBy = (amount: number) => {
34+
if (scrollerRef.current) {
35+
scrollerRef.current.scrollBy({ top: amount })
36+
}
37+
}
38+
39+
const setScrollerRef = (element: HTMLElement | null) => {
40+
scrollerRef.current = element
41+
}
42+
43+
return (
44+
<SchemaContext.Provider value={{
45+
query,
46+
setQuery,
47+
scrollerRef,
48+
scrollBy,
49+
setScrollerRef,
50+
}}>
51+
{children}
52+
</SchemaContext.Provider>
53+
)
54+
}

0 commit comments

Comments
 (0)