-
-
Notifications
You must be signed in to change notification settings - Fork 171
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
8e3afb5
commit 7a957af
Showing
58 changed files
with
2,203 additions
and
171 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@zag-js/tour": minor | ||
--- | ||
|
||
Introduce new guided tour component |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } }) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
Oops, something went wrong.