Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG,
EARLY_TAB_ACCEPT_MESSAGE_TYPE,
EARLY_TAB_ACCEPT_REQUEST_EVENT,
EARLY_TAB_ACCEPT_VISIBLE_ATTR,
} from "./EarlyTabAcceptBridgeProtocol";

type FluentTyperManagedElement = HTMLElement;
Expand All @@ -13,46 +14,22 @@ 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 {
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"
);
}

function findManagedSuggestionTarget(
start: HTMLElement | null,
doc: Document,
): FluentTyperManagedElement | null {
function findManagedSuggestionTarget(start: HTMLElement | null): FluentTyperManagedElement | null {
let current: Node | null = start;
while (current) {
if (current instanceof HTMLElement && isManagedSuggestionTarget(current, doc)) {
if (current instanceof HTMLElement && isManagedSuggestionTarget(current)) {
return current;
}
current = current.parentNode;
Expand All @@ -67,17 +44,15 @@ function resolveManagedSuggestionTarget(
const path = typeof event.composedPath === "function" ? event.composedPath() : [event.target];
for (const node of path) {
if (node instanceof HTMLElement) {
const match = findManagedSuggestionTarget(node, doc);
const match = findManagedSuggestionTarget(node);
if (match) {
return match;
}
}
}

const activeElement = doc.activeElement;
return activeElement instanceof HTMLElement
? findManagedSuggestionTarget(activeElement, doc)
: null;
return activeElement instanceof HTMLElement ? findManagedSuggestionTarget(activeElement) : null;
}

export function installEarlyTabAcceptMainWorldBridge(doc: Document = document): void {
Expand Down Expand Up @@ -155,4 +130,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";
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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 {
Expand Down Expand Up @@ -529,7 +530,8 @@ export class SuggestionManagerRuntime {
EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR,
String(this.shouldUseEarlyTabBridge(elem)),
);
menu.dataset.ftSuggestionId = String(id);
elem.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false");
menu.id = SuggestionMenuView.resolveHostId(id);
elem.tributeMenu = menu;
elem.suggestionMenu = menu;

Expand Down Expand Up @@ -566,6 +568,7 @@ export class SuggestionManagerRuntime {
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);
entry.elem.removeAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR);

this.entryRegistry.unregister(id);
this.sessionRegistry.delete(id);
Expand Down Expand Up @@ -711,7 +714,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,
Expand All @@ -723,6 +726,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { EARLY_TAB_ACCEPT_VISIBLE_ATTR } from "./EarlyTabAcceptBridgeProtocol";
import { SuggestionPositioningService } from "./SuggestionPositioningService";
import { SuggestionMenuView } from "./SuggestionMenuView";
import type { SuggestionElement } from "./types";

export interface SuggestionMenuRenderModel {
menuId: number;
menu: HTMLDivElement;
list: HTMLUListElement;
target: SuggestionElement;
Expand Down Expand Up @@ -44,7 +46,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,
Expand All @@ -64,7 +66,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");
}
Expand All @@ -75,7 +77,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");
}
Expand All @@ -85,18 +87,20 @@ 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");
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");
target?.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false");
if (header) {
header.textContent = "";
header.hidden = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,24 @@ export interface SuggestionMenuElements {

export class SuggestionMenuView {
static readonly CONTAINER_CLASS = "ft-suggestion-container";
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 HOST_ID_PREFIX = "ft-menu-";
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 `${SuggestionMenuView.HOST_ID_PREFIX}${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(
Expand All @@ -38,6 +34,7 @@ export class SuggestionMenuView {
);
} else {
this.applyBaseHostStyles(menu, false);
menu.className = SuggestionMenuView.CONTAINER_CLASS;
list = doc.createElement("ul");
list.className = SuggestionMenuView.LIST_CLASS;
menu.appendChild(this.createHeader(doc));
Expand Down Expand Up @@ -72,7 +69,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");
Expand Down
16 changes: 6 additions & 10 deletions tests/EarlyTabAcceptMainWorldBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,10 +31,9 @@ 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");
input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true");
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");
document.body.append(input, menu);

const pageCaptureBlocker = (event: Event) => {
Expand Down Expand Up @@ -71,10 +71,9 @@ 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");
input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true");
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");
document.body.append(input, menu);

const windowCaptureBlocker = (event: Event) => {
Expand Down Expand Up @@ -112,10 +111,9 @@ 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");
input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false");
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");
document.body.append(input, menu);

const keydown = new window.KeyboardEvent("keydown", {
Expand All @@ -140,10 +138,9 @@ 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");
input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true");
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");
document.body.append(input, menu);

const keydown = new window.KeyboardEvent("keydown", {
Expand All @@ -168,10 +165,9 @@ 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");
input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true");
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");
document.body.append(input, menu);

const keydown = new window.KeyboardEvent("keydown", {
Expand Down
2 changes: 2 additions & 0 deletions tests/SuggestionMenuPresenter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe("SuggestionMenuPresenter", () => {
menu.appendChild(list);

const rendered = presenter.render({
menuId: 1,
menu,
list,
target,
Expand Down Expand Up @@ -48,6 +49,7 @@ describe("SuggestionMenuPresenter", () => {
menu.appendChild(list);

const rendered = presenter.render({
menuId: 1,
menu,
list,
target,
Expand Down
27 changes: 27 additions & 0 deletions tests/SuggestionMenuView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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);
});
});
Loading
Loading