diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 9dfc0ac39f..8da00671a7 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -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", diff --git a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-keyboard-shortcut-modal.tsx b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-keyboard-shortcut-modal.tsx index 1bf3f6c022..eff7fc664d 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-keyboard-shortcut-modal.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-keyboard-shortcut-modal.tsx @@ -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] ) diff --git a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-root.tsx b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-root.tsx index 8dcda22387..63001cd8a0 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-root.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-root.tsx @@ -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[] @@ -98,7 +95,7 @@ const getCommonPinningStyles = ( 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(null) const [rangeEnd, setRangeEnd] = useState(null) @@ -317,25 +314,28 @@ export const DataGridRoot = < anchor, }) - const { handleKeyDownEvent } = useDataGridKeydownEvent({ - matrix, - queryTool, - anchor, - rangeEnd, - isEditing, - setRangeEnd, - getSelectionValues, - getValues, - setSelectionValues, - onEditingChangeHandler, - restoreSnapshot, - setSingleRange, - scrollToCoordinates, - execute, - undo, - redo, - setValue, - }) + const { handleKeyDownEvent, handleSpecialFocusKeys } = + useDataGridKeydownEvent({ + containerRef, + matrix, + queryTool, + anchor, + rangeEnd, + isEditing, + setTrapActive, + setRangeEnd, + getSelectionValues, + getValues, + setSelectionValues, + onEditingChangeHandler, + restoreSnapshot, + setSingleRange, + scrollToCoordinates, + execute, + undo, + redo, + setValue, + }) const { handleMouseUpEvent } = useDataGridMouseUpEvent({ 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 (
@@ -526,178 +548,116 @@ export const DataGridRoot = < isHighlighted={isHighlighted} onHeaderInteractionChange={handleHeaderInteractionChange} /> - { - 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, - }} - > -
-
!trapActive && setTrapActive(true)} - className="relative h-full select-none overflow-auto outline-none" - > -
-
- {grid.getHeaderGroups().map((headerGroup) => ( -
- {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( -
- ) - } +
+
+
+
+ {grid.getHeaderGroups().map((headerGroup) => ( +
+ {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(
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
+ /> ) + } - return acc - }, [] as ReactNode[])} - {virtualPaddingRight ? ( + acc.push(
- ) : null} -
- ))} -
-
- {virtualRows.map((virtualRow) => { - const row = visibleRows[virtualRow.index] as Row - 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() + )} +
+ ) - return ( - - ) - })} -
+ ) : null} +
+ ))} +
+
+ {virtualRows.map((virtualRow) => { + const row = visibleRows[virtualRow.index] as Row + const rowIndex = flatRows.findIndex((r) => r.id === row.id) + + return ( + + ) + })}
- +
) @@ -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")} @@ -821,7 +782,7 @@ type DataGridCellProps = { columnIndex: number rowIndex: number anchor: DataGridCoordinates | null - onDragToFillStart: (e: MouseEvent) => void + onDragToFillStart: (e: React.MouseEvent) => void } const DataGridCell = ({ @@ -880,7 +841,7 @@ type DataGridRowProps = { virtualColumns: VirtualItem[] flatColumns: Column[] anchor: DataGridCoordinates | null - onDragToFillStart: (e: MouseEvent) => void + onDragToFillStart: (e: React.MouseEvent) => void } const DataGridRow = ({ diff --git a/packages/admin-next/dashboard/src/components/data-grid/context/data-grid-context.tsx b/packages/admin-next/dashboard/src/components/data-grid/context/data-grid-context.tsx index bc32d16535..83f2612d58 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/context/data-grid-context.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/context/data-grid-context.tsx @@ -12,6 +12,7 @@ type DataGridContextType = { // Grid state anchor: DataGridCoordinates | null trapActive: boolean + setTrapActive: (value: boolean) => void errors: FieldErrors // Cell handlers getIsCellSelected: (coords: DataGridCoordinates) => boolean diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx index 1eee7f669f..d180b5d683 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx @@ -7,7 +7,7 @@ import { DataGridCellRenderProps, DataGridCoordinates, } from "../types" -import { isCellMatch } from "../utils" +import { isCellMatch, isSpecialFocusKey } from "../utils" type UseDataGridCellOptions = { context: CellContext @@ -162,6 +162,10 @@ export const useDataGridCell = ({ return } + if (isSpecialFocusKey(e.nativeEvent)) { + return + } + const event = new KeyboardEvent(e.type, e.nativeEvent) inputRef.current.focus() diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx index 02155129bf..86e2a7f890 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx @@ -15,6 +15,7 @@ import { import { DataGridCoordinates } from "../types" type UseDataGridKeydownEventOptions = { + containerRef: React.RefObject matrix: DataGridMatrix anchor: DataGridCoordinates | null rangeEnd: DataGridCoordinates | null @@ -23,6 +24,7 @@ type UseDataGridKeydownEventOptions = { 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) { + const focusableElements = Array.from( + document.querySelectorAll( + "[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 } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/utils.ts b/packages/admin-next/dashboard/src/components/data-grid/utils.ts index b80cdc5fd9..a10c6338d4 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/utils.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/utils.ts @@ -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 +} \ No newline at end of file diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 968a8c426f..0e268a5589 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 194285e295..46c13b828d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"