fix(dashboard): Tab behaviour in DataGrid (#8973)

* fix focus in datagrid

* cleanup

* fix keyboard commands

* cleanup

* cleanup

* always allow handler for special focus to fire

* cleanup
This commit is contained in:
Kasper Fabricius Kristensen
2024-09-03 20:54:47 +02:00
committed by GitHub
parent f47f1aff49
commit 58c78a7f62
9 changed files with 270 additions and 255 deletions

View File

@@ -43,14 +43,12 @@
"@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0",
"date-fns": "^3.6.0",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.0.3",
"i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0",
"i18next-http-backend": "2.4.2",
"lodash": "^4.17.21",
"match-sorter": "^6.3.4",
"prop-types": "^15.8.1",
"qs": "^6.12.0",
"react": "^18.2.0",
"react-country-flag": "^3.1.0",

View File

@@ -136,6 +136,20 @@ const useDataGridShortcuts = () => {
Windows: ["Shift", "Ctrl", "↑"],
},
},
{
label: t("dataGrid.shortcuts.commands.focusToolbar"),
keys: {
Mac: ["⌃", "⌥", ","],
Windows: ["Ctrl", "Alt", ","],
},
},
{
label: t("dataGrid.shortcuts.commands.focusCancel"),
keys: {
Mac: ["⌃", "⌥", "."],
Windows: ["Ctrl", "Alt", "."],
},
},
],
[t]
)

View File

