diff --git a/.changeset/slider-push-mode.md b/.changeset/slider-push-mode.md new file mode 100644 index 0000000..e673068 --- /dev/null +++ b/.changeset/slider-push-mode.md @@ -0,0 +1,10 @@ +--- +"@perspective-ai/sdk": minor +"@perspective-ai/sdk-react": minor +--- + +Add slider `push` mode and trigger toggle + +- **`sliderMode: "overlay" | "push"`** option (default `"overlay"`, backward compatible). In `push` mode the slider shifts page content aside so it occupies real layout space instead of overlaying it — no backdrop, the page stays interactive, and clicking the page no longer closes the slider. Falls back to `"overlay"` on narrow viewports. Available via the JS API, the `data-perspective-slider-mode="push"` attribute, and the `useSlider` React hook. +- **Trigger toggle**: clicking the same `data-perspective-slider` trigger again now closes an open slider instead of re-opening it (respects `disableClose`). The React `useSlider` hook continues to expose `toggle()`. +- **Fix**: adjust the slider close-button position. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61daf23..7528a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,10 +58,28 @@ jobs: name: E2E Tests runs-on: ubuntu-latest needs: ci + timeout-minutes: 15 + # Browsers + system deps are pre-baked into this image, so there is no + # `playwright install` download step (it deterministically stalled on the CDN). + # Keep the tag in sync with the @playwright/test version in the root package.json. + container: + image: mcr.microsoft.com/playwright:v1.60.0-noble + # GitHub overrides HOME to /github/home in container jobs, so Playwright looks + # for browsers in the default ~/.cache path instead of where the image bakes + # them. Point it at the image's location — and note turbo.json must allowlist + # this var via passThroughEnv, or strict env mode strips it from `playwright test`. + env: + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright steps: - uses: actions/checkout@v4 + # The container runs as root but the mounted workspace is owned by the host + # runner UID, so git's dubious-ownership guard blocks the lefthook postinstall + # hook. Trust the workspace before install runs. + - name: Trust workspace for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 @@ -74,15 +92,6 @@ jobs: - name: Build run: pnpm build - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('packages/sdk/package.json') }} - - - name: Install Playwright - run: pnpm --filter @perspective-ai/sdk exec playwright install --with-deps chromium - - name: Test E2E run: pnpm test:e2e diff --git a/package.json b/package.json index 3ddc54f..92af6d1 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", "@changesets/cli": "^2.30.0", - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.60.0", "@vitest/coverage-v8": "^4.0.17", "happy-dom": "^20.8.9", "lefthook": "^2.0.15", diff --git a/packages/sdk-react/src/hooks/useSlider.ts b/packages/sdk-react/src/hooks/useSlider.ts index d7a9e10..7847956 100644 --- a/packages/sdk-react/src/hooks/useSlider.ts +++ b/packages/sdk-react/src/hooks/useSlider.ts @@ -48,6 +48,7 @@ export function useSlider(options: UseSliderOptions): UseSliderReturn { theme, host, disableClose, + sliderMode, onReady, onSubmit, onNavigate, @@ -102,6 +103,7 @@ export function useSlider(options: UseSliderOptions): UseSliderReturn { theme, host, disableClose, + sliderMode, _apiConfig: embedConfigRef.current, onReady: stableOnReady, onSubmit: stableOnSubmit, @@ -120,6 +122,7 @@ export function useSlider(options: UseSliderOptions): UseSliderReturn { theme, host, disableClose, + sliderMode, stableOnReady, stableOnSubmit, stableOnNavigate, diff --git a/packages/sdk/src/browser.test.ts b/packages/sdk/src/browser.test.ts index a3c22a1..4f66e30 100644 --- a/packages/sdk/src/browser.test.ts +++ b/packages/sdk/src/browser.test.ts @@ -302,6 +302,27 @@ describe("browser entry", () => { expect(document.querySelector(".perspective-slider")).toBeTruthy(); }); + + it("toggles the slider closed on a second trigger click", async () => { + document.body.innerHTML = ` + + `; + autoInit(); + await flushConfigFetch(); + + const button = document.querySelector( + "[data-perspective-slider]" + ) as HTMLButtonElement; + + button.click(); + expect(document.querySelector(".perspective-slider")).toBeTruthy(); + + button.click(); + expect(document.querySelector(".perspective-slider")).toBeFalsy(); + + button.click(); + expect(document.querySelector(".perspective-slider")).toBeTruthy(); + }); }); describe("destroyAll", () => { diff --git a/packages/sdk/src/browser.ts b/packages/sdk/src/browser.ts index ae0ad01..6d4067f 100644 --- a/packages/sdk/src/browser.ts +++ b/packages/sdk/src/browser.ts @@ -642,25 +642,38 @@ function autoInit(): void { const params = parseParamsAttr(el); const brandConfig = extractBrandConfig(el); const disableClose = el.hasAttribute(DATA_ATTRS.disableClose); + const sliderMode = + el.getAttribute(DATA_ATTRS.sliderMode) === "push" + ? "push" + : undefined; const persistedOpen = getPersistedOpenState({ researchId, type: "slider", }); let sliderConfig: EmbedApiConfig | undefined; + // Tracks the live handle so a second click on the same trigger toggles + // the slider closed instead of re-opening it. + let sliderHandle: EmbedHandle | null = null; const disableJsonLdAttribution = el.hasAttribute( DATA_ATTRS.disableJsonLdAttribution ); - const initSlider = () => - init({ + const initSlider = () => { + sliderHandle = init({ researchId, type: "slider", params, disableClose, disableJsonLdAttribution, + sliderMode, ...brandConfig, ...(sliderConfig && { _apiConfig: sliderConfig }), - } as InternalEmbedConfig); + onClose: () => { + sliderHandle = null; + }, + } as InternalEmbedConfig) as EmbedHandle; + return sliderHandle; + }; const dg = globalDestroyGen; const ig = idDestroyGen.get(researchId) ?? 0; @@ -682,6 +695,12 @@ function autoInit(): void { // Cancel any pending API auto-trigger (manual open takes precedence) triggerCleanups.get(researchId)?.(); triggerCleanups.delete(researchId); + // Toggle: a second click on the same trigger closes the open slider + // (unless disableClose, which forbids dismissal entirely). + if (sliderHandle) { + if (!disableClose) sliderHandle.destroy(); + return; + } initSlider(); }); diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 2752a5b..0168138 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -91,6 +91,7 @@ export const DATA_ATTRS = { autoOpen: "data-perspective-auto-open", showOnce: "data-perspective-show-once", disableClose: "data-perspective-disable-close", + sliderMode: "data-perspective-slider-mode", launcherIcon: "data-perspective-launcher-icon", launcherStyle: "data-perspective-launcher-style", launcherClass: "data-perspective-launcher-class", diff --git a/packages/sdk/src/slider.test.ts b/packages/sdk/src/slider.test.ts index 56d184d..4ae7187 100644 --- a/packages/sdk/src/slider.test.ts +++ b/packages/sdk/src/slider.test.ts @@ -277,6 +277,97 @@ describe("openSlider", () => { }); }); + describe("sliderMode: push", () => { + const setViewport = (width: number) => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: width, + }); + }; + + afterEach(() => { + setViewport(1024); + document.documentElement.style.marginRight = ""; + document.documentElement.style.transition = ""; + }); + + it("omits backdrop and pushes the page in push mode", () => { + setViewport(1024); + const handle = openSlider({ + researchId: "test-research-id", + sliderMode: "push", + }); + + expect( + document.querySelector(".perspective-slider-backdrop") + ).toBeFalsy(); + expect(document.querySelector(".perspective-slider-push")).toBeTruthy(); + expect(document.documentElement.style.marginRight).not.toBe(""); + + handle.unmount(); + }); + + it("restores page margin on close", () => { + setViewport(1024); + const handle = openSlider({ + researchId: "test-research-id", + sliderMode: "push", + }); + + handle.unmount(); + + expect(document.documentElement.style.marginRight).toBe(""); + }); + + it("does not close on page interaction (no backdrop)", () => { + setViewport(1024); + const onClose = vi.fn(); + const handle = openSlider({ + researchId: "test-research-id", + sliderMode: "push", + onClose, + }); + + document.body.click(); + + expect(onClose).not.toHaveBeenCalled(); + expect(document.querySelector(".perspective-slider")).toBeTruthy(); + + handle.unmount(); + }); + + it("falls back to overlay on narrow viewports", () => { + setViewport(480); + const handle = openSlider({ + researchId: "test-research-id", + sliderMode: "push", + }); + + expect( + document.querySelector(".perspective-slider-backdrop") + ).toBeTruthy(); + expect(document.querySelector(".perspective-slider-push")).toBeFalsy(); + expect(document.documentElement.style.marginRight).toBe(""); + + handle.unmount(); + }); + + it("overlay mode is unchanged (default)", () => { + const handle = openSlider({ + researchId: "test-research-id", + sliderMode: "overlay", + }); + + expect( + document.querySelector(".perspective-slider-backdrop") + ).toBeTruthy(); + expect(document.documentElement.style.marginRight).toBe(""); + + handle.unmount(); + }); + }); + describe("update() behavior", () => { const host = "https://getperspective.ai"; const researchId = "test-research-id"; diff --git a/packages/sdk/src/slider.ts b/packages/sdk/src/slider.ts index 16a45bd..ad62fb2 100644 --- a/packages/sdk/src/slider.ts +++ b/packages/sdk/src/slider.ts @@ -19,6 +19,9 @@ import { cn, getThemeClass } from "./utils"; import { enrichContainer } from "./attribution"; import { perfLog } from "./perf"; +/** Below this viewport width, "push" mode falls back to "overlay" so content isn't shoved off-screen. */ +const PUSH_MIN_VIEWPORT = 640; + function createNoOpHandle(researchId: string): EmbedHandle { return { unmount: () => {}, @@ -44,7 +47,12 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle { injectStyles(); ensureGlobalListeners(); - // Create backdrop + // Push mode shifts page content aside instead of overlaying it. Falls back + // to overlay on narrow viewports where there's no room to push. + const isPush = + config.sliderMode === "push" && window.innerWidth >= PUSH_MIN_VIEWPORT; + + // Create backdrop (overlay mode only — push mode keeps the page interactive) const backdrop = document.createElement("div"); backdrop.className = cn( "perspective-slider-backdrop perspective-embed-root", @@ -55,6 +63,7 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle { const slider = document.createElement("div"); slider.className = cn( "perspective-slider perspective-embed-root", + isPush && "perspective-slider-push", getThemeClass(config.theme) ); @@ -88,10 +97,37 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle { slider.appendChild(closeBtn); slider.appendChild(loading); slider.appendChild(iframe); - document.body.appendChild(backdrop); + if (!isPush) { + document.body.appendChild(backdrop); + } document.body.appendChild(slider); enrichContainer(slider, "slider", config); + // Push mode: shrink the page by the slider's width, animated in sync with the + // slide-in. Margin lives on to avoid clobbering site-set body margins. + const root = document.documentElement; + const prevRootMarginRight = root.style.marginRight; + const prevRootTransition = root.style.transition; + const syncPush = () => { + const fits = window.innerWidth >= PUSH_MIN_VIEWPORT; + root.style.marginRight = fits + ? `${slider.getBoundingClientRect().width}px` + : "0px"; + }; + if (isPush) { + root.style.transition = "margin-right 0.3s ease-out"; + syncPush(); + window.addEventListener("resize", syncPush); + } + const removePush = () => { + if (!isPush) return; + window.removeEventListener("resize", syncPush); + root.style.marginRight = prevRootMarginRight; + setTimeout(() => { + root.style.transition = prevRootTransition; + }, 300); + }; + // Mutable config reference for updates let currentConfig = { ...config }; let isOpen = true; @@ -126,6 +162,7 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle { isOpen = false; messageCleanup?.(); unregisterIframe(); + removePush(); slider.remove(); backdrop.remove(); document.removeEventListener("keydown", escHandler); @@ -184,7 +221,10 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle { if (!config.disableClose) { closeBtn.addEventListener("click", destroy); - backdrop.addEventListener("click", destroy); + // Push mode has no backdrop — page stays interactive and clicks don't close. + if (!isPush) { + backdrop.addEventListener("click", destroy); + } document.addEventListener("keydown", escHandler); } diff --git a/packages/sdk/src/styles.ts b/packages/sdk/src/styles.ts index 4cb4709..623458b 100644 --- a/packages/sdk/src/styles.ts +++ b/packages/sdk/src/styles.ts @@ -221,8 +221,8 @@ export function injectStyles(): void { } .perspective-slider .perspective-close { - top: 1rem; - right: 2rem; + top: 1.2rem; + right: 1.5rem; } /* Slider backdrop */ diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 791a0d2..b26dbdb 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -112,6 +112,14 @@ export interface EmbedConfig { autoOpen?: AutoOpenConfig; /** When true, prevents the user from closing the popup/slider (hides close button, disables overlay click and ESC key) */ disableClose?: boolean; + /** + * Slider display mode. `"overlay"` (default) floats the panel over the page + * with a dimming backdrop that closes on outside click. `"push"` shifts the + * page content aside so the slider occupies real layout space — no backdrop, + * and clicking the page does not close it. Falls back to `"overlay"` on narrow + * viewports. Only used for slider-type embeds. + */ + sliderMode?: "overlay" | "push"; /** When true, skips JSON-LD structured data injection into the parent page. Other attribution signals (data attributes, global metadata, HTML comments) remain active. */ disableJsonLdAttribution?: boolean; /** Customize the floating launcher button appearance. Only used for float-type embeds. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae6471c..0297344 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: ^2.30.0 version: 2.30.0(@types/node@25.5.0) '@playwright/test': - specifier: ^1.52.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 '@vitest/coverage-v8': specifier: ^4.0.17 version: 4.0.17(vitest@4.0.17(@types/node@25.5.0)(happy-dom@20.8.9)) @@ -437,8 +437,8 @@ packages: cpu: [x64] os: [win32] - '@playwright/test@1.57.0': - resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} hasBin: true @@ -1426,13 +1426,13 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -2256,9 +2256,9 @@ snapshots: '@oxlint/win32-x64@1.39.0': optional: true - '@playwright/test@1.57.0': + '@playwright/test@1.60.0': dependencies: - playwright: 1.57.0 + playwright: 1.60.0 '@rollup/rollup-android-arm-eabi@4.60.1': optional: true @@ -3128,11 +3128,11 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - playwright-core@1.57.0: {} + playwright-core@1.60.0: {} - playwright@1.57.0: + playwright@1.60.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 diff --git a/turbo.json b/turbo.json index 5ad574d..104acf7 100644 --- a/turbo.json +++ b/turbo.json @@ -27,7 +27,8 @@ "test:e2e": { "dependsOn": ["build"], "inputs": ["$TURBO_DEFAULT$", "src/**", "e2e/**", "playwright.config.ts"], - "outputs": ["playwright-report/**", "test-results/**"] + "outputs": ["playwright-report/**", "test-results/**"], + "passThroughEnv": ["PLAYWRIGHT_BROWSERS_PATH"] }, "test:ssr": { "dependsOn": ["build"],