diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..a53bab7a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node +{ + "name": "Node.js & TypeScript", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/typescript-node:5-24-trixie" + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "yarn install", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e05ce79..f969b4d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,7 +62,12 @@ jobs: # Default turbo concurrency (10) oversubscribes ubuntu-latest's 4 # vCPUs across 5 packages' test tasks running at once — same # contention issue typecheck.yml already caps for tsgo. - run: GITHUB_ACTIONS=false bun turbo test --concurrency=4 + run: GITHUB_ACTIONS=false bun turbo test --concurrency=4 --filter='!@opencode-ai/ui' + + - name: Run UI unit tests + timeout-minutes: 10 + working-directory: packages/ui + run: bun test --conditions=browser --preload ../app/happydom.ts src - name: Check generated client timeout-minutes: 5 diff --git a/packages/ui/src/context/dialog.test.ts b/packages/ui/src/context/dialog.test.ts new file mode 100644 index 00000000..d6fd1917 --- /dev/null +++ b/packages/ui/src/context/dialog.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { createComponent, type Component, type JSX } from "solid-js" +import { render } from "solid-js/web" + +type ElementProps = Record & { children?: unknown } + +function appendChildren(element: Node, children: unknown[]) { + for (const child of children.flat(Infinity)) { + if (child instanceof Node) { + element.appendChild(child) + continue + } + if (child !== undefined && child !== null && child !== false) { + element.appendChild(document.createTextNode(String(child))) + } + } +} + +function createElement(type: unknown, props: ElementProps | null, ...children: unknown[]): JSX.Element { + const attributes = props ?? {} + const content = children.length > 0 ? children : [attributes.children] + + if (typeof type === "function") { + const result: unknown = createComponent(type as Component<{ children: unknown }>, { + ...attributes, + children: content.length === 1 ? content[0] : content, + }) + return (typeof result === "function" ? (result as () => unknown)() : result) as JSX.Element + } + + const element = document.createElement(String(type)) + for (const [name, value] of Object.entries(attributes)) { + if (name === "children" || name === "ref" || value === undefined || value === false) continue + if (name.startsWith("on") && typeof value === "function") { + element.addEventListener(name.slice(2).toLowerCase(), value as EventListener) + continue + } + element.setAttribute(name, String(value)) + } + appendChildren(element, content) + return element as unknown as JSX.Element +} + +globalThis.React = { createElement } as unknown as typeof globalThis.React + +let clickOverlay: (() => void) | undefined + +mock.module("@kobalte/core/dialog", () => { + const Dialog = (props: ElementProps) => props.children + Dialog.Portal = (props: ElementProps) => props.children + Dialog.Overlay = (props: ElementProps) => { + clickOverlay = props.onClick as (() => void) + return createElement("div", props) + } + return { Dialog } +}) + +const { DialogProvider, useDialog } = await import("./dialog") + +function createDialogHost() { + const host = document.createElement("div") + document.body.append(host) + return host +} + +function TestApp(props: { onClose: () => void; onRender?: () => void }) { + const dialog = useDialog() + const button = document.createElement("button") + button.textContent = "Open" + button.addEventListener("click", () => + dialog.show( + () => { + props.onRender?.() + return createElement("div", { "data-dialog": "first" }, "First dialog") + }, + props.onClose, + ), + ) + + return button +} + +describe("DialogProvider", () => { + beforeEach(() => { + document.body.replaceChildren() + clickOverlay = undefined + }) + + test("mounts dialogs and closes the active dialog with Escape", async () => { + const host = createDialogHost() + let closeCount = 0 + let renderCount = 0 + const dispose = render( + () => + createComponent(DialogProvider, { + children: (() => createComponent(TestApp, { onClose: () => closeCount++, onRender: () => renderCount++ })) as unknown as JSX.Element, + }), + host, + ) + + host.querySelector("button")?.click() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(renderCount).toBe(1) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) + await new Promise((resolve) => setTimeout(resolve, 120)) + + expect(closeCount).toBe(1) + expect(host.querySelector('[data-component="dialog-overlay"]')).toBeNull() + dispose() + host.remove() + }) + + test("closes a dialog when its overlay is clicked", async () => { + const host = createDialogHost() + const dispose = render( + () => + createComponent(DialogProvider, { + children: (() => createComponent(TestApp, { onClose: () => {} })) as unknown as JSX.Element, + }), + host, + ) + + host.querySelector("button")?.click() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(clickOverlay).toBeDefined() + clickOverlay?.() + await new Promise((resolve) => setTimeout(resolve, 120)) + + expect(host.querySelector('[data-dialog="first"]')).toBeNull() + dispose() + host.remove() + }) +}) diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index 39ef8ea1..350faa7a 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -29,109 +29,139 @@ type Active = { const Context = createContext>() +type DialogTimer = { + current: ReturnType | undefined +} + +type DialogState = { + stack: () => Active[] + setStack: (updater: (items: Active[]) => Active[]) => void + timer: DialogTimer + lock: { value: boolean } +} + +function clearTimer(timer: DialogTimer) { + if (timer.current === undefined) return + clearTimeout(timer.current) + timer.current = undefined +} + +function getActiveDialog(items: Active[], id?: string) { + return id ? items.find((item) => item.id === id) : items.at(-1) +} + +function closeActive(item: Active, state: DialogState) { + if (state.lock.value) return + + state.lock.value = true + item.onClose?.() + item.setClosing(true) + + const closed = item.id + clearTimer(state.timer) + + state.timer.current = setTimeout(() => { + state.timer.current = undefined + item.dispose() + state.setStack((items) => items.filter((dialog) => dialog.id !== closed)) + state.lock.value = false + }, 100) +} + +function createEscapeHandler(close: (id?: string) => void) { + return (event: KeyboardEvent) => { + if (event.key !== "Escape") return + close() + event.preventDefault() + event.stopPropagation() + } +} + +function createDialogNode( + element: DialogElement, + owner: Owner, + onClose: (() => void) | undefined, + layer: number, + close: (id?: string) => void, +) { + const id = Math.random().toString(36).slice(2) + const zIndex = 50 + layer * 10 + let dispose: (() => void) | undefined + let setClosing: ((closing: boolean) => void) | undefined + + const node = runWithOwner(owner, () => + createRoot((d: () => void) => { + dispose = d + const [closing, setClosingSignal] = createSignal(false) + setClosing = setClosingSignal + return ( + { + if (open) return + close(id) + }} + > + + close(id)} + /> +
+ {element()} +
+
+
+ ) + }), + ) + + if (!dispose || !setClosing) return + + return { id, node, dispose, owner, onClose, setClosing } +} + function init() { const [stack, setStack] = createSignal([]) - const timer = { current: undefined as ReturnType | undefined } + const timer: DialogTimer = { current: undefined } const lock = { value: false } onCleanup(() => { - if (timer.current === undefined) return - clearTimeout(timer.current) - timer.current = undefined + clearTimer(timer) }) const close = (id?: string) => { - const items = stack() - const current = id ? items.find((item) => item.id === id) : items.at(-1) + const current = getActiveDialog(stack(), id) if (!current || lock.value) return - lock.value = true - current.onClose?.() - current.setClosing(true) - - const closed = current.id - if (timer.current !== undefined) { - clearTimeout(timer.current) - timer.current = undefined - } - - timer.current = setTimeout(() => { - timer.current = undefined - current.dispose() - setStack((items) => items.filter((item) => item.id !== closed)) - lock.value = false - }, 100) + closeActive(current, { stack, setStack, timer, lock }) } createEffect(() => { if (stack().length === 0) return - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return - close() - event.preventDefault() - event.stopPropagation() - } - - makeEventListener(window, "keydown", onKeyDown, { capture: true }) + makeEventListener(window, "keydown", createEscapeHandler(close), { capture: true }) }) const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, layer: number) => { - const id = Math.random().toString(36).slice(2) - const zIndex = 50 + layer * 10 - let dispose: (() => void) | undefined - let setClosing: ((closing: boolean) => void) | undefined - - const node = runWithOwner(owner, () => - createRoot((d: () => void) => { - dispose = d - const [closing, setClosingSignal] = createSignal(false) - setClosing = setClosingSignal - return ( - { - if (open) return - close(id) - }} - > - - close(id)} - /> -
- {element()} -
-
-
- ) - }), - ) - - if (!dispose || !setClosing) return - - const active: Active = { id, node, dispose, owner, onClose, setClosing } + const active = createDialogNode(element, owner, onClose, layer, close) + if (!active) return setStack((items) => [...items, active]) } const push = (element: DialogElement, owner: Owner, onClose?: () => void) => { - if (timer.current !== undefined) { - clearTimeout(timer.current) - timer.current = undefined - } + clearTimer(timer) lock.value = false mount(element, owner, onClose, stack().length) } @@ -139,10 +169,7 @@ function init() { const show = (element: DialogElement, owner: Owner, onClose?: () => void) => { for (const item of stack()) item.dispose() setStack([]) - if (timer.current !== undefined) { - clearTimeout(timer.current) - timer.current = undefined - } + clearTimer(timer) lock.value = false mount(element, owner, onClose, 0) }