@@ -16,10 +16,8 @@ import {
useReactTable,
} from "@tanstack/react-table"
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
import FocusTrap from "focus-trap-react"
import {
import React, {
CSSProperties,
MouseEvent,
ReactNode,
useCallback,
useEffect,
@@ -48,12 +46,11 @@ import {
} from "../hooks"
import { DataGridMatrix } from "../models"
import { DataGridCoordinates, GridColumnOption } from "../types"
import { generateCellId, isCellMatch } from "../utils"
import { isCellMatch, isSpecialFocusKey } from "../utils"
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues
TFieldValues extends FieldValues = FieldValues,
> {
data?: TData[]
columns: ColumnDef<TData>[]
@@ -98,7 +95,7 @@ const getCommonPinningStyles = <TData,>(
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues
TFieldValues extends FieldValues = FieldValues,
>({
data = [],
columns,
@@ -117,7 +114,7 @@ export const DataGridRoot = <
formState: { errors },
} = state
const [trapActive, setTrapActive] = useState(false)
const [trapActive, setTrapActive] = useState(true)
const [anchor, setAnchor] = useState<DataGridCoordinates | null>(null)
const [rangeEnd, setRangeEnd] = useState<DataGridCoordinates | null>(null)
@@ -317,25 +314,28 @@ export const DataGridRoot = <
anchor,
})
const { handleKeyDownEvent } = useDataGridKeydownEvent<TData, TFieldValues>({
matrix,
queryTool,
anchor,
rangeEnd,
isEditing,
setRangeEnd,
getSelectionValues,
getValues,
setSelectionValues,
onEditingChangeHandler,
restoreSnapshot,
setSingleRange,
scrollToCoordinates,
execute,
undo,
redo,
setValue,
})
const { handleKeyDownEvent, handleSpecialFocusKeys } =
useDataGridKeydownEvent<TData, TFieldValues>({
containerRef,
matrix,
queryTool,
anchor,
rangeEnd,
isEditing,
setTrapActive,
setRangeEnd,
getSelectionValues,
getValues,
setSelectionValues,
onEditingChangeHandler,
restoreSnapshot,
setSingleRange,
scrollToCoordinates,
execute,
undo,
redo,
setValue,
})
const { handleMouseUpEvent } = useDataGridMouseUpEvent<TData, TFieldValues>({
matrix,
@@ -401,26 +401,20 @@ export const DataGridRoot = <
* Register all handlers for the grid.
*/
useEffect(() => {
const container = containerRef.current
if (
!container ||
!container.contains(document.activeElement) ||
!trapActive
) {
if (!trapActive) {
return
}
container.addEventListener("keydown", handleKeyDownEvent)
container.addEventListener("mouseup", handleMouseUpEvent)
window.addEventListener("keydown", handleKeyDownEvent)
window.addEventListener("mouseup", handleMouseUpEvent)
// Copy and paste event listeners need to be added to the window
window.addEventListener("copy", handleCopyEvent)
window.addEventListener("paste", handlePasteEvent)
return () => {
container.removeEventListener("keydown", handleKeyDownEvent)
container.removeEventListener("mouseup", handleMouseUpEvent)
window.removeEventListener("keydown", handleKeyDownEvent)
window.removeEventListener("mouseup", handleMouseUpEvent)
window.removeEventListener("copy", handleCopyEvent)
window.removeEventListener("paste", handlePasteEvent)
@@ -433,12 +427,25 @@ export const DataGridRoot = <
handlePasteEvent,
])
const [isHeaderInteractionActive, setIsHeaderInteractionActive] =
useState(false)
useEffect(() => {
const specialFocusHandler = (e: KeyboardEvent) => {
if (isSpecialFocusKey(e)) {
handleSpecialFocusKeys(e)
return
}
}
window.addEventListener("keydown", specialFocusHandler)
return () => {
window.removeEventListener("keydown", specialFocusHandler)
}
}, [handleSpecialFocusKeys])
const handleHeaderInteractionChange = useCallback((isActive: boolean) => {
setIsHeaderInteractionActive(isActive)
setTrapActive(!isActive)
if (isActive) {
setTrapActive(false)
}
}, [])
/**
@@ -476,6 +483,7 @@ export const DataGridRoot = <
control,
trapActive,
errors,
setTrapActive,
setIsSelecting,
setIsEditing: onEditingChangeHandler,
setSingleRange,
@@ -496,6 +504,7 @@ export const DataGridRoot = <
control,
trapActive,
errors,
setTrapActive,
setIsSelecting,
onEditingChangeHandler,
setSingleRange,
@@ -513,6 +522,19 @@ export const DataGridRoot = <
]
)
const handleRestoreGridFocus = useCallback(() => {
if (anchor && !trapActive) {
setTrapActive(true)
setSingleRange(anchor)
scrollToCoordinates(anchor, "both")
requestAnimationFrame(() => {
queryTool?.getContainer(anchor)?.focus()
})
}
}, [anchor, trapActive, queryTool])
return (
<DataGridContext.Provider value={values}>
<div className="bg-ui-bg-subtle flex size-full flex-col">
@@ -526,178 +548,116 @@ export const DataGridRoot = <
isHighlighted={isHighlighted}
onHeaderInteractionChange={handleHeaderInteractionChange}
/>
<FocusTrap
active={trapActive && !isHeaderInteractionActive}
focusTrapOptions={{
initialFocus: () => {
if (!anchor) {
const coords = matrix.getFirstNavigableCell()
if (!coords) {
return undefined
}
const id = generateCellId(coords)
return containerRef.current?.querySelector(
`[data-container-id="${id}"]`
)
}
const id = generateCellId(anchor)
const anchorContainer = containerRef.current?.querySelector(
`[data-container-id="${id}`
) as HTMLElement | null
return anchorContainer ?? undefined
},
onActivate: () => setTrapActive(true),
onDeactivate: () => setTrapActive(false),
fallbackFocus: () => {
if (!anchor) {
const coords = matrix.getFirstNavigableCell()
if (!coords) {
return containerRef.current!
}
const id = generateCellId(coords)
const firstCell = containerRef.current?.querySelector(
`[data-container-id="${id}"]`
) as HTMLElement | null
if (firstCell) {
return firstCell
}
return containerRef.current!
}
const id = generateCellId(anchor)
const anchorContainer = containerRef.current?.querySelector(
`[data-container-id="${id}`
) as HTMLElement | null
if (anchorContainer) {
return anchorContainer
}
return containerRef.current!
},
allowOutsideClick: true,
escapeDeactivates: false,
}}
>
<div className="size-full overflow-hidden">
<div
ref={containerRef}
tabIndex={-1}
onFocus={() => !trapActive && setTrapActive(true)}
className="relative h-full select-none overflow-auto outline-none"
>
<div role="grid" className="text-ui-fg-subtle grid">
<div
role="rowgroup"
className="txt-compact-small-plus bg-ui-bg-subtle sticky top-0 z-[1] grid"
>
{grid.getHeaderGroups().map((headerGroup) => (
<div
role="row"
key={headerGroup.id}
className="flex h-10 w-full"
>
{virtualPaddingLeft ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.reduce((acc, vc, index, array) => {
const header = headerGroup.headers[vc.index]
const previousVC = array[index - 1]
if (previousVC && vc.index !== previousVC.index + 1) {
// If there's a gap between the current and previous virtual columns
acc.push(
<div
key={`padding-${previousVC.index}-${vc.index}`}
role="presentation"
style={{
display: "flex",
width: `${vc.start - previousVC.end}px`,
}}
/>
)
}
<div className="size-full overflow-hidden">
<div
ref={containerRef}
autoFocus
tabIndex={0}
className="relative h-full select-none overflow-auto outline-none"
onFocus={handleRestoreGridFocus}
onClick={handleRestoreGridFocus}
data-container={true}
role="application"
>
<div role="grid" className="text-ui-fg-subtle grid">
<div
role="rowgroup"
className="txt-compact-small-plus bg-ui-bg-subtle sticky top-0 z-[1] grid"
>
{grid.getHeaderGroups().map((headerGroup) => (
<div
role="row"
key={headerGroup.id}
className="flex h-10 w-full"
>
{virtualPaddingLeft ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.reduce((acc, vc, index, array) => {
const header = headerGroup.headers[vc.index]
const previousVC = array[index - 1]
if (previousVC && vc.index !== previousVC.index + 1) {
// If there's a gap between the current and previous virtual columns
acc.push(
<div
key={header.id}
role="columnheader"
data-column-index={vc.index}
key={`padding-${previousVC.index}-${vc.index}`}
role="presentation"
style={{
width: header.getSize(),
...getCommonPinningStyles(header.column),
display: "flex",
width: `${vc.start - previousVC.end}px`,
}}
className="bg-ui-bg-base txt-compact-small-plus flex items-center border-b border-r px-4 py-2.5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
/>
)
}
return acc
}, [] as ReactNode[])}
{virtualPaddingRight ? (
acc.push(
<div
role="presentation"
key={header.id}
role="columnheader"
data-column-index={vc.index}
style={{
display: "flex",
width: virtualPaddingRight,
width: header.getSize(),
...getCommonPinningStyles(header.column),
}}
/>
) : null}
</div>
))}
</div>
<div
role="rowgroup"
className="relative grid"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map((virtualRow) => {
const row = visibleRows[virtualRow.index] as Row<TData>
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
className="bg-ui-bg-base txt-compact-small-plus flex items-center border-b border-r px-4 py-2.5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
)
return (
<DataGridRow
key={row.id}
row={row}
rowIndex={rowIndex}
virtualRow={virtualRow}
flatColumns={flatColumns}
virtualColumns={virtualColumns}
anchor={anchor}
virtualPaddingLeft={virtualPaddingLeft}
virtualPaddingRight={virtualPaddingRight}
onDragToFillStart={onDragToFillStart}
return acc
}, [] as ReactNode[])}
{virtualPaddingRight ? (
<div
role="presentation"
style={{
display: "flex",
width: virtualPaddingRight,
}}
/>
)
})}
</div>
) : null}
</div>
))}
</div>
<div
role="rowgroup"
className="relative grid"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map((virtualRow) => {
const row = visibleRows[virtualRow.index] as Row<TData>
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
return (
<DataGridRow
key={row.id}
row={row}
rowIndex={rowIndex}
virtualRow={virtualRow}
flatColumns={flatColumns}
virtualColumns={virtualColumns}
anchor={anchor}
virtualPaddingLeft={virtualPaddingLeft}
virtualPaddingRight={virtualPaddingRight}
onDragToFillStart={onDragToFillStart}
/>
)
})}
</div>
</div>
</div>
</FocusTrap>
</div>
</div>
</DataGridContext.Provider>
)
@@ -783,6 +743,7 @@ const DataGridHeader = ({
type="button"
onClick={onResetColumns}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
data-id="reset-columns"
>
{t("dataGrid.columns.resetToDefault")}
</Button>
@@ -821,7 +782,7 @@ type DataGridCellProps<TData> = {
columnIndex: number
rowIndex: number
anchor: DataGridCoordinates | null
onDragToFillStart: (e: MouseEvent<HTMLElement>) => void
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
}
const DataGridCell = <TData,>({
@@ -880,7 +841,7 @@ type DataGridRowProps<TData> = {
virtualColumns: VirtualItem<Element>[]
flatColumns: Column<TData, unknown>[]
anchor: DataGridCoordinates | null
onDragToFillStart: (e: MouseEvent<HTMLElement>) => void
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
}
const DataGridRow = <TData,>({

View File

@@ -12,6 +12,7 @@ type DataGridContextType<TFieldValues extends FieldValues> = {
// Grid state
anchor: DataGridCoordinates | null
trapActive: boolean
setTrapActive: (value: boolean) => void
errors: FieldErrors<TFieldValues>
// Cell handlers
getIsCellSelected: (coords: DataGridCoordinates) => boolean

View File

@@ -7,7 +7,7 @@ import {
DataGridCellRenderProps,
DataGridCoordinates,
} from "../types"
import { isCellMatch } from "../utils"
import { isCellMatch, isSpecialFocusKey } from "../utils"
type UseDataGridCellOptions<TData, TValue> = {
context: CellContext<TData, TValue>
@@ -162,6 +162,10 @@ export const useDataGridCell = <TData, TValue>({
return
}
if (isSpecialFocusKey(e.nativeEvent)) {
return
}
const event = new KeyboardEvent(e.type, e.nativeEvent)
inputRef.current.focus()

View File

@@ -15,6 +15,7 @@ import {
import { DataGridCoordinates } from "../types"
type UseDataGridKeydownEventOptions<TData, TFieldValues extends FieldValues> = {
containerRef: React.RefObject<HTMLDivElement>
matrix: DataGridMatrix<TData, TFieldValues>
anchor: DataGridCoordinates | null
rangeEnd: DataGridCoordinates | null
@@ -23,6 +24,7 @@ type UseDataGridKeydownEventOptions<TData, TFieldValues extends FieldValues> = {
coords: DataGridCoordinates,
direction: "horizontal" | "vertical" | "both"
) => void
setTrapActive: (active: boolean) => void
setSingleRange: (coordinates: DataGridCoordinates | null) => void
setRangeEnd: (coordinates: DataGridCoordinates | null) => void
onEditingChangeHandler: (value: boolean) => void
@@ -44,12 +46,14 @@ const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"]
export const useDataGridKeydownEvent = <
TData,
TFieldValues extends FieldValues
TFieldValues extends FieldValues,
>({
containerRef,
matrix,
anchor,
rangeEnd,
isEditing,
setTrapActive,
scrollToCoordinates,
setSingleRange,
setRangeEnd,
@@ -104,8 +108,8 @@ export const useDataGridKeydownEvent = <
direction === "horizontal"
? setSingleRange
: e.shiftKey
? setRangeEnd
: setSingleRange
? setRangeEnd
: setSingleRange
if (!basis) {
return
@@ -115,6 +119,7 @@ export const useDataGridKeydownEvent = <
const handleNavigation = (coords: DataGridCoordinates) => {
e.preventDefault()
e.stopPropagation()
scrollToCoordinates(coords, direction)
updater(coords)
@@ -140,6 +145,33 @@ export const useDataGridKeydownEvent = <
]
)
const handleTabKey = useCallback(
(e: KeyboardEvent) => {
if (!anchor) {
return
}
e.preventDefault()
e.stopPropagation()
const { row, col } = anchor
const key = e.shiftKey ? "ArrowLeft" : "ArrowRight"
const direction = "horizontal"
const next = matrix.getValidMovement(
row,
col,
key,
e.metaKey || e.ctrlKey
)
scrollToCoordinates(next, direction)
setSingleRange(next)
},
[anchor, scrollToCoordinates, setSingleRange, matrix]
)
const handleUndo = useCallback(
(e: KeyboardEvent) => {
e.preventDefault()
@@ -460,27 +492,33 @@ export const useDataGridKeydownEvent = <
[queryTool, isEditing, anchor, restoreSnapshot]
)
const handleTabKey = useCallback(
const handleSpecialFocusKeys = useCallback(
(e: KeyboardEvent) => {
if (!anchor || isEditing) {
if (!containerRef || isEditing) {
return
}
e.preventDefault()
const focusableElements = getFocusableElements(containerRef)
const direction = e.shiftKey ? "ArrowLeft" : "ArrowRight"
const focusElement = (element: HTMLElement | null) => {
if (element) {
setTrapActive(false)
element.focus()
}
}
const next = matrix.getValidMovement(
anchor.row,
anchor.col,
direction,
e.metaKey || e.ctrlKey
)
setSingleRange(next)
scrollToCoordinates(next, "horizontal")
switch (e.key) {
case ".":
focusElement(focusableElements.cancel)
break
case ",":
focusElement(focusableElements.shortcuts)
break
default:
break
}
},
[anchor, isEditing, scrollToCoordinates, setSingleRange, matrix]
[anchor, isEditing, setTrapActive, containerRef]
)
const handleKeyDownEvent = useCallback(
@@ -533,5 +571,29 @@ export const useDataGridKeydownEvent = <
return {
handleKeyDownEvent,
handleSpecialFocusKeys,
}
}
function getFocusableElements(ref: React.RefObject<HTMLDivElement>) {
const focusableElements = Array.from(
document.querySelectorAll<HTMLElement>(
"[tabindex], a, button, input, select, textarea"
)
)
const currentElementIndex = focusableElements.indexOf(ref.current!)
const shortcuts =
currentElementIndex > 0 ? focusableElements[currentElementIndex - 1] : null
let cancel = null
for (let i = currentElementIndex + 1; i < focusableElements.length; i++) {
if (!ref.current!.contains(focusableElements[i])) {
cancel = focusableElements[i]
break
}
}
return { shortcuts, cancel }
}

View File

@@ -20,3 +20,9 @@ export function isCellMatch(
return cell.row === coords.row && cell.col === coords.col
}
const SPECIAL_FOCUS_KEYS = [".", ","]
export function isSpecialFocusKey(event: KeyboardEvent) {
return SPECIAL_FOCUS_KEYS.includes(event.key) && event.ctrlKey && event.altKey
}

View File

@@ -243,7 +243,9 @@
"selectDown": "Select down",
"selectUp": "Select up",
"selectColumnDown": "Select column down",
"selectColumnUp": "Select column up"
"selectColumnUp": "Select column up",
"focusToolbar": "Focus toolbar",
"focusCancel": "Focus cancel"
}
},
"errors": {
@@ -576,7 +578,6 @@
"successToast": "Option {{title}} was successfully created."
},
"deleteWarning": "You are about to delete the product option: {{title}}. This action cannot be undone."
},
"organization": {
"header": "Organize",

View File

@@ -4551,7 +4551,6 @@ __metadata:
autoprefixer: ^10.4.17
cmdk: ^0.2.0
date-fns: ^3.6.0
focus-trap-react: ^10.2.3
framer-motion: ^11.0.3
i18next: 23.7.11
i18next-browser-languagedetector: 7.2.0
@@ -4560,7 +4559,6 @@ __metadata:
match-sorter: ^6.3.4
postcss: ^8.4.33
prettier: ^3.1.1
prop-types: ^15.8.1
qs: ^6.12.0
react: ^18.2.0
react-country-flag: ^3.1.0
@@ -18451,29 +18449,6 @@ __metadata:
languageName: node
linkType: hard
"focus-trap-react@npm:^10.2.3":
version: 10.2.3
resolution: "focus-trap-react@npm:10.2.3"
dependencies:
focus-trap: ^7.5.4
tabbable: ^6.2.0
peerDependencies:
prop-types: ^15.8.1
react: ">=16.3.0"
react-dom: ">=16.3.0"
checksum: 18527771cacc1083ecdf472276a4c46581a6289dc98b6caf1c31641091732933cbffdec78e4c86c2c1568ac4f82a83c367d6a4d7e0e84dc96b239d2d4c12c071
languageName: node
linkType: hard
"focus-trap@npm:^7.5.4":
version: 7.5.4
resolution: "focus-trap@npm:7.5.4"
dependencies:
tabbable: ^6.2.0
checksum: c09e12b957862b2608977ff90de782645f99c3555cc5d93977240c179befa8723b9b1183e93890b4ad9d364d52a1af36416e63a728522ecce656a447d9ddd945
languageName: node
linkType: hard
"follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.14.4, follow-redirects@npm:^1.15.6":
version: 1.15.6
resolution: "follow-redirects@npm:1.15.6"
@@ -28925,13 +28900,6 @@ __metadata:
languageName: node
linkType: hard
"tabbable@npm:^6.2.0":
version: 6.2.0
resolution: "tabbable@npm:6.2.0"
checksum: ced8b38f05f2de62cd46836d77c2646c42b8c9713f5bd265daf0e78ff5ac73d3ba48a7ca45f348bafeef29b23da7187c72250742d37627883ef89cbd7fa76898
languageName: node
linkType: hard
"table@npm:^6.0.9":
version: 6.8.2
resolution: "table@npm:6.8.2"