From 218566505b0dc82277d9caf73d7a54f28ceb3f42 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 13 Nov 2025 20:01:37 +0100 Subject: [PATCH] Handle keyboard navigation Signed-off-by: Jonathan --- packages/helpers/src/dom.ts | 55 +++++++++++++++++-- packages/helpers/src/index.ts | 7 ++- .../ui/src/Molecules/Table/EditableCell.tsx | 29 ++++++++-- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/packages/helpers/src/dom.ts b/packages/helpers/src/dom.ts index 5979237b3..3905d4a79 100644 --- a/packages/helpers/src/dom.ts +++ b/packages/helpers/src/dom.ts @@ -24,12 +24,59 @@ export function downloadFile(url: string | undefined | null, filename: string) { export function safeOpenUrl(url: string) { try { const parsedUrl = new URL(url); - if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') { - window.open(url, '_blank', 'noopener,noreferrer'); + if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") { + window.open(url, "_blank", "noopener,noreferrer"); } else { - console.error('Invalid URL protocol. Only HTTP and HTTPS URLs are allowed:', url); + console.error( + "Invalid URL protocol. Only HTTP and HTTPS URLs are allowed:", + url, + ); } } catch (error) { - console.error('Invalid URL format:', url, error); + console.error("Invalid URL format:", url, error); } } + +export function focusSiblingElement(direction = 1) { + const current = document.activeElement as HTMLElement; + + // Selector for all focusable elements + const focusableSelector = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]', + ].join(", "); + + // Get all focusable elements in the document + const focusableElements = Array.from( + document.querySelectorAll(focusableSelector), + ).filter((el) => { + // Filter out elements that are not visible or have display: none + const style = window.getComputedStyle(el); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + el.offsetParent !== null + ); + }); + + const currentIndex = focusableElements.indexOf(current); + + let nextIndex = currentIndex + direction; + + if (nextIndex >= focusableElements.length || nextIndex < 0) { + return null; + } + + const nextElement = focusableElements[nextIndex]; + if (nextElement) { + nextElement.focus(); + return nextElement; + } + + return null; +} diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 647210cc1..65fd387aa 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -6,7 +6,12 @@ export { getRiskLikelihoods, getSeverity, } from "./risk"; -export { withViewTransition, downloadFile, safeOpenUrl } from "./dom"; +export { + withViewTransition, + downloadFile, + safeOpenUrl, + focusSiblingElement, +} from "./dom"; export { times, groupBy, isEmpty } from "./array"; export { randomInt } from "./number"; export { getMeasureStateLabel, measureStates } from "./measure"; diff --git a/packages/ui/src/Molecules/Table/EditableCell.tsx b/packages/ui/src/Molecules/Table/EditableCell.tsx index ac5952575..147281b65 100644 --- a/packages/ui/src/Molecules/Table/EditableCell.tsx +++ b/packages/ui/src/Molecules/Table/EditableCell.tsx @@ -10,6 +10,7 @@ import { Cell } from "../../Atoms/DataTable/DataTable.tsx"; import { Command } from "cmdk"; import { useTranslate } from "@probo/i18n"; import { Spinner } from "../../Atoms/Spinner/Spinner.tsx"; +import { focusSiblingElement } from "@probo/helpers"; type Props = | { @@ -112,12 +113,30 @@ export function EditableCell(props: Props) { return value as ReactNode; })(); + // Handle keyboard navigation inside the cells + const onKeyDown: KeyboardEventHandler = (e) => { + if (e.key === "ArrowRight") { + focusSiblingElement(1); + } else if (e.key === "ArrowLeft") { + focusSiblingElement(-1); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + focusSiblingElement(td.current?.parentNode?.children.length ?? 0); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + focusSiblingElement( + (td.current?.parentNode?.children.length ?? 0) * -1, + ); + } + }; + return ( {/* Keep the height of the cell when the popover is open, so that the popover doesn't jump when it opens/closes. */}