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;
|
memo?: Memo;
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
username: string;
|
username: string;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean | (() => boolean);
|
||||||
defaultVisibility?: Visibility;
|
defaultVisibility?: Visibility;
|
||||||
defaultCreateTime?: Date;
|
defaultCreateTime?: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +59,7 @@ export const useMemoInit = ({
|
||||||
if (cachedCursor !== undefined) {
|
if (cachedCursor !== undefined) {
|
||||||
editorRef.current?.setCursor(cachedCursor);
|
editorRef.current?.setCursor(cachedCursor);
|
||||||
}
|
}
|
||||||
if (autoFocus) {
|
if (typeof autoFocus === "function" ? autoFocus() : autoFocus) {
|
||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ export interface MemoEditorProps {
|
||||||
parentMemoName?: string;
|
parentMemoName?: string;
|
||||||
/** Assigns a newly created top-level memo to this Space. Ignored for edits and comments. */
|
/** Assigns a newly created top-level memo to this Space. Ignored for edits and comments. */
|
||||||
defaultSpace?: string;
|
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
|
* 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
|
* 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 { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||||
import { spaceScopedCacheKey } from "@/lib/resource-names";
|
import { spaceScopedCacheKey } from "@/lib/resource-names";
|
||||||
|
import { ROUTES } from "@/router/routes";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
||||||
interface GlobalMemoEditorContextValue {
|
interface GlobalMemoEditorContextValue {
|
||||||
/** Whether the signed-in user is ready to compose; gates every visible entry point. */
|
/** Whether the signed-in user is ready to compose; gates every visible entry point. */
|
||||||
canOpen: boolean;
|
canOpen: boolean;
|
||||||
openEditor: () => void;
|
openEditor: () => void;
|
||||||
|
/** Claims the initial desktop Home focus once, after the editor restores its draft. */
|
||||||
|
claimHomeAutoFocus: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue | null>(null);
|
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue | null>(null);
|
||||||
|
|
@ -42,6 +46,9 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
|
||||||
const currentUserName = useCurrentUser()?.name;
|
const currentUserName = useCurrentUser()?.name;
|
||||||
const { selectedSpaceName } = useSpaceContext();
|
const { selectedSpaceName } = useSpaceContext();
|
||||||
const { isUserSettingsInitialized } = useAuth();
|
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 { setMobileOpen, setQuickFindOpen } = useAppSidebar();
|
||||||
const routePolicy = getRouteActionPolicy(location.pathname);
|
const routePolicy = getRouteActionPolicy(location.pathname);
|
||||||
const composeSpace = routePolicy.composePlacement === "remembered-space" ? selectedSpaceName : undefined;
|
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 returnFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const openRequestVersionRef = useRef(0);
|
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(() => {
|
const closeEditor = useCallback(() => {
|
||||||
openRequestVersionRef.current += 1;
|
openRequestVersionRef.current += 1;
|
||||||
isSavingRef.current = false;
|
isSavingRef.current = false;
|
||||||
|
|
@ -74,6 +101,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
|
||||||
|
|
||||||
const openEditor = useCallback(() => {
|
const openEditor = useCallback(() => {
|
||||||
if (!canOpen || !currentUserName) return;
|
if (!canOpen || !currentUserName) return;
|
||||||
|
homeAutoFocusPending.current = false;
|
||||||
|
|
||||||
// Owned here so no caller can leave a sidebar surface open underneath.
|
// Owned here so no caller can leave a sidebar surface open underneath.
|
||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
|
|
@ -112,7 +140,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const editorIsOpen = opened !== undefined && opened.user === currentUserName;
|
const editorIsOpen = opened !== undefined && opened.user === currentUserName;
|
||||||
const value = useMemo(() => ({ canOpen, openEditor }), [canOpen, openEditor]);
|
const value = useMemo(() => ({ canOpen, openEditor, claimHomeAutoFocus }), [canOpen, openEditor, claimHomeAutoFocus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalMemoEditorContext.Provider value={value}>
|
<GlobalMemoEditorContext.Provider value={value}>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/util
|
||||||
import MemoView from "@/components/MemoView";
|
import MemoView from "@/components/MemoView";
|
||||||
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
|
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
|
||||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||||
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
||||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||||
|
|
@ -18,6 +19,7 @@ const Home = () => {
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const { isUserSettingsInitialized } = useAuth();
|
const { isUserSettingsInitialized } = useAuth();
|
||||||
|
const { claimHomeAutoFocus } = useGlobalMemoEditor();
|
||||||
const { filters } = useMemoFilterContext();
|
const { filters } = useMemoFilterContext();
|
||||||
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
|
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
|
||||||
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
|
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
|
||||||
|
|
@ -53,6 +55,7 @@ const Home = () => {
|
||||||
return (
|
return (
|
||||||
<MemoEditor
|
<MemoEditor
|
||||||
key={editorCacheKey}
|
key={editorCacheKey}
|
||||||
|
autoFocus={claimHomeAutoFocus}
|
||||||
className={useGrid ? undefined : "mb-2"}
|
className={useGrid ? undefined : "mb-2"}
|
||||||
cacheKey={editorCacheKey}
|
cacheKey={editorCacheKey}
|
||||||
placeholder={t("editor.any-thoughts")}
|
placeholder={t("editor.any-thoughts")}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||||
setQuickFindOpen: vi.fn(),
|
setQuickFindOpen: vi.fn(),
|
||||||
selectedSpaceName: undefined as string | undefined,
|
selectedSpaceName: undefined as string | undefined,
|
||||||
pathname: "/",
|
pathname: "/",
|
||||||
|
desktop: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-router-dom", async (importOriginal) => ({
|
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||||
|
|
@ -42,6 +43,10 @@ vi.mock("@/hooks/useCurrentUser", () => ({
|
||||||
default: () => mocks.currentUser,
|
default: () => mocks.currentUser,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useMediaQuery", () => ({
|
||||||
|
default: () => mocks.desktop,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/i18n", () => ({
|
vi.mock("@/utils/i18n", () => ({
|
||||||
useTranslate: () => (key: string) => key,
|
useTranslate: () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
|
|
@ -124,6 +129,57 @@ describe("GlobalMemoEditorProvider", () => {
|
||||||
mocks.setQuickFindOpen.mockClear();
|
mocks.setQuickFindOpen.mockClear();
|
||||||
mocks.selectedSpaceName = undefined;
|
mocks.selectedSpaceName = undefined;
|
||||||
mocks.pathname = "/";
|
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 () => {
|
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 }),
|
useAuth: () => ({ isUserSettingsInitialized: true }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/GlobalMemoEditorContext", () => ({
|
||||||
|
useGlobalMemoEditor: () => ({ claimHomeAutoFocus: () => true }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/contexts/MemoFilterContext", () => ({
|
vi.mock("@/contexts/MemoFilterContext", () => ({
|
||||||
useMemoFilterContext: () => ({ filters: [] }),
|
useMemoFilterContext: () => ({ filters: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
@ -80,6 +84,7 @@ describe("<Home>", () => {
|
||||||
expect(screen.getByTestId("memo-view")).toBeInTheDocument();
|
expect(screen.getByTestId("memo-view")).toBeInTheDocument();
|
||||||
expect(state.listProps).toMatchObject({ contextFilter: undefined });
|
expect(state.listProps).toMatchObject({ contextFilter: undefined });
|
||||||
expect(state.editorProps).toMatchObject({ cacheKey: "home-memo-editor", defaultSpace: 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", () => {
|
it("filters the feed and sets new memo placement to the selected Space", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 type { RefObject } from "react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useMemoInit } from "@/components/MemoEditor/hooks/useMemoInit";
|
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>;
|
const editorRef = { current: null } as RefObject<EditorController | null>;
|
||||||
let getEditorState: ReturnType<typeof useEditorContext>["getState"];
|
let getEditorState: ReturnType<typeof useEditorContext>["getState"];
|
||||||
|
|
||||||
function Probe() {
|
function Probe({ autoFocus }: { autoFocus?: boolean | (() => boolean) }) {
|
||||||
getEditorState = useEditorContext().getState;
|
getEditorState = useEditorContext().getState;
|
||||||
useMemoInit({
|
useMemoInit({
|
||||||
editorRef,
|
editorRef,
|
||||||
username: "users/steven",
|
username: "users/steven",
|
||||||
cacheKey: "restored-draft",
|
cacheKey: "restored-draft",
|
||||||
|
autoFocus,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -33,9 +34,36 @@ describe("useMemoInit", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
editorRef.current = null;
|
||||||
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
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 () => {
|
it("restores uploaded attachment bindings with a new memo draft", async () => {
|
||||||
const attachment = create(AttachmentSchema, {
|
const attachment = create(AttachmentSchema, {
|
||||||
name: "attachments/image-one",
|
name: "attachments/image-one",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue