diff --git a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts index 6612202f..b257449f 100644 --- a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts +++ b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts @@ -3,6 +3,7 @@ export const EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG = "__ftEarlyTabAcceptBridgeInstall export const EARLY_TAB_ACCEPT_ENTRY_ID_ATTR = "data-ft-suggestion-id"; export const EARLY_TAB_ACCEPT_ENABLED_ATTR = "data-ft-autocomplete-on-tab"; export const EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR = "data-ft-early-tab-bridge"; +export const EARLY_TAB_ACCEPT_VISIBLE_ATTR = "data-ft-suggestion-visible"; export const EARLY_TAB_ACCEPT_MESSAGE_TYPE = "ft-early-tab-accept-message"; export interface EarlyTabAcceptMessage { diff --git a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts index 88ffea6a..9fd1b99d 100644 --- a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts @@ -5,7 +5,9 @@ import { EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG, EARLY_TAB_ACCEPT_MESSAGE_TYPE, EARLY_TAB_ACCEPT_REQUEST_EVENT, + EARLY_TAB_ACCEPT_VISIBLE_ATTR, } from "./EarlyTabAcceptBridgeProtocol"; +import { isSuggestionMenuHostVisible, resolveSuggestionMenuHost } from "./SuggestionMenuHost"; type FluentTyperManagedElement = HTMLElement; type FluentTyperBridgeWindow = Window & { @@ -13,36 +15,20 @@ type FluentTyperBridgeWindow = Window & { __ftEarlyTabAcceptBridgeKeydownHandler?: (event: KeyboardEvent) => void; }; -function resolveSuggestionMenu( - element: FluentTyperManagedElement, - doc: Document, -): HTMLElement | null { - const entryId = element.getAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); - if (!entryId) { - return null; - } - - const menu = doc.querySelector( - `[data-ft-suggestion-role="menu"][${EARLY_TAB_ACCEPT_ENTRY_ID_ATTR}="${entryId}"]`, - ); - return menu instanceof HTMLElement ? menu : null; -} - -function hasVisibleSuggestionMenu(element: FluentTyperManagedElement, doc: Document): boolean { - const menu = resolveSuggestionMenu(element, doc); - return menu instanceof HTMLElement && menu.isConnected && menu.style.display !== "none"; -} - function isManagedSuggestionTarget( element: HTMLElement | null, doc: Document, ): element is FluentTyperManagedElement { + const entryId = + element instanceof HTMLElement ? element.getAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR) : null; return ( element instanceof HTMLElement && element.getAttribute("data-suggestion") === "true" && element.getAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR) === "true" && element.getAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR) === "true" && - hasVisibleSuggestionMenu(element, doc) + element.getAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR) === "true" && + !!entryId && + isSuggestionMenuHostVisible(resolveSuggestionMenuHost(doc, entryId)) ); } @@ -155,4 +141,5 @@ export { EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, EARLY_TAB_ACCEPT_MESSAGE_TYPE, EARLY_TAB_ACCEPT_REQUEST_EVENT, + EARLY_TAB_ACCEPT_VISIBLE_ATTR, } from "./EarlyTabAcceptBridgeProtocol"; diff --git a/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts b/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts index 7f489198..22947177 100644 --- a/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts +++ b/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts @@ -1,3 +1,5 @@ +import { resolveSuggestionOverlayRoot } from "./SuggestionOverlayRoot"; + export class InlineSuggestionView { static readonly CLASS_NAME = "ft-suggestion-inline"; static readonly OWNED_ATTR = "data-ft-suggestion-owned"; @@ -54,7 +56,7 @@ export class InlineSuggestionView { ghost.style.maxWidth = `${maxWidth}px`; } - doc.body.appendChild(ghost); + resolveSuggestionOverlayRoot(doc).appendChild(ghost); return ghost; } diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts b/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts index 1c191f4d..7df720ea 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts @@ -1,3 +1,4 @@ +import { isSuggestionMenuHostVisible } from "./SuggestionMenuHost"; import type { SuggestionEntry } from "./types"; export interface SuggestionLifecycleControllerOptions { @@ -222,10 +223,7 @@ export class SuggestionLifecycleController { } private isDocumentTabFallbackEligible(entry: SuggestionEntry): boolean { - return ( - entry.inlineSuggestion !== null || - (entry.suggestions.length > 0 && entry.menu.style.display !== "none") - ); + return entry.inlineSuggestion !== null || isSuggestionMenuHostVisible(entry.menu); } private onDocumentSelectionChange(): void { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index 7c4504cc..d201a933 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -17,8 +17,10 @@ import { SuggestionLifecycleController } from "./SuggestionLifecycleController"; import { SuggestionMenuPresenter } from "./SuggestionMenuPresenter"; import { SuggestionPositioningService } from "./SuggestionPositioningService"; import { SuggestionPredictionCoordinator } from "./SuggestionPredictionCoordinator"; +import { resolveSuggestionStateHost } from "./SuggestionStateHost"; import { SuggestionMenuView } from "./SuggestionMenuView"; import { SuggestionTelemetryService } from "./SuggestionTelemetryService"; +import { resolveSuggestionOverlayRoot } from "./SuggestionOverlayRoot"; import { EditableContextResolver } from "./EditableContextResolver"; import { SuggestionTextEditService } from "./SuggestionTextEditService"; import { ContentEditableAdapter } from "./ContentEditableAdapter"; @@ -27,6 +29,7 @@ import { EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, EARLY_TAB_ACCEPT_ENABLED_ATTR, EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, + EARLY_TAB_ACCEPT_VISIBLE_ATTR, } from "./EarlyTabAcceptBridgeProtocol"; import { resolveTraceAgeMs } from "../predictionTrace"; import type { @@ -454,8 +457,11 @@ export class SuggestionManagerRuntime { this.removeManualAttachUi(elem); const id = this.entryRegistry.allocateId(); + const stateHost = resolveSuggestionStateHost(elem); - const { menu, list } = SuggestionMenuView.ensureMenu(document.body ?? document.documentElement); + const { menu, list } = SuggestionMenuView.ensureMenu( + resolveSuggestionOverlayRoot(elem.ownerDocument ?? document), + ); const entry: SuggestionEntry = { id, @@ -521,15 +527,16 @@ export class SuggestionManagerRuntime { }; entry.handlers.menuClick = this.onMenuClick.bind(this, id); - elem.setAttribute("data-tribute", "true"); - elem.setAttribute("data-suggestion", "true"); - elem.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, String(id)); - elem.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, String(this.autocompleteOnTab)); - elem.setAttribute( + stateHost.setAttribute("data-tribute", "true"); + stateHost.setAttribute("data-suggestion", "true"); + stateHost.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, String(id)); + stateHost.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, String(this.autocompleteOnTab)); + stateHost.setAttribute( EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, String(this.shouldUseEarlyTabBridge(elem)), ); - menu.dataset.ftSuggestionId = String(id); + stateHost.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); + menu.id = SuggestionMenuView.resolveHostId(id); elem.tributeMenu = menu; elem.suggestionMenu = menu; @@ -558,14 +565,16 @@ export class SuggestionManagerRuntime { this.sessionRegistry.get(id)?.dispose(); this.lifecycleController.detachEntryListeners(entry); entry.menu.remove(); + const stateHost = resolveSuggestionStateHost(entry.elem); delete entry.elem.tributeMenu; delete entry.elem.suggestionMenu; - entry.elem.removeAttribute("data-tribute"); - entry.elem.removeAttribute("data-suggestion"); - entry.elem.removeAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); - entry.elem.removeAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR); - entry.elem.removeAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR); + stateHost.removeAttribute("data-tribute"); + stateHost.removeAttribute("data-suggestion"); + stateHost.removeAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); + stateHost.removeAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR); + stateHost.removeAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR); + stateHost.removeAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR); this.entryRegistry.unregister(id); this.sessionRegistry.delete(id); @@ -711,7 +720,7 @@ export class SuggestionManagerRuntime { entry, editableContextResolver: this.editableContextResolver, clearPendingFallback: () => this.clearPendingKeyFallback(entry.id), - hideMenu: () => this.menuPresenter.hide(entry.menu, entry.list), + hideMenu: () => this.menuPresenter.hide(entry.menu, entry.list, entry.elem), clearInlinePresenter: () => this.inlinePresenter.clearAll(), isFocused: () => this.isEntryFocused(entry), displayLangHeader: this.displayLangHeader, @@ -723,6 +732,7 @@ export class SuggestionManagerRuntime { getPendingFallback: () => this.pendingKeyFallbacks.get(entry.id), renderMenu: ({ suggestions, selectedIndex, menuHeader, mentionText }) => this.menuPresenter.render({ + menuId: entry.id, menu: entry.menu, list: entry.list, target: entry.elem, diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuHost.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuHost.ts new file mode 100644 index 00000000..9142dd44 --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuHost.ts @@ -0,0 +1,31 @@ +export const SUGGESTION_MENU_HOST_ID_PREFIX = "ft-menu-"; + +export function resolveSuggestionMenuHostId(entryId: number | string): string { + return `${SUGGESTION_MENU_HOST_ID_PREFIX}${entryId}`; +} + +export function resolveSuggestionMenuHost( + doc: Document, + entryId: number | string, +): HTMLElement | null { + const menu = doc.getElementById(resolveSuggestionMenuHostId(entryId)); + return menu instanceof HTMLElement ? menu : null; +} + +export function isSuggestionMenuHostVisible(menu: HTMLElement | null): boolean { + if (!(menu instanceof HTMLElement) || !menu.isConnected) { + return false; + } + + const win = menu.ownerDocument?.defaultView; + if (!win) { + return false; + } + + const computed = win.getComputedStyle(menu); + return ( + computed.display !== "none" && + computed.visibility !== "hidden" && + computed.visibility !== "collapse" + ); +} diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts index 1ddb25a6..a2240892 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts @@ -1,8 +1,12 @@ +import { EARLY_TAB_ACCEPT_VISIBLE_ATTR } from "./EarlyTabAcceptBridgeProtocol"; +import { isSuggestionMenuHostVisible } from "./SuggestionMenuHost"; +import { resolveSuggestionStateHost } from "./SuggestionStateHost"; import { SuggestionPositioningService } from "./SuggestionPositioningService"; import { SuggestionMenuView } from "./SuggestionMenuView"; import type { SuggestionElement } from "./types"; export interface SuggestionMenuRenderModel { + menuId: number; menu: HTMLDivElement; list: HTMLUListElement; target: SuggestionElement; @@ -44,7 +48,7 @@ export class SuggestionMenuPresenter { model.suggestions.forEach((suggestion, index) => { const li = document.createElement("li"); - li.id = `ft-suggestion-option-${model.menu.dataset.ftSuggestionId ?? "runtime"}-${index}`; + li.id = `ft-suggestion-option-${model.menuId}-${index}`; li.innerHTML = this.buildSuggestionMenuItemHtml({ mentionText: model.mentionText, suggestion, @@ -64,7 +68,7 @@ export class SuggestionMenuPresenter { }); if (model.suggestions.length === 0) { - this.hide(model.menu, model.list); + this.hide(model.menu, model.list, model.target); if (panel !== model.menu) { panel.setAttribute("aria-hidden", "true"); } @@ -75,7 +79,7 @@ export class SuggestionMenuPresenter { model.menu.style.setProperty("visibility", "hidden", "important"); this.positioningService.syncMenuTypography(model.menu, model.target); if (!this.positioningService.positionMenu(model.menu, model.target)) { - this.hide(model.menu, model.list); + this.hide(model.menu, model.list, model.target); if (panel !== model.menu) { panel.setAttribute("aria-hidden", "true"); } @@ -85,18 +89,22 @@ export class SuggestionMenuPresenter { panel.setAttribute("aria-hidden", "false"); panel.setAttribute( "aria-activedescendant", - `ft-suggestion-option-${model.menu.dataset.ftSuggestionId ?? "runtime"}-${model.selectedIndex}`, + `ft-suggestion-option-${model.menuId}-${model.selectedIndex}`, ); model.menu.style.setProperty("display", "block", "important"); model.menu.style.setProperty("visibility", "visible", "important"); + resolveSuggestionStateHost(model.target).setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); return true; } - public hide(menu: HTMLDivElement, list: HTMLUListElement): void { + public hide(menu: HTMLDivElement, list: HTMLUListElement, target?: SuggestionElement): void { const header = SuggestionMenuView.resolveHeader(menu); const panel = SuggestionMenuView.resolvePanel(menu); menu.style.setProperty("display", "none", "important"); menu.style.setProperty("visibility", "visible", "important"); + if (target) { + resolveSuggestionStateHost(target).setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); + } if (header) { header.textContent = ""; header.hidden = true; @@ -107,7 +115,7 @@ export class SuggestionMenuPresenter { } public isVisible(menu: HTMLDivElement, suggestionCount: number): boolean { - return menu.style.display !== "none" && suggestionCount > 0; + return suggestionCount > 0 && isSuggestionMenuHostVisible(menu); } public updateHighlight(list: HTMLUListElement, selectedIndex: number): void { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts index 90ee8338..391c3992 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts @@ -1,3 +1,4 @@ +import { resolveSuggestionMenuHostId, SUGGESTION_MENU_HOST_ID_PREFIX } from "./SuggestionMenuHost"; import { SUGGESTION_POPUP_SHADOW_CSS } from "./SuggestionPopupShadowStyles"; export interface SuggestionMenuElements { @@ -7,28 +8,27 @@ export interface SuggestionMenuElements { export class SuggestionMenuView { static readonly CONTAINER_CLASS = "ft-suggestion-container"; + static readonly HOST_ID_PREFIX = SUGGESTION_MENU_HOST_ID_PREFIX; static readonly OWNED_ATTR = "data-ft-suggestion-owned"; static readonly ROLE_ATTR = "data-ft-suggestion-role"; static readonly MENU_ROLE = "menu"; - static readonly SHADOW_ATTR = "data-ft-suggestion-shadow"; static readonly PANEL_CLASS = "ft-suggestion-panel"; static readonly HEADER_CLASS = "ft-suggestion-header"; static readonly LIST_CLASS = "ft-suggestion-list"; + static resolveHostId(entryId: number | string): string { + return resolveSuggestionMenuHostId(entryId); + } + static ensureMenu( container: HTMLElement = document.body ?? document.documentElement, ): SuggestionMenuElements { const doc = container.ownerDocument ?? document; const menu = doc.createElement("div"); - menu.className = SuggestionMenuView.CONTAINER_CLASS; - menu.setAttribute(SuggestionMenuView.OWNED_ATTR, "true"); - menu.setAttribute(SuggestionMenuView.ROLE_ATTR, SuggestionMenuView.MENU_ROLE); - menu.setAttribute("tabindex", "-1"); let list!: HTMLUListElement; if (typeof menu.attachShadow === "function") { this.applyBaseHostStyles(menu, true); - menu.setAttribute(SuggestionMenuView.SHADOW_ATTR, "true"); const shadowRoot = menu.attachShadow({ mode: "open" }); shadowRoot.appendChild(this.createShadowStyle(doc)); shadowRoot.appendChild( @@ -38,6 +38,9 @@ export class SuggestionMenuView { ); } else { this.applyBaseHostStyles(menu, false); + menu.className = SuggestionMenuView.CONTAINER_CLASS; + menu.setAttribute(SuggestionMenuView.OWNED_ATTR, "true"); + menu.setAttribute(SuggestionMenuView.ROLE_ATTR, SuggestionMenuView.MENU_ROLE); list = doc.createElement("ul"); list.className = SuggestionMenuView.LIST_CLASS; menu.appendChild(this.createHeader(doc)); @@ -72,7 +75,7 @@ export class SuggestionMenuView { onListCreated: (list: HTMLUListElement) => void, ): HTMLDivElement { const panel = doc.createElement("div"); - panel.className = SuggestionMenuView.PANEL_CLASS; + panel.className = `${SuggestionMenuView.PANEL_CLASS} ${SuggestionMenuView.CONTAINER_CLASS}`; panel.setAttribute("part", "panel"); panel.setAttribute("role", "listbox"); panel.setAttribute("aria-hidden", "true"); diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionOverlayRoot.ts b/src/adapters/chrome/content-script/suggestions/SuggestionOverlayRoot.ts new file mode 100644 index 00000000..21762340 --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionOverlayRoot.ts @@ -0,0 +1,3 @@ +export function resolveSuggestionOverlayRoot(doc: Document = document): HTMLElement { + return doc.documentElement ?? doc.body; +} diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionStateHost.ts b/src/adapters/chrome/content-script/suggestions/SuggestionStateHost.ts new file mode 100644 index 00000000..aa60448e --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionStateHost.ts @@ -0,0 +1,9 @@ +import type { SuggestionElement } from "./types"; + +export function resolveSuggestionStateHost(target: SuggestionElement): HTMLElement { + const doc = target.ownerDocument ?? document; + if (target === doc.body && target.isContentEditable) { + return doc.documentElement ?? target; + } + return target; +} diff --git a/tests/EarlyTabAcceptMainWorldBridge.test.ts b/tests/EarlyTabAcceptMainWorldBridge.test.ts index 7928c0cd..33138e42 100644 --- a/tests/EarlyTabAcceptMainWorldBridge.test.ts +++ b/tests/EarlyTabAcceptMainWorldBridge.test.ts @@ -4,6 +4,7 @@ import { EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, EARLY_TAB_ACCEPT_ENABLED_ATTR, EARLY_TAB_ACCEPT_MESSAGE_TYPE, + EARLY_TAB_ACCEPT_VISIBLE_ATTR, installEarlyTabAcceptMainWorldBridge, resetEarlyTabAcceptMainWorldBridgeForTests, } from "../src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge"; @@ -14,9 +15,25 @@ declare global { } } +function createMenu(entryId: string, styles: Partial = {}): HTMLDivElement { + const menu = document.createElement("div"); + menu.id = `ft-menu-${entryId}`; + menu.style.display = "block"; + Object.assign(menu.style, styles); + return menu; +} + describe("EarlyTabAcceptMainWorldBridge", () => { afterEach(() => { document.body.innerHTML = ""; + document.body.removeAttribute("contenteditable"); + delete (document.body as { isContentEditable?: boolean }).isContentEditable; + document.documentElement.removeAttribute("data-suggestion"); + document.documentElement.removeAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR); + document.documentElement.removeAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR); + document.documentElement.removeAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); + document.documentElement.removeAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR); + document.querySelectorAll('[id^="ft-menu-"]').forEach((node) => node.remove()); resetEarlyTabAcceptMainWorldBridgeForTests(document); }); @@ -30,10 +47,8 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); - const menu = document.createElement("div"); - menu.style.display = "block"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("7"); document.body.append(input, menu); const pageCaptureBlocker = (event: Event) => { @@ -71,10 +86,8 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "9"); - const menu = document.createElement("div"); - menu.style.display = "block"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "9"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("9"); document.body.append(input, menu); const windowCaptureBlocker = (event: Event) => { @@ -112,10 +125,8 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); - const menu = document.createElement("div"); - menu.style.display = "none"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); + const menu = createMenu("7", { display: "none" }); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { @@ -140,10 +151,8 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "false"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "11"); - const menu = document.createElement("div"); - menu.style.display = "block"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "11"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("11"); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { @@ -168,10 +177,61 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "false"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "13"); - const menu = document.createElement("div"); - menu.style.display = "block"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "13"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("13"); + document.body.append(input, menu); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + postMessageSpy.mockRestore(); + }); + + test("does not post when the popup host was removed without clearing the visible flag", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("div"); + input.setAttribute("contenteditable", "true"); + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "17"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("17"); + document.body.append(input, menu); + menu.remove(); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + postMessageSpy.mockRestore(); + }); + + test("does not post when the popup host is computed hidden without clearing the visible flag", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("div"); + input.setAttribute("contenteditable", "true"); + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "19"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("19", { visibility: "hidden" }); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { @@ -185,4 +245,40 @@ describe("EarlyTabAcceptMainWorldBridge", () => { expect(keydown.defaultPrevented).toBe(false); postMessageSpy.mockRestore(); }); + + test("posts for a contenteditable body when the bridge markers live on the html root", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + document.body.setAttribute("contenteditable", "true"); + Object.defineProperty(document.body, "isContentEditable", { + value: true, + configurable: true, + }); + document.documentElement.setAttribute("data-suggestion", "true"); + document.documentElement.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + document.documentElement.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + document.documentElement.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "29"); + document.documentElement.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("29"); + document.documentElement.append(menu); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + document.body.dispatchEvent(keydown); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + source: "ft-early-tab-accept-request", + type: EARLY_TAB_ACCEPT_MESSAGE_TYPE, + entryId: "29", + }, + "*", + ); + expect(keydown.defaultPrevented).toBe(true); + postMessageSpy.mockRestore(); + }); }); diff --git a/tests/InlineSuggestionView.test.ts b/tests/InlineSuggestionView.test.ts new file mode 100644 index 00000000..70048a67 --- /dev/null +++ b/tests/InlineSuggestionView.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { InlineSuggestionView } from "../src/adapters/chrome/content-script/suggestions/InlineSuggestionView"; + +describe("InlineSuggestionView", () => { + afterEach(() => { + document.documentElement.innerHTML = ""; + }); + + test("mounts inline ghost outside a contenteditable body root", () => { + document.body.setAttribute("contenteditable", "true"); + Object.defineProperty(document.body, "isContentEditable", { + value: true, + configurable: true, + }); + document.body.textContent = "hello"; + + const ghost = InlineSuggestionView.render({ + target: document.body, + text: " world", + caretRect: { left: 10, top: 20, width: 0, height: 16 } as DOMRect, + doc: document, + }); + + expect(ghost).not.toBeNull(); + expect(ghost?.parentElement).toBe(document.documentElement); + expect(document.body.querySelector(`.${InlineSuggestionView.CLASS_NAME}`)).toBeNull(); + }); +}); diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index 31bb5ecc..4e7181b6 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -117,6 +117,11 @@ function clickManualAttachButton(button: HTMLButtonElement): void { button.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true })); } +function removeSuggestionOverlayNodes(): void { + document.querySelectorAll('[id^="ft-menu-"]').forEach((node) => node.remove()); + document.querySelectorAll(".ft-suggestion-inline").forEach((node) => node.remove()); +} + function mockRect( element: Element, rect: Pick, @@ -165,6 +170,9 @@ describe("SuggestionManagerRuntime", () => { (globalThis as unknown as { getComputedStyle: typeof getComputedStyle }).getComputedStyle = baseGlobals.getComputedStyle; document.body.innerHTML = ""; + document.body.removeAttribute("contenteditable"); + delete (document.body as { isContentEditable?: boolean }).isContentEditable; + removeSuggestionOverlayNodes(); document.documentElement.dir = ""; (globalThis as unknown as { chrome: unknown }).chrome = { runtime: { @@ -195,6 +203,7 @@ describe("SuggestionManagerRuntime", () => { (globalThis as unknown as { getComputedStyle: typeof getComputedStyle }).getComputedStyle = baseGlobals.getComputedStyle; (globalThis as unknown as { chrome: unknown }).chrome = baseGlobals.chrome; + removeSuggestionOverlayNodes(); releaseDomGlobalLock?.(); releaseDomGlobalLock = null; }); @@ -227,6 +236,51 @@ describe("SuggestionManagerRuntime", () => { expect(input.hasAttribute("data-suggestion")).toBe(false); }); + test("mounts the popup host outside a contenteditable body root", () => { + const runtime = makeRuntime(); + const originalBodyContentEditable = Object.getOwnPropertyDescriptor( + document.body, + "isContentEditable", + ); + document.body.setAttribute("contenteditable", "true"); + Object.defineProperty(document.body, "isContentEditable", { + value: true, + configurable: true, + }); + try { + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(document.body); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + expect(entry.menu.parentElement).toBe(document.documentElement); + expect(document.body.querySelector(`#${entry.menu.id}`)).toBeNull(); + expect(document.body.hasAttribute("data-suggestion")).toBe(false); + expect(document.body.hasAttribute("data-tribute")).toBe(false); + expect(document.body.hasAttribute("data-ft-suggestion-id")).toBe(false); + expect(document.documentElement.getAttribute("data-suggestion")).toBe("true"); + expect(document.documentElement.getAttribute("data-tribute")).toBe("true"); + expect(document.documentElement.getAttribute("data-ft-suggestion-id")).toBe(String(entry.id)); + + runtime.detachAllHelpers(); + expect(document.documentElement.hasAttribute("data-suggestion")).toBe(false); + expect(document.documentElement.hasAttribute("data-tribute")).toBe(false); + expect(document.documentElement.hasAttribute("data-ft-suggestion-id")).toBe(false); + } finally { + document.body.removeAttribute("contenteditable"); + if (originalBodyContentEditable) { + Object.defineProperty(document.body, "isContentEditable", originalBodyContentEditable); + } else { + delete (document.body as { isContentEditable?: boolean }).isContentEditable; + } + } + }); + test("runtime only orchestrates attach, active-session lookup, and response routing", () => { const runtime = makeRuntime(); const input = document.createElement("input"); @@ -612,6 +666,50 @@ describe("SuggestionManagerRuntime", () => { expect(acceptSuggestionAtIndex).toHaveBeenCalledWith(0); }); + test("early bridge accept reports no visible suggestion state when the popup host is hidden", () => { + const runtime = makeRuntime(); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(editable); + + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(editable); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + entry.suggestions = ["hello"]; + entry.selectedIndex = 0; + entry.menu.style.display = "block"; + entry.menu.style.visibility = "hidden"; + + const session = getAttachedSession(runtime, entry.id); + const acceptSuggestionAtIndex = jest.fn(() => true); + session.acceptSuggestionAtIndex = acceptSuggestionAtIndex; + + expect( + ( + runtime as unknown as { + handleEarlyTabAcceptRequest: (entryId: string) => { + accepted: boolean; + reason: string; + }; + } + ).handleEarlyTabAcceptRequest(String(entry.id)), + ).toEqual( + expect.objectContaining({ + accepted: false, + reason: "no_visible_suggestion_state", + }), + ); + expect(acceptSuggestionAtIndex).not.toHaveBeenCalled(); + }); + test("early bridge accept reports failure when session acceptance returns false", () => { const runtime = makeRuntime(); const editable = document.createElement("div"); @@ -650,6 +748,53 @@ describe("SuggestionManagerRuntime", () => { ); }); + test("document-level Tab capture ignores suggestions when the popup host was removed", () => { + const runtime = makeRuntime(); + const wrapper = document.createElement("div"); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + wrapper.appendChild(editable); + document.body.appendChild(wrapper); + + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(editable); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + entry.suggestions = ["hello"]; + entry.selectedIndex = 0; + entry.menu.style.display = "block"; + entry.menu.remove(); + + const session = getAttachedSession(runtime, entry.id); + const acceptSuggestionAtIndex = jest.fn(() => true); + session.acceptSuggestionAtIndex = acceptSuggestionAtIndex; + + wrapper.addEventListener( + "keydown", + (event) => { + event.stopPropagation(); + }, + true, + ); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + editable.dispatchEvent(keydown); + + expect(acceptSuggestionAtIndex).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + }); + test("selection reconciliation delegates to the attached session", () => { const runtime = makeRuntime("input"); const input = document.createElement("input"); diff --git a/tests/SuggestionMenuPresenter.test.ts b/tests/SuggestionMenuPresenter.test.ts index 9e9bea68..3db30fb1 100644 --- a/tests/SuggestionMenuPresenter.test.ts +++ b/tests/SuggestionMenuPresenter.test.ts @@ -15,6 +15,7 @@ describe("SuggestionMenuPresenter", () => { menu.appendChild(list); const rendered = presenter.render({ + menuId: 1, menu, list, target, @@ -48,6 +49,7 @@ describe("SuggestionMenuPresenter", () => { menu.appendChild(list); const rendered = presenter.render({ + menuId: 1, menu, list, target, diff --git a/tests/SuggestionMenuView.test.ts b/tests/SuggestionMenuView.test.ts new file mode 100644 index 00000000..d9e0bc6c --- /dev/null +++ b/tests/SuggestionMenuView.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { SuggestionMenuView } from "../src/adapters/chrome/content-script/suggestions/SuggestionMenuView"; + +describe("SuggestionMenuView", () => { + test("keeps the public container class inside the shadow root", () => { + const mount = document.createElement("div"); + document.body.appendChild(mount); + + const { menu, list } = SuggestionMenuView.ensureMenu(mount); + + expect(menu.parentElement).toBe(mount); + expect(menu.classList.contains(SuggestionMenuView.CONTAINER_CLASS)).toBe(false); + expect(menu.getAttribute("data-ft-suggestion-owned")).toBeNull(); + expect(menu.getAttribute("data-ft-suggestion-role")).toBeNull(); + expect(menu.getAttribute("data-ft-suggestion-shadow")).toBeNull(); + expect(menu.getAttribute("tabindex")).toBeNull(); + expect(document.querySelector(`.${SuggestionMenuView.CONTAINER_CLASS}`)).toBeNull(); + + const shadowRoot = menu.shadowRoot; + expect(shadowRoot).not.toBeNull(); + expect(list.getRootNode()).toBe(shadowRoot); + + const panel = shadowRoot?.querySelector(`.${SuggestionMenuView.PANEL_CLASS}`); + expect(panel).not.toBeNull(); + expect(panel?.classList.contains(SuggestionMenuView.CONTAINER_CLASS)).toBe(true); + }); + + test("keeps styling hooks on the light-DOM fallback host when shadow DOM is unavailable", () => { + const mount = document.createElement("div"); + document.body.appendChild(mount); + + const originalAttachShadow = HTMLElement.prototype.attachShadow; + Object.defineProperty(HTMLElement.prototype, "attachShadow", { + value: undefined, + configurable: true, + }); + + try { + const { menu, list } = SuggestionMenuView.ensureMenu(mount); + + expect(menu.parentElement).toBe(mount); + expect(menu.classList.contains(SuggestionMenuView.CONTAINER_CLASS)).toBe(true); + expect(menu.getAttribute("data-ft-suggestion-owned")).toBe("true"); + expect(menu.getAttribute("data-ft-suggestion-role")).toBe("menu"); + expect(list.getRootNode()).toBe(document); + expect(menu.shadowRoot).toBeNull(); + } finally { + Object.defineProperty(HTMLElement.prototype, "attachShadow", { + value: originalAttachShadow, + configurable: true, + }); + } + }); +}); diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index d0643adb..9f6d9313 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -1131,15 +1131,50 @@ async function waitForVisibleSuggestionTexts( const texts = await page.evaluate(() => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getKnownMenus = (): Element[] => { + const seen = new Set(); + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -1170,15 +1205,48 @@ async function hasVisibleSuggestions(page: Page): Promise { return page.evaluate(() => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getKnownMenus = (): Element[] => { + const seen = new Set(); + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; return containers.some((container) => { const style = window.getComputedStyle(container); @@ -1203,15 +1271,50 @@ async function waitForNoVisibleSuggestions( () => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getKnownMenus = (): Element[] => { + const seen = new Set(); + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; return containers.every((container) => { const style = window.getComputedStyle(container); @@ -1238,15 +1341,50 @@ async function clickFirstVisibleSuggestion( () => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getKnownMenus = (): Element[] => { + const seen = new Set(); + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -2053,10 +2191,55 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { const state = await page.evaluate(() => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getKnownMenus = (): Element[] => { + const seen = new Set(); + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => + typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; const hasInlineSuggestion = Boolean( document.querySelector(".ft-suggestion-inline")?.textContent, ); - const containers = Array.from(document.querySelectorAll(".ft-suggestion-container")); + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; + const containers = [ + ...(activeMenu instanceof Element ? [activeMenu] : []), + ...getKnownMenus().filter((container) => container !== activeMenu), + ]; const hasVisiblePopup = containers.some((container) => { const style = window.getComputedStyle(container); if ( diff --git a/tests/e2e/smoke.e2e.test.ts b/tests/e2e/smoke.e2e.test.ts index 6ebaa3cb..25475c5d 100644 --- a/tests/e2e/smoke.e2e.test.ts +++ b/tests/e2e/smoke.e2e.test.ts @@ -672,15 +672,50 @@ async function waitForSuggestionTexts(page: Page): Promise { () => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getKnownMenus = (): Element[] => { + const seen = new Set(); + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -716,15 +751,50 @@ async function getVisibleSuggestionThemeSnapshot(page: Page): Promise<{ ((container as HTMLElement).shadowRoot?.querySelector( ".ft-suggestion-panel", ) as Element | null) ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getKnownMenus = (): Element[] => { + const seen = new Set(); + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); diff --git a/tests/suggestionTestUtils.ts b/tests/suggestionTestUtils.ts index a14447e6..1e64e2d3 100644 --- a/tests/suggestionTestUtils.ts +++ b/tests/suggestionTestUtils.ts @@ -2,6 +2,8 @@ import type { SuggestionEntry, SuggestionElement, } from "../src/adapters/chrome/content-script/suggestions/types"; +import { EARLY_TAB_ACCEPT_ENTRY_ID_ATTR } from "../src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge"; +import { SuggestionMenuView } from "../src/adapters/chrome/content-script/suggestions/SuggestionMenuView"; export function createSuggestionEntry( overrides: Partial & { elem?: SuggestionElement } = {}, @@ -76,9 +78,27 @@ export function createRect(left = 10, top = 20, width = 30, height = 12): DOMRec } export function getSuggestionMenuRoots(doc: Document = document): ParentNode[] { - return Array.from(doc.querySelectorAll(".ft-suggestion-container")).map( - (container) => container.shadowRoot ?? container, - ); + const seen = new Set(); + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + + return collectManagedElements(doc) + .map((element) => element.getAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR)) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => doc.getElementById(SuggestionMenuView.resolveHostId(entryId))) + .filter((menu): menu is HTMLElement => menu instanceof HTMLElement) + .map((container) => container.shadowRoot ?? container) + .filter((root) => { + if (seen.has(root)) { + return false; + } + seen.add(root); + return true; + }); } export function querySuggestionMenuItems(doc: Document = document): HTMLLIElement[] {