Skip to content

Commit

Permalink
refactor: vanilla example
Browse files Browse the repository at this point in the history
  • Loading branch information
segunadebayo committed May 24, 2024
1 parent f09ff85 commit eb71907
Show file tree
Hide file tree
Showing 7 changed files with 193 additions and 128 deletions.
28 changes: 19 additions & 9 deletions examples/vanilla-ts/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + TS</title>
</head>
<body x-data="$checkbox({ id: '1' })" style="padding: 40px">
<h1>Welcome to home</h1>
<label x-bind="api.rootProps">
<div x-bind="api.controlProps"></div>
<span x-bind="api.labelProps">Checkbox</span>
<input x-bind="api.hiddenInputProps" />
</label>
<body style="padding: 40px">
<h1>Accordion</h1>

<div class="accordion">
<div class="accordion-item" data-value="a">
<button class="accordion-trigger">First Button</button>
<div class="accordion-content">First Content</div>
</div>

<div class="accordion-item" data-value="b">
<button class="accordion-trigger">Second Button</button>
<div class="accordion-content">Second Content</div>
</div>

<div class="accordion-item" data-value="c">
<button class="accordion-trigger">Third Button</button>
<div class="accordion-content">Third Content</div>
</div>
</div>

<button type="button" x-bind:disabled="api.checked" x-on:click="api.setChecked(true)">Check</button>
<button type="button" x-bind:disabled="!api.checked" x-on:click="api.setChecked(false)">Uncheck</button>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
4 changes: 0 additions & 4 deletions examples/vanilla-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,9 @@
"@zag-js/tree-view": "workspace:*",
"@zag-js/types": "workspace:*",
"@zag-js/utils": "workspace:*",
"alpinejs": "3.13.10",
"form-serialize": "0.7.2",
"match-sorter": "6.3.4",
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
"dependencies": {
"@types/alpinejs": "^3.13.10"
}
}
75 changes: 75 additions & 0 deletions examples/vanilla-ts/src/accordion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as accordion from "@zag-js/accordion"
import { normalizeProps } from "./normalize-props"
import { spreadProps } from "./spread-props"

export class Accordion {
rootEl: HTMLElement
service: ReturnType<typeof accordion.machine>
api: accordion.Api<any>

constructor(root: string, context: accordion.Context) {
const rootEl = document.querySelector<HTMLElement>(root)

if (!rootEl) throw new Error("Root element not found")
this.rootEl = rootEl

this.service = accordion.machine(context)
this.api = accordion.connect(this.service.state, this.service.send, normalizeProps)
}

private disposable = new Map<HTMLElement, VoidFunction>()

init = () => {
const { service } = this
this.service.subscribe(() => {
this.api = accordion.connect(service.state, service.send, normalizeProps)
this.render()
})

this.service.start()
}

destroy = () => {
this.service.stop()
}

private get items() {
return Array.from(this.rootEl!.querySelectorAll<HTMLElement>(".accordion-item"))
}

private renderRoot = () => {
const rootEl = this.rootEl
this.disposable.set(rootEl, spreadProps(this.rootEl, this.api.rootProps))
}

private renderItem = (itemEl: HTMLElement) => {
const value = itemEl.dataset.value
if (!value) throw new Error("Expected value to be defined")

const itemTriggerEl = itemEl.querySelector<HTMLButtonElement>(".accordion-trigger")
const itemContentEl = itemEl.querySelector<HTMLElement>(".accordion-content")

if (!itemTriggerEl) throw new Error("Expected triggerEl to be defined")
if (!itemContentEl) throw new Error("Expected contentEl to be defined")

const cleanup = this.disposable.get(itemEl)
cleanup?.()

const cleanups = [
spreadProps(itemEl, this.api.getItemProps({ value })),
spreadProps(itemTriggerEl, this.api.getItemTriggerProps({ value })),
spreadProps(itemContentEl, this.api.getItemContentProps({ value })),
]

this.disposable.set(itemEl, () => {
cleanups.forEach((fn) => fn())
})
}

render = () => {
this.renderRoot()
this.items.forEach((itemEl) => {
this.renderItem(itemEl)
})
}
}
90 changes: 6 additions & 84 deletions examples/vanilla-ts/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,9 @@
import * as checkbox from "@zag-js/checkbox"
import { subscribe } from "@zag-js/core"
import { createNormalizer } from "@zag-js/types"
import Alpine from "alpinejs"
import "../../../shared/src/style.css"
import { Accordion } from "./accordion"
import { nanoid } from "nanoid"

const propMap: any = {
htmlFor: "for",
className: "class",
onDoubleClick: "onDblclick",
onChange: "onInput",
onFocus: "onFocusin",
onBlur: "onFocusout",
defaultValue: "value",
defaultChecked: "checked",
}

const toStyleString = (style: any) => {
let string = ""
for (let key in style) {
const value = style[key]
if (value === null || value === undefined) continue
if (!key.startsWith("--")) key = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
string += `${key}:${value};`
}
return string
}

// all event handlers should use the format @click
const normalizeProps = createNormalizer((props: any) => {
return Object.entries(props).reduce<any>((acc, [key, value]) => {
if (value === undefined) return acc

if (key in propMap) {
key = propMap[key]
}

if (key === "style" && typeof value === "object") {
acc.style = toStyleString(value)
return acc
}

if (key.startsWith("on")) {
const _key = key.replace(/^on/, "@").toLowerCase()
acc[_key] = value
} else {
acc[`:${key.toLowerCase()}`] = () => value
}

return acc
}, {})
})

document.addEventListener("alpine:init", () => {
Alpine.magic("checkbox", (el, { Alpine, effect }) => (context: any) => {
const service = checkbox.machine(context)

const state = Alpine.reactive({ value: service.getState() })

service.start()

effect(() => {
service.setContext(context)

const unsubscribe = subscribe(service.state, () => {
state.value = service.getState()
})

return () => {
unsubscribe()
service.stop()
}
})

Alpine.bind(el, {
"x-data"() {
return {
get api() {
return checkbox.connect(state.value, service.send, normalizeProps)
},
}
},
})
})
const accordion = new Accordion(".accordion", {
id: nanoid(),
multiple: true,
})

Alpine.start()
accordion.init()
46 changes: 46 additions & 0 deletions examples/vanilla-ts/src/normalize-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createNormalizer } from "@zag-js/types"

export interface AttrMap {
[key: string]: string
}

export const propMap: AttrMap = {
onFocus: "onFocusin",
onBlur: "onFocusout",
onChange: "onInput",
onDoubleClick: "onDblclick",
htmlFor: "for",
className: "class",
defaultValue: "value",
defaultChecked: "checked",
}

const toStyleString = (style: any) => {
let string = ""
for (let key in style) {
const value = style[key]
if (value === null || value === undefined) continue
if (!key.startsWith("--")) key = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
string += `${key}:${value};`
}
return string
}

export const normalizeProps = createNormalizer((props: any) => {
return Object.entries(props).reduce<any>((acc, [key, value]) => {
if (value === undefined) return acc

if (key in propMap) {
key = propMap[key]
}

if (key === "style" && typeof value === "object") {
acc.style = toStyleString(value)
return acc
}

acc[key.toLowerCase()] = value

return acc
}, {})
})
47 changes: 47 additions & 0 deletions examples/vanilla-ts/src/spread-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export interface Attrs {
[key: string]: any // Change 'any' to the specific type you want to allow for attributes
}

export function spreadProps(node: HTMLElement, attrs: Attrs): () => void {
const attrKeys = Object.keys(attrs)

const addEvt = (e: string, f: EventListener) => {
node.addEventListener(e.toLowerCase(), f)
}

const remEvt = (e: string, f: EventListener) => {
node.removeEventListener(e.toLowerCase(), f)
}

const onEvents = (attr: string) => attr.startsWith("on")
const others = (attr: string) => !attr.startsWith("on")

const setup = (attr: string) => addEvt(attr.substring(2), attrs[attr])
const teardown = (attr: string) => remEvt(attr.substring(2), attrs[attr])

const apply = (attrName: string) => {
let value = attrs[attrName]

if (typeof value === "boolean") {
value = value || undefined
}

if (value != null) {
if (["value", "checked", "htmlFor"].includes(attrName)) {
;(node as any)[attrName] = value // Using 'any' here because TypeScript can't narrow the type based on the array check
} else {
node.setAttribute(attrName.toLowerCase(), value)
}
return
}

node.removeAttribute(attrName.toLowerCase())
}

attrKeys.filter(onEvents).forEach(setup)
attrKeys.filter(others).forEach(apply)

return function cleanup() {
attrKeys.filter(onEvents).forEach(teardown)
}
}
Loading

0 comments on commit eb71907

Please sign in to comment.