feat: improve advanced preview activation
This commit is contained in:
1 parent
64a57bac82
commit
c9fe69c5ec
4 files changed
+398
-38
No files matched your search
@@ -6,23 +6,21 @@ tags:
|
||||
|
||||
Homarr has the following keyboard shortcuts:
|
||||
|
||||
| Shortcut | Description |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` while hovering a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell |
|
||||
| Shortcut | Description |
|
||||
| ----------------------------------------------- | ------------------------------------------ |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
|
||||
:::tip
|
||||
|
||||
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
|
||||
|
||||
Advanced view and precise placement are available outside edit mode. Widgets without a distinct advanced experience
|
||||
adapt to their normal dimensions instead. Precise placement still requires permission to change the board and the
|
||||
right-click interaction preference to be enabled.
|
||||
Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal
|
||||
dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -88,6 +88,58 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.holdIndicator {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: var(--mantine-z-index-modal);
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--mantine-color-text);
|
||||
pointer-events: none;
|
||||
transform: translate3d(var(--advanced-focus-pointer-x), var(--advanced-focus-pointer-y), 0) translate(-50%, -50%);
|
||||
place-items: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.holdIndicatorRing {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 1px 3px rgb(0 0 0 / 35%));
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.holdIndicatorTrack,
|
||||
.holdIndicatorProgress {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.holdIndicatorTrack {
|
||||
fill: color-mix(in srgb, var(--mantine-color-body) 82%, transparent);
|
||||
stroke: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
|
||||
}
|
||||
|
||||
.holdIndicatorProgress {
|
||||
fill: none;
|
||||
stroke: var(--mantine-primary-color-filled);
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
animation: advanced-focus-hold-progress 500ms linear forwards;
|
||||
}
|
||||
|
||||
.holdIndicatorLabel {
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
@keyframes focus-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -116,12 +168,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes advanced-focus-hold-progress {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop,
|
||||
.surface,
|
||||
.manualSurface {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.holdIndicatorProgress {
|
||||
stroke-dashoffset: 0;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
@@ -129,4 +192,21 @@
|
||||
background: Canvas;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.holdIndicatorTrack {
|
||||
fill: Canvas;
|
||||
}
|
||||
|
||||
.holdIndicatorTrack {
|
||||
stroke: GrayText;
|
||||
}
|
||||
|
||||
.holdIndicatorProgress {
|
||||
stroke: Highlight;
|
||||
}
|
||||
|
||||
.holdIndicatorLabel {
|
||||
color: CanvasText;
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { BoardAdvancedFocusProvider, useAdvancedFocus } from "./context";
|
||||
|
||||
const sourceRect = {
|
||||
left: 20,
|
||||
top: 30,
|
||||
right: 220,
|
||||
bottom: 150,
|
||||
width: 200,
|
||||
height: 120,
|
||||
x: 20,
|
||||
y: 30,
|
||||
toJSON: () => undefined,
|
||||
};
|
||||
|
||||
describe("BoardAdvancedFocusProvider preview interaction", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let focus: ReturnType<typeof useAdvancedFocus>;
|
||||
|
||||
const Harness = () => {
|
||||
focus = useAdvancedFocus();
|
||||
return createElement(
|
||||
"div",
|
||||
null,
|
||||
createElement("div", { "data-testid": "source" }),
|
||||
createElement("div", { "data-advanced-focus-surface": true, "data-testid": "surface" }),
|
||||
createElement(
|
||||
"div",
|
||||
{ "data-portal": true },
|
||||
createElement("button", { role: "menuitem", "data-testid": "portalled-menu-item" }),
|
||||
),
|
||||
createElement("div", { "data-testid": "outside" }),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
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(),
|
||||
})),
|
||||
);
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => []),
|
||||
});
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(
|
||||
EditModeProvider,
|
||||
null,
|
||||
createElement(BoardAdvancedFocusProvider, null, createElement(Harness)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const source = host.querySelector<HTMLElement>("[data-testid='source']");
|
||||
if (!source) throw new Error("Expected source fixture");
|
||||
vi.spyOn(source, "getBoundingClientRect").mockReturnValue(sourceRect);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const startPreview = async () => {
|
||||
const source = host.querySelector<HTMLElement>("[data-testid='source']");
|
||||
if (!source) throw new Error("Expected source fixture");
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 48, clientY: 52 }));
|
||||
focus.hover("widget-1", source);
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" }));
|
||||
});
|
||||
};
|
||||
|
||||
test("shows pointer-following hold feedback for the full 500 ms delay", async () => {
|
||||
await startPreview();
|
||||
|
||||
const indicator = document.querySelector<HTMLElement>("[data-advanced-focus-hold-indicator]");
|
||||
expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-x")).toBe("48px");
|
||||
expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-y")).toBe("52px");
|
||||
expect(focus.active).toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(499));
|
||||
expect(focus.active).toBeNull();
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).not.toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(1));
|
||||
expect(focus.active?.itemId).toBe("widget-1");
|
||||
expect(focus.active?.activation).toBe("preview");
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
|
||||
});
|
||||
|
||||
test("does not show hold feedback when Shift is pressed without a hovered source", async () => {
|
||||
await act(async () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" })));
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(500));
|
||||
expect(focus.active).toBeNull();
|
||||
});
|
||||
|
||||
test("cancels the hold feedback and activation when Shift is released early", async () => {
|
||||
await startPreview();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(250));
|
||||
await act(async () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" })));
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(250));
|
||||
expect(focus.active).toBeNull();
|
||||
});
|
||||
|
||||
test("cancels the hold feedback and activation when the source is left early", async () => {
|
||||
await startPreview();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(250));
|
||||
await act(async () => focus.leave("widget-1"));
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(250));
|
||||
expect(focus.active).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the preview open across the source-to-portal gap and closes after leaving both", async () => {
|
||||
await startPreview();
|
||||
await act(async () => vi.advanceTimersByTime(500));
|
||||
|
||||
await act(async () => focus.leave("widget-1"));
|
||||
await act(async () => vi.advanceTimersByTime(40));
|
||||
const surface = host.querySelector<HTMLElement>("[data-testid='surface']");
|
||||
if (!surface) throw new Error("Expected surface fixture");
|
||||
await act(async () =>
|
||||
surface.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 300, clientY: 200 })),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTime(80));
|
||||
expect(focus.active?.phase).toBe("visible");
|
||||
|
||||
const outside = host.querySelector<HTMLElement>("[data-testid='outside']");
|
||||
if (!outside) throw new Error("Expected outside fixture");
|
||||
await act(async () =>
|
||||
outside.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 500, clientY: 500 })),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTime(79));
|
||||
expect(focus.active?.phase).toBe("visible");
|
||||
await act(async () => vi.advanceTimersByTime(1));
|
||||
expect(focus.active?.phase).toBe("closing");
|
||||
await act(async () => vi.advanceTimersByTime(180));
|
||||
expect(focus.active).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the preview open while a widget-owned portalled popup is hovered", async () => {
|
||||
await startPreview();
|
||||
await act(async () => vi.advanceTimersByTime(500));
|
||||
|
||||
await act(async () => focus.leave("widget-1"));
|
||||
await act(async () => vi.advanceTimersByTime(40));
|
||||
const menuItem = host.querySelector<HTMLElement>("[data-testid='portalled-menu-item']");
|
||||
if (!menuItem) throw new Error("Expected portalled menu fixture");
|
||||
await act(async () =>
|
||||
menuItem.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 320, clientY: 210 })),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTime(80));
|
||||
|
||||
expect(focus.active?.phase).toBe("visible");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { CSSProperties, PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Overlay, Portal } from "@mantine/core";
|
||||
import { useIsomorphicEffect } from "@mantine/hooks";
|
||||
@@ -36,10 +36,9 @@ interface AdvancedFocusContextValue {
|
||||
}
|
||||
|
||||
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
|
||||
const HOVER_DELAY_MS = 150;
|
||||
const HOVER_DELAY_MS = 500;
|
||||
const POINTER_TRANSITION_GRACE_MS = 80;
|
||||
const CLOSE_DURATION_MS = 180;
|
||||
const interactiveSelector =
|
||||
"a[href], button, input, textarea, select, [contenteditable='true'], [role='button'], [role='link'], [role='menuitem'], [role='option']";
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null) =>
|
||||
target instanceof HTMLElement &&
|
||||
@@ -48,16 +47,28 @@ const isEditableTarget = (target: EventTarget | null) =>
|
||||
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [active, setActive] = useState<ActiveFocus | null>(null);
|
||||
const [isHoldPending, setIsHoldPending] = useState(false);
|
||||
const activeRef = useRef<ActiveFocus | null>(null);
|
||||
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
|
||||
const shiftHeldRef = useRef(false);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const previewLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const holdIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const pointerPositionRef = useRef({ x: -100, y: -100 });
|
||||
const pointerFrameRef = useRef<number | null>(null);
|
||||
|
||||
const cancelHoverTimer = useCallback(() => {
|
||||
if (hoverTimerRef.current === null) return;
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
setIsHoldPending(false);
|
||||
}, []);
|
||||
|
||||
const cancelPreviewLeaveTimer = useCallback(() => {
|
||||
if (previewLeaveTimerRef.current === null) return;
|
||||
clearTimeout(previewLeaveTimerRef.current);
|
||||
previewLeaveTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const updateActive = useCallback((next: ActiveFocus | null) => {
|
||||
@@ -75,6 +86,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
(itemId: string, source: HTMLElement, options: OpenAdvancedFocusOptions = {}) => {
|
||||
if (isEditMode) return;
|
||||
cancelHoverTimer();
|
||||
cancelPreviewLeaveTimer();
|
||||
cancelCloseTimer();
|
||||
const rect = source.getBoundingClientRect();
|
||||
updateActive({
|
||||
@@ -87,12 +99,13 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
phase: "visible",
|
||||
});
|
||||
},
|
||||
[cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive],
|
||||
[cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, isEditMode, updateActive],
|
||||
);
|
||||
|
||||
const close = useCallback(
|
||||
(restoreFocus = true) => {
|
||||
cancelHoverTimer();
|
||||
cancelPreviewLeaveTimer();
|
||||
const current = activeRef.current;
|
||||
if (!current || current.phase === "closing") return;
|
||||
updateActive({ ...current, phase: "closing" });
|
||||
@@ -103,22 +116,43 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
|
||||
}, closeDuration);
|
||||
},
|
||||
[cancelHoverTimer, updateActive],
|
||||
[cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
|
||||
);
|
||||
|
||||
const schedulePreviewClose = useCallback(
|
||||
(itemId: string) => {
|
||||
if (previewLeaveTimerRef.current !== null) return;
|
||||
previewLeaveTimerRef.current = setTimeout(() => {
|
||||
previewLeaveTimerRef.current = null;
|
||||
const current = activeRef.current;
|
||||
if (current?.activation !== "preview" || current.itemId !== itemId) return;
|
||||
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
|
||||
close(current.restorePreviewFocus);
|
||||
}, POINTER_TRANSITION_GRACE_MS);
|
||||
},
|
||||
[close],
|
||||
);
|
||||
|
||||
const startHoverTimer = useCallback(() => {
|
||||
cancelHoverTimer();
|
||||
const hovered = hoveredRef.current;
|
||||
if (!hovered || !shiftHeldRef.current || activeRef.current) return;
|
||||
hoverTimerRef.current = setTimeout(
|
||||
() => open(hovered.itemId, hovered.source, { activation: "preview" }),
|
||||
HOVER_DELAY_MS,
|
||||
);
|
||||
if (pointerPositionRef.current.x < 0 || pointerPositionRef.current.y < 0) {
|
||||
const rect = hovered.source.getBoundingClientRect();
|
||||
pointerPositionRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}
|
||||
setIsHoldPending(true);
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
hoverTimerRef.current = null;
|
||||
setIsHoldPending(false);
|
||||
open(hovered.itemId, hovered.source, { activation: "preview" });
|
||||
}, HOVER_DELAY_MS);
|
||||
}, [cancelHoverTimer, open]);
|
||||
|
||||
const hover = useCallback(
|
||||
(itemId: string, source: HTMLElement) => {
|
||||
hoveredRef.current = { itemId, source };
|
||||
cancelPreviewLeaveTimer();
|
||||
const current = activeRef.current;
|
||||
if (current?.activation === "preview" && current.itemId !== itemId) {
|
||||
cancelCloseTimer();
|
||||
@@ -127,31 +161,54 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
startHoverTimer();
|
||||
},
|
||||
[cancelCloseTimer, startHoverTimer, updateActive],
|
||||
[cancelCloseTimer, cancelPreviewLeaveTimer, startHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
pointerPositionRef.current = { x: event.clientX, y: event.clientY };
|
||||
if (holdIndicatorRef.current && pointerFrameRef.current === null) {
|
||||
pointerFrameRef.current = requestAnimationFrame(() => {
|
||||
pointerFrameRef.current = null;
|
||||
const indicator = holdIndicatorRef.current;
|
||||
if (!indicator) return;
|
||||
indicator.style.setProperty("--advanced-focus-pointer-x", `${pointerPositionRef.current.x}px`);
|
||||
indicator.style.setProperty("--advanced-focus-pointer-y", `${pointerPositionRef.current.y}px`);
|
||||
});
|
||||
}
|
||||
|
||||
const current = activeRef.current;
|
||||
if (!shiftHeldRef.current || current?.activation !== "preview") return;
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest(interactiveSelector)?.closest("[data-advanced-focus-surface]")
|
||||
)
|
||||
if (event.target instanceof Element && event.target.closest("[data-advanced-focus-surface], [data-portal]")) {
|
||||
cancelPreviewLeaveTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const isInsideSourceRect =
|
||||
event.clientX >= current.sourceRect.left &&
|
||||
event.clientX <= current.sourceRect.left + current.sourceRect.width &&
|
||||
event.clientY >= current.sourceRect.top &&
|
||||
event.clientY <= current.sourceRect.top + current.sourceRect.height;
|
||||
if (isInsideSourceRect) {
|
||||
cancelPreviewLeaveTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const underlyingSource = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) =>
|
||||
element instanceof HTMLElement
|
||||
? element.matches(".grid-stack-item-content")
|
||||
? element.matches("[data-advanced-focus-source]")
|
||||
? element
|
||||
: element.closest<HTMLElement>(".grid-stack-item-content")
|
||||
: element.closest<HTMLElement>("[data-advanced-focus-source]")
|
||||
: null,
|
||||
)
|
||||
.find((source) => source !== null && source !== current.source);
|
||||
const itemId = underlyingSource?.closest<HTMLElement>(".grid-stack-item[data-id]")?.dataset.id;
|
||||
if (!underlyingSource || !itemId) return;
|
||||
const itemId = underlyingSource?.dataset.itemId;
|
||||
if (!underlyingSource || !itemId) {
|
||||
schedulePreviewClose(current.itemId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
|
||||
hover(itemId, underlyingSource);
|
||||
@@ -160,31 +217,38 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
hoveredRef.current = null;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
schedulePreviewClose(current.itemId);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
}, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]);
|
||||
window.addEventListener("pointerover", handlePointerMove, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
window.removeEventListener("pointerover", handlePointerMove, { capture: true });
|
||||
if (pointerFrameRef.current !== null) cancelAnimationFrame(pointerFrameRef.current);
|
||||
};
|
||||
}, [cancelHoverTimer, cancelPreviewLeaveTimer, hover, schedulePreviewClose]);
|
||||
|
||||
const leave = useCallback(
|
||||
(itemId: string) => {
|
||||
if (hoveredRef.current?.itemId !== itemId) return;
|
||||
hoveredRef.current = null;
|
||||
cancelHoverTimer();
|
||||
if (activeRef.current?.activation === "preview" && activeRef.current.itemId === itemId)
|
||||
schedulePreviewClose(itemId);
|
||||
},
|
||||
[cancelHoverTimer],
|
||||
[cancelHoverTimer, schedulePreviewClose],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(itemId: string) => {
|
||||
if (activeRef.current?.itemId !== itemId) return;
|
||||
cancelHoverTimer();
|
||||
cancelPreviewLeaveTimer();
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
},
|
||||
[cancelCloseTimer, cancelHoverTimer, updateActive],
|
||||
[cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -223,9 +287,10 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
cancelHoverTimer();
|
||||
cancelPreviewLeaveTimer();
|
||||
cancelCloseTimer();
|
||||
};
|
||||
}, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]);
|
||||
}, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, close, isEditMode, startHoverTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && activeRef.current) close(false);
|
||||
@@ -252,6 +317,28 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<AdvancedFocusContext.Provider value={value}>
|
||||
{children}
|
||||
{isHoldPending && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={holdIndicatorRef}
|
||||
data-advanced-focus-hold-indicator
|
||||
className={classes.holdIndicator}
|
||||
style={
|
||||
{
|
||||
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
|
||||
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
|
||||
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
|
||||
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
|
||||
</svg>
|
||||
<span className={classes.holdIndicatorLabel}>⇧</span>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
{active?.activation === "preview" && (
|
||||
<Portal>
|
||||
<Overlay
|
||||
|
||||
Reference in new issue
Block a user