Skip to content

Commit

Permalink
refactor: composite widgets
Browse files Browse the repository at this point in the history
  • Loading branch information
segunadebayo committed May 12, 2024
1 parent 6cd5dbc commit 630f722
Show file tree
Hide file tree
Showing 54 changed files with 861 additions and 398 deletions.
8 changes: 4 additions & 4 deletions .xstate/combobox.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,11 @@ const fetchMachine = createMachine({
}],
"INPUT.CHANGE": [{
cond: "isOpenControlled && openOnChange",
actions: ["setInputValue", "invokeOnOpen"]
actions: ["setInputValue", "invokeOnOpen", "highlightFirstItemIfNeeded"]
}, {
cond: "openOnChange",
target: "suggesting",
actions: ["setInputValue", "invokeOnOpen"]
actions: ["setInputValue", "invokeOnOpen", "highlightFirstItemIfNeeded"]
}, {
actions: "setInputValue"
}],
Expand Down Expand Up @@ -220,7 +220,7 @@ const fetchMachine = createMachine({
},
interacting: {
tags: ["open", "focused"],
activities: ["scrollIntoView", "trackDismissableLayer", "computePlacement", "hideOtherElements", "trackContentHeight"],
activities: ["scrollToHighlightedItem", "trackDismissableLayer", "computePlacement", "hideOtherElements"],
on: {
"CONTROLLED.CLOSE": [{
cond: "restoreFocus",
Expand Down Expand Up @@ -337,7 +337,7 @@ const fetchMachine = createMachine({
},
suggesting: {
tags: ["open", "focused"],
activities: ["trackDismissableLayer", "scrollIntoView", "computePlacement", "trackChildNodes", "hideOtherElements", "trackContentHeight"],
activities: ["trackDismissableLayer", "scrollToHighlightedItem", "computePlacement", "trackChildNodes", "hideOtherElements"],
entry: ["focusInput"],
on: {
"CONTROLLED.CLOSE": [{
Expand Down
8 changes: 0 additions & 8 deletions .xstate/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ const fetchMachine = createMachine({
"isArrowLeftEvent": false,
"!isTriggerItem && isOpenControlled": false,
"!isTriggerItem": false,
"isForwardTabNavigation": false,
"isSubmenu && isOpenControlled": false,
"isSubmenu": false,
"isTriggerItemHighlighted": false,
Expand Down Expand Up @@ -284,12 +283,6 @@ const fetchMachine = createMachine({
target: "closed",
actions: "invokeOnClose"
}],
TAB: [{
cond: "isForwardTabNavigation",
actions: ["highlightNextItem"]
}, {
actions: ["highlightPrevItem"]
}],
ARROW_UP: {
actions: ["highlightPrevItem", "focusMenu"]
},
Expand Down Expand Up @@ -400,7 +393,6 @@ const fetchMachine = createMachine({
"isArrowLeftEvent": ctx => ctx["isArrowLeftEvent"],
"!isTriggerItem && isOpenControlled": ctx => ctx["!isTriggerItem && isOpenControlled"],
"!isTriggerItem": ctx => ctx["!isTriggerItem"],
"isForwardTabNavigation": ctx => ctx["isForwardTabNavigation"],
"isSubmenu && isOpenControlled": ctx => ctx["isSubmenu && isOpenControlled"],
"isTriggerItemHighlighted": ctx => ctx["isTriggerItemHighlighted"],
"closeOnSelect && isOpenControlled": ctx => ctx["closeOnSelect && isOpenControlled"],
Expand Down
4 changes: 2 additions & 2 deletions .xstate/popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ const fetchMachine = createMachine({
on: {
"CONTROLLED.CLOSE": {
target: "closed",
actions: ["restoreFocus"]
actions: ["setFinalFocus"]
},
CLOSE: [{
cond: "isOpenControlled",
actions: ["invokeOnClose"]
}, {
target: "closed",
actions: ["invokeOnClose", "restoreFocus"]
actions: ["invokeOnClose", "setFinalFocus"]
}],
TOGGLE: [{
cond: "isOpenControlled",
Expand Down
4 changes: 2 additions & 2 deletions .xstate/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const fetchMachine = createMachine({
},
focused: {
tags: ["closed"],
entry: ["focusTriggerEl"],
entry: ["setFinalFocus"],
on: {
"CONTROLLED.OPEN": [{
cond: "isTriggerClickEvent",
Expand Down Expand Up @@ -188,7 +188,7 @@ const fetchMachine = createMachine({
},
open: {
tags: ["open"],
entry: ["focusContentEl"],
entry: ["setInitialFocus"],
exit: ["scrollContentToTop"],
activities: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItem"],
on: {
Expand Down
5 changes: 4 additions & 1 deletion .xstate/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const fetchMachine = createMachine({
"selectOnFocus": false,
"!selectOnFocus": false
},
entry: ["checkRenderedElements", "syncIndicatorRect", "setContentTabIndex"],
entry: ["checkRenderedElements", "syncIndicatorRect", "syncTabIndex"],
exit: ["cleanupObserver"],
on: {
SET_VALUE: {
Expand All @@ -29,6 +29,9 @@ const fetchMachine = createMachine({
},
SET_INDICATOR_RECT: {
actions: "setIndicatorRect"
},
SYNC_TAB_INDEX: {
actions: "syncTabIndex"
}
},
on: {
Expand Down
73 changes: 50 additions & 23 deletions e2e/menu.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,60 @@
import { expect, type Page, test } from "@playwright/test"
import { controls, part } from "./_utils"
import { test } from "@playwright/test"
import { MenuModel } from "./models/menu.model"

const trigger = part("trigger")
const menu = part("content")

const expectToBeFocused = async (page: Page, id: string) => {
return expect(page.locator(`[id=${id}]`).first()).toHaveAttribute("data-highlighted", "")
}
let I: MenuModel

test.describe("menu", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/menu")
I = new MenuModel(page)
await I.goto()
})

test("should have no accessibility violation", async () => {
await I.checkAccessibility()
})

test("on arrow up and down, change highlighted item", async () => {
await I.clickTrigger()
await I.pressKey("ArrowDown", 2)
await I.seeItemIsHighlighted("Duplicate")
await I.pressKey("ArrowUp")
await I.seeItemIsHighlighted("Edit")
})

test("on typeahead, highlight matching item", async () => {
await I.clickTrigger()
await I.type("E")
await I.seeItemIsHighlighted("Edit")
await I.type("E")
await I.seeItemIsHighlighted("Export")
})

test("when closeOnSelect=false, stay open on selection", async () => {
await I.controls.bool("closeOnSelect", false)
await I.clickTrigger()
await I.pressKey("ArrowDown")
await I.pressKey("Enter")
await I.seeDropdown()
})

test("hover out, clear highlighted item", async () => {
await I.clickViz()
await I.clickTrigger()
await I.hoverItem("Delete")
await I.hoverOut()
await I.dontSeeHighlightedItem()
})

test("should stay open when `closeOnSelect` is false", async ({ page }) => {
await controls(page).bool("closeOnSelect", false)
await page.click(trigger)
await page.keyboard.press("ArrowDown")
await page.keyboard.press("Enter", { delay: 10 })
await expect(page.locator(menu)).toBeVisible()
test("with keyboard, can select item", async () => {
await I.clickTrigger()
await I.pressKey("ArrowDown")
await I.pressKey("Enter")
await I.dontSeeDropdown()
})

test("should navigate menu items with tab", async ({ page }) => {
await page.click(trigger)
await page.keyboard.press("Tab")
await page.keyboard.press("Tab")
await expectToBeFocused(page, "duplicate")
await page.keyboard.press("Tab")
await page.keyboard.press("Shift+Tab")
await expectToBeFocused(page, "duplicate")
test("on click outside, close menu", async () => {
await I.clickTrigger()
await I.clickOutside()
await I.dontSeeDropdown()
})
})
81 changes: 81 additions & 0 deletions e2e/models/menu.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect, type Page } from "@playwright/test"
import { a11y, isInViewport } from "../_utils"
import { Model } from "./model"

export class MenuModel extends Model {
constructor(public page: Page) {
super(page)
}

checkAccessibility() {
return a11y(this.page, "main")
}

goto(url = "/menu") {
return this.page.goto(url)
}

private get trigger() {
return this.page.locator("[data-scope=menu][data-part=trigger]")
}

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

getItem = (text: string) => {
return this.page.locator(`[data-part=item]`, { hasText: text })
}

get highlightedItem() {
return this.page.locator("[data-part=item][data-highlighted]")
}

type(input: string) {
return this.content.pressSequentially(input)
}

clickTrigger = async () => {
await this.trigger.click()
}

hoverItem = async (text: string) => {
await this.getItem(text).hover()
}

hoverOut = async () => {
await this.page.mouse.move(0, 0)
}

seeDropdown = async () => {
await expect(this.content).toBeVisible()
}

dontSeeDropdown = async () => {
await expect(this.content).not.toBeVisible()
}

seeItemIsHighlighted = async (text: string) => {
const item = this.getItem(text)
await expect(item).toHaveAttribute("data-highlighted", "")
}

dontSeeHighlightedItem = async () => {
await expect(this.highlightedItem).not.toBeVisible()
}

seeItemIsChecked = async (text: string) => {
const item = this.getItem(text)
await expect(item).toHaveAttribute("data-state", "checked")
}

seeItemIsNotChecked = async (text: string) => {
const item = this.getItem(text)
await expect(item).not.toHaveAttribute("data-state", "checked")
}

seeItemInViewport = async (text: string) => {
const item = this.getItem(text)
expect(await isInViewport(this.content, item)).toBe(true)
}
}
9 changes: 9 additions & 0 deletions examples/next-ts/hooks/use-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useCallback, useRef } from "react"

type AnyFunction = (...args: any[]) => any

export function useEvent<T extends AnyFunction>(callback: T | undefined): T {
const ref = useRef(callback)
ref.current = callback
return useCallback((...args: any[]) => ref.current?.(...args), []) as T
}
2 changes: 1 addition & 1 deletion examples/next-ts/pages/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default function Page() {
<label {...api.labelProps}>Select country</label>
<div {...api.controlProps}>
<input data-testid="input" {...api.inputProps} />
<button data-testid="trigger" {...api.triggerProps}>
<button data-testid="trigger" {...api.getTriggerProps()}>
</button>
<button {...api.clearTriggerProps}>
Expand Down
6 changes: 3 additions & 3 deletions examples/next-ts/pages/composition/combo-textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,13 @@ export default function Page() {
ref={ref}
{...mergeProps(textareaProps, {
rows: 5,
placeholder: "Type @, # or :",
placeholder: "Type @ to see completion",
style: { width: 400, padding: 4 },
onScroll() {
api.reposition()
},
onPointerDown() {
api.setOpen(true)
api.setOpen(false)
},
onKeyDown(event) {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
Expand All @@ -122,7 +122,7 @@ export default function Page() {
<div {...api.positionerProps}>
<ul {...api.contentProps}>
{options.map((item) => (
<li data-testid={item.code} key={item.code} {...api.getItemProps({ item })}>
<li key={item.code} {...api.getItemProps({ item })}>
{item.label}
</li>
))}
Expand Down
10 changes: 5 additions & 5 deletions examples/next-ts/pages/composition/dialog-controlled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,26 @@ import { useMachine, normalizeProps, Portal } from "@zag-js/react"
import React, { useState } from "react"

export default function Dialog() {
const [isOpen, setOpen] = useState(false)
const [open, setOpen] = useState(false)

const [state, send] = useMachine(
dialog.machine({
id: "1",
open: false,
open,
onOpenChange(details) {
setOpen(details.open)
},
}),
{
context: { open: isOpen },
context: { open },
},
)
const api = dialog.connect(state, send, normalizeProps)

return (
<div>
<button onClick={() => setOpen(!isOpen)}>Open Dialog</button>
<p>state - isOpen: {String(isOpen)}</p>
<button onClick={() => setOpen(!open)}>Open Dialog</button>
<p>state - isOpen: {String(open)}</p>
<p>machine - isOpen: {String(api.open)}</p>
{api.open && (
<Portal>
Expand Down
Loading

0 comments on commit 630f722

Please sign in to comment.