From 1fbf545f0256d6540cc29a24c7fe37c64ad3d93a Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 16 Mar 2026 12:55:08 +0100 Subject: [PATCH 1/6] wip Signed-off-by: Simo --- .../components/ChatItemHrefButtons.test.tsx | 216 ++++++- ...CommonDropdownItemsDefaultWrapper.test.tsx | 147 ++++- .../components/waves/drops/WaveDrop.test.tsx | 53 +- .../waves/drops/WaveDropActions.test.tsx | 155 +++-- .../drops/view/part/DropPartMarkdown.tsx | 5 + .../part/DropPartMarkdownWithPropLogger.tsx | 1 + .../part/dropPartMarkdown/youtubePreview.tsx | 4 +- .../CommonDropdownItemsDefaultWrapper.tsx | 32 +- components/waves/ChatItemHrefButtons.tsx | 533 +++++++++++++----- components/waves/LinkHandlerFrame.tsx | 7 +- components/waves/LinkPreviewContext.tsx | 10 +- components/waves/OpenGraphPreview.tsx | 10 +- components/waves/drops/WaveDrop.tsx | 22 + components/waves/drops/WaveDropActions.tsx | 16 +- components/waves/drops/WaveDropContent.tsx | 5 + components/waves/drops/WaveDropPart.tsx | 5 + .../waves/drops/WaveDropPartContent.tsx | 5 + .../drops/WaveDropPartContentMarkdown.tsx | 5 + components/waves/drops/WaveDropPartDrop.tsx | 5 + 19 files changed, 1010 insertions(+), 226 deletions(-) diff --git a/__tests__/components/ChatItemHrefButtons.test.tsx b/__tests__/components/ChatItemHrefButtons.test.tsx index bba32bad02..7be58b6d18 100644 --- a/__tests__/components/ChatItemHrefButtons.test.tsx +++ b/__tests__/components/ChatItemHrefButtons.test.tsx @@ -1,13 +1,27 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import ChatItemHrefButtons from "@/components/waves/ChatItemHrefButtons"; import { LinkPreviewProvider } from "@/components/waves/LinkPreviewContext"; +import useHasTouchInput from "@/hooks/useHasTouchInput"; +import useIsMobileDevice from "@/hooks/isMobileDevice"; + +jest.mock("@/hooks/useHasTouchInput"); +jest.mock("@/hooks/isMobileDevice"); const writeText = jest.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText }, }); +const useHasTouchInputMock = useHasTouchInput as jest.Mock; +const useIsMobileDeviceMock = useIsMobileDevice as jest.Mock; + describe("ChatItemHrefButtons", () => { + beforeEach(() => { + useHasTouchInputMock.mockReturnValue(false); + useIsMobileDeviceMock.mockReturnValue(false); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -52,4 +66,204 @@ describe("ChatItemHrefButtons", () => { fireEvent.click(screen.getByRole("button", { name: "Hide link previews" })); expect(onToggle).toHaveBeenCalledTimes(1); }); + + it("opens the overlay menu and keeps copy action available", () => { + render( +
+ +
+ ); + + fireEvent.click(screen.getByRole("button", { name: "Link actions" })); + fireEvent.click(screen.getByRole("button", { name: "Copy link" })); + + expect(writeText).toHaveBeenCalledWith("https://a"); + }); + + it("dismisses the overlay menu without activating the underlying card", () => { + const onCardClick = jest.fn(); + + render( +
+ + +
+ ); + + fireEvent.click(screen.getByRole("button", { name: "Link actions" })); + fireEvent.click( + screen.getByRole("button", { name: "Dismiss link actions" }) + ); + + expect( + screen.queryByRole("button", { name: "Copy link" }) + ).not.toBeInTheDocument(); + expect(onCardClick).not.toHaveBeenCalled(); + }); + + it("reports overlay action activity when the menu opens and closes", () => { + const onCardActionsActiveChange = jest.fn(); + + render( +
+ + + +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + + fireEvent.click(trigger); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 1, + actionSurfaceId, + true + ); + + fireEvent.click(trigger); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("reports overlay action activity when focus enters and leaves the trigger", () => { + const onCardActionsActiveChange = jest.fn(); + + render( +
+
+ + + +
+ +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + const outsideButton = screen.getByRole("button", { name: "Outside" }); + + fireEvent.focus(trigger); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 1, + actionSurfaceId, + true + ); + + fireEvent.blur(trigger, { relatedTarget: outsideButton }); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("reports overlay actions as inactive when the component unmounts while active", () => { + const onCardActionsActiveChange = jest.fn(); + + const { unmount } = render( +
+ + + +
+ ); + + fireEvent.focus(screen.getByRole("button", { name: "Link actions" })); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + + unmount(); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("moves focus to the first overlay action when opened from keyboard", async () => { + const user = userEvent.setup(); + + render( +
+ + + +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + await user.keyboard("{Enter}"); + + expect( + screen.getByRole("button", { name: "Hide link previews" }) + ).toHaveFocus(); + }); + + it("skips disabled overlay actions when moving keyboard focus into the menu", async () => { + const user = userEvent.setup(); + + render( +
+ + + +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + await user.keyboard("{Enter}"); + + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + }); + + it("keeps the overlay trigger visible on touch devices", () => { + useHasTouchInputMock.mockReturnValue(true); + + render( +
+ +
+ ); + + expect(screen.getByRole("button", { name: "Link actions" })).toBeVisible(); + }); }); diff --git a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx index 2c4d57c9b3..c38c32d172 100644 --- a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx +++ b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx @@ -1,50 +1,163 @@ -import { render, fireEvent, waitFor } from '@testing-library/react'; -import React from 'react'; -import CommonDropdownItemsDefaultWrapper from '@/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import CommonDropdownItemsDefaultWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper"; -jest.mock('framer-motion', () => ({ +jest.mock("framer-motion", () => ({ AnimatePresence: ({ children }: any) =>
{children}
, motion: { div: (props: any) =>
}, })); -jest.mock('react-use', () => ({ +jest.mock("react-use", () => ({ useClickAway: (ref: React.RefObject, handler: () => void) => { React.useEffect(() => { const listener = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) handler(); }; - document.addEventListener('mousedown', listener); - return () => document.removeEventListener('mousedown', listener); + document.addEventListener("mousedown", listener); + return () => document.removeEventListener("mousedown", listener); }, [ref, handler]); }, useKeyPressEvent: (key: string, cb: () => void) => { React.useEffect(() => { const handler = (e: KeyboardEvent) => e.key === key && cb(); - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); }, [key, cb]); }, })); -test('closes on escape press', () => { +const createRect = ({ + top, + bottom, + left, + right, +}: { + readonly top: number; + readonly bottom: number; + readonly left: number; + readonly right: number; +}) => ({ + top, + bottom, + left, + right, + width: right - left, + height: bottom - top, + x: left, + y: top, + toJSON: () => ({}), +}); + +const originalInnerWidth = window.innerWidth; +const originalInnerHeight = window.innerHeight; + +afterEach(() => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: originalInnerWidth, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: originalInnerHeight, + }); + jest.restoreAllMocks(); +}); + +test("closes on escape press", () => { const setOpen = jest.fn(); render( - +
  • item
  • ); - fireEvent.keyDown(window, { key: 'Escape' }); + fireEvent.keyDown(window, { key: "Escape" }); expect(setOpen).toHaveBeenCalledWith(false); }); -test('positions dropdown when buttonPosition provided', async () => { - jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(40); - const { container } = render( - {}} buttonRef={{ current: null }} buttonPosition={{ right:100 }}> +test("positions the dropdown below the trigger when there is space", async () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1280, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 720, + }); + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(160); + jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(120); + + const button = document.createElement("button"); + Object.defineProperty(button, "getBoundingClientRect", { + configurable: true, + value: () => + createRect({ + top: 200, + bottom: 232, + left: 100, + right: 132, + }), + }); + + render( + {}} + buttonRef={{ current: button }} + > +
  • item
  • +
    + ); + + await waitFor(() => { + expect(screen.getByRole("menu").parentElement).toHaveStyle({ + left: "100px", + top: "240px", + }); + }); +}); + +test("positions the dropdown above the trigger near the viewport bottom", async () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1280, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 500, + }); + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(160); + jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(120); + + const button = document.createElement("button"); + Object.defineProperty(button, "getBoundingClientRect", { + configurable: true, + value: () => + createRect({ + top: 440, + bottom: 472, + left: 100, + right: 132, + }), + }); + + render( + {}} + buttonRef={{ current: button }} + >
  • item
  • ); + await waitFor(() => { - expect((container.firstChild as HTMLElement).style.left).toBe('60px'); + expect(screen.getByRole("menu").parentElement).toHaveStyle({ + left: "100px", + top: "312px", + }); }); }); diff --git a/__tests__/components/waves/drops/WaveDrop.test.tsx b/__tests__/components/waves/drops/WaveDrop.test.tsx index 92577a62be..025839bc89 100644 --- a/__tests__/components/waves/drops/WaveDrop.test.tsx +++ b/__tests__/components/waves/drops/WaveDrop.test.tsx @@ -1,19 +1,27 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; import { configureStore } from "@reduxjs/toolkit"; import WaveDrop from "@/components/waves/drops/WaveDrop"; import useIsMobileDevice from "@/hooks/isMobileDevice"; import { editSlice } from "@/store/editSlice"; -jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => ( -
    -)); +const mockWaveDropActions = jest.fn(); +jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => { + mockWaveDropActions(props); + return
    ; +}); jest.mock("@/components/waves/drops/WaveDropReply", () => () => (
    )); -jest.mock("@/components/waves/drops/WaveDropContent", () => () => ( -
    +jest.mock("@/components/waves/drops/WaveDropContent", () => (props: any) => ( + + ), +})); + +jest.mock("@/components/waves/drops/WaveDropActionsQuickReact", () => ({ + __esModule: true, + default: () =>
    , +})); + +jest.mock("@/components/waves/drops/WaveDropActionsRate", () => ({ + __esModule: true, + default: () =>
    , +})); + +jest.mock("@/components/waves/drops/WaveDropActionsReply", () => ({ + __esModule: true, + default: () =>
    , +})); + +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettings: jest.fn(), })); -const rulesMock = useDropInteractionRules as jest.Mock; const settingsMock = useSeizeSettings as jest.Mock; -const auth = { connectedProfile: { handle: 'alice' } } as any; -const wrapper = ({ children }: any) => {children}; +const baseDrop: any = { + id: "drop-1", + wave: { id: "wave-1" }, + drop_type: ApiDropType.Chat, +}; -const baseDrop: any = { id: 'drop1', author: { handle: 'bob' }, wave: { id: 'w1' }, drop_type: ApiDropType.Chat }; +describe("WaveDropActions", () => { + beforeEach(() => { + settingsMock.mockReturnValue({ isMemesWave: () => false }); + }); -beforeEach(() => { - rulesMock.mockReturnValue({ canDelete: true }); - settingsMock.mockReturnValue({ isMemesWave: () => false }); -}); + it("keeps hidden actions non-interactive while closed", () => { + const { container } = render( + {}} /> + ); -describe('WaveDropActions', () => { - it('renders follow button and voting and options', () => { - render( {}} onQuote={() => {}} />, { wrapper }); - expect(screen.getByTestId('follow')).toBeInTheDocument(); - expect(screen.getByTestId('rate')).toBeInTheDocument(); - expect(screen.getByTestId('options')).toBeInTheDocument(); + expect(container.firstElementChild).toHaveClass( + "tw-pointer-events-none", + "tw-opacity-0" + ); }); - it('hides follow when author is current profile', () => { + it("keeps actions interactive when the more dropdown is open", () => { + const { container } = render( + {}} + suppressed={true} + /> + ); + + fireEvent.click(screen.getByRole("button", { name: "Open more actions" })); + + expect(container.firstElementChild).toHaveClass( + "tw-pointer-events-auto", + "tw-opacity-100" + ); + }); + + it("renders the voting control for regular drops", () => { render( - - {}} onQuote={() => {}} /> - + {}} /> ); - expect(screen.queryByTestId('follow')).toBeNull(); + + expect(screen.getByTestId("rate")).toBeInTheDocument(); }); - it('hides voting when participation drop in memes wave', () => { + it("hides voting for participation drops in memes waves", () => { settingsMock.mockReturnValue({ isMemesWave: () => true }); - const drop = { ...baseDrop, drop_type: ApiDropType.Participatory }; - render( {}} onQuote={() => {}} />, { wrapper }); - expect(screen.queryByTestId('rate')).toBeNull(); - }); - it('does not render options when cannot delete', () => { - rulesMock.mockReturnValue({ canDelete: false }); - render( {}} onQuote={() => {}} />, { wrapper }); - expect(screen.queryByTestId('options')).toBeNull(); + render( + {}} + /> + ); + + expect(screen.queryByTestId("rate")).toBeNull(); }); }); diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index c664425621..3e1730ebe7 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -259,6 +259,9 @@ export interface DropPartMarkdownProps { readonly embedDepth?: number | undefined; readonly maxEmbedDepth?: number | undefined; readonly linkPreviewToggleControl?: LinkPreviewToggleControl | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } function DropPartMarkdown({ @@ -276,6 +279,7 @@ function DropPartMarkdown({ embedDepth = 0, maxEmbedDepth = DEFAULT_MAX_EMBED_DEPTH, linkPreviewToggleControl, + onLinkCardActionsActiveChange, }: DropPartMarkdownProps) { const queryClient = useQueryClient(); const isMobile = useIsMobileScreen(); @@ -430,6 +434,7 @@ function DropPartMarkdown({ { }; return ( -
    +
    {card()}
    - {!hideActions && } + {!hideActions && }
    ); }; diff --git a/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx b/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx index 5594083173..313d90f92e 100644 --- a/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx +++ b/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx @@ -12,6 +12,9 @@ import { } from "react"; import { useClickAway, useKeyPressEvent } from "react-use"; +const VIEWPORT_PADDING = 16; +const MENU_GAP = 8; + export default function CommonDropdownItemsDefaultWrapper({ isOpen, setOpen, @@ -50,24 +53,39 @@ export default function CommonDropdownItemsDefaultWrapper({ // Get width from the list element if possible, otherwise the wrapper const width = listRef.current?.offsetWidth ?? dropdownEl.offsetWidth ?? 288; + const height = listRef.current?.offsetHeight ?? dropdownEl.offsetHeight; const scrollX = window.scrollX; const scrollY = window.scrollY; - - // Vertical position: below the button - const top = buttonRect.bottom + scrollY; + const viewportTop = scrollY + VIEWPORT_PADDING; + const viewportLeft = scrollX + VIEWPORT_PADDING; + const viewportRight = scrollX + window.innerWidth - VIEWPORT_PADDING; + const viewportBottom = scrollY + window.innerHeight - VIEWPORT_PADDING; + const availableAbove = buttonRect.top - VIEWPORT_PADDING; + const availableBelow = + window.innerHeight - buttonRect.bottom - VIEWPORT_PADDING; + const shouldOpenAbove = + height > 0 && + availableBelow < height + MENU_GAP && + availableAbove > availableBelow; + + const unclampedTop = shouldOpenAbove + ? buttonRect.top + scrollY - height - MENU_GAP + : buttonRect.bottom + scrollY + MENU_GAP; + const maxTop = Math.max(viewportTop, viewportBottom - height); + const top = Math.min(Math.max(unclampedTop, viewportTop), maxTop); // Horizontal position: default to left align let left = buttonRect.left + scrollX; // Check if it overflows right edge of viewport - if (buttonRect.left + width > window.innerWidth - 16) { + if (buttonRect.left + width > window.innerWidth - VIEWPORT_PADDING) { // Switch to right align (align right edge of dropdown with right edge of button) left = buttonRect.right + scrollX - width; } - // Ensure it doesn't overflow left edge - left = Math.max(16, left); + const maxLeft = Math.max(viewportLeft, viewportRight - width); + left = Math.min(Math.max(left, viewportLeft), maxLeft); dropdownEl.style.top = `${top}px`; dropdownEl.style.left = `${left}px`; @@ -107,7 +125,7 @@ export default function CommonDropdownItemsDefaultWrapper({ ref={listRef} role="menu" tabIndex={-1} - className="tw-mt-2 tw-w-56 tw-rounded-lg tw-bg-iron-900 tw-py-1 tw-shadow-lg tw-ring-1 tw-ring-white/10 focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-white/20" + className="tw-w-56 tw-rounded-lg tw-bg-iron-900 tw-py-1 tw-shadow-lg tw-ring-1 tw-ring-white/10 focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-white/20" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} diff --git a/components/waves/ChatItemHrefButtons.tsx b/components/waves/ChatItemHrefButtons.tsx index 3ca29fd110..21cf931b92 100644 --- a/components/waves/ChatItemHrefButtons.tsx +++ b/components/waves/ChatItemHrefButtons.tsx @@ -2,152 +2,368 @@ import Link from "next/link"; import { + ArrowTopRightOnSquareIcon, + DocumentDuplicateIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/24/outline"; +import { + useEffect, + useId, + useRef, useState, + type FocusEvent, + type KeyboardEvent, type MouseEvent, type PointerEvent, type TouchEvent, } from "react"; + +import CommonDropdownItemsDefaultWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper"; +import useHasTouchInput from "@/hooks/useHasTouchInput"; +import useIsMobileDevice from "@/hooks/isMobileDevice"; + import { useLinkPreviewContext } from "./LinkPreviewContext"; type StopEvent = | MouseEvent + | KeyboardEvent | PointerEvent | TouchEvent; +type ChatItemHrefButtonsLayout = "overlay" | "rail"; + const stopPropagation = (event: StopEvent) => { event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation?.(); + event.nativeEvent.stopImmediatePropagation(); }; +const OVERLAY_TRIGGER_BUTTON_CLASSES = + "tw-flex tw-h-8 tw-w-8 tw-items-center tw-justify-center tw-rounded-full tw-border-0 tw-bg-black/70 tw-text-iron-100 tw-shadow-lg tw-shadow-black/40 tw-backdrop-blur-sm tw-transition tw-duration-200 hover:tw-bg-black/85 hover:tw-text-white focus-visible:tw-outline focus-visible:tw-outline-2 focus-visible:tw-outline-offset-2 focus-visible:tw-outline-primary-400"; + +const MENU_ITEM_CLASSES = + "tw-flex tw-w-full tw-items-center tw-gap-x-2 tw-rounded-md tw-border-0 tw-bg-transparent tw-px-3 tw-py-2 tw-text-left tw-text-sm tw-font-medium tw-text-iron-100 tw-transition-colors hover:tw-bg-iron-800 focus-visible:tw-outline-none focus-visible:tw-bg-iron-800"; + +const RAIL_BUTTON_CLASSES = + "tw-flex tw-items-center tw-gap-x-2 tw-rounded-xl tw-border-0 tw-bg-iron-900 tw-p-2 hover:tw-text-iron-400"; + +function PreviewToggleIcon({ isLoading }: { readonly isLoading: boolean }) { + if (isLoading) { + return ( + + ); + } + + return ( + + ); +} + +function ExternalLinkIcon() { + return ( + + ); +} + export default function ChatItemHrefButtons({ href, relativeHref, hideLink = false, + layout = "rail", }: { href: string; relativeHref?: string | undefined; hideLink?: boolean | undefined; + layout?: ChatItemHrefButtonsLayout | undefined; }) { - const { previewToggle } = useLinkPreviewContext(); + const { previewToggle, onCardActionsActiveChange } = useLinkPreviewContext(); + const hasTouchInput = useHasTouchInput(); + const isMobileDevice = useIsMobileDevice(); const [copied, setCopied] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const actionSurfaceId = useId(); + const buttonRef = useRef(null); + const previewToggleButtonRef = useRef(null); + const copyButtonRef = useRef(null); + const openLinkButtonRef = useRef(null); + const lastReportedActiveRef = useRef(false); + const shouldFocusFirstMenuItemRef = useRef(false); + const actionSurfaceStateRef = useRef({ + isMenuOpen: false, + isHovered: false, + isFocused: false, + }); const showPreviewToggle = Boolean(previewToggle && !previewToggle.isHidden); + const isOverlay = layout === "overlay"; + const showPersistentOverlayTrigger = + isOverlay && (hasTouchInput || isMobileDevice); + + useEffect(() => { + return () => { + if (lastReportedActiveRef.current && onCardActionsActiveChange) { + onCardActionsActiveChange(actionSurfaceId, false); + lastReportedActiveRef.current = false; + } + }; + }, [actionSurfaceId, onCardActionsActiveChange]); + + const updateActionSurfaceState = ( + nextStatePatch: Partial + ) => { + const currentState = actionSurfaceStateRef.current; + const nextState = { ...currentState, ...nextStatePatch }; + + if ( + nextState.isMenuOpen === currentState.isMenuOpen && + nextState.isHovered === currentState.isHovered && + nextState.isFocused === currentState.isFocused + ) { + return; + } + + actionSurfaceStateRef.current = nextState; + + if (nextState.isMenuOpen !== currentState.isMenuOpen) { + setIsMenuOpen(nextState.isMenuOpen); + } + + if (!isOverlay || !onCardActionsActiveChange) { + return; + } + + const isActionSurfaceActive = + nextState.isMenuOpen || nextState.isHovered || nextState.isFocused; + + if (lastReportedActiveRef.current === isActionSurfaceActive) { + return; + } + + onCardActionsActiveChange(actionSurfaceId, isActionSurfaceActive); + lastReportedActiveRef.current = isActionSurfaceActive; + }; + + const focusFirstMenuItemIfRequested = () => { + if (!shouldFocusFirstMenuItemRef.current) { + return; + } + + const firstEnabledMenuItem = [ + previewToggleButtonRef.current, + copyButtonRef.current, + openLinkButtonRef.current, + ].find((element): element is HTMLButtonElement | HTMLAnchorElement => { + return Boolean(element) && !element?.hasAttribute("disabled"); + }); + + if (!firstEnabledMenuItem) { + return; + } + + firstEnabledMenuItem.focus(); + shouldFocusFirstMenuItemRef.current = false; + }; + + const handlePreviewToggleButtonRef = (element: HTMLButtonElement | null) => { + previewToggleButtonRef.current = element; + focusFirstMenuItemIfRequested(); + }; + + const handleCopyButtonRef = (element: HTMLButtonElement | null) => { + copyButtonRef.current = element; + focusFirstMenuItemIfRequested(); + }; + + const handleOpenLinkButtonRef = (element: HTMLAnchorElement | null) => { + openLinkButtonRef.current = element; + focusFirstMenuItemIfRequested(); + }; + + const closeMenu = () => { + shouldFocusFirstMenuItemRef.current = false; + updateActionSurfaceState({ isMenuOpen: false }); + }; + + const handleMenuOpenChange = (nextIsOpen: boolean) => { + if (!nextIsOpen) { + shouldFocusFirstMenuItemRef.current = false; + } + + updateActionSurfaceState({ isMenuOpen: nextIsOpen }); + }; const toggleLinkPreviews = (event: MouseEvent) => { stopPropagation(event); if (!previewToggle || previewToggle.isLoading || !previewToggle.canToggle) { return; } + previewToggle.onToggle(); + closeMenu(); }; const copyToClipboard = (event: MouseEvent) => { stopPropagation(event); - navigator.clipboard.writeText(href).then(() => { + closeMenu(); + void navigator.clipboard.writeText(href).then(() => { setCopied(true); setTimeout(() => setCopied(false), 500); }); }; - return ( -
    - {showPreviewToggle && ( - - )} - + ) : null; + + const copyButton = ( + - {!hideLink && ( - Copy link} + + ); + + const openLinkButton = !hideLink ? ( + { + stopPropagation(event); + closeMenu(); + }} + onPointerDown={stopPropagation} + onMouseDown={stopPropagation} + onTouchStart={stopPropagation} + > + {isOverlay ? ( + + ) : ( + + )} + {isOverlay && Open link} + + ) : null; + + if (!isOverlay) { + return ( +
    + {previewToggleButton} + {copyButton} + {openLinkButton} +
    + ); + } + + return ( + <> + {isMenuOpen && ( + +
    + + {previewToggleButton && ( +
  • {previewToggleButton}
  • + )} +
  • {copyButton}
  • + {!hideLink &&
  • {openLinkButton}
  • } +
    +
    + ); } diff --git a/components/waves/LinkHandlerFrame.tsx b/components/waves/LinkHandlerFrame.tsx index 812e58e566..e028ba0dea 100644 --- a/components/waves/LinkHandlerFrame.tsx +++ b/components/waves/LinkHandlerFrame.tsx @@ -23,12 +23,12 @@ export default function LinkHandlerFrame({ relativeHref ?? (() => { const relative = removeBaseEndpoint(href); - return relative?.startsWith("/") ? relative : undefined; + return relative.startsWith("/") ? relative : undefined; })(); return ( -
    -
    +
    +
    {children}
    {!hideActions && ( @@ -36,6 +36,7 @@ export default function LinkHandlerFrame({ href={href} hideLink={hideLink} relativeHref={effectiveRelativeHref} + layout="overlay" /> )}
    diff --git a/components/waves/LinkPreviewContext.tsx b/components/waves/LinkPreviewContext.tsx index a565eee9f3..3a5c657e89 100644 --- a/components/waves/LinkPreviewContext.tsx +++ b/components/waves/LinkPreviewContext.tsx @@ -22,6 +22,9 @@ type LinkPreviewContextValue = { readonly hideActions: boolean; readonly previewToggle?: LinkPreviewToggleControl | undefined; readonly inlineShowControl?: LinkPreviewInlineShowControl | undefined; + readonly onCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; }; const DEFAULT_CONTEXT: LinkPreviewContextValue = { @@ -36,11 +39,15 @@ export const LinkPreviewProvider = ({ variant = "chat", previewToggle, inlineShowControl, + onCardActionsActiveChange, children, }: { readonly variant?: LinkPreviewVariant | undefined; readonly previewToggle?: LinkPreviewToggleControl | undefined; readonly inlineShowControl?: LinkPreviewInlineShowControl | undefined; + readonly onCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; readonly children: ReactNode; }) => { const value = useMemo( @@ -49,8 +56,9 @@ export const LinkPreviewProvider = ({ hideActions: variant === "home", previewToggle, inlineShowControl, + onCardActionsActiveChange, }), - [variant, previewToggle, inlineShowControl] + [variant, previewToggle, inlineShowControl, onCardActionsActiveChange] ); return ( diff --git a/components/waves/OpenGraphPreview.tsx b/components/waves/OpenGraphPreview.tsx index 034e3226f3..0d21d49545 100644 --- a/components/waves/OpenGraphPreview.tsx +++ b/components/waves/OpenGraphPreview.tsx @@ -260,12 +260,16 @@ export function LinkPreviewCardLayout({ } return ( -
    -
    +
    +
    {children}
    {!hideActions && ( - + )}
    ); diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index 8f93530aae..e6b71fc9eb 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -158,6 +158,9 @@ const WaveDrop = ({ const [activePartIndex, setActivePartIndex] = useState(0); const [isSlideUp, setIsSlideUp] = useState(false); const [longPressTriggered, setLongPressTriggered] = useState(false); + const [activeLinkCardActionIds, setActiveLinkCardActionIds] = useState< + string[] + >([]); const [boostAnimation, setBoostAnimation] = useState(null); const dropRef = useRef(null); @@ -183,6 +186,7 @@ const WaveDrop = ({ const isMdUp = breakpoint === "MD"; const allowLongPress = hasTouch && !isMdUp; const compact = useCompactMode(); + const hasActiveLinkCardActions = activeLinkCardActionIds.length > 0; const isProfileView = location === DropLocation.PROFILE; const showAuthorInfo = !shouldGroupWithPreviousDrop || isProfileView; @@ -343,6 +347,22 @@ const WaveDrop = ({ setBoostAnimation(null); }, []); + const handleLinkCardActionsActiveChange = useCallback( + (actionId: string, active: boolean) => { + setActiveLinkCardActionIds((current) => { + const hasActionId = current.includes(actionId); + if (active) { + return hasActionId ? current : [...current, actionId]; + } + + return hasActionId + ? current.filter((item) => item !== actionId) + : current; + }); + }, + [] + ); + // Handler for mobile menu boost animation const handleMobileBoostAnimation = useCallback(() => { if (!dropRef.current) return; @@ -429,6 +449,7 @@ const WaveDrop = ({ onSave={handleEditSave} onCancel={handleEditCancel} hasTouch={allowLongPress} + onLinkCardActionsActiveChange={handleLinkCardActionsActiveChange} />
    @@ -439,6 +460,7 @@ const WaveDrop = ({ activePartIndex={activePartIndex} onReply={handleOnReply} onEdit={handleOnEdit} + suppressed={hasActiveLinkCardActions} /> )} diff --git a/components/waves/drops/WaveDropActions.tsx b/components/waves/drops/WaveDropActions.tsx index 22a4e1c890..2e55ecfec6 100644 --- a/components/waves/drops/WaveDropActions.tsx +++ b/components/waves/drops/WaveDropActions.tsx @@ -19,6 +19,7 @@ interface WaveDropActionsProps { readonly showVoting?: boolean | undefined; readonly onReply: () => void; readonly onEdit?: (() => void) | undefined; + readonly suppressed?: boolean | undefined; } export default function WaveDropActions({ @@ -27,6 +28,7 @@ export default function WaveDropActions({ showVoting = true, onReply, onEdit, + suppressed = false, }: WaveDropActionsProps) { const { isMemesWave } = useSeizeSettings(); const compact = useCompactMode(); @@ -38,16 +40,20 @@ export default function WaveDropActions({ !( drop.drop_type === ApiDropType.Participatory && isMemesWave(drop.wave.id) ); + let visibilityClasses = + "tw-pointer-events-none tw-opacity-0 desktop-hover:group-hover:tw-pointer-events-auto desktop-hover:group-hover:tw-opacity-100 desktop-hover:hover:tw-pointer-events-auto desktop-hover:hover:tw-opacity-100"; + + if (isMoreDropdownOpen) { + visibilityClasses = "tw-pointer-events-auto tw-opacity-100"; + } else if (suppressed) { + visibilityClasses = "tw-pointer-events-none tw-opacity-0"; + } return (
    diff --git a/components/waves/drops/WaveDropContent.tsx b/components/waves/drops/WaveDropContent.tsx index 7923c36511..11785ee53b 100644 --- a/components/waves/drops/WaveDropContent.tsx +++ b/components/waves/drops/WaveDropContent.tsx @@ -28,6 +28,9 @@ interface WaveDropContentProps { readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; readonly hasTouch?: boolean | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropContent: React.FC = ({ @@ -45,6 +48,7 @@ const WaveDropContent: React.FC = ({ isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, hasTouch, + onLinkCardActionsActiveChange, }) => { const isTouchDevice = useIsTouchDevice(); const effectiveHasTouch = hasTouch ?? isTouchDevice; @@ -65,6 +69,7 @@ const WaveDropContent: React.FC = ({ isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} hasTouch={effectiveHasTouch} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> ); }; diff --git a/components/waves/drops/WaveDropPart.tsx b/components/waves/drops/WaveDropPart.tsx index e1d3dc4271..eaf9498aa4 100644 --- a/components/waves/drops/WaveDropPart.tsx +++ b/components/waves/drops/WaveDropPart.tsx @@ -29,6 +29,9 @@ interface WaveDropPartProps { readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; readonly hasTouch?: boolean | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const LONG_PRESS_DURATION = 500; // milliseconds @@ -50,6 +53,7 @@ const WaveDropPart: React.FC = memo( isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, hasTouch = false, + onLinkCardActionsActiveChange, }) => { const activePart = drop.parts[activePartIndex]; @@ -146,6 +150,7 @@ const WaveDropPart: React.FC = memo( onCancel={onCancel} isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
    diff --git a/components/waves/drops/WaveDropPartContent.tsx b/components/waves/drops/WaveDropPartContent.tsx index 679a47e9be..ff815da201 100644 --- a/components/waves/drops/WaveDropPartContent.tsx +++ b/components/waves/drops/WaveDropPartContent.tsx @@ -36,6 +36,9 @@ interface WaveDropPartContentProps { readonly drop?: ApiDrop | undefined; readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropPartContent: React.FC = ({ @@ -57,6 +60,7 @@ const WaveDropPartContent: React.FC = ({ drop, isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, + onLinkCardActionsActiveChange, }) => { const contentRef = React.useRef(null); @@ -146,6 +150,7 @@ const WaveDropPartContent: React.FC = ({ onSave={onSave} onCancel={onCancel} drop={drop} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
    {!!activePart.media.length && ( diff --git a/components/waves/drops/WaveDropPartContentMarkdown.tsx b/components/waves/drops/WaveDropPartContentMarkdown.tsx index 5952cf6da1..a59902c0be 100644 --- a/components/waves/drops/WaveDropPartContentMarkdown.tsx +++ b/components/waves/drops/WaveDropPartContentMarkdown.tsx @@ -28,6 +28,9 @@ interface WaveDropPartContentMarkdownProps { | undefined; readonly onCancel?: (() => void) | undefined; readonly drop?: ApiDrop | undefined; // Add drop to check for edited status + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropPartContentMarkdown: React.FC< @@ -44,6 +47,7 @@ const WaveDropPartContentMarkdown: React.FC< onSave, onCancel, drop, + onLinkCardActionsActiveChange, }) => { const linkPreviewToggleControl = useDropLinkPreviewToggleControl(drop); const currentQuotePath = @@ -89,6 +93,7 @@ const WaveDropPartContentMarkdown: React.FC< hideLinkPreviews={drop?.hide_link_preview} quotePath={currentQuotePath} linkPreviewToggleControl={linkPreviewToggleControl} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> {typeof drop?.updated_at === "number" && drop.updated_at !== drop.created_at && ( diff --git a/components/waves/drops/WaveDropPartDrop.tsx b/components/waves/drops/WaveDropPartDrop.tsx index 1a957f5107..b4049f1f4d 100644 --- a/components/waves/drops/WaveDropPartDrop.tsx +++ b/components/waves/drops/WaveDropPartDrop.tsx @@ -28,6 +28,9 @@ interface WaveDropPartDropProps { readonly onCancel?: (() => void) | undefined; isCompetitionDrop?: boolean | undefined; mediaImageScale?: ImageScale | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropPartDrop: React.FC = ({ @@ -45,6 +48,7 @@ const WaveDropPartDrop: React.FC = ({ onCancel, isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, + onLinkCardActionsActiveChange, }) => { return (
    @@ -70,6 +74,7 @@ const WaveDropPartDrop: React.FC = ({ drop={drop} isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
    From 15ba82d1d4bbdd33fb331a871fd0bc4606b66b22 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 16 Mar 2026 14:14:31 +0100 Subject: [PATCH 2/6] wip Signed-off-by: Simo --- .../components/ChatItemHrefButtons.test.tsx | 131 +++++++++++++++++- ...CommonDropdownItemsDefaultWrapper.test.tsx | 29 ++++ .../WaveDropPartContentMarkdown.test.tsx | 5 + .../waves/drops/WaveDropQuote.test.tsx | 52 +++++++ .../CommonDropdownItemsDefaultWrapper.tsx | 27 ++++ components/waves/ChatItemHrefButtons.tsx | 72 ++++++++-- .../drops/WaveDropPartContentMarkdown.tsx | 1 + components/waves/drops/WaveDropQuote.tsx | 12 ++ .../waves/drops/WaveDropQuoteWithDropId.tsx | 5 + .../waves/drops/WaveDropQuoteWithSerialNo.tsx | 5 + 10 files changed, 329 insertions(+), 10 deletions(-) diff --git a/__tests__/components/ChatItemHrefButtons.test.tsx b/__tests__/components/ChatItemHrefButtons.test.tsx index 7be58b6d18..bde7a10c98 100644 --- a/__tests__/components/ChatItemHrefButtons.test.tsx +++ b/__tests__/components/ChatItemHrefButtons.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import ChatItemHrefButtons from "@/components/waves/ChatItemHrefButtons"; import { LinkPreviewProvider } from "@/components/waves/LinkPreviewContext"; @@ -252,7 +252,136 @@ describe("ChatItemHrefButtons", () => { await user.keyboard("{Enter}"); + expect( + screen.getByRole("button", { name: "Hide link previews" }) + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + }); + + it("reports overlay actions as inactive after pointer-driven trigger close", () => { + const onCardActionsActiveChange = jest.fn(); + + render( +
    + + + +
    + ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + + fireEvent.pointerDown(trigger); + fireEvent.mouseDown(trigger); + fireEvent.focus(trigger); + fireEvent.click(trigger); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 1, + actionSurfaceId, + true + ); + + fireEvent.pointerDown(trigger); + fireEvent.mouseDown(trigger); + fireEvent.focus(trigger); + fireEvent.click(trigger); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("returns focus to the trigger after a keyboard-activated overlay action", async () => { + const user = userEvent.setup(); + + render( +
    + +
    + ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + await user.keyboard("{Enter}"); expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + + await user.keyboard("{Enter}"); + + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); + + it("returns focus to the trigger when escape closes the overlay menu", async () => { + const user = userEvent.setup(); + + render( +
    + +
    + ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + + fireEvent.keyDown(window, { key: "Escape" }); + + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); + + it("closes the overlay menu when keyboard focus tabs past the last action", async () => { + const user = userEvent.setup(); + const onCardActionsActiveChange = jest.fn(); + + render( +
    +
    + + + +
    + +
    + ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole("link", { name: "Open link" })).toHaveFocus(); + + await user.tab(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Outside" })).toHaveFocus(); + expect( + screen.queryByRole("button", { name: "Copy link" }) + ).not.toBeInTheDocument(); + }); + + expect(onCardActionsActiveChange).toHaveBeenLastCalledWith( + actionSurfaceId, + false + ); }); it("keeps the overlay trigger visible on touch devices", () => { diff --git a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx index c38c32d172..2ef0a28e5c 100644 --- a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx +++ b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx @@ -161,3 +161,32 @@ test("positions the dropdown above the trigger near the viewport bottom", async }); }); }); + +test("closes when focus moves outside the trigger and portaled menu", async () => { + const setOpen = jest.fn(); + const buttonRef = React.createRef(); + + render( + <> + + + +
  • + +
  • +
    + + ); + + await screen.findByRole("menu"); + + fireEvent.focusIn(screen.getByRole("button", { name: "Outside" })); + + expect(setOpen).toHaveBeenCalledWith(false); +}); diff --git a/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx b/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx index 511c140b55..d1715e091f 100644 --- a/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx +++ b/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx @@ -55,6 +55,7 @@ it("renders quoted drop", () => { quoted_drop: { drop_id: "d", drop_part_id: 1, drop: null }, } as any; const drop = { id: "root-drop", serial_no: 7 } as any; + const onLinkCardActionsActiveChange = jest.fn(); render( { wave={wave} drop={drop} onQuoteClick={jest.fn()} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> ); expect(screen.getByTestId("quote")).toHaveAttribute("data-id", "d"); @@ -70,6 +72,9 @@ it("renders quoted drop", () => { expect(quoteProps.embedPath).toEqual(["root-drop"]); expect(quoteProps.quotePath).toEqual(["w:7"]); expect(quoteProps.embedDepth).toBe(1); + expect(quoteProps.onLinkCardActionsActiveChange).toBe( + onLinkCardActionsActiveChange + ); }); it("passes link preview toggle control for author drops with links", () => { diff --git a/__tests__/components/waves/drops/WaveDropQuote.test.tsx b/__tests__/components/waves/drops/WaveDropQuote.test.tsx index d407f59f4a..8dbb917bec 100644 --- a/__tests__/components/waves/drops/WaveDropQuote.test.tsx +++ b/__tests__/components/waves/drops/WaveDropQuote.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import WaveDropQuote from "@/components/waves/drops/WaveDropQuote"; +import { LinkPreviewProvider } from "@/components/waves/LinkPreviewContext"; let markdownProps: any; @@ -107,3 +108,54 @@ test("clears quoted part content when drop becomes null", () => { rerender(); expect(screen.getByTestId("markdown")).toHaveTextContent(""); }); + +test("passes explicit link-card suppression callback into nested markdown", () => { + const drop = { + id: "d1", + serial_no: 42, + wave: { id: "w1", name: "wave" }, + author: { handle: "a", level: 1, cic: "BRONZE", pfp: null }, + parts: [{ part_id: 5, content: "text" }], + created_at: "2020-01-01", + mentioned_users: [], + referenced_nfts: [], + } as any; + const onLinkCardActionsActiveChange = jest.fn(); + + render( + + ); + + expect(markdownProps.onLinkCardActionsActiveChange).toBe( + onLinkCardActionsActiveChange + ); +}); + +test("falls back to link preview context for nested markdown suppression", () => { + const drop = { + id: "d1", + serial_no: 42, + wave: { id: "w1", name: "wave" }, + author: { handle: "a", level: 1, cic: "BRONZE", pfp: null }, + parts: [{ part_id: 5, content: "text" }], + created_at: "2020-01-01", + mentioned_users: [], + referenced_nfts: [], + } as any; + const onCardActionsActiveChange = jest.fn(); + + render( + + + + ); + + expect(markdownProps.onLinkCardActionsActiveChange).toBe( + onCardActionsActiveChange + ); +}); diff --git a/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx b/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx index 313d90f92e..823a3c3797 100644 --- a/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx +++ b/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx @@ -106,6 +106,33 @@ export default function CommonDropdownItemsDefaultWrapper({ }; }, [dynamicPosition, isOpen, position]); + useEffect(() => { + if (!isOpen) { + return; + } + + const handleFocusIn = (event: FocusEvent) => { + if (!(event.target instanceof Node)) { + return; + } + + if ( + buttonRef.current?.contains(event.target) || + listRef.current?.contains(event.target) + ) { + return; + } + + setOpen(false); + }; + + document.addEventListener("focusin", handleFocusIn); + + return () => { + document.removeEventListener("focusin", handleFocusIn); + }; + }, [buttonRef, isOpen, setOpen]); + const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); diff --git a/components/waves/ChatItemHrefButtons.tsx b/components/waves/ChatItemHrefButtons.tsx index 21cf931b92..f4787082cf 100644 --- a/components/waves/ChatItemHrefButtons.tsx +++ b/components/waves/ChatItemHrefButtons.tsx @@ -156,6 +156,8 @@ export default function ChatItemHrefButtons({ const openLinkButtonRef = useRef(null); const lastReportedActiveRef = useRef(false); const shouldFocusFirstMenuItemRef = useRef(false); + const shouldIgnoreNextPointerFocusRef = useRef(false); + const shouldRestoreTriggerFocusRef = useRef(false); const actionSurfaceStateRef = useRef({ isMenuOpen: false, isHovered: false, @@ -165,6 +167,9 @@ export default function ChatItemHrefButtons({ const isOverlay = layout === "overlay"; const showPersistentOverlayTrigger = isOverlay && (hasTouchInput || isMobileDevice); + const isPreviewToggleDisabled = Boolean( + previewToggle?.isLoading ?? !previewToggle?.canToggle + ); useEffect(() => { return () => { @@ -175,6 +180,41 @@ export default function ChatItemHrefButtons({ }; }, [actionSurfaceId, onCardActionsActiveChange]); + useEffect(() => { + if (isMenuOpen || !shouldRestoreTriggerFocusRef.current) { + return; + } + + const timeoutId = window.setTimeout(() => { + shouldRestoreTriggerFocusRef.current = false; + buttonRef.current?.focus(); + }, 0); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [isMenuOpen]); + + useEffect(() => { + if (!isOverlay || !isMenuOpen) { + return; + } + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape") { + return; + } + + shouldRestoreTriggerFocusRef.current = true; + }; + + window.addEventListener("keydown", handleKeyDown, true); + + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isMenuOpen, isOverlay]); + const updateActionSurfaceState = ( nextStatePatch: Partial ) => { @@ -246,8 +286,13 @@ export default function ChatItemHrefButtons({ focusFirstMenuItemIfRequested(); }; - const closeMenu = () => { + const closeMenu = ({ + restoreFocusToTrigger = false, + }: { + readonly restoreFocusToTrigger?: boolean; + } = {}) => { shouldFocusFirstMenuItemRef.current = false; + shouldRestoreTriggerFocusRef.current = restoreFocusToTrigger; updateActionSurfaceState({ isMenuOpen: false }); }; @@ -266,12 +311,12 @@ export default function ChatItemHrefButtons({ } previewToggle.onToggle(); - closeMenu(); + closeMenu({ restoreFocusToTrigger: event.detail === 0 }); }; const copyToClipboard = (event: MouseEvent) => { stopPropagation(event); - closeMenu(); + closeMenu({ restoreFocusToTrigger: event.detail === 0 }); void navigator.clipboard.writeText(href).then(() => { setCopied(true); setTimeout(() => setCopied(false), 500); @@ -280,6 +325,7 @@ export default function ChatItemHrefButtons({ const handleTriggerClick = (event: MouseEvent) => { stopPropagation(event); + shouldIgnoreNextPointerFocusRef.current = false; updateActionSurfaceState({ isMenuOpen: !actionSurfaceStateRef.current.isMenuOpen, }); @@ -300,6 +346,7 @@ export default function ChatItemHrefButtons({ const handleTriggerPointerStart = (event: StopEvent) => { shouldFocusFirstMenuItemRef.current = false; + shouldIgnoreNextPointerFocusRef.current = true; stopPropagation(event); }; @@ -321,17 +368,24 @@ export default function ChatItemHrefButtons({ updateActionSurfaceState({ isFocused: false }); }; + const handleOverlayFocusCapture = () => { + if (shouldIgnoreNextPointerFocusRef.current) { + shouldIgnoreNextPointerFocusRef.current = false; + return; + } + + updateActionSurfaceState({ isFocused: true }); + }; + const previewToggleButton = showPreviewToggle ? (
    )} diff --git a/components/waves/drops/WaveDropQuote.tsx b/components/waves/drops/WaveDropQuote.tsx index aeb1fc6330..40bfdb132e 100644 --- a/components/waves/drops/WaveDropQuote.tsx +++ b/components/waves/drops/WaveDropQuote.tsx @@ -12,6 +12,7 @@ import WaveDropTime from "./time/WaveDropTime"; import { getWaveRoute } from "@/helpers/navigation.helpers"; import Image from "next/image"; import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext"; +import { useLinkPreviewContext } from "@/components/waves/LinkPreviewContext"; interface WaveDropQuoteProps { readonly drop: ApiDrop | null; @@ -21,6 +22,9 @@ interface WaveDropQuoteProps { readonly quotePath?: readonly string[] | undefined; readonly embedDepth?: number | undefined; readonly maxEmbedDepth?: number | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropQuote: React.FC = ({ @@ -31,7 +35,9 @@ const WaveDropQuote: React.FC = ({ quotePath, embedDepth, maxEmbedDepth, + onLinkCardActionsActiveChange, }) => { + const { onCardActionsActiveChange } = useLinkPreviewContext(); const quotedPart = useMemo(() => { if (!drop) { return null; @@ -115,6 +121,9 @@ const WaveDropQuote: React.FC = ({ return path; }, [drop, quotePath]); + const resolvedOnLinkCardActionsActiveChange = + onLinkCardActionsActiveChange ?? onCardActionsActiveChange; + return (
    = ({ quotePath={effectiveQuotePath} embedDepth={embedDepth} maxEmbedDepth={maxEmbedDepth} + onLinkCardActionsActiveChange={ + resolvedOnLinkCardActionsActiveChange + } />
    diff --git a/components/waves/drops/WaveDropQuoteWithDropId.tsx b/components/waves/drops/WaveDropQuoteWithDropId.tsx index e04cbed8ae..12f7c2206e 100644 --- a/components/waves/drops/WaveDropQuoteWithDropId.tsx +++ b/components/waves/drops/WaveDropQuoteWithDropId.tsx @@ -17,6 +17,9 @@ interface WaveDropQuoteWithDropIdProps { readonly quotePath?: readonly string[] | undefined; readonly embedDepth?: number | undefined; readonly maxEmbedDepth?: number | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropQuoteWithDropId: React.FC = ({ @@ -28,6 +31,7 @@ const WaveDropQuoteWithDropId: React.FC = ({ quotePath, embedDepth, maxEmbedDepth, + onLinkCardActionsActiveChange, }) => { const { connectedProfile } = useContext(AuthContext); const { data: drop } = useQuery({ @@ -58,6 +62,7 @@ const WaveDropQuoteWithDropId: React.FC = ({ quotePath={quotePath} embedDepth={embedDepth} maxEmbedDepth={maxEmbedDepth} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> ); }; diff --git a/components/waves/drops/WaveDropQuoteWithSerialNo.tsx b/components/waves/drops/WaveDropQuoteWithSerialNo.tsx index ac724cb5a7..925d5e6ad7 100644 --- a/components/waves/drops/WaveDropQuoteWithSerialNo.tsx +++ b/components/waves/drops/WaveDropQuoteWithSerialNo.tsx @@ -17,6 +17,9 @@ interface WaveDropQuoteWithSerialNoProps { readonly quotePath?: readonly string[] | undefined; readonly embedDepth?: number | undefined; readonly maxEmbedDepth?: number | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropQuoteWithSerialNo: React.FC = ({ @@ -27,6 +30,7 @@ const WaveDropQuoteWithSerialNo: React.FC = ({ quotePath, embedDepth, maxEmbedDepth, + onLinkCardActionsActiveChange, }) => { const { data } = useQuery({ queryKey: [ @@ -70,6 +74,7 @@ const WaveDropQuoteWithSerialNo: React.FC = ({ quotePath={quotePath} embedDepth={embedDepth} maxEmbedDepth={maxEmbedDepth} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> ); }; From 2ffe704869d21e3a36066556f2a360c635c05ffc Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 16 Mar 2026 14:34:22 +0100 Subject: [PATCH 3/6] wip Signed-off-by: Simo --- .../select/dropdown/CommonDropdownItemsDefaultWrapper.tsx | 2 +- components/waves/ChatItemHrefButtons.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx b/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx index 823a3c3797..ac01028e40 100644 --- a/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx +++ b/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx @@ -52,7 +52,7 @@ export default function CommonDropdownItemsDefaultWrapper({ const dropdownEl = dropdownRef.current; // Get width from the list element if possible, otherwise the wrapper - const width = listRef.current?.offsetWidth ?? dropdownEl.offsetWidth ?? 288; + const width = listRef.current?.offsetWidth ?? dropdownEl.offsetWidth; const height = listRef.current?.offsetHeight ?? dropdownEl.offsetHeight; const scrollX = window.scrollX; diff --git a/components/waves/ChatItemHrefButtons.tsx b/components/waves/ChatItemHrefButtons.tsx index f4787082cf..512cbd9c20 100644 --- a/components/waves/ChatItemHrefButtons.tsx +++ b/components/waves/ChatItemHrefButtons.tsx @@ -168,7 +168,7 @@ export default function ChatItemHrefButtons({ const showPersistentOverlayTrigger = isOverlay && (hasTouchInput || isMobileDevice); const isPreviewToggleDisabled = Boolean( - previewToggle?.isLoading ?? !previewToggle?.canToggle + previewToggle && (previewToggle.isLoading || !previewToggle.canToggle) ); useEffect(() => { @@ -306,7 +306,7 @@ export default function ChatItemHrefButtons({ const toggleLinkPreviews = (event: MouseEvent) => { stopPropagation(event); - if (!previewToggle || previewToggle.isLoading || !previewToggle.canToggle) { + if (!previewToggle || isPreviewToggleDisabled) { return; } From 236324c9aedd1805be95c6587158393940396272 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 16 Mar 2026 16:08:37 +0100 Subject: [PATCH 4/6] wip Signed-off-by: Simo --- ...CommonDropdownItemsDefaultWrapper.test.tsx | 32 +++++++- .../waves/LinkHandlerFrame.test.tsx | 73 +++++++++++++++++++ .../view/part/dropPartMarkdown/renderers.tsx | 2 +- .../CommonDropdownItemsDefaultWrapper.tsx | 6 +- components/waves/LinkHandlerFrame.tsx | 32 ++++++-- 5 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 __tests__/components/waves/LinkHandlerFrame.test.tsx diff --git a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx index 2ef0a28e5c..9133c357b5 100644 --- a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx +++ b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx @@ -162,7 +162,7 @@ test("positions the dropdown above the trigger near the viewport bottom", async }); }); -test("closes when focus moves outside the trigger and portaled menu", async () => { +test("closes when focus moves outside the trigger and portaled menu by default", async () => { const setOpen = jest.fn(); const buttonRef = React.createRef(); @@ -190,3 +190,33 @@ test("closes when focus moves outside the trigger and portaled menu", async () = expect(setOpen).toHaveBeenCalledWith(false); }); + +test("does not close when opted out and focus moves outside the trigger and portaled menu", async () => { + const setOpen = jest.fn(); + const buttonRef = React.createRef(); + + render( + <> + + + +
  • + +
  • +
    + + ); + + await screen.findByRole("menu"); + + fireEvent.focusIn(screen.getByRole("button", { name: "Outside" })); + + expect(setOpen).not.toHaveBeenCalled(); +}); diff --git a/__tests__/components/waves/LinkHandlerFrame.test.tsx b/__tests__/components/waves/LinkHandlerFrame.test.tsx new file mode 100644 index 0000000000..ef145a2724 --- /dev/null +++ b/__tests__/components/waves/LinkHandlerFrame.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; + +import LinkHandlerFrame from "@/components/waves/LinkHandlerFrame"; + +jest.mock("@/components/waves/ChatItemHrefButtons", () => ({ + __esModule: true, + default: ({ href, layout }: { href: string; layout: string }) => ( +
    + ), +})); + +jest.mock("@/components/waves/LinkPreviewContext", () => ({ + useLinkPreviewContext: () => ({ hideActions: false }), +})); + +describe("LinkHandlerFrame", () => { + it("uses the full-width frame wrapper by default", () => { + render( + +
    + Preview +
    +
    + ); + + const actions = screen.getByTestId("chat-item-href-buttons"); + const anchor = actions.parentElement; + const outer = anchor?.parentElement; + + expect(anchor).not.toBeNull(); + expect(anchor?.className).toContain("tw-group/link-card"); + expect(anchor?.className).toContain("tw-relative"); + expect(anchor?.className).toContain("tw-w-full"); + expect(anchor?.className).toContain("tw-max-w-full"); + expect(anchor?.className).not.toContain("tw-inline-flex"); + expect(anchor?.className).not.toContain("tw-w-fit"); + expect(outer).not.toBeNull(); + expect(actions).toHaveAttribute("data-layout", "overlay"); + }); + + it("supports content-width overlay anchoring when explicitly requested", () => { + render( + +
    + Preview +
    +
    + ); + + const actions = screen.getByTestId("chat-item-href-buttons"); + const anchor = actions.parentElement; + const outer = anchor?.parentElement; + + expect(anchor).not.toBeNull(); + expect(anchor?.className).toContain("tw-group/link-card"); + expect(anchor?.className).toContain("tw-relative"); + expect(anchor?.className).toContain("tw-inline-flex"); + expect(anchor?.className).toContain("tw-w-fit"); + expect(anchor?.className).toContain("tw-max-w-full"); + + expect(outer).not.toBeNull(); + expect(outer?.className).toContain("tw-flex"); + expect(outer?.className).toContain("tw-w-full"); + expect(actions).toHaveAttribute("data-layout", "overlay"); + }); +}); diff --git a/components/drops/view/part/dropPartMarkdown/renderers.tsx b/components/drops/view/part/dropPartMarkdown/renderers.tsx index 4eec02e092..5e83adffc2 100644 --- a/components/drops/view/part/dropPartMarkdown/renderers.tsx +++ b/components/drops/view/part/dropPartMarkdown/renderers.tsx @@ -39,7 +39,7 @@ const renderTweetEmbed = ( const { tweetId, href: normalizedHref } = ensureTwitterLink(href); const renderFallback = () => ; return ( - +
    renderFallback()}> void; readonly buttonRef: RefObject; readonly dynamicPosition?: boolean | undefined; + readonly closeOnFocusOutside?: boolean | undefined; readonly children: ReactNode; }) { const listRef = useRef(null); @@ -107,7 +109,7 @@ export default function CommonDropdownItemsDefaultWrapper({ }, [dynamicPosition, isOpen, position]); useEffect(() => { - if (!isOpen) { + if (!isOpen || !closeOnFocusOutside) { return; } @@ -131,7 +133,7 @@ export default function CommonDropdownItemsDefaultWrapper({ return () => { document.removeEventListener("focusin", handleFocusIn); }; - }, [buttonRef, isOpen, setOpen]); + }, [buttonRef, closeOnFocusOutside, isOpen, setOpen]); const [mounted, setMounted] = useState(false); useEffect(() => { diff --git a/components/waves/LinkHandlerFrame.tsx b/components/waves/LinkHandlerFrame.tsx index e028ba0dea..7ccdcef86c 100644 --- a/components/waves/LinkHandlerFrame.tsx +++ b/components/waves/LinkHandlerFrame.tsx @@ -10,6 +10,7 @@ interface LinkHandlerFrameProps { readonly children: ReactNode; readonly hideLink?: boolean | undefined; readonly relativeHref?: string | undefined; + readonly overlayAnchor?: "frame" | "content" | undefined; } export default function LinkHandlerFrame({ @@ -17,6 +18,7 @@ export default function LinkHandlerFrame({ children, hideLink = false, relativeHref, + overlayAnchor = "frame", }: LinkHandlerFrameProps) { const { hideActions } = useLinkPreviewContext(); const effectiveRelativeHref = @@ -25,20 +27,34 @@ export default function LinkHandlerFrame({ const relative = removeBaseEndpoint(href); return relative.startsWith("/") ? relative : undefined; })(); + const actionButtons = !hideActions ? ( + + ) : null; + + if (overlayAnchor === "content") { + return ( +
    +
    +
    + {children} +
    + {actionButtons} +
    +
    + ); + } return (
    {children}
    - {!hideActions && ( - - )} + {actionButtons}
    ); } From a4c7d71b4ac2716092b96093a7026606fad4e069 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 17 Mar 2026 11:00:03 +0100 Subject: [PATCH 5/6] wip Signed-off-by: Simo --- .../components/ChatItemHrefButtons.test.tsx | 29 +++++++++++++++ .../waves/LinkHandlerFrame.test.tsx | 35 ++++++++++++++++++- components/waves/ChatItemHrefButtons.tsx | 4 ++- components/waves/LinkHandlerFrame.tsx | 4 ++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/__tests__/components/ChatItemHrefButtons.test.tsx b/__tests__/components/ChatItemHrefButtons.test.tsx index bde7a10c98..54f82affe2 100644 --- a/__tests__/components/ChatItemHrefButtons.test.tsx +++ b/__tests__/components/ChatItemHrefButtons.test.tsx @@ -228,6 +228,35 @@ describe("ChatItemHrefButtons", () => { ).toHaveFocus(); }); + it("moves pointer-opened overlay menus into the tab sequence", async () => { + const user = userEvent.setup(); + + render( +
    +
    + +
    + +
    + ); + + await user.click(screen.getByRole("button", { name: "Link actions" })); + + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole("link", { name: "Open link" })).toHaveFocus(); + + await user.tab(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Outside" })).toHaveFocus(); + expect( + screen.queryByRole("button", { name: "Copy link" }) + ).not.toBeInTheDocument(); + }); + }); + it("skips disabled overlay actions when moving keyboard focus into the menu", async () => { const user = userEvent.setup(); diff --git a/__tests__/components/waves/LinkHandlerFrame.test.tsx b/__tests__/components/waves/LinkHandlerFrame.test.tsx index ef145a2724..4e35bbb5a1 100644 --- a/__tests__/components/waves/LinkHandlerFrame.test.tsx +++ b/__tests__/components/waves/LinkHandlerFrame.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react"; import LinkHandlerFrame from "@/components/waves/LinkHandlerFrame"; +let mockHideActions = false; + jest.mock("@/components/waves/ChatItemHrefButtons", () => ({ __esModule: true, default: ({ href, layout }: { href: string; layout: string }) => ( @@ -14,10 +16,14 @@ jest.mock("@/components/waves/ChatItemHrefButtons", () => ({ })); jest.mock("@/components/waves/LinkPreviewContext", () => ({ - useLinkPreviewContext: () => ({ hideActions: false }), + useLinkPreviewContext: () => ({ hideActions: mockHideActions }), })); describe("LinkHandlerFrame", () => { + beforeEach(() => { + mockHideActions = false; + }); + it("uses the full-width frame wrapper by default", () => { render( @@ -70,4 +76,31 @@ describe("LinkHandlerFrame", () => { expect(outer?.className).toContain("tw-w-full"); expect(actions).toHaveAttribute("data-layout", "overlay"); }); + + it("falls back to the full-width frame wrapper when actions are hidden", () => { + mockHideActions = true; + + render( + +
    + Preview +
    +
    + ); + + const preview = screen.getByTestId("preview-card"); + const anchor = preview.parentElement?.parentElement; + + expect( + screen.queryByTestId("chat-item-href-buttons") + ).not.toBeInTheDocument(); + expect(anchor).not.toBeNull(); + expect(anchor?.className).toContain("tw-group/link-card"); + expect(anchor?.className).toContain("tw-w-full"); + expect(anchor?.className).not.toContain("tw-inline-flex"); + expect(anchor?.className).not.toContain("tw-w-fit"); + }); }); diff --git a/components/waves/ChatItemHrefButtons.tsx b/components/waves/ChatItemHrefButtons.tsx index 512cbd9c20..f995118523 100644 --- a/components/waves/ChatItemHrefButtons.tsx +++ b/components/waves/ChatItemHrefButtons.tsx @@ -326,8 +326,10 @@ export default function ChatItemHrefButtons({ const handleTriggerClick = (event: MouseEvent) => { stopPropagation(event); shouldIgnoreNextPointerFocusRef.current = false; + const nextIsMenuOpen = !actionSurfaceStateRef.current.isMenuOpen; + shouldFocusFirstMenuItemRef.current = nextIsMenuOpen; updateActionSurfaceState({ - isMenuOpen: !actionSurfaceStateRef.current.isMenuOpen, + isMenuOpen: nextIsMenuOpen, }); }; diff --git a/components/waves/LinkHandlerFrame.tsx b/components/waves/LinkHandlerFrame.tsx index 7ccdcef86c..9f7d8e383a 100644 --- a/components/waves/LinkHandlerFrame.tsx +++ b/components/waves/LinkHandlerFrame.tsx @@ -35,8 +35,10 @@ export default function LinkHandlerFrame({ layout="overlay" /> ) : null; + const shouldAnchorOverlayToContent = + overlayAnchor === "content" && actionButtons !== null; - if (overlayAnchor === "content") { + if (shouldAnchorOverlayToContent) { return (
    From eb8909f0490165b0029fe3072bcb455e0b2904e6 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 17 Mar 2026 11:54:28 +0100 Subject: [PATCH 6/6] wip Signed-off-by: Simo --- .../components/ChatItemHrefButtons.test.tsx | 33 +++++++++++++++++++ components/waves/ChatItemHrefButtons.tsx | 33 +++++++------------ components/waves/LinkHandlerFrame.tsx | 4 +-- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/__tests__/components/ChatItemHrefButtons.test.tsx b/__tests__/components/ChatItemHrefButtons.test.tsx index 54f82affe2..e72b416a68 100644 --- a/__tests__/components/ChatItemHrefButtons.test.tsx +++ b/__tests__/components/ChatItemHrefButtons.test.tsx @@ -173,6 +173,39 @@ describe("ChatItemHrefButtons", () => { ); }); + it("reports overlay action activity when the trigger is hovered", () => { + const onCardActionsActiveChange = jest.fn(); + + render( +
    + + + +
    + ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + + fireEvent.mouseEnter(trigger); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 1, + actionSurfaceId, + true + ); + + fireEvent.mouseLeave(trigger); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + it("reports overlay actions as inactive when the component unmounts while active", () => { const onCardActionsActiveChange = jest.fn(); diff --git a/components/waves/ChatItemHrefButtons.tsx b/components/waves/ChatItemHrefButtons.tsx index f995118523..869c3cce0d 100644 --- a/components/waves/ChatItemHrefButtons.tsx +++ b/components/waves/ChatItemHrefButtons.tsx @@ -11,7 +11,6 @@ import { useId, useRef, useState, - type FocusEvent, type KeyboardEvent, type MouseEvent, type PointerEvent, @@ -185,13 +184,13 @@ export default function ChatItemHrefButtons({ return; } - const timeoutId = window.setTimeout(() => { + const timeoutId = globalThis.window.setTimeout(() => { shouldRestoreTriggerFocusRef.current = false; buttonRef.current?.focus(); }, 0); return () => { - window.clearTimeout(timeoutId); + globalThis.window.clearTimeout(timeoutId); }; }, [isMenuOpen]); @@ -208,10 +207,10 @@ export default function ChatItemHrefButtons({ shouldRestoreTriggerFocusRef.current = true; }; - window.addEventListener("keydown", handleKeyDown, true); + globalThis.window.addEventListener("keydown", handleKeyDown, true); return () => { - window.removeEventListener("keydown", handleKeyDown, true); + globalThis.window.removeEventListener("keydown", handleKeyDown, true); }; }, [isMenuOpen, isOverlay]); @@ -358,19 +357,11 @@ export default function ChatItemHrefButtons({ closeMenu(); }; - const handleOverlayBlur = (event: FocusEvent) => { - const nextTarget = event.relatedTarget; - if ( - nextTarget instanceof Node && - event.currentTarget.contains(nextTarget) - ) { - return; - } - + const handleTriggerBlur = () => { updateActionSurfaceState({ isFocused: false }); }; - const handleOverlayFocusCapture = () => { + const handleTriggerFocus = () => { if (shouldIgnoreNextPointerFocusRef.current) { shouldIgnoreNextPointerFocusRef.current = false; return; @@ -432,7 +423,7 @@ export default function ChatItemHrefButtons({ ); - const openLinkButton = !hideLink ? ( + const openLinkButton = hideLink ? null : ( Open link} - ) : null; + ); if (!isOverlay) { return ( @@ -486,10 +477,6 @@ export default function ChatItemHrefButtons({ ? "tw-pointer-events-auto tw-opacity-100" : "tw-pointer-events-none tw-opacity-0 group-focus-within/link-card:tw-pointer-events-auto group-focus-within/link-card:tw-opacity-100 desktop-hover:group-hover/link-card:tw-pointer-events-auto desktop-hover:group-hover/link-card:tw-opacity-100" }`} - onMouseEnter={() => updateActionSurfaceState({ isHovered: true })} - onMouseLeave={() => updateActionSurfaceState({ isHovered: false })} - onFocusCapture={handleOverlayFocusCapture} - onBlurCapture={handleOverlayBlur} >