Skip to content

Commit

Permalink
feat: tour machine (#1253)
Browse files Browse the repository at this point in the history
* chore: initial machinedesign

* feat: implement callbacks

* refactor: code

* chore: update component

* refactor: account for iframes

* refactor: examples to include iframe

* chore: update types

* chore: rm unused

* fix: incorrect offset

* docs: update readme

* fix(ally): use polite

* fix(ally): add atomic

* feat: add support for step effects

* chore: export more types

* refactor: tour machine

* chore: turn off effects for now

* fix: utils

* docs: add changeset

* test: add initial e2e tests

* refactor(e2e): tree model

* refactor(e2e): tree model

* chore: add vue iframe
  • Loading branch information
segunadebayo authored Feb 16, 2024
1 parent 8e3afb5 commit 7a957af
Show file tree
Hide file tree
Showing 58 changed files with 2,203 additions and 171 deletions.
5 changes: 5 additions & 0 deletions .changeset/lemon-ligers-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/tour": minor
---

Introduce new guided tour component
106 changes: 106 additions & 0 deletions .xstate/tour.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use strict";

var _xstate = require("xstate");
const {
actions,
createMachine,
assign
} = _xstate;
const {
choose
} = actions;
const fetchMachine = createMachine({
id: "tour",
initial: "closed",
context: {
"isValidStep": false,
"completeOnSkip": false,
"isLastStep": false
},
activities: ["trackBoundarySize"],
exit: ["clearStep", "cleanupFns"],
on: {
"STEPS.SET": {
actions: ["setSteps"]
},
"STEP.SET": {
actions: ["setStep"]
}
},
on: {
UPDATE_CONTEXT: {
actions: "updateContext"
}
},
states: {
closed: {
tags: ["closed"],
on: {
START: {
target: "open",
actions: ["setInitialStep", "invokeOnStart"]
},
RESUME: {
target: "scrolling",
actions: ["invokeOnStart"]
}
}
},
scrolling: {
tags: ["open"],
entry: ["scrollStepTargetIntoView"],
activities: ["trapFocus", "trackPlacement", "trackDismissableElement"],
after: {
0: "open"
}
},
open: {
tags: ["open"],
activities: ["trapFocus", "trackPlacement", "trackDismissableElement"],
on: {
"STEP.CHANGED": {
cond: "isValidStep",
target: "scrolling"
},
NEXT: {
actions: ["setNextStep"]
},
PREV: {
actions: ["setPrevStep"]
},
PAUSE: {
target: "closed",
actions: ["invokeOnStop"]
},
SKIP: [{
cond: "completeOnSkip",
target: "closed",
actions: ["invokeOnComplete", "invokeOnSkip", "clearStep"]
}, {
actions: ["invokeOnSkip", "setNextStep"]
}],
STOP: [{
cond: "isLastStep",
target: "closed",
actions: ["invokeOnStop", "invokeOnComplete", "clearStep"]
}, {
target: "closed",
actions: ["invokeOnStop", "clearStep"]
}]
}
}
}
}, {
actions: {
updateContext: assign((context, event) => {
return {
[event.contextKey]: true
};
})
},
guards: {
"isValidStep": ctx => ctx["isValidStep"],
"completeOnSkip": ctx => ctx["completeOnSkip"],
"isLastStep": ctx => ctx["isLastStep"]
}
});
4 changes: 4 additions & 0 deletions e2e/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,7 @@ export const pointer = {
return el.dispatchEvent("pointermove", { button: 0 })
},
}

export const textSelection = (page: Page) => {
return page.evaluate(() => window.getSelection()?.toString())
}
79 changes: 79 additions & 0 deletions e2e/models/tour.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect, type Page } from "@playwright/test"
import { controls, rect, textSelection } from "../_utils"

