Handle keyboard navigation
Signed-off-by: Jonathan <contact@grafikart.fr>
This commit is contained in:
@@ -24,12 +24,59 @@ export function downloadFile(url: string | undefined | null, filename: string) {
|
|||||||
export function safeOpenUrl(url: string) {
|
export function safeOpenUrl(url: string) {
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
|
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
} else {
|
} 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) {
|
} 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<HTMLElement>(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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ export {
|
|||||||
getRiskLikelihoods,
|
getRiskLikelihoods,
|
||||||
getSeverity,
|
getSeverity,
|
||||||
} from "./risk";
|
} from "./risk";
|
||||||
export { withViewTransition, downloadFile, safeOpenUrl } from "./dom";
|
export {
|
||||||
|
withViewTransition,
|
||||||
|
downloadFile,
|
||||||
|
safeOpenUrl,
|
||||||
|
focusSiblingElement,
|
||||||
|
} from "./dom";
|
||||||
export { times, groupBy, isEmpty } from "./array";
|
export { times, groupBy, isEmpty } from "./array";
|
||||||
export { randomInt } from "./number";
|
export { randomInt } from "./number";
|
||||||
export { getMeasureStateLabel, measureStates } from "./measure";
|
export { getMeasureStateLabel, measureStates } from "./measure";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Cell } from "../../Atoms/DataTable/DataTable.tsx";
|
|||||||
import { Command } from "cmdk";
|
import { Command } from "cmdk";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Spinner } from "../../Atoms/Spinner/Spinner.tsx";
|
import { Spinner } from "../../Atoms/Spinner/Spinner.tsx";
|
||||||
|
import { focusSiblingElement } from "@probo/helpers";
|
||||||
|
|
||||||
type Props<T> =
|
type Props<T> =
|
||||||
| {
|
| {
|
||||||
@@ -112,12 +113,30 @@ export function EditableCell<T>(props: Props<T>) {
|
|||||||
return value as ReactNode;
|
return value as ReactNode;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Handle keyboard navigation inside the cells
|
||||||
|
const onKeyDown: KeyboardEventHandler<HTMLButtonElement> = (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 (
|
return (
|
||||||
<Popover.Root onOpenChange={onOpenChange} open={isOpen}>
|
<Popover.Root onOpenChange={onOpenChange} open={isOpen}>
|
||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
<Cell ref={td} asChild>
|
<Cell ref={td} asChild>
|
||||||
{/* Keep the height of the cell when the popover is open, so that the popover doesn't jump when it opens/closes. */}
|
{/* Keep the height of the cell when the popover is open, so that the popover doesn't jump when it opens/closes. */}
|
||||||
<button
|
<button
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
className="flex flex-row justify-start hover:bg-level-2 flex-wrap gap-1 items-center relative"
|
className="flex flex-row justify-start hover:bg-level-2 flex-wrap gap-1 items-center relative"
|
||||||
style={{ height }}
|
style={{ height }}
|
||||||
>
|
>
|
||||||
@@ -197,7 +216,7 @@ function Select<T>(props: PropsField<"select", T>) {
|
|||||||
.map((item) => (
|
.map((item) => (
|
||||||
<Command.Item
|
<Command.Item
|
||||||
key={getKey(item)}
|
key={getKey(item)}
|
||||||
className="py-2 px-3 hover:bg-level-3"
|
className="py-2 px-3 hover:bg-level-3 data-[selected]:bg-level-3"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
props.onValueChange(item);
|
props.onValueChange(item);
|
||||||
props.onOpenChange(false);
|
props.onOpenChange(false);
|
||||||
@@ -209,7 +228,7 @@ function Select<T>(props: PropsField<"select", T>) {
|
|||||||
) : (
|
) : (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="py-2 px-3 hover:bg-level-3">
|
<div className="py-2 px-3">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -275,7 +294,7 @@ function Multiple<T>(props: PropsField<"multiple", T>) {
|
|||||||
.map((item, k) => (
|
.map((item, k) => (
|
||||||
<Command.Item
|
<Command.Item
|
||||||
key={k}
|
key={k}
|
||||||
className="py-2 px-3 hover:bg-level-3"
|
className="py-2 px-3 hover:bg-level-3 data-[selected]:bg-level-3"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
pushValue(item);
|
pushValue(item);
|
||||||
}}
|
}}
|
||||||
@@ -288,7 +307,7 @@ function Multiple<T>(props: PropsField<"multiple", T>) {
|
|||||||
) : (
|
) : (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="py-2 px-3 hover:bg-level-3">
|
<div className="py-2 px-3">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -327,7 +346,7 @@ function SelectItems<T>(props: {
|
|||||||
.map((item) => (
|
.map((item) => (
|
||||||
<Command.Item
|
<Command.Item
|
||||||
key={getKey(item)}
|
key={getKey(item)}
|
||||||
className="py-2 px-3 hover:bg-level-3"
|
className="py-2 px-3 hover:bg-level-3 data-[selected]:bg-level-3"
|
||||||
onSelect={() => props.onSelect(item)}
|
onSelect={() => props.onSelect(item)}
|
||||||
>
|
>
|
||||||
{props.itemRenderer({ item })}
|
{props.itemRenderer({ item })}
|
||||||
|
|||||||
Reference in New Issue
Block a user