feat: modernize widget experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:48 +02:00
1 parent 6331c88911
commit 1fa345edd1
165 files changed
+3698 -1649

No files matched your search

@@ -3,6 +3,10 @@
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.manualBackdrop {
pointer-events: auto;
}
.surface {
position: fixed;
z-index: 1;
@@ -15,6 +19,25 @@
transform-origin: top left;
}
.manualSurface {
position: fixed;
max-width: none;
max-height: none;
overflow: visible;
background: transparent;
box-shadow: none;
overscroll-behavior: contain;
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: top left;
}
.manualSurface > div {
width: 100%;
height: 100%;
max-height: none !important;
overflow: visible;
}
.backdropClosing {
animation: focus-backdrop-out 180ms ease-in forwards;
}
@@ -32,6 +55,18 @@
height: 100%;
}
.surfacePortalTarget,
.manualSurfaceMount {
width: 100%;
height: 100%;
min-height: 0;
}
.manualSurfaceMount {
overflow: hidden;
border-radius: inherit;
}
.surfaceHostAdvanced {
position: fixed;
inset: 0;
@@ -49,7 +84,7 @@
}
.closeButton {
position: fixed;
position: absolute;
z-index: 2;
pointer-events: auto;
}
@@ -99,7 +134,8 @@
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface {
.surface,
.manualSurface {
animation: none;
}
}
@@ -16,6 +16,7 @@ interface ActiveFocus {
sourceRect: FocusRect;
activation: "preview" | "manual";
autofocusClose: boolean;
restorePreviewFocus: boolean;
phase: "visible" | "closing";
}
@@ -79,6 +80,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
activation: options.activation ?? "manual",
autofocusClose: options.autofocusClose ?? false,
restorePreviewFocus: options.activation === "preview" && document.activeElement === source,
phase: "visible",
});
},
@@ -118,12 +120,46 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
if (current?.activation === "preview" && current.itemId !== itemId) {
cancelCloseTimer();
updateActive(null);
if (current.restorePreviewFocus && current.source.isConnected) current.source.focus({ preventScroll: true });
}
startHoverTimer();
},
[cancelCloseTimer, startHoverTimer, updateActive],
);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const current = activeRef.current;
if (!shiftHeldRef.current || current?.activation !== "preview") return;
const underlyingSource = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) =>
element instanceof HTMLElement
? element.matches(".grid-stack-item-content")
? element
: element.closest<HTMLElement>(".grid-stack-item-content")
: null,
)
.find((source) => source !== null && source !== current.source);
const itemId = underlyingSource?.closest<HTMLElement>(".grid-stack-item[data-id]")?.dataset.id;
if (!underlyingSource || !itemId) return;
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
hover(itemId, underlyingSource);
return;
}
hoveredRef.current = null;
cancelHoverTimer();
cancelCloseTimer();
updateActive(null);
};
window.addEventListener("pointermove", handlePointerMove, { capture: true });
return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true });
}, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]);
const leave = useCallback(
(itemId: string) => {
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
@@ -145,7 +181,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && activeRef.current) {
const advancedSurface = document.querySelector(`.${classes.surface}`);
const advancedSurface = document.querySelector("[data-advanced-focus-surface]");
if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface))
return;
event.preventDefault();
@@ -170,11 +206,11 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
if (activeRef.current?.activation === "preview") close(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
cancelHoverTimer();
@@ -194,7 +230,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
return (
<AdvancedFocusContext.Provider value={value}>
{children}
{active && (
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
@@ -5,7 +5,7 @@ import { isEscapeOwnedByNestedOverlay } from "./escape";
describe("isEscapeOwnedByNestedOverlay", () => {
test("allows Escape from the advanced surface itself", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "region");
surface.setAttribute("role", "dialog");
const button = document.createElement("button");
surface.append(button);
@@ -44,6 +44,24 @@ describe("isEscapeOwnedByNestedOverlay", () => {
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
});
test("leaves Escape to a visible portalled menu when focus stays in the advanced surface", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const target = document.createElement("button");
surface.append(target);
document.body.append(surface);
const menu = document.createElement("div");
menu.setAttribute("role", "menu");
menu.getClientRects = () => [{ width: 100, height: 100 }] as unknown as DOMRectList;
document.body.append(menu);
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
menu.remove();
surface.remove();
});
test("does not mistake the compact widget source for a nested overlay", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "region");
@@ -6,8 +6,14 @@ const escapeOwnerSelector = [
].join(", ");
export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => {
if (!(target instanceof Element)) return false;
const escapeOwner = target instanceof Element ? target.closest(escapeOwnerSelector) : null;
if (escapeOwner !== null && escapeOwner !== advancedSurface) return true;
const escapeOwner = target.closest(escapeOwnerSelector);
return escapeOwner !== null && escapeOwner !== advancedSurface;
return Array.from(document.querySelectorAll(escapeOwnerSelector)).some(
(owner) =>
owner !== advancedSurface &&
owner.getAttribute("aria-hidden") !== "true" &&
owner.closest('[aria-hidden="true"]') === null &&
owner.getClientRects().length > 0,
);
};
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { act, createElement, Fragment, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AdvancedFocusManualSurface } from "./manual-surface";
const sourceRect = { left: 100, top: 120, width: 240, height: 160 };
const rect = { left: 40, top: 80, width: 640, height: 480 };
const closePosition = { left: 688, top: 80 };
describe("AdvancedFocusManualSurface", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
vi.unstubAllGlobals();
});
test("portals dialog semantics, traps focus, closes from the backdrop, and restores focus", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const Harness = () => {
const [opened, setOpened] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
return createElement(
Fragment,
null,
createElement("button", { type: "button", onClick: () => setOpened(true) }, "Open advanced view"),
createElement(
AdvancedFocusManualSurface,
{
opened,
phase: "visible",
id: "advanced-focus-test",
label: "Test widget advanced view",
closeLabel: "Close advanced view",
rect,
closePosition,
sourceRect,
radius: "md",
contentRef,
onClose: () => setOpened(false),
},
createElement("button", { type: "button" }, "First widget action"),
createElement("button", { type: "button" }, "Last widget action"),
),
);
};
await act(async () => root?.render(createElement(MantineProvider, null, createElement(Harness))));
const trigger = document.querySelector<HTMLButtonElement>("button");
expect(trigger).not.toBeNull();
trigger?.focus();
await act(async () => trigger?.click());
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
const dialog = document.querySelector<HTMLElement>("[role='dialog']");
const closeButton = dialog?.querySelector<HTMLButtonElement>("[aria-label='Close advanced view']");
const actions = dialog?.querySelectorAll<HTMLButtonElement>("button");
expect(dialog?.id).toBe("advanced-focus-test");
expect(dialog?.getAttribute("aria-modal")).toBe("true");
expect(dialog?.closest("[data-portal]")).not.toBeNull();
expect(dialog?.closest(".grid-stack-item")).toBeNull();
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
"Test widget advanced view",
);
expect(document.activeElement).toBe(closeButton);
const lastAction = actions?.item((actions?.length ?? 1) - 1);
lastAction?.focus();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
expect(document.activeElement).toBe(closeButton);
closeButton?.focus();
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(lastAction);
const backdrop = document.querySelector<HTMLElement>("[data-advanced-focus-overlay]");
await act(async () => backdrop?.click());
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
expect(document.activeElement).toBe(trigger);
});
});
@@ -0,0 +1,101 @@
"use client";
import type { CSSProperties, PropsWithChildren, Ref } from "react";
import { Modal, VisuallyHidden } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import classes from "./advanced-focus.module.css";
import type { FocusClosePosition, FocusRect } from "./geometry";
interface AdvancedFocusManualSurfaceProps extends PropsWithChildren {
opened: boolean;
phase: "visible" | "closing";
id: string;
label: string;
closeLabel: string;
rect: FocusRect;
closePosition: FocusClosePosition;
sourceRect: FocusRect;
radius: string | number;
contentRef: Ref<HTMLDivElement>;
onClose: () => void;
}
export const AdvancedFocusManualSurface = ({
opened,
phase,
id,
label,
closeLabel,
rect,
closePosition,
sourceRect,
radius,
contentRef,
onClose,
children,
}: AdvancedFocusManualSurfaceProps) => {
const reduceMotion = useReducedMotion();
const transitionDuration = reduceMotion ? 0 : 180;
const geometryStyle = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
"--focus-translate-x": `${sourceRect.left - rect.left}px`,
"--focus-translate-y": `${sourceRect.top - rect.top}px`,
"--focus-scale-x": rect.width > 0 ? sourceRect.width / rect.width : 1,
"--focus-scale-y": rect.height > 0 ? sourceRect.height / rect.height : 1,
} as CSSProperties;
const relativeClosePosition = {
left: closePosition.left - rect.left,
top: closePosition.top - rect.top,
};
return (
<Modal.Root
opened={opened}
onClose={onClose}
radius={radius}
padding={0}
shadow="none"
zIndex="calc(var(--mantine-z-index-modal) - 1)"
closeOnEscape={false}
closeOnClickOutside
lockScroll
trapFocus
returnFocus
removeScrollProps={{ allowPinchZoom: true }}
transitionProps={{ duration: transitionDuration, transition: "fade" }}
>
<Modal.Overlay
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
className={`${classes.backdrop} ${classes.manualBackdrop} ${
phase === "closing" ? classes.backdropClosing : ""
}`}
/>
<Modal.Content
ref={contentRef}
id={id}
data-advanced-focus-surface
className={`${classes.manualSurface} ${phase === "closing" ? classes.surfaceClosing : ""}`}
style={geometryStyle}
>
<Modal.Title>
<VisuallyHidden>{label}</VisuallyHidden>
</Modal.Title>
<Modal.CloseButton
className={classes.closeButton}
variant="default"
size={44}
aria-label={closeLabel}
aria-controls={id}
style={relativeClosePosition}
/>
{children}
</Modal.Content>
</Modal.Root>
);
};
@@ -13,10 +13,8 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null
if (delta === 0) return false;
let current = target instanceof Element ? target : null;
let startedInScrollContainer = false;
while (current && root.contains(current)) {
const isCurrentScrollContainer = isVerticalScrollContainer(current);
startedInScrollContainer ||= isCurrentScrollContainer;
if (isCurrentScrollContainer && canScrollVertically(current, delta)) {
current.scrollTop += delta;
return true;
@@ -24,8 +22,9 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null
if (current === root) break;
current = current.parentElement;
}
if (startedInScrollContainer) return false;
// A nested control can reach its edge before the widget's main Mantine viewport.
// Hand the gesture to that viewport instead of making Shift+wheel feel stuck.
const viewport = [...root.querySelectorAll<HTMLElement>("[data-scrollbars='y'], [data-scrollbars='xy']")].find(
(element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta),
);
@@ -28,7 +28,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);