feat(editor): autofocus Home on initial desktop load (#6275)
This commit is contained in:
parent
f8b55ad16e
commit
60b89736a2
7 changed files with 127 additions and 6 deletions
|
|
@ -9,7 +9,7 @@ interface UseMemoInitOptions {
|
|||
memo?: Memo;
|
||||
cacheKey?: string;
|
||||
username: string;
|
||||
autoFocus?: boolean;
|
||||
autoFocus?: boolean | (() => boolean);
|
||||
defaultVisibility?: Visibility;
|
||||
defaultCreateTime?: Date;
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ export const useMemoInit = ({
|
|||
if (cachedCursor !== undefined) {
|
||||
editorRef.current?.setCursor(cachedCursor);
|
||||
}
|
||||
if (autoFocus) {
|
||||
if (typeof autoFocus === "function" ? autoFocus() : autoFocus) {
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
}, 100);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ export interface MemoEditorProps {
|
|||
parentMemoName?: string;
|
||||
/** Assigns a newly created top-level memo to this Space. Ignored for edits and comments. */
|
||||
defaultSpace?: string;
|
||||
autoFocus?: boolean;
|
||||
/** A callback can decide whether focus is still appropriate after draft restoration. */
|
||||
autoFocus?: boolean | (() => boolean);
|
||||
/**
|
||||
* Marks the instance as *hosted*: a host (the global composer dialog) presents
|
||||
* the editor in the focus-mode layout and owns that frame. The editor mounts
|
||||
|
|
|
|||
|
|
@ -9,13 +9,17 @@ import { useAppSidebar } from "@/contexts/AppSidebarContext";
|
|||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { spaceScopedCacheKey } from "@/lib/resource-names";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface GlobalMemoEditorContextValue {
|
||||
/** Whether the signed-in user is ready to compose; gates every visible entry point. */
|
||||
canOpen: boolean;
|
||||
openEditor: () => void;
|
||||
/** Claims the initial desktop Home focus once, after the editor restores its draft. */
|
||||
claimHomeAutoFocus: () => boolean;
|
||||
}
|
||||
|
||||
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue | null>(null);
|
||||
|
|
@ -42,6 +46,9 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
|
|||
const currentUserName = useCurrentUser()?.name;
|
||||
const { selectedSpaceName } = useSpaceContext();
|
||||
const { isUserSettingsInitialized } = useAuth();
|
||||
const desktop = useMediaQuery("md");
|
||||
const [initialHome] = useState(() => ({ location, user: currentUserName, space: selectedSpaceName }));
|
||||
const homeAutoFocusPending = useRef(desktop && location.pathname === ROUTES.HOME && Boolean(currentUserName));
|
||||
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
|
||||
const routePolicy = getRouteActionPolicy(location.pathname);
|
||||
const composeSpace = routePolicy.composePlacement === "remembered-space" ? selectedSpaceName : undefined;
|
||||
|
|
@ -56,6 +63,26 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
|
|||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const openRequestVersionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
location.key !== initialHome.location.key ||
|
||||
location.pathname !== initialHome.location.pathname ||
|
||||
location.search !== initialHome.location.search ||
|
||||
currentUserName !== initialHome.user ||
|
||||
selectedSpaceName !== initialHome.space ||
|
||||
!desktop
|
||||
) {
|
||||
homeAutoFocusPending.current = false;
|
||||
}
|
||||
}, [location, currentUserName, selectedSpaceName, desktop, initialHome]);
|
||||
|
||||
const claimHomeAutoFocus = useCallback(() => {
|
||||
const pending = homeAutoFocusPending.current;
|
||||
homeAutoFocusPending.current = false;
|
||||
// A user who already focused another control takes precedence over startup focus.
|
||||
return pending && (document.activeElement === document.body || document.activeElement === null);
|
||||
}, []);
|
||||
|
||||
const closeEditor = useCallback(() => {
|
||||
openRequestVersionRef.current += 1;
|
||||
isSavingRef.current = false;
|
||||
|
|
@ -74,6 +101,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
|
|||
|
||||
const openEditor = useCallback(() => {
|
||||
if (!canOpen || !currentUserName) return;
|
||||
homeAutoFocusPending.current = false;
|
||||
|
||||
// Owned here so no caller can leave a sidebar surface open underneath.
|
||||
setMobileOpen(false);
|
||||
|
|
@ -112,7 +140,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
|
|||
}, []);
|
||||
|
||||
const editorIsOpen = opened !== undefined && opened.user === currentUserName;
|
||||
const value = useMemo(() => ({ canOpen, openEditor }), [canOpen, openEditor]);
|
||||
const value = useMemo(() => ({ canOpen, openEditor, claimHomeAutoFocus }), [canOpen, openEditor, claimHomeAutoFocus]);
|
||||
|
||||
return (
|
||||
<GlobalMemoEditorContext.Provider value={value}>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/util
|
|||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||
|
|
@ -18,6 +19,7 @@ const Home = () => {
|
|||
const user = useCurrentUser();
|
||||
const t = useTranslate();
|
||||
const { isUserSettingsInitialized } = useAuth();
|
||||
const { claimHomeAutoFocus } = useGlobalMemoEditor();
|
||||
const { filters } = useMemoFilterContext();
|
||||
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
|
||||
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
|
||||
|
|
@ -53,6 +55,7 @@ const Home = () => {
|
|||
return (
|
||||
<MemoEditor
|
||||
key={editorCacheKey}
|
||||
autoFocus={claimHomeAutoFocus}
|
||||
className={useGrid ? undefined : "mb-2"}
|
||||
cacheKey={editorCacheKey}
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
|||
setQuickFindOpen: vi.fn(),
|
||||
selectedSpaceName: undefined as string | undefined,
|
||||
pathname: "/",
|
||||
desktop: true,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||
|
|
@ -42,6 +43,10 @@ vi.mock("@/hooks/useCurrentUser", () => ({
|
|||
default: () => mocks.currentUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useMediaQuery", () => ({
|
||||
default: () => mocks.desktop,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => key,
|
||||
}));
|
||||
|
|
@ -124,6 +129,57 @@ describe("GlobalMemoEditorProvider", () => {
|
|||
mocks.setQuickFindOpen.mockClear();
|
||||
mocks.selectedSpaceName = undefined;
|
||||
mocks.pathname = "/";
|
||||
mocks.desktop = true;
|
||||
});
|
||||
|
||||
it("allows Home autofocus once after settings finish loading", () => {
|
||||
let claimFocus!: () => boolean;
|
||||
const Probe = () => {
|
||||
claimFocus = useGlobalMemoEditor().claimHomeAutoFocus;
|
||||
return null;
|
||||
};
|
||||
mocks.isUserSettingsInitialized = false;
|
||||
const { rerender } = renderProvider(<Probe />);
|
||||
mocks.isUserSettingsInitialized = true;
|
||||
rerender(
|
||||
<GlobalMemoEditorProvider>
|
||||
<Probe />
|
||||
</GlobalMemoEditorProvider>,
|
||||
);
|
||||
|
||||
expect(claimFocus()).toBe(true);
|
||||
expect(claimFocus()).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["mobile", "other route", "navigation", "Space change", "existing focus"])("does not autofocus Home after %s", (scenario) => {
|
||||
let claimFocus!: () => boolean;
|
||||
const Probe = () => {
|
||||
claimFocus = useGlobalMemoEditor().claimHomeAutoFocus;
|
||||
return <button type="button">Another control</button>;
|
||||
};
|
||||
if (scenario === "mobile") mocks.desktop = false;
|
||||
if (scenario === "other route") mocks.pathname = "/explore";
|
||||
const { rerender } = renderProvider(<Probe />);
|
||||
|
||||
if (scenario === "navigation") mocks.pathname = "/inbox";
|
||||
if (scenario === "Space change") mocks.selectedSpaceName = "spaces/product";
|
||||
if (scenario === "existing focus") screen.getByRole("button", { name: "Another control" }).focus();
|
||||
rerender(
|
||||
<GlobalMemoEditorProvider>
|
||||
<Probe />
|
||||
</GlobalMemoEditorProvider>,
|
||||
);
|
||||
|
||||
// Returning to the original route, Space, or viewport must not rearm focus.
|
||||
mocks.pathname = "/";
|
||||
mocks.selectedSpaceName = undefined;
|
||||
mocks.desktop = true;
|
||||
rerender(
|
||||
<GlobalMemoEditorProvider>
|
||||
<Probe />
|
||||
</GlobalMemoEditorProvider>,
|
||||
);
|
||||
expect(claimFocus()).toBe(false);
|
||||
});
|
||||
|
||||
it("opens a modal focus-mode editor, closes the sidebar surfaces, and restores focus after Escape", async () => {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ vi.mock("@/contexts/AuthContext", () => ({
|
|||
useAuth: () => ({ isUserSettingsInitialized: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/GlobalMemoEditorContext", () => ({
|
||||
useGlobalMemoEditor: () => ({ claimHomeAutoFocus: () => true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/MemoFilterContext", () => ({
|
||||
useMemoFilterContext: () => ({ filters: [] }),
|
||||
}));
|
||||
|
|
@ -80,6 +84,7 @@ describe("<Home>", () => {
|
|||
expect(screen.getByTestId("memo-view")).toBeInTheDocument();
|
||||
expect(state.listProps).toMatchObject({ contextFilter: undefined });
|
||||
expect(state.editorProps).toMatchObject({ cacheKey: "home-memo-editor", defaultSpace: undefined });
|
||||
expect(state.editorProps?.autoFocus).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("filters the feed and sets new memo placement to the selected Space", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import type { RefObject } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useMemoInit } from "@/components/MemoEditor/hooks/useMemoInit";
|
||||
|
|
@ -11,12 +11,13 @@ import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
|
|||
const editorRef = { current: null } as RefObject<EditorController | null>;
|
||||
let getEditorState: ReturnType<typeof useEditorContext>["getState"];
|
||||
|
||||
function Probe() {
|
||||
function Probe({ autoFocus }: { autoFocus?: boolean | (() => boolean) }) {
|
||||
getEditorState = useEditorContext().getState;
|
||||
useMemoInit({
|
||||
editorRef,
|
||||
username: "users/steven",
|
||||
cacheKey: "restored-draft",
|
||||
autoFocus,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
|
@ -33,9 +34,36 @@ describe("useMemoInit", () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
editorRef.current = null;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([true, false])("restores the draft and cursor before evaluating autofocus (%s)", (allowed) => {
|
||||
vi.useFakeTimers();
|
||||
const key = cacheService.key("users/steven", "restored-draft");
|
||||
cacheService.saveNow(key, "An unfinished memo");
|
||||
cacheService.saveCursor(key, 5);
|
||||
const focus = vi.fn();
|
||||
const setCursor = vi.fn();
|
||||
editorRef.current = { focus, setCursor } as unknown as EditorController;
|
||||
const autoFocus = vi.fn(() => {
|
||||
expect(getEditorState().content).toBe("An unfinished memo");
|
||||
expect(setCursor).toHaveBeenCalledWith(5);
|
||||
return allowed;
|
||||
});
|
||||
|
||||
render(
|
||||
<EditorProvider>
|
||||
<Probe autoFocus={autoFocus} />
|
||||
</EditorProvider>,
|
||||
);
|
||||
expect(autoFocus).not.toHaveBeenCalled();
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
expect(autoFocus).toHaveBeenCalledOnce();
|
||||
expect(focus).toHaveBeenCalledTimes(allowed ? 1 : 0);
|
||||
});
|
||||
|
||||
it("restores uploaded attachment bindings with a new memo draft", async () => {
|
||||
const attachment = create(AttachmentSchema, {
|
||||
name: "attachments/image-one",
|
||||
|
|
|
|||
Loading…
Reference in a new issue