- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.0.0, shown as text in the browser.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { Column } from "../types";
import { IconLock } from "./Icons";
interface MoveMenuProps {
anchor: HTMLElement;
cardTitle: string;
columns: Column[];
currentColumnId: string;
isFull: (columnId: string) => boolean;
onSelect: (columnId: string) => void;
onClose: (restoreFocus: boolean) => void;
}
export function MoveMenu({ anchor, cardTitle, columns, currentColumnId, isFull, onSelect, onClose }: MoveMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu) return;
const a = anchor.getBoundingClientRect();
const m = menu.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
let left = a.right - m.width;
left = Math.max(8, Math.min(left, vw - m.width - 8));
let top = a.bottom + 6;
if (top + m.height > vh - 8) top = Math.max(8, a.top - m.height - 6);
setPos({ top, left });
}, [anchor]);
useEffect(() => {
const firstEnabled = menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])');
firstEnabled?.focus();
}, [pos === null]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
function onPointerDown(e: PointerEvent) {
const t = e.target as Node;
if (menuRef.current?.contains(t) || anchor.contains(t)) return;
onClose(false);
}
function onViewportChange(e: Event) {
if (e.target instanceof Node && menuRef.current?.contains(e.target)) return;
onClose(false);
}
document.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("resize", onViewportChange);
window.addEventListener("scroll", onViewportChange, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
};
}, [anchor, onClose]);
function onKeyDown(e: React.KeyboardEvent) {
const items = Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
const i = items.indexOf(document.activeElement as HTMLElement);
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose(true);
} else if (e.key === "Tab") {
e.preventDefault();
onClose(true);
} else if (e.key === "ArrowDown") {
e.preventDefault();
items[(i + 1) % items.length]?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
items[(i - 1 + items.length) % items.length]?.focus();
} else if (e.key === "Home") {
e.preventDefault();
items[0]?.focus();
} else if (e.key === "End") {
e.preventDefault();
items[items.length - 1]?.focus();
}
}
return createPortal(
<div
ref={menuRef}
className="move-menu"
role="menu"
aria-label={`Move “${cardTitle}” to…`}
style={pos ? { top: pos.top, left: pos.left } : { top: 0, left: 0, visibility: "hidden" }}
onKeyDown={onKeyDown}
>
<p className="move-menu-heading" aria-hidden="true">
Move to…
</p>
{columns.map((column) => {
const current = column.id === currentColumnId;
const full = !current && isFull(column.id);
return (
<button
key={column.id}
type="button"
role="menuitem"
tabIndex={-1}
className="move-menu-item"
aria-disabled={current || undefined}
data-full={full || undefined}
onClick={() => {
if (current) return;
onSelect(column.id);
}}
>
<span>{column.title}</span>
{current ? (
<span className="move-menu-meta">Current</span>
) : full ? (
<span className="move-menu-meta move-menu-full">
<IconLock size={12} /> Full · limit {column.wip_limit}
</span>
) : (
<span className="move-menu-meta">{column.card_ids.length}</span>
)}
</button>
);
})}
</div>,
document.body,
);
}Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.