feat: modernize widget experience
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user