diff --git a/src/content/hint-tooltip.test.ts b/src/content/hint-tooltip.test.ts new file mode 100644 index 0000000..f54a59c --- /dev/null +++ b/src/content/hint-tooltip.test.ts @@ -0,0 +1,163 @@ +/** + * Tests for hint tooltip + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { resetMockStorage } from "../test/setup.ts" + +// Must import after test setup has mocked chrome +import { + loadDismissedState, + showHintTooltip, + hideHintTooltip, + refreshHintTooltipStyles, +} from "./hint-tooltip.ts" + +function createTextarea(): HTMLTextAreaElement { + const el = document.createElement("textarea") + document.body.appendChild(el) + // Give it dimensions so getBoundingClientRect returns values + Object.defineProperty(el, "getBoundingClientRect", { + value: () => ({ + top: 100, + left: 50, + right: 400, + bottom: 250, + width: 350, + height: 150, + }), + }) + return el +} + +describe("hint-tooltip", () => { + beforeEach(() => { + resetMockStorage() + // Reset DOM + const existing = document.getElementById("slashPaletteHintTooltip") + if (existing) existing.remove() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + hideHintTooltip() + // Clean up textareas + document.querySelectorAll("textarea").forEach((el) => el.remove()) + }) + + it("shows tooltip after delay when field is focused", async () => { + await loadDismissedState() + const field = createTextarea() + + showHintTooltip(field) + + // Not visible immediately + expect(document.getElementById("slashPaletteHintTooltip")).toBeNull() + + // Advance past show delay + vi.advanceTimersByTime(700) + + const tooltip = document.getElementById("slashPaletteHintTooltip") + expect(tooltip).not.toBeNull() + expect(tooltip!.textContent).toContain("//") + expect(tooltip!.textContent).toContain("commands") + }) + + it("does not show tooltip when dismissed", async () => { + // Set dismissed state in storage + await chrome.storage.local.set({ hintTooltipDismissed: true }) + await loadDismissedState() + + const field = createTextarea() + showHintTooltip(field) + + vi.advanceTimersByTime(700) + + expect(document.getElementById("slashPaletteHintTooltip")).toBeNull() + }) + + it("hides tooltip when hideHintTooltip is called", async () => { + await loadDismissedState() + const field = createTextarea() + + showHintTooltip(field) + vi.advanceTimersByTime(700) + + expect(document.getElementById("slashPaletteHintTooltip")).not.toBeNull() + + hideHintTooltip() + + // Advance past fade-out + vi.advanceTimersByTime(200) + + expect(document.getElementById("slashPaletteHintTooltip")).toBeNull() + }) + + it("auto-hides after delay", async () => { + await loadDismissedState() + const field = createTextarea() + + showHintTooltip(field) + vi.advanceTimersByTime(700) + + expect(document.getElementById("slashPaletteHintTooltip")).not.toBeNull() + + // Advance past auto-hide delay + vi.advanceTimersByTime(8100) + + expect(document.getElementById("slashPaletteHintTooltip")).toBeNull() + }) + + it("tooltip has dismiss button", async () => { + await loadDismissedState() + const field = createTextarea() + + showHintTooltip(field) + vi.advanceTimersByTime(700) + + const tooltip = document.getElementById("slashPaletteHintTooltip") + expect(tooltip).not.toBeNull() + + const btn = tooltip!.querySelector("button") + expect(btn).not.toBeNull() + expect(btn!.getAttribute("aria-label")).toBe("Dismiss hint") + }) + + it("dismiss button stores dismissed state", async () => { + await loadDismissedState() + const field = createTextarea() + + showHintTooltip(field) + vi.advanceTimersByTime(700) + + const tooltip = document.getElementById("slashPaletteHintTooltip") + const btn = tooltip!.querySelector("button")! + btn.click() + + // Advance timers for fade-out + vi.advanceTimersByTime(200) + + // Storage should be updated + const stored = await chrome.storage.local.get({ hintTooltipDismissed: false }) + expect(stored.hintTooltipDismissed).toBe(true) + }) + + it("refreshHintTooltipStyles does not throw when no tooltip", () => { + expect(() => refreshHintTooltipStyles()).not.toThrow() + }) + + it("cancels show if hideHintTooltip is called before delay", async () => { + await loadDismissedState() + const field = createTextarea() + + showHintTooltip(field) + + // Hide before show delay completes + vi.advanceTimersByTime(300) + hideHintTooltip() + vi.advanceTimersByTime(500) + + expect(document.getElementById("slashPaletteHintTooltip")).toBeNull() + }) +}) diff --git a/src/content/hint-tooltip.ts b/src/content/hint-tooltip.ts new file mode 100644 index 0000000..b747e84 --- /dev/null +++ b/src/content/hint-tooltip.ts @@ -0,0 +1,229 @@ +/** + * Hint tooltip that appears near qualifying textareas + * to inform users they can type "//" to open the command list. + */ + +import { COMMAND_PREFIX } from "../utils/command-prefix.ts" +import { getStorageValue, setStorageValue } from "../utils/storage.ts" +import { isDarkMode } from "../utils/theme.ts" +import { fontSystemUi, fontSansSerif } from "../utils/theme.ts" + +const DISMISSED_KEY = "hintTooltipDismissed" +const TOOLTIP_ID = "slashPaletteHintTooltip" +const SHOW_DELAY = 600 +const AUTO_HIDE_DELAY = 8000 + +let tooltipEl: HTMLElement | null = null +let activeField: HTMLTextAreaElement | null = null +let showTimer: ReturnType | null = null +let hideTimer: ReturnType | null = null +let dismissed = false + +/** + * Load dismissed state from storage + */ +export async function loadDismissedState(): Promise { + dismissed = await getStorageValue(DISMISSED_KEY, false) +} + +/** + * Apply theme-aware styles to the tooltip element + */ +function applyTooltipStyles(el: HTMLElement): void { + const dark = isDarkMode() + + el.style.position = "absolute" + el.style.zIndex = "999998" + el.style.padding = "6px 10px" + el.style.borderRadius = "6px" + el.style.fontSize = "12px" + el.style.fontFamily = `${fontSystemUi()}, ${fontSansSerif()}` + el.style.pointerEvents = "auto" + el.style.whiteSpace = "nowrap" + el.style.display = "flex" + el.style.alignItems = "center" + el.style.gap = "6px" + el.style.transition = "opacity 150ms" + + if (dark) { + el.style.backgroundColor = "#161b22" + el.style.color = "#8d96a0" + el.style.border = "1px solid #3d444d" + el.style.boxShadow = "0 2px 8px rgba(1,4,9,0.6)" + } else { + el.style.backgroundColor = "#ffffff" + el.style.color = "#656d76" + el.style.border = "1px solid #d0d7de" + el.style.boxShadow = "0 2px 8px rgba(140,149,159,0.15)" + } +} + +/** + * Create the tooltip element + */ +function createTooltip(): HTMLElement { + const el = document.createElement("div") + el.id = TOOLTIP_ID + el.setAttribute("role", "tooltip") + + const text = document.createElement("span") + text.textContent = `Type ${COMMAND_PREFIX} for commands` + + const closeBtn = document.createElement("button") + closeBtn.textContent = "\u00D7" + closeBtn.setAttribute("aria-label", "Dismiss hint") + closeBtn.style.background = "none" + closeBtn.style.border = "none" + closeBtn.style.cursor = "pointer" + closeBtn.style.padding = "0 2px" + closeBtn.style.fontSize = "14px" + closeBtn.style.lineHeight = "1" + closeBtn.style.color = "inherit" + closeBtn.style.opacity = "0.6" + + closeBtn.addEventListener("mouseenter", () => { + closeBtn.style.opacity = "1" + }) + closeBtn.addEventListener("mouseleave", () => { + closeBtn.style.opacity = "0.6" + }) + closeBtn.addEventListener("click", (ev) => { + ev.preventDefault() + ev.stopPropagation() + dismissHint() + }) + + // Prevent focus steal from textarea + el.addEventListener("mousedown", (ev) => { + ev.preventDefault() + }) + + el.appendChild(text) + el.appendChild(closeBtn) + + return el +} + +/** + * Position the tooltip near the given textarea + */ +function positionTooltip(el: HTMLElement, field: HTMLTextAreaElement): void { + const rect = field.getBoundingClientRect() + const scrollX = window.scrollX || document.documentElement.scrollLeft + const scrollY = window.scrollY || document.documentElement.scrollTop + + // Position at top-right corner of the textarea + el.style.top = `${rect.top + scrollY - el.offsetHeight - 4}px` + el.style.left = `${rect.right + scrollX - el.offsetWidth}px` + + // If tooltip goes above viewport, position below the textarea instead + const topVal = parseFloat(el.style.top) + if (topVal < scrollY) { + el.style.top = `${rect.bottom + scrollY + 4}px` + } +} + +/** + * Show the hint tooltip for a textarea + */ +export function showHintTooltip(field: HTMLTextAreaElement): void { + if (dismissed) return + + // Clear any pending timers + clearTimers() + + activeField = field + + showTimer = setTimeout(() => { + if (activeField !== field) return + + if (!tooltipEl) { + tooltipEl = createTooltip() + } + + applyTooltipStyles(tooltipEl) + tooltipEl.style.opacity = "0" + + // Mount into the same container strategy as the picker + const mount = field.closest( + [ + "details-dialog", + "dialog", + "[role='dialog']", + ".Overlay", + ".Popover", + ".SelectMenu", + ".SelectMenu-modal", + ".details-overlay", + "details", + ].join(", ") + ) as HTMLElement | null + + const container = mount || document.body + if (tooltipEl.parentElement !== container) { + container.appendChild(tooltipEl) + } + + // Position and fade in after layout so offsetHeight is accurate + requestAnimationFrame(() => { + if (tooltipEl && activeField === field) { + positionTooltip(tooltipEl, field) + tooltipEl.style.opacity = "1" + } + }) + + // Auto-hide after delay + hideTimer = setTimeout(() => { + hideHintTooltip() + }, AUTO_HIDE_DELAY) + }, SHOW_DELAY) +} + +/** + * Hide the hint tooltip + */ +export function hideHintTooltip(): void { + clearTimers() + activeField = null + + if (tooltipEl) { + tooltipEl.style.opacity = "0" + setTimeout(() => { + if (tooltipEl && tooltipEl.parentElement) { + tooltipEl.parentElement.removeChild(tooltipEl) + } + }, 150) + } +} + +/** + * Permanently dismiss the hint tooltip + */ +async function dismissHint(): Promise { + dismissed = true + hideHintTooltip() + await setStorageValue(DISMISSED_KEY, true) +} + +/** + * Update tooltip styles when theme changes + */ +export function refreshHintTooltipStyles(): void { + if (tooltipEl && tooltipEl.parentElement) { + applyTooltipStyles(tooltipEl) + } +} + +/** + * Clear all pending timers + */ +function clearTimers(): void { + if (showTimer) { + clearTimeout(showTimer) + showTimer = null + } + if (hideTimer) { + clearTimeout(hideTimer) + hideTimer = null + } +} diff --git a/src/content/index.ts b/src/content/index.ts index 17b74db..d82f44a 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -23,6 +23,12 @@ import { moveSelectionGrid, applyPickerStyles, } from "./picker/index.ts" +import { + loadDismissedState, + showHintTooltip, + hideHintTooltip, + refreshHintTooltipStyles, +} from "./hint-tooltip.ts" // Import commands to register them import "./commands/giphy/index.ts" @@ -119,6 +125,7 @@ async function handleCommandInput( state.activeCommand = cmdName setHeader("Slash Palette", COMMAND_PREFIX + cmdName + (query ? " " + query : "")) + hideHintTooltip() showPicker(field) positionPickerAtCaret(field) @@ -303,6 +310,9 @@ function attachToField(field: HTMLTextAreaElement): void { ;(field as unknown as { __slashPaletteBound: boolean }).__slashPaletteBound = true field.addEventListener("input", () => handleFieldInput(field)) + field.addEventListener("focus", () => { + if (!isPickerVisible()) showHintTooltip(field) + }) field.addEventListener("keyup", (ev) => { // Skip action keys that are handled elsewhere if (ev.key === "Escape") return @@ -340,6 +350,7 @@ function attachToField(field: HTMLTextAreaElement): void { field.addEventListener("keydown", (ev) => onFieldKeyDown(ev, field)) field.addEventListener("blur", () => { + hideHintTooltip() setTimeout(() => { // Never hide while settings panel is open - user is interacting with controls if (state.showingSettings) return @@ -432,14 +443,15 @@ function boot(): void { // Handle theme changes onThemeChange(() => { + refreshHintTooltipStyles() if (!isPickerVisible()) return if (state.pickerEl) applyPickerStyles(state.pickerEl) refreshSelectionStyles() }) } -// Load theme preference and start the extension +// Load theme preference and dismissed state, then start the extension getThemePreference().then((pref) => { setThemeOverride(pref) - boot() + loadDismissedState().then(() => boot()) })