Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/slider-push-mode.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 18 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk-react/src/hooks/useSlider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function useSlider(options: UseSliderOptions): UseSliderReturn {
theme,
host,
disableClose,
sliderMode,
onReady,
onSubmit,
onNavigate,
Expand Down Expand Up @@ -102,6 +103,7 @@ export function useSlider(options: UseSliderOptions): UseSliderReturn {
theme,
host,
disableClose,
sliderMode,
_apiConfig: embedConfigRef.current,
onReady: stableOnReady,
onSubmit: stableOnSubmit,
Expand All @@ -120,6 +122,7 @@ export function useSlider(options: UseSliderOptions): UseSliderReturn {
theme,
host,
disableClose,
sliderMode,
stableOnReady,
stableOnSubmit,
stableOnNavigate,
Expand Down
21 changes: 21 additions & 0 deletions packages/sdk/src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<button data-perspective-slider="slider1">Toggle</button>
`;
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", () => {
Expand Down
25 changes: 22 additions & 3 deletions packages/sdk/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
});

Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions packages/sdk/src/slider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
46 changes: 43 additions & 3 deletions packages/sdk/src/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {},
Expand All @@ -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",
Expand All @@ -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)
);

Expand Down Expand Up @@ -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 <html> 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;
Expand Down Expand Up @@ -126,6 +162,7 @@ export function openSlider(config: InternalEmbedConfig): EmbedHandle {
isOpen = false;
messageCleanup?.();
unregisterIframe();
removePush();
slider.remove();
backdrop.remove();
document.removeEventListener("keydown", escHandler);
Expand Down Expand Up @@ -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);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ export function injectStyles(): void {
}

.perspective-slider .perspective-close {
top: 1rem;
right: 2rem;
top: 1.2rem;
right: 1.5rem;
}

/* Slider backdrop */
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading