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:
1 parent
f47f1aff49
commit
58c78a7f62
9 files changed
+161
-146
No files matched your search
@@ -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",
|
||||
|
||||
+14
@@ -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]
|
||||
)
|
||||
|
||||
+55
-94
@@ -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,12 +314,15 @@ export const DataGridRoot = <
|
||||
anchor,
|
||||
})
|
||||
|
||||
const { handleKeyDownEvent } = useDataGridKeydownEvent<TData, TFieldValues>({
|
||||
const { handleKeyDownEvent, handleSpecialFocusKeys } =
|
||||
useDataGridKeydownEvent<TData, TFieldValues>({
|
||||
containerRef,
|
||||
matrix,
|
||||
queryTool,
|
||||
anchor,
|
||||
rangeEnd,
|
||||
isEditing,
|
||||
setTrapActive,
|
||||
setRangeEnd,
|
||||
getSelectionValues,
|
||||
getValues,
|
||||
@@ -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,77 +548,16 @@ 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)}
|
||||
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
|
||||
@@ -697,7 +658,6 @@ export const DataGridRoot = <
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</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,>({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+77
-15
@@ -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,
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user