export class TourModel {
constructor(private page: Page) {}

get controls() {
return controls(this.page)
}

getSelection() {
return textSelection(this.page)
}

goto() {
return this.page.goto("/tour")
}

start() {
return this.page.getByRole("button", { name: "Start" }).click()
}

arrowRight() {
return this.page.keyboard.press("ArrowRight")
}

arrowLeft() {
return this.page.keyboard.press("ArrowLeft")
}

esc() {
return this.page.keyboard.press("Escape")
}

get content() {
return this.page.locator("[data-scope=tour][data-part=content]")
}

get title() {
return this.content.locator("[data-part=title]")
}

get target() {
return this.page.locator("[data-tour-highlighted]")
}

get iframeTarget() {
return this.page.frameLocator("iframe").first().locator("[data-tour-highlighted]")
}

get spotlight() {
return this.page.locator("[data-scope=tour][data-part=spotlight]")
}

getTargetRect() {
return rect(this.target)
}

getSpotlightRect() {
return rect(this.spotlight)
}

private isContentCentered() {
return this.content.evaluate((el) => {
const rect = el.getBoundingClientRect()
const centeredX = rect.left + rect.width / 2 === window.innerWidth / 2
const centeredY = rect.top + rect.height / 2 === window.innerHeight / 2
return centeredX && centeredY
})
}

async expectToBeCentered() {
return expect(await this.isContentCentered()).toBeTruthy()
}

clickOutside() {
return this.page.click("body", { force: true, position: { x: 200, y: 100 } })
}
}
2 changes: 1 addition & 1 deletion e2e/tree-view.model.ts → e2e/models/tree-view.model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test"
import { clickViz, controls } from "./_utils"
import { clickViz, controls } from "../_utils"

interface ClickOptions {
modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift">
Expand Down
111 changes: 111 additions & 0 deletions e2e/tour.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { expect, test } from "@playwright/test"
import { TourModel } from "./models/tour.model"

test.describe("tour", () => {
let tour: TourModel

test.beforeEach(async ({ page }) => {
tour = new TourModel(page)
await tour.goto()
})

test("should open tour on click start", async () => {
await tour.start()
// first step is centered
await tour.expectToBeCentered()
await expect(tour.content).toBeVisible()
})

test("should close on escape", async () => {
await tour.start()
await tour.esc()
await expect(tour.content).not.toBeVisible()
})

test("should align with spotlight (due to offset)", async () => {
await tour.start()
await tour.arrowRight()
await expect(tour.spotlight).toBeInViewport()

const targetRect = await tour.getTargetRect()
const spotlightRect = await tour.getSpotlightRect()

// spotlight rect to be greater than content rect
expect(spotlightRect.width).toBeGreaterThan(targetRect.width)
expect(spotlightRect.height).toBeGreaterThan(targetRect.height)
})

test("keyboard navigation", async () => {
await tour.start()
await tour.expectToBeCentered()

await tour.arrowRight()
await expect(tour.target).toContainText("Step 1")

// in overflow container
await tour.arrowRight()
await expect(tour.spotlight).toBeInViewport()
await expect(tour.target).toContainText("Step 2")
await expect(tour.target).toBeInViewport()

// iframe content
await tour.arrowRight()
await expect(tour.spotlight).toBeInViewport()
await expect(tour.iframeTarget).toBeInViewport()
await expect(tour.iframeTarget).toContainText("Iframe Content")

// Close to the bottom
await tour.arrowRight()
await expect(tour.spotlight).toBeInViewport()
await expect(tour.target).toContainText("Step 3")

// Bottom of the page
await tour.arrowRight()
await expect(tour.spotlight).toBeInViewport()
await expect(tour.target).toContainText("Step 4")

// final step
await tour.arrowRight()
await expect(tour.title).toContainText("all sorted!")
})

test("[no keyboard navigation] should do not advance", async () => {
await tour.controls.bool("keyboardNavigation", false)
await tour.start()

await tour.arrowRight() // should not do anything
await tour.expectToBeCentered() // stay on the first step
})

test("[preventInteraction=true] should not allow interacting with target", async () => {
await tour.start()
await tour.arrowRight()

// double click on target to select text
await tour.target.selectText()

// check if the window selection is still empty
const selection = await tour.getSelection()
expect(selection).toBe("")
})

test("[preventInteraction=false] should allow interacting with target", async () => {
await tour.controls.bool("preventInteraction", false)

await tour.start()
await tour.arrowRight()

// double click on target to select text
await tour.target.selectText()

// check if the window selection is still empty
const selection = await tour.getSelection()
expect(selection).toContain("Step 1")
})

test("should close on interaction outside", async () => {
await tour.start()
await tour.clickOutside()
await expect(tour.content).not.toBeVisible()
})
})
Loading

0 comments on commit 7a957af

Please sign in to comment.