diff --git a/.changeset/dom-dialog-exit-in-place.md b/.changeset/dom-dialog-exit-in-place.md new file mode 100644 index 0000000..b474cf7 --- /dev/null +++ b/.changeset/dom-dialog-exit-in-place.md @@ -0,0 +1,18 @@ +--- +'@dunky.dev/dom-dialog': minor +--- + +`startExitWindow` takes `inPlace`, with the layer's `viewport`, for a dialog +rendered without a portal. The exit window hides the still-painting layer by +walking up to the content's outermost ancestor below the portal container — +the layer itself when portalled, but for a layer that sits in the page's own +branch that walk takes the page around it out of interaction too. In place, +the hiding stops at the layer: from its viewport down, or the content alone +when it has none. + +```ts +startExitWindow(content, { inPlace: true, viewport, backdrop, onComplete }) +``` + +The option defaults off, so portalled dialogs and today's callers behave as +before. The Vue binding passes it for a `Content` rendered without a `Portal`. diff --git a/.changeset/vue-dialog.md b/.changeset/vue-dialog.md new file mode 100644 index 0000000..3d0ac74 --- /dev/null +++ b/.changeset/vue-dialog.md @@ -0,0 +1,54 @@ +--- +'@dunky.dev/vue-dialog': minor +--- + +New substrate: the Vue binding for `@dunky.dev/dialog`, targeting Vue 3.5+ +(peer `vue@^3.5.0` — the base id comes from `useId`, so server-rendered ids +survive hydration). The same compound anatomy and behavior contract as the +React and Solid bindings — one core machine, a new host — delivered in Vue's +native shape: the core options are props, the core callbacks are emits, and +`v-model:open` binds the controlled pair. Listeners run synchronously, so +`preventDefault()` on a dismissal payload still vetoes it. `update:open` +reports a change, it never requests one: per the controlled contract a +controlled dialog — `v-model:open` included — doesn't move on its own Trigger, +Escape, or outside press, so wire each intent at its source. + +```vue + + + +``` + +An uncontrolled `` needs none of that: its own parts open and close +it, and an absent boolean prop keeps the core default rather than Vue's +`false` cast — `` is modal and uncontrolled, `` +starts open. `Content`'s `initialFocus` and `restoreFocus` accept an element, +a ref, or a getter, resolved at open and close time; a template unwraps refs +before the element mounts, so it passes a getter +(`:initial-focus="() => cancelButton"`). The Portal renders nothing on the +server and teleports once mounted, so hydration matches; under `` +a cached dialog parks its layers off the page, state intact, until the view +returns. Everything else +follows the core spec: a `div` window with the dialog role, layer stack with +assistive-tech containment, focus trap with Close as the cycle's last stop, +scroll lock (scoped to the Portal container when given), exit animations +through `data-state="closing"`, and `closeOnBack` with the Forward reopen. diff --git a/.changeset/vue-hooks.md b/.changeset/vue-hooks.md new file mode 100644 index 0000000..b0f7765 --- /dev/null +++ b/.changeset/vue-hooks.md @@ -0,0 +1,14 @@ +--- +'@dunky.dev/vue-use-focus-trap': minor +'@dunky.dev/vue-use-scroll-lock': minor +--- + +New substrate: the Vue lifecycle wrappers over the framework-free DOM utils, +mirroring the Solid primitives' shape (peer `vue@^3.3.0`). +`useFocusTrap(target, options?)` and `useScrollLock(locked?, target?)` take a +`MaybeRefOrGetter` — a template ref, a getter, or the element — and follow it +reactively. Both start on mount, when a template ref has filled, and never +run during server rendering; a `` deactivation releases them like +an unmount and reactivation picks them back up, as React's `` does. +The behavior itself lives in `@dunky.dev/dom-focus-trap` and +`@dunky.dev/dom-scroll-lock` — these composables own only the lifecycle. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0d5e090..1432b80 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,7 +6,7 @@ one per host environment — the **substrates**. Behavior cannot drift between hosts because it exists in exactly one place. A substrate is any environment a primitive is delivered to: a framework -(react), another framework (solid), or a different host entirely +(react), other frameworks (solid, vue), or a different host entirely (native). Substrates are cheap by design; the expensive thing — the behavior — is written once. @@ -66,9 +66,9 @@ edges — lives under `dom/components/` instead. A util is primitive-agnostic and imports nothing from the repo but a smaller util; a component package is the opposite, and may import the primitive's core package and any DOM util. Both are equally -framework-free. The split matters as substrates multiply: React and Solid -differ in how they schedule an effect, not in what the effect does, so the -what is written once and each binding contributes only its lifecycle. +framework-free. The split matters as substrates multiply: React, Solid, and +Vue differ in how they schedule an effect, not in what the effect does, so +the what is written once and each binding contributes only its lifecycle. Machine logic that several primitives need — the controlled/uncontrolled machinery (`@dunky.dev/controllable`) — lives the same way under diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01912a0..961ddaf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ pnpm install | Command | What it does | | ------------------------------ | ---------------------------------------------------------- | | `pnpm scaffold ` | Stamps a new primitive across every substrate | -| `pnpm test` | vitest suite (core + dom + react), watch mode | +| `pnpm test` | vitest suite (core, dom, web bindings), watch mode | | `pnpm test:native` | The native substrate's jest suite (jest-expo + RNTL) | | `pnpm test:ci` | Everything once (vitest + native jest) — what CI runs | | `pnpm typecheck` | `tsc --noEmit` across the whole workspace | @@ -36,7 +36,7 @@ pnpm test packages/core/dialog/tests/machine.test.ts ## Storybook -Each UI substrate (React, Solid, ...) is a self-contained package under +Each UI substrate (React, Solid, Vue, ...) is a self-contained package under `packages/` with its own Storybook — the fastest way to see a change actually render. Every substrate gets an explicit `dev:` script: @@ -44,6 +44,8 @@ script: ```bash pnpm dev # alias for dev:react (the default substrate) pnpm dev:react # @dunky-dev/react Storybook at http://localhost:6006 +pnpm dev:solid # @dunky-dev/solid Storybook at http://localhost:6008 +pnpm dev:vue # @dunky-dev/vue Storybook at http://localhost:6007 pnpm build-storybook # static build of the react substrate's Storybook ``` diff --git a/README.md b/README.md index 51c3a18..51a3e89 100644 --- a/README.md +++ b/README.md @@ -25,23 +25,23 @@ up there with the same behavior and the same a11y. connect() -> logical bindings (onPress, role, data-state, ...) | - +----------------+----------------+ - | | - v | - +------------------------+ | - | dom/components/ | the DOM half | - | document listeners, | shared by every | - | focus/stack sequence | DOM host | - +-----+------------+-----+ | - | ^ | - | | dom/utils/* | - | | focus-trap, overlay, | - | | scroll-lock, ... | - v v - +-----------+ +-----------+ +-----------+ - | react | | solid | | native | - +-----------+ +-----------+ +-----------+ - render + host lifecycle only + +----------------+----------------------+ + | | + v | + +------------------------+ | + | dom/components/ | the DOM half | + | document listeners, | shared by every | + | focus/stack sequence | DOM host | + +-----+------------+-----+ | + | ^ | + | | dom/utils/* | + | | focus-trap, overlay, | + | | scroll-lock, ... | + v v + +-----------+ +-----------+ +-----------+ +-----------+ + | react | | solid | | vue | | native | + +-----------+ +-----------+ +-----------+ +-----------+ + render + host lifecycle only ``` The engine that runs the core machines lives in its own repo: @@ -63,8 +63,8 @@ renders, not how machines execute. ordered focus sequences), written once and shared by every DOM host. Published as `@dunky.dev/dom-`. - **`/`** — the render. A thin binding per host (`react/`, - `solid/`, `native/`) that wires the machine to real elements. Published as - `@dunky.dev/-` (e.g. `@dunky.dev/react-dialog`). + `solid/`, `vue/`, `native/`) that wires the machine to real elements. + Published as `@dunky.dev/-` (e.g. `@dunky.dev/react-dialog`). The dependency direction is one-way: `substrate -> dom -> core`. A binding adds no behavior of its own — if a decision is needed, it moves into the core diff --git a/knip.config.ts b/knip.config.ts index 9f7fa4c..ff8fb22 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -28,6 +28,9 @@ const config: KnipConfig = { // The babel presets are referenced as strings in tsdown.config.ts. ignoreDependencies: ['babel-preset-solid', '@babel/preset-typescript'], }, + 'packages/vue/*': { + entry: ['stories/*.stories.ts'], + }, }, } diff --git a/package.json b/package.json index 12fd423..bb3ef32 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test:native": "pnpm --filter @dunky-dev/native test", "test:ci": "vitest run && pnpm test:native", "build": "tsdown", - "typecheck": "tsc --noEmit && tsc --noEmit -p packages/solid", + "typecheck": "tsc --noEmit && tsc --noEmit -p packages/solid && tsc --noEmit -p packages/vue", "lint": "oxlint --ignore-pattern '.worktrees' .", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -18,6 +18,7 @@ "dev": "pnpm dev:react", "dev:react": "pnpm --filter @dunky-dev/react dev", "dev:solid": "pnpm --filter @dunky-dev/solid dev", + "dev:vue": "pnpm --filter @dunky-dev/vue dev", "dev:expo": "pnpm --filter @dunky-dev/native dev", "dev:ios": "pnpm --filter @dunky-dev/native ondevice:ios", "dev:android": "pnpm --filter @dunky-dev/native ondevice:android", diff --git a/packages/dom/components/dialog/SPEC.md b/packages/dom/components/dialog/SPEC.md index 95cacc2..83bdfec 100644 --- a/packages/dom/components/dialog/SPEC.md +++ b/packages/dom/components/dialog/SPEC.md @@ -103,6 +103,12 @@ and watched for the end of its visual, which the substrate forwards to the machine as `exit.complete`. The disposer undoes both — it is the reopen interrupt as much as the final unmount. +What gets hidden is the layer: the content's outermost ancestor below the +portal `container`. A layer rendered in place — without a portal — sits in the +page's own branch instead, where that walk would take the page around it out +too, so `inPlace` bounds the hiding to the layer itself: from its `viewport` +down, or the content alone when it has none. + ### Back navigation `guardBackNavigation` makes the host's Back a dismissal, and its Forward the diff --git a/packages/dom/components/dialog/src/exit-window.ts b/packages/dom/components/dialog/src/exit-window.ts index 26ae75b..21539dc 100644 --- a/packages/dom/components/dialog/src/exit-window.ts +++ b/packages/dom/components/dialog/src/exit-window.ts @@ -5,6 +5,16 @@ export interface ExitWindowOptions { container?: HTMLElement | null /** The layer's backdrop, portalled alongside the content. */ backdrop?: Element | null + /** + * The layer renders in place, without a portal, so it sits in the page's + * own branch rather than a container of its own: the exit hides the layer + * alone — from its `viewport` down, or the content when it has none — + * instead of the content's outermost ancestor below `container`. + * @default false + */ + inPlace?: boolean + /** The layer's viewport: what an `inPlace` exit hides along with the content. */ + viewport?: Element | null /** Forwarded to the machine as `exit.complete`. */ onComplete: () => void } @@ -16,11 +26,12 @@ export interface ExitWindowOptions { * interrupt (and the final unmount) undoing both. */ export function startExitWindow(content: HTMLElement, options: ExitWindowOptions): () => void { - const undoHide = hideExitingLayer( - content, - options.container ?? document.body, - options.backdrop ?? null, - ) + // In place, the layer's own parent bounds the walk, so it stops at the + // layer instead of climbing into the page around it. + const boundary = options.inPlace + ? ((options.viewport ?? content).parentElement ?? document.body) + : (options.container ?? document.body) + const undoHide = hideExitingLayer(content, boundary, options.backdrop ?? null) const cancelWatch = watchExitAnimation(content, options.onComplete) return () => { cancelWatch() diff --git a/packages/dom/components/dialog/tests/dialog.test.ts b/packages/dom/components/dialog/tests/dialog.test.ts index 81755f8..eac0cbd 100644 --- a/packages/dom/components/dialog/tests/dialog.test.ts +++ b/packages/dom/components/dialog/tests/dialog.test.ts @@ -361,6 +361,36 @@ describe('startExitWindow', () => { content.dispatchEvent(new Event('transitionend')) expect(onComplete).not.toHaveBeenCalled() }) + + // A layer rendered in place sits in the page's own branch: climbing to the + // outermost ancestor below the body would take the page out with it. + describe('in place', () => { + const mountInPlace = (withViewport: boolean): Record => { + const page = document.createElement('main') + const viewport = document.createElement('div') + const content = document.createElement('div') + page.append(withViewport ? viewport : content) + if (withViewport) viewport.append(content) + document.body.append(page) + return { page, viewport, content } + } + + it('hides the layer from its viewport down, leaving the page around it live', () => { + const { page, viewport, content } = mountInPlace(true) + startExitWindow(content, { inPlace: true, viewport, onComplete: vi.fn() }) + + expect(viewport.hasAttribute('inert')).toBe(true) + expect(page.hasAttribute('inert')).toBe(false) + }) + + it('hides the content alone when the layer has no viewport', () => { + const { page, content } = mountInPlace(false) + startExitWindow(content, { inPlace: true, onComplete: vi.fn() }) + + expect(content.hasAttribute('inert')).toBe(true) + expect(page.hasAttribute('inert')).toBe(false) + }) + }) }) describe('guardBackNavigation', () => { diff --git a/packages/vue/.storybook/main.ts b/packages/vue/.storybook/main.ts new file mode 100644 index 0000000..80c022d --- /dev/null +++ b/packages/vue/.storybook/main.ts @@ -0,0 +1,39 @@ +import type { StorybookConfig } from '@storybook/vue3-vite' +import { mergeConfig } from 'vite' + +const config: StorybookConfig = { + stories: ['../**/*.stories.@(ts|tsx)'], + framework: { + name: '@storybook/vue3-vite', + // Docgen reads `.vue` files; the parts are `.ts` components. + options: { docgen: false }, + }, + core: { + disableTelemetry: true, + disableWhatsNewNotifications: true, + }, + features: { + sidebarOnboardingChecklist: false, + }, + viteFinal: viteConfig => + mergeConfig(viteConfig, { + // A linked state-machine adapter (cross-repo development) resolves + // `vue` from its own checkout; two copies split the component instance, + // so every import is pinned to this workspace's. + resolve: { dedupe: ['vue'] }, + // Vue's esm-bundler build takes its feature flags from the bundler + // (@vitejs/plugin-vue defines them; this framework adds no such plugin) + // and logs a notice on every load without them. The binding, its + // adapter, the stories, and Storybook's renderer use setup() and render + // functions only, so the Options API stays out — which also shows the + // dialog working in an app that turns it off. A static Storybook has no + // production devtools and no hydration. + define: { + __VUE_OPTIONS_API__: 'false', + __VUE_PROD_DEVTOOLS__: 'false', + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', + }, + }), +} + +export default config diff --git a/packages/vue/.storybook/manager.ts b/packages/vue/.storybook/manager.ts new file mode 100644 index 0000000..3bfb5c2 --- /dev/null +++ b/packages/vue/.storybook/manager.ts @@ -0,0 +1,14 @@ +import { addons } from 'storybook/manager-api' +import { create } from 'storybook/theming' + +addons.setConfig({ + showToolbar: true, + layoutCustomisations: { + showPanel: () => false, + }, + theme: create({ + base: 'light', + brandTitle: 'dunky', + brandUrl: './', + }), +}) diff --git a/packages/vue/dialog/README.md b/packages/vue/dialog/README.md new file mode 100644 index 0000000..6f8880e --- /dev/null +++ b/packages/vue/dialog/README.md @@ -0,0 +1,41 @@ +# @dunky.dev/vue-dialog + +Vue binding for [`@dunky.dev/dialog`](../../core/dialog): a compound +component — `Dialog` plus its parts — that drives the framework-free dialog +machine. The root owns the machine; parts translate the core's logical +bindings into DOM attributes and handlers, and wire the DOM-only concerns +(teleport, focus trap, scroll lock, layer stack). + +Behavior contract: [`../../core/dialog/SPEC.md`](../../core/dialog/SPEC.md). +Vue-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/vue-dialog +``` + +## Usage + +```vue + + + +``` diff --git a/packages/vue/dialog/SPEC.md b/packages/vue/dialog/SPEC.md new file mode 100644 index 0000000..8b04061 --- /dev/null +++ b/packages/vue/dialog/SPEC.md @@ -0,0 +1,298 @@ +# SPEC / Vue / Dialog + +The Vue implementation of the [core spec](../../core/dialog/SPEC.md). + +## Docs + +🔗 [`dunky.dev/ui/components/dialog`](https://dunky.dev/ui/components/dialog). + +## Install + +```sh +npm install @dunky.dev/vue-dialog +``` + +## Usage + +```vue + + + +``` + +The parts hang off the root: ` + + +``` diff --git a/packages/vue/hooks/use-focus-trap/SPEC.md b/packages/vue/hooks/use-focus-trap/SPEC.md new file mode 100644 index 0000000..e660cb0 --- /dev/null +++ b/packages/vue/hooks/use-focus-trap/SPEC.md @@ -0,0 +1,63 @@ +# SPEC / Vue / useFocusTrap + +The Vue binding of the +[DOM focus-trap spec](../../../dom/utils/focus-trap/SPEC.md) — the trap +behavior is framework-free; this composable owns only the Vue lifecycle. + +## Install + +```sh +npm install @dunky.dev/vue-use-focus-trap +``` + +## Usage + +```vue + + + +``` + +Vue-specific notes on top of the DOM contract: + +- The target is a `MaybeRefOrGetter` — a template ref, a getter, or the + element itself; a component counts as its root element (`$el`), read when + the target resolves — `$el` isn't reactive, so a component that swaps its + root element needs a ref on the element instead. The trap arms when the + component mounts (a template ref has filled by then), re-arms when the + target yields a new element, and releases on unmount. A `` + deactivation releases it too and reactivation arms it again, as React's + `` runs an effect's cleanup; mounted into a view that is already + deactivated, it waits for the view's return, and reached by a restored + view's activation before it has mounted (an async component under + ``), it waits for its own mount. Mounted hooks never run during + server rendering, so the trap touches no DOM there. +- The deactivated-view walk and the `$el` unwrapping are local copies of the + dialog binding's, by design — see [its SPEC](../../dialog/SPEC.md)'s + `` note. +- The options object is read on each Tab press, so inline `enabled` / `last` + see the latest state without re-binding the listener — the per-press + re-evaluation the DOM contract promises. + +## API + +### `useFocusTrap(target, options?)` + +Returns nothing — the trap lives and dies with the component. + +| Param | Type | Default | Description | +| --------- | ------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------- | +| `target` | `MaybeRefOrGetter` | — | The container to trap Tab / Shift+Tab within. | +| `options` | `UseFocusTrapOptions` | `{}` | The DOM trap's options: `enabled?: () => boolean`, `last?: () => HTMLElement \| null`. | diff --git a/packages/vue/hooks/use-focus-trap/package.json b/packages/vue/hooks/use-focus-trap/package.json new file mode 100644 index 0000000..07dc807 --- /dev/null +++ b/packages/vue/hooks/use-focus-trap/package.json @@ -0,0 +1,48 @@ +{ + "name": "@dunky.dev/vue-use-focus-trap", + "version": "0.0.0", + "description": "Vue binding for @dunky.dev/dom-focus-trap.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/vue/hooks/use-focus-trap" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dom-focus-trap": "workspace:*" + }, + "devDependencies": { + "@testing-library/vue": "^8.1.0", + "vue": "^3.5.43" + }, + "peerDependencies": { + "vue": "^3.3.0" + } +} diff --git a/packages/vue/hooks/use-focus-trap/src/index.ts b/packages/vue/hooks/use-focus-trap/src/index.ts new file mode 100644 index 0000000..52559c7 --- /dev/null +++ b/packages/vue/hooks/use-focus-trap/src/index.ts @@ -0,0 +1 @@ +export { useFocusTrap, type UseFocusTrapOptions } from './use-focus-trap' diff --git a/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts new file mode 100644 index 0000000..cd2c1bd --- /dev/null +++ b/packages/vue/hooks/use-focus-trap/src/use-focus-trap.ts @@ -0,0 +1,100 @@ +import { + effectScope, + getCurrentInstance, + onActivated, + onDeactivated, + onMounted, + onScopeDispose, + onUnmounted, + toValue, + watch, + type ComponentPublicInstance, + type EffectScope, + type MaybeRefOrGetter, +} from 'vue' +import { trapFocus } from '@dunky.dev/dom-focus-trap' +import type { TrapFocusOptions } from '@dunky.dev/dom-focus-trap' + +export interface UseFocusTrapOptions extends TrapFocusOptions {} + +// The instance fields the walk reads, structurally: Vue 3.6 types the chain +// as its Vapor-aware generic instance. +interface InstanceNode { + isDeactivated: boolean + parent: InstanceNode | null +} + +// Whether the component sits in a deactivated view — it mounted +// into a cached view after the user left it, and waits for the return. +function inDeactivatedView(instance: InstanceNode | null): boolean { + for (let node = instance; node !== null; node = node.parent) { + if (node.isDeactivated) return true + } + return false +} + +// A template ref on a component holds its instance; the element is its `$el` +// (a text or comment node for a fragment root). Duck-typed: an element from +// another realm — an iframe's document — fails `instanceof HTMLElement`. +function toElement( + target: HTMLElement | ComponentPublicInstance | null | undefined, +): HTMLElement | null { + if (typeof target !== 'object' || target === null) return null + const element = ('$el' in target ? target.$el : target) as Node | null + return element?.nodeType === Node.ELEMENT_NODE ? (element as HTMLElement) : null +} + +/** + * Traps Tab / Shift+Tab within `target` while the component is mounted and + * the target holds an element — the Vue lifecycle around `trapFocus`. Arms on + * mount, re-arms when the target yields a new element, releases on unmount. + * A component counts as its root element (`$el`). + */ +export function useFocusTrap( + target: MaybeRefOrGetter, + options: UseFocusTrapOptions = {}, +): void { + // Armed from mount, when a template ref has filled — never during server + // rendering — and released after the DOM is gone, as React runs an + // effect's cleanup after commit. A deactivation releases it + // too, as React's does. The trap reads the options per Tab + // press, so `enabled` / `last` stay live without re-binding. Released by + // hand: Vue 3.6 also runs a watcher's onCleanup when its source merely + // re-evaluates. + const instance = getCurrentInstance() + let scope: EffectScope | undefined + const arm = (): void => { + if (scope !== undefined) return + // Detached: the component scope stops before the DOM is removed. + scope = effectScope(true) + scope.run(() => { + let untrap: (() => void) | undefined + watch( + () => toElement(toValue(target)), + container => { + untrap?.() + untrap = container === null ? undefined : trapFocus(container, options) + }, + { immediate: true, flush: 'post' }, + ) + onScopeDispose(() => untrap?.()) + }) + } + const disarm = (): void => { + scope?.stop() + scope = undefined + } + let mounted = false + onMounted(() => { + mounted = true + if (!inDeactivatedView(instance)) arm() + }) + // Also fires on a kept-alive first mount, right after `onMounted` — and can + // reach a component that hasn't mounted yet (an async one under + // ), which `onMounted` starts instead. + onActivated(() => { + if (mounted) arm() + }) + onDeactivated(disarm) + onUnmounted(disarm) +} diff --git a/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx b/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx new file mode 100644 index 0000000..18fcf75 --- /dev/null +++ b/packages/vue/hooks/use-focus-trap/tests/use-focus-trap.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +// The Vue lifecycle around @dunky.dev/dom-focus-trap — the wrap/no-op/enabled +// behavior itself is covered in the util's own tests. +import { KeepAlive, Suspense, defineComponent, nextTick, ref, type PropType } from 'vue' +import { cleanup, render, screen } from '@testing-library/vue' +import { afterEach, describe, expect, it, onTestFinished } from 'vitest' +import { useFocusTrap } from '@dunky.dev/vue-use-focus-trap' + +const Trap = defineComponent({ + props: { enabled: { type: Function as PropType<() => boolean>, default: undefined } }, + setup(props) { + const target = ref(null) + // The closure defers the props read to each Tab press. + useFocusTrap(target, { enabled: () => props.enabled?.() !== false }) + return () => ( +
+ + +
+ ) + }, +}) + +// dispatchEvent returns false when a handler called preventDefault — Vue +// TL's fireEvent resolves void, so it can't report that. +const tab = (target: Element): boolean => + target.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }), + ) + +// The trap listens on the document: a Tab pressed off the document (on a +// detached or cached container) reaches no listener either way, so a check +// that the trap let go presses it on the page. +const outsideButton = (): HTMLButtonElement => { + const outside = document.createElement('button') + document.body.append(outside) + onTestFinished(() => outside.remove()) + return outside +} + +// Auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('useFocusTrap', () => { + it('traps while mounted and releases on unmount', () => { + const { unmount } = render(Trap) + screen.getByText('last').focus() + + expect(tab(screen.getByTestId('container'))).toBe(false) + expect(document.activeElement).toBe(screen.getByText('first')) + + const outside = outsideButton() + unmount() + expect(tab(outside)).toBe(true) + }) + + it('forwards enabled() to the trap without re-binding', () => { + render(Trap, { props: { enabled: () => false } }) + const last = screen.getByText('last') + last.focus() + + expect(tab(screen.getByTestId('container'))).toBe(true) + expect(document.activeElement).toBe(last) + }) + + it('lets go while a KeepAlive deactivation holds the component, and re-arms on reactivation', async () => { + const shown = ref(true) + render(() => {shown.value ? : null}) + const container = screen.getByTestId('container') + const outside = outsideButton() + + shown.value = false + await nextTick() + expect(tab(outside)).toBe(true) + + shown.value = true + await nextTick() + expect(tab(container)).toBe(false) + }) + + it('stays unarmed when mounted into a deactivated KeepAlive view, and arms once it returns', async () => { + const shown = ref(true) + const loaded = ref(false) + const Page = defineComponent(() => () => (loaded.value ? : loading)) + render(() => {shown.value ? : null}) + shown.value = false + await nextTick() + + loaded.value = true // mounts the trap into the cached view + await nextTick() + expect(tab(outsideButton())).toBe(true) + + shown.value = true + await nextTick() + expect(tab(screen.getByTestId('container'))).toBe(false) + }) + + // A restored view's activation reaches every part it holds, mounted or + // not: an async one still pending under arms with its own mount. + it('stays unarmed for an async component a restored view reaches before it mounts', async () => { + let resolve = (): void => {} + const pending = new Promise(done => (resolve = done)) + const container = document.createElement('div') + container.append(document.createElement('button')) + document.body.append(container) + onTestFinished(() => container.remove()) + const Async = defineComponent({ + async setup() { + useFocusTrap(container) + await pending + return () => null + }, + }) + const shown = ref(true) + const Page = defineComponent(() => () => ( + + + + )) + render(() => {shown.value ? : null}) + shown.value = false + await nextTick() + shown.value = true + await nextTick() + expect(tab(outsideButton())).toBe(true) + + resolve() + await new Promise(settled => setTimeout(settled)) + expect(tab(container)).toBe(false) + }) + + it("takes a component's ref as its root element", () => { + const Panel = defineComponent((_, { slots }) => () => ( +
+ {slots.default?.()} +
+ )) + render( + defineComponent(() => { + const panel = ref | null>(null) + useFocusTrap(panel) + return () => ( + + + + + ) + }), + ) + screen.getByText('last').focus() + expect(tab(screen.getByTestId('container'))).toBe(false) + expect(document.activeElement).toBe(screen.getByText('first')) + }) + + // An iframe's elements belong to another realm, which `instanceof` misses. + it('traps within an element from another document', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + onTestFinished(() => frame.remove()) + const frameDocument = frame.contentDocument as Document + const container = frameDocument.createElement('div') + container.append(frameDocument.createElement('button')) + frameDocument.body.append(container) + render( + defineComponent(() => { + useFocusTrap(container) + return () => null + }), + ) + expect(tab(container)).toBe(false) + }) +}) diff --git a/packages/vue/hooks/use-scroll-lock/README.md b/packages/vue/hooks/use-scroll-lock/README.md new file mode 100644 index 0000000..ae4e6b3 --- /dev/null +++ b/packages/vue/hooks/use-scroll-lock/README.md @@ -0,0 +1,28 @@ +# @dunky.dev/vue-use-scroll-lock + +Vue binding for [`@dunky.dev/dom-scroll-lock`](../../../dom/utils/scroll-lock): +`useScrollLock(locked, target?)` locks scrolling while the component is +mounted — on the page body, or on the `target` element when one is given (e.g. +a scoped surface locks its own container, not the page). The lock behavior +itself is framework-free — this composable only owns the Vue lifecycle. + +## Install + +```sh +npm install @dunky.dev/vue-use-scroll-lock +``` + +## Usage + +```vue + + + +``` diff --git a/packages/vue/hooks/use-scroll-lock/SPEC.md b/packages/vue/hooks/use-scroll-lock/SPEC.md new file mode 100644 index 0000000..4d59a07 --- /dev/null +++ b/packages/vue/hooks/use-scroll-lock/SPEC.md @@ -0,0 +1,62 @@ +# SPEC / Vue / useScrollLock + +The Vue binding of the +[DOM scroll-lock spec](../../../dom/utils/scroll-lock/SPEC.md) — the lock +behavior is framework-free; this composable owns only the Vue lifecycle. + +## Install + +```sh +npm install @dunky.dev/vue-use-scroll-lock +``` + +## Usage + +```vue + + + +``` + +Vue-specific notes on top of the DOM contract: + +- The lock holds while the component is mounted and `locked` resolves true; + unmounting or turning `locked` off releases it, and so does a `` + deactivation — reactivation locks again, as React's `` runs an + effect's cleanup; mounted into a view that is already deactivated, it waits + for the view's return, and reached by a restored view's activation before it + has mounted (an async component under ``), it waits for its own + mount. Both parameters accept a `MaybeRefOrGetter` — a plain value, a ref, + or a getter, a component counting as its root element (`$el`) — so the lock + tracks reactive state: a `target` change releases the old container and + locks the new one. `$el` itself isn't reactive: a component that swaps its + root element needs a ref on the element instead. +- An omitted `target` means the page body. A target that resolves to + `null` or `undefined` — a template ref before its element renders — means + "no target yet" and locks nothing; the lock engages once the element + resolves. Mounted hooks never run during server rendering, so there is no + body to touch there. +- The DOM contract's shared per-container lock does the multi-holder + arithmetic: several mounted lockers (nested modal layers) hold one lock, + and the container restores when the last releases. +- The deactivated-view walk and the `$el` unwrapping are local copies of the + dialog binding's, by design — see [its SPEC](../../dialog/SPEC.md)'s + `` note. + +## API + +### `useScrollLock(locked?, target?)` + +Returns nothing — the lock lives and dies with the component. + +| Param | Type | Default | Description | +| -------- | ------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `locked` | `MaybeRefOrGetter` | `true` | Whether the lock is held. | +| `target` | `MaybeRefOrGetter` | the page body | The scroll container to lock (e.g. a scoped surface locks its own container, not the page). `null` / `undefined` locks nothing. | diff --git a/packages/vue/hooks/use-scroll-lock/package.json b/packages/vue/hooks/use-scroll-lock/package.json new file mode 100644 index 0000000..d4383e3 --- /dev/null +++ b/packages/vue/hooks/use-scroll-lock/package.json @@ -0,0 +1,48 @@ +{ + "name": "@dunky.dev/vue-use-scroll-lock", + "version": "0.0.0", + "description": "Vue binding for @dunky.dev/dom-scroll-lock.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/vue/hooks/use-scroll-lock" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/dom-scroll-lock": "workspace:*" + }, + "devDependencies": { + "@testing-library/vue": "^8.1.0", + "vue": "^3.5.43" + }, + "peerDependencies": { + "vue": "^3.3.0" + } +} diff --git a/packages/vue/hooks/use-scroll-lock/src/index.ts b/packages/vue/hooks/use-scroll-lock/src/index.ts new file mode 100644 index 0000000..3d8ec04 --- /dev/null +++ b/packages/vue/hooks/use-scroll-lock/src/index.ts @@ -0,0 +1 @@ +export { useScrollLock } from './use-scroll-lock' diff --git a/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts new file mode 100644 index 0000000..d05e3f7 --- /dev/null +++ b/packages/vue/hooks/use-scroll-lock/src/use-scroll-lock.ts @@ -0,0 +1,101 @@ +import { + effectScope, + getCurrentInstance, + onActivated, + onDeactivated, + onMounted, + onScopeDispose, + onUnmounted, + toValue, + watch, + type ComponentPublicInstance, + type EffectScope, + type MaybeRefOrGetter, +} from 'vue' +import { lockScroll } from '@dunky.dev/dom-scroll-lock' + +// The instance fields the walk reads, structurally: Vue 3.6 types the chain +// as its Vapor-aware generic instance. +interface InstanceNode { + isDeactivated: boolean + parent: InstanceNode | null +} + +// Whether the component sits in a deactivated view — it mounted +// into a cached view after the user left it, and waits for the return. +function inDeactivatedView(instance: InstanceNode | null): boolean { + for (let node = instance; node !== null; node = node.parent) { + if (node.isDeactivated) return true + } + return false +} + +// A template ref on a component holds its instance; the element is its `$el` +// (a text or comment node for a fragment root). Duck-typed: an element from +// another realm — an iframe's document — fails `instanceof HTMLElement`. +function toElement( + target: HTMLElement | ComponentPublicInstance | null | undefined, +): HTMLElement | null { + if (typeof target !== 'object' || target === null) return null + const element = ('$el' in target ? target.$el : target) as Node | null + return element?.nodeType === Node.ELEMENT_NODE ? (element as HTMLElement) : null +} + +/** + * Locks scrolling while the component is mounted and `locked` — the Vue + * lifecycle around `lockScroll`. Targets the page body unless a `target` is + * given. A target that resolves to nothing — a template ref before its + * element renders — means "no target yet" and locks nothing; the lock engages + * once the element resolves. A component counts as its root element (`$el`). + * The lock is shared per container: it restores when the last holder + * releases. + */ +export function useScrollLock( + locked: MaybeRefOrGetter = true, + target?: MaybeRefOrGetter, +): void { + // Held from mount — never during server rendering, where there is no body + // — and released after the DOM is gone, as React runs an effect's cleanup + // after commit. A deactivation releases it too, as React's + // does. Released by hand: Vue 3.6 also runs a watcher's + // onCleanup when its source merely re-evaluates. + const instance = getCurrentInstance() + let scope: EffectScope | undefined + const hold = (): void => { + if (scope !== undefined) return + // Detached: the component scope stops before the DOM is removed. + scope = effectScope(true) + scope.run(() => { + let unlock: (() => void) | undefined + watch( + [ + () => toValue(locked), + () => (target === undefined ? document.body : toElement(toValue(target))), + ], + ([isLocked, container]) => { + unlock?.() + unlock = !isLocked || container === null ? undefined : lockScroll(container) + }, + { immediate: true, flush: 'post' }, + ) + onScopeDispose(() => unlock?.()) + }) + } + const drop = (): void => { + scope?.stop() + scope = undefined + } + let mounted = false + onMounted(() => { + mounted = true + if (!inDeactivatedView(instance)) hold() + }) + // Also fires on a kept-alive first mount, right after `onMounted` — and can + // reach a component that hasn't mounted yet (an async one under + // ), which `onMounted` starts instead. + onActivated(() => { + if (mounted) hold() + }) + onDeactivated(drop) + onUnmounted(drop) +} diff --git a/packages/vue/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts b/packages/vue/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts new file mode 100644 index 0000000..9221486 --- /dev/null +++ b/packages/vue/hooks/use-scroll-lock/tests/use-scroll-lock.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +// The Vue lifecycle around @dunky.dev/dom-scroll-lock — the refcount/restore +// behavior itself is covered in the util's own tests. +import { KeepAlive, Suspense, defineComponent, h, nextTick, ref } from 'vue' +import { cleanup, render } from '@testing-library/vue' +import { afterEach, describe, expect, it, onTestFinished } from 'vitest' +import { useScrollLock } from '@dunky.dev/vue-use-scroll-lock' + +// TL Vue has no renderHook: a component that only calls the composable. +const Locker = (...args: Parameters) => + defineComponent({ + setup() { + useScrollLock(...args) + return () => null + }, + }) + +const mountLock = (...args: Parameters): ReturnType => + render(Locker(...args)) + +// Auto-cleanup needs vitest globals; this repo runs with globals: false. A +// test that fails before its end must not leave the body locked. +afterEach(cleanup) + +describe('useScrollLock', () => { + it('locks body scroll while mounted and releases on unmount', () => { + const { unmount } = mountLock() + expect(document.body.style.overflowY).toBe('hidden') + + unmount() + expect(document.body.style.overflowY).toBe('') + }) + + it('does not lock when locked=false', () => { + mountLock(false) + expect(document.body.style.overflowY).toBe('') + }) + + it('locks nothing when target is null — a target not yet resolved', () => { + mountLock(true, () => null) + expect(document.body.style.overflowY).toBe('') + }) + + // `ref()` holds undefined until its element renders. + it('locks nothing while a template ref is still empty, and its element once it renders', async () => { + const shown = ref(false) + const target = ref() + render( + defineComponent({ + setup() { + useScrollLock(true, target) + return () => h('div', shown.value ? [h('section', { ref: target })] : []) + }, + }), + ) + expect(document.body.style.overflowY).toBe('') + + shown.value = true + await nextTick() + expect(target.value?.style.overflowY).toBe('hidden') + expect(document.body.style.overflowY).toBe('') + }) + + it('follows a reactive locked flag, releasing when it turns off', async () => { + const locked = ref(true) + mountLock(locked) + expect(document.body.style.overflowY).toBe('hidden') + + locked.value = false + await nextTick() + expect(document.body.style.overflowY).toBe('') + }) + + it('releases while a KeepAlive deactivation holds the component, and locks again on reactivation', async () => { + const shown = ref(true) + const Lock = Locker() + render(() => h(KeepAlive, null, () => (shown.value ? h(Lock) : null))) + expect(document.body.style.overflowY).toBe('hidden') + + shown.value = false + await nextTick() + expect(document.body.style.overflowY).toBe('') + + shown.value = true + await nextTick() + expect(document.body.style.overflowY).toBe('hidden') + }) + + it('holds nothing when mounted into a deactivated KeepAlive view, and locks once it returns', async () => { + const shown = ref(true) + const loaded = ref(false) + const Lock = Locker() + const Page = defineComponent(() => () => (loaded.value ? h(Lock) : null)) + render(() => h(KeepAlive, null, () => (shown.value ? h(Page) : null))) + shown.value = false + await nextTick() + + loaded.value = true // mounts the locker into the cached view + await nextTick() + expect(document.body.style.overflowY).toBe('') + + shown.value = true + await nextTick() + expect(document.body.style.overflowY).toBe('hidden') + }) + + // A restored view's activation reaches every part it holds, mounted or + // not: an async one still pending under waits for its own mount. + it('holds nothing for an async component a restored view reaches before it mounts', async () => { + let resolve = (): void => {} + const pending = new Promise(done => (resolve = done)) + const Async = defineComponent({ + async setup() { + useScrollLock() + await pending + return () => null + }, + }) + const shown = ref(true) + const Page = defineComponent(() => () => h(Suspense, null, { default: () => h(Async) })) + render(() => h(KeepAlive, null, () => (shown.value ? h(Page) : null))) + shown.value = false + await nextTick() + shown.value = true + await nextTick() + expect(document.body.style.overflowY).toBe('') + + resolve() + await new Promise(settled => setTimeout(settled)) + expect(document.body.style.overflowY).toBe('hidden') + }) + + it("takes a component's ref as its root element", () => { + const Surface = defineComponent(() => () => h('section')) + const surface = ref | null>(null) + render( + defineComponent({ + setup() { + useScrollLock(true, surface) + return () => h(Surface, { ref: surface }) + }, + }), + ) + expect((surface.value?.$el as HTMLElement | undefined)?.style.overflowY).toBe('hidden') + expect(document.body.style.overflowY).toBe('') + }) + + // An iframe's elements belong to another realm, which `instanceof` misses. + it('locks an element from another document', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + onTestFinished(() => frame.remove()) + const surface = frame.contentDocument?.body ?? null + mountLock(true, surface) + expect(surface?.style.overflowY).toBe('hidden') + }) +}) diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 0000000..20aded9 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,18 @@ +{ + "name": "@dunky-dev/vue", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "storybook dev -p 6007 -c .storybook", + "build": "storybook build -c .storybook" + }, + "devDependencies": { + "@storybook/vue3-vite": "^10.6.1", + "@vitejs/plugin-vue-jsx": "^5.1.6", + "storybook": "^10.6.1", + "vite": "^8.1.4", + "vue": "^3.5.43" + } +} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 0000000..8da94a5 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,12 @@ +{ + // Vue's TSX (tests) needs Vue's JSX namespace, not React's — so + // packages/vue typechecks as its own project (the root tsconfig excludes + // it; `pnpm typecheck` runs both). `paths` is inherited from the root file. + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "vue" + }, + "include": ["."], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/packages/vue/vitest.config.ts b/packages/vue/vitest.config.ts new file mode 100644 index 0000000..ece52a1 --- /dev/null +++ b/packages/vue/vitest.config.ts @@ -0,0 +1,21 @@ +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + // The TSX tests compile through Vue's JSX transform, which must not rewrite + // the React `.tsx` tests — hence a project of its own. + plugins: [vueJsx()], + resolve: { + // A linked state-machine adapter (cross-repo development) resolves `vue` + // from its own checkout; two copies split the component instance, so + // every import is pinned to this workspace's. + dedupe: ['vue'], + }, + test: { + name: 'vue', + globals: false, + // node by default; DOM tests opt into jsdom per-file via `@vitest-environment`. + environment: 'node', + include: ['**/tests/**/*.test.{ts,tsx}'], + }, +}) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 56c79a4..6d0d34f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ minimumReleaseAgeExclude: - '@dunky.dev/state-machine-dom@0.1.0' - '@dunky.dev/state-machine-utils@0.4.0' - '@dunky.dev/state-machine@0.3.3' + - '@dunky.dev/vue-state-machine@0.3.0' - '@dom-expressions/babel-plugin-jsx@0.50.0-next.43' - '@solidjs/signals@2.0.0-rc.1' - '@solidjs/web@2.0.0-rc.1' diff --git a/scripts/templates/packages/vue/__name__/README.md b/scripts/templates/packages/vue/__name__/README.md new file mode 100644 index 0000000..3be7d38 --- /dev/null +++ b/scripts/templates/packages/vue/__name__/README.md @@ -0,0 +1,29 @@ +# @dunky.dev/vue-__name__ + +Vue binding for [`@dunky.dev/__name__`](../../core/__name__): a compound +component — `__Name__` plus its parts — that drives the framework-free +machine. The root owns the machine; parts translate the core's logical +bindings into DOM attributes and handlers. + +Behavior contract: [`../../core/__name__/SPEC.md`](../../core/__name__/SPEC.md). +Vue-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/vue-__name__ +``` + +## Usage + +```vue + + + +``` diff --git a/scripts/templates/packages/vue/__name__/SPEC.md b/scripts/templates/packages/vue/__name__/SPEC.md new file mode 100644 index 0000000..d6d80c2 --- /dev/null +++ b/scripts/templates/packages/vue/__name__/SPEC.md @@ -0,0 +1,36 @@ +# SPEC / Vue / __Name__ + +The Vue implementation of the [core spec](../../core/__name__/SPEC.md). + +## Docs + +🔗 [`dunky.dev/ui/components/__name__`](https://dunky.dev/ui/components/__name__). + + +## Install + +```sh +npm install @dunky.dev/vue-__name__ +``` + +## Usage + + +```vue + + + +``` + + +## API + + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `prop` | `number` | `1337` | Magical number. | +| `...` | `...` | `...` | ... | diff --git a/scripts/templates/packages/vue/__name__/package.json b/scripts/templates/packages/vue/__name__/package.json new file mode 100644 index 0000000..5269535 --- /dev/null +++ b/scripts/templates/packages/vue/__name__/package.json @@ -0,0 +1,49 @@ +{ + "name": "@dunky.dev/vue-__name__", + "version": "0.0.0", + "description": "Vue binding for @dunky.dev/__name__.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/vue/__name__" + }, + "files": [ + "dist", + "src", + "SPEC.md" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/__name__": "workspace:*", + "@dunky.dev/vue-state-machine": "^0.3.0" + }, + "devDependencies": { + "@testing-library/vue": "^8.1.0", + "vue": "^3.5.43" + }, + "peerDependencies": { + "vue": "^3.4.0" + } +} diff --git a/scripts/templates/packages/vue/__name__/src/__name__.ts b/scripts/templates/packages/vue/__name__/src/__name__.ts new file mode 100644 index 0000000..7633e49 --- /dev/null +++ b/scripts/templates/packages/vue/__name__/src/__name__.ts @@ -0,0 +1,97 @@ +import { + defineComponent, + h, + provide, + type ButtonHTMLAttributes, + type ComponentOptionsMixin, + type DefineComponent, + type EmitsOptions, +} from 'vue' +import type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__' + +import { mergeProps, normalize } from '@dunky.dev/vue-state-machine' +import { __Name__ContextKey, use__Name__Context } from './context' +import { use__Name__ } from './use-__name__' + +// The options-carrying type an SFC gets: the function-signature +// defineComponent infers a bare constructor, which tooling built around SFCs +// (Storybook's Meta) rejects. The value is the same options object either +// way, so each part's cast only names it — and gives the export the explicit +// type --isolatedDeclarations needs. +type __Name__Component = DefineComponent< + Props, + {}, + {}, + {}, + {}, + ComponentOptionsMixin, + ComponentOptionsMixin, + Emits +> + +// Vue casts an absent Boolean prop to `false`; `default: undefined` keeps it +// absent, so the core's defaults hold. +const booleanOption = { type: Boolean, default: undefined } + +// ============================================================================= +// <__Name__> — root, owns the machine and renders no DOM +// ============================================================================= + +/** The core options; the core callbacks are the emits (`__Name__Emits`). */ +export interface __Name__Props extends Omit<__Name__Options, keyof __Name__Callbacks> {} + +/** The core callbacks as emits, listeners called synchronously. */ +export type __Name__Emits = { + /** Fired when the primitive becomes disabled. */ + disable: () => void +} + +const __Name__Root = defineComponent<__Name__Props, __Name__Emits>( + (props, { emit, slots }) => { + // Built once: a fresh forwarder per read would re-run the effects keyed on + // a callback on every prop change. + const callbacks: __Name__Callbacks = { disable: () => emit('disable') } + const value = use__Name__(() => ({ ...props, ...callbacks })) + provide(__Name__ContextKey, value) + return () => slots.default?.() + }, + { + name: '__Name__', + // Renders no element of its own. + inheritAttrs: false, + props: { disabled: booleanOption }, + emits: ['disable'], + }, +) as __Name__Component<__Name__Props, __Name__Emits> + +// ============================================================================= +// <__Name__.Root> — placeholder part: wires the root bindings onto an element. +// TODO(spec): replace with one part per piece of the anatomy in SPEC.md. +// ============================================================================= + +export interface __Name__RootProps extends ButtonHTMLAttributes {} + +export const Root = defineComponent( + (_props: __Name__RootProps, { attrs, slots }) => { + const { api } = use__Name__Context() + return () => + h( + 'button', + mergeProps({ type: 'button', ...attrs }, normalize(api.value.parts.root)), + slots.default?.(), + ) + }, + { name: '__Name__Root', inheritAttrs: false }, +) as __Name__Component<__Name__RootProps> + +// Parts +// ----------------------------------------------------------------------------- + +export interface Parts { + Root: typeof Root +} + +export const __Name__: __Name__Component<__Name__Props, __Name__Emits> & Parts = Object.assign( + __Name__Root, + { Root }, +) diff --git a/scripts/templates/packages/vue/__name__/src/context.ts b/scripts/templates/packages/vue/__name__/src/context.ts new file mode 100644 index 0000000..def8656 --- /dev/null +++ b/scripts/templates/packages/vue/__name__/src/context.ts @@ -0,0 +1,19 @@ +import { inject, type ComputedRef, type InjectionKey } from 'vue' +import type { __Name__Api, __Name__Machine } from '@dunky.dev/__name__' + +export interface __Name__ContextValue { + // The connected api: its value is replaced on every machine change, so a + // part whose render reads it re-renders exactly then. + api: ComputedRef<__Name__Api> + machine: __Name__Machine +} + +export const __Name__ContextKey: InjectionKey<__Name__ContextValue> = Symbol('__Name__Context') + +export const use__Name__Context = (): __Name__ContextValue => { + const context = inject(__Name__ContextKey, null) + if (context === null) { + throw new Error('__Name__ parts must be rendered within a <__Name__> root') + } + return context +} diff --git a/scripts/templates/packages/vue/__name__/src/effects.ts b/scripts/templates/packages/vue/__name__/src/effects.ts new file mode 100644 index 0000000..c4bf220 --- /dev/null +++ b/scripts/templates/packages/vue/__name__/src/effects.ts @@ -0,0 +1,23 @@ +import type { ComponentEffect } from '@dunky.dev/vue-state-machine' +import type { __Name__Machine, __Name__Options } from '@dunky.dev/__name__' + +// Substrate effects: prop-driven or platform work the machine can't own. +// useMachine runs each entry after mount and re-runs it when a listed prop +// changes. +type __Name__Effect = ComponentEffect<__Name__Machine, __Name__Options> + +// Config that lives in machine context is synced through events, so guards keep +// working at runtime — the machine never reads props. Document listeners and +// anything else a DOM host would write identically do NOT belong here — they +// live in @dunky.dev/dom-__name__, so every DOM substrate shares one copy. +const syncDisabled: __Name__Effect = [ + (machine, props) => { + const disabled = props.disabled ?? false + if (machine.context.disabled !== disabled) { + machine.send({ type: 'SET_DISABLED', disabled }) + } + }, + ['disabled'], +] + +export const __camelName__Effects: __Name__Effect[] = [syncDisabled] diff --git a/scripts/templates/packages/vue/__name__/src/index.ts b/scripts/templates/packages/vue/__name__/src/index.ts new file mode 100644 index 0000000..87039d6 --- /dev/null +++ b/scripts/templates/packages/vue/__name__/src/index.ts @@ -0,0 +1,7 @@ +export { + __Name__, + type __Name__Props, + type __Name__Emits, + type __Name__RootProps, +} from './__name__' +export type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__' diff --git a/scripts/templates/packages/vue/__name__/src/use-__name__.ts b/scripts/templates/packages/vue/__name__/src/use-__name__.ts new file mode 100644 index 0000000..8759ebc --- /dev/null +++ b/scripts/templates/packages/vue/__name__/src/use-__name__.ts @@ -0,0 +1,22 @@ +import { computed, toValue, type MaybeRefOrGetter } from 'vue' +import { useMachine } from '@dunky.dev/vue-state-machine' +import { __camelName__Machine, __camelName__Connect } from '@dunky.dev/__name__' +import type { __Name__Options } from '@dunky.dev/__name__' + +import type { __Name__ContextValue } from './context' +import { __camelName__Effects } from './effects' + +/** + * Owns one __name__ machine for the <__Name__> root: created once, options + * re-synced through the reactive getter, effects run after mount, api a ref. + */ +export function use__Name__(options: MaybeRefOrGetter<__Name__Options>): __Name__ContextValue { + // A computed: the adapter reads the options from every effect's dependency + // getter, and the object is built once per change rather than per read. + return useMachine( + __camelName__Machine, + __camelName__Connect, + __camelName__Effects, + computed(() => toValue(options)), + ) +} diff --git a/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts b/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts new file mode 100644 index 0000000..9aefb64 --- /dev/null +++ b/scripts/templates/packages/vue/__name__/stories/__name__.stories.ts @@ -0,0 +1,33 @@ +import type { Component } from 'vue' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { __Name__ } from '@dunky.dev/vue-__name__' + +const meta: Meta = { + title: 'Primitives/__Name__', + component: __Name__, +} + +export default meta +type StoryType = StoryObj + +// Runtime-compiled templates resolve components by registered name, so the +// dotted part names an SFC resolves from the `__Name__` import are registered +// as such, derived from the parts themselves so none can be missed. +const components: Record = { __Name__ } +for (const [name, part] of Object.entries(__Name__)) { + if (/^[A-Z]/.test(name)) components[`__Name__.${name}`] = part as Component +} + +// The primitive ships headless — the story is the consumer, so it brings the +// styles. `data-state` on every part is the real styling hook. +export const standard: StoryType = { + render: () => ({ + components, + setup: () => ({ disable: () => console.log('disabled') }), + template: ` + <__Name__ @disable="disable"> + <__Name__.Root>go + + `, + }), +} diff --git a/scripts/templates/packages/vue/__name__/tests/__name__.test.tsx b/scripts/templates/packages/vue/__name__/tests/__name__.test.tsx new file mode 100644 index 0000000..cf3b25e --- /dev/null +++ b/scripts/templates/packages/vue/__name__/tests/__name__.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +// The Vue edge of the __name__ — behavior only; the machine's own contract +// is covered in @dunky.dev/__name__'s tests. +import { nextTick, ref, type EmitsToProps } from 'vue' +import { cleanup, render, screen } from '@testing-library/vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { __Name__, type __Name__Emits, type __Name__Props } from '@dunky.dev/vue-__name__' + +const Default__Name__ = (props: __Name__Props & EmitsToProps<__Name__Emits>) => ( + <__Name__ {...props}> + <__Name__.Root>go + +) + +// Auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('__Name__', () => { + it('disables on press', () => { + const onDisable = vi.fn() + render(() => ) + screen.getByRole('button').click() + expect(onDisable).toHaveBeenCalledTimes(1) + }) + + it('fires disable when the controlled disabled prop turns on', async () => { + const onDisable = vi.fn() + const disabled = ref(false) + render(() => ) + expect(onDisable).not.toHaveBeenCalled() + + disabled.value = true + await nextTick() // Vue batches the update into a microtask + expect(onDisable).toHaveBeenCalledTimes(1) + }) + + it('translates the core bindings onto the element', () => { + render(() => ) + const root = screen.getByRole('button') + expect(root.getAttribute('data-state')).toBe('idle') + expect(root.getAttribute('aria-disabled')).toBe('true') + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 4e39ec3..f7dc284 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "@dunky.dev/native-dialog": ["./packages/native/dialog/src"], "@dunky.dev/react-dialog": ["./packages/react/dialog/src"], "@dunky.dev/solid-dialog": ["./packages/solid/dialog/src"], + "@dunky.dev/vue-dialog": ["./packages/vue/dialog/src"], "@dunky.dev/dom-dialog": ["./packages/dom/components/dialog/src"], "@dunky.dev/dom-overlay": ["./packages/dom/utils/overlay/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], @@ -29,21 +30,25 @@ "@dunky.dev/react-use-focus-trap": ["./packages/react/hooks/use-focus-trap/src"], "@dunky.dev/react-use-scroll-lock": ["./packages/react/hooks/use-scroll-lock/src"], "@dunky.dev/solid-use-focus-trap": ["./packages/solid/hooks/use-focus-trap/src"], - "@dunky.dev/solid-use-scroll-lock": ["./packages/solid/hooks/use-scroll-lock/src"] + "@dunky.dev/solid-use-scroll-lock": ["./packages/solid/hooks/use-scroll-lock/src"], + "@dunky.dev/vue-use-focus-trap": ["./packages/vue/hooks/use-focus-trap/src"], + "@dunky.dev/vue-use-scroll-lock": ["./packages/vue/hooks/use-scroll-lock/src"] }, "types": ["@types/node", "vitest/globals"] }, "include": ["./*.ts", "./packages"], // The native Expo shell (entry + .rnstorybook) imports the generated, // gitignored storybook.requires.ts — Metro compiles that glue, tsc would - // only ever see the missing module in CI. packages/solid needs Solid's JSX - // namespace (`jsx: preserve` + solid-js import source), so it typechecks as - // its own project — packages/solid/tsconfig.json, run by `pnpm typecheck`. + // only ever see the missing module in CI. packages/solid and packages/vue + // need their framework's JSX namespace (`jsx: preserve` + its import + // source), so each typechecks as its own project — + // packages//tsconfig.json, run by `pnpm typecheck`. "exclude": [ "**/node_modules", "**/dist", "packages/native/index.ts", "packages/native/.rnstorybook", - "packages/solid" + "packages/solid", + "packages/vue" ] } diff --git a/tsdown.config.ts b/tsdown.config.ts index 2704eb7..57aaa4a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -26,6 +26,9 @@ export default defineConfig({ 'packages/solid/dialog', 'packages/solid/hooks/use-focus-trap', 'packages/solid/hooks/use-scroll-lock', + 'packages/vue/dialog', + 'packages/vue/hooks/use-focus-trap', + 'packages/vue/hooks/use-scroll-lock', ], entry: ['src/index.ts'], format: ['esm'], diff --git a/vitest.config.ts b/vitest.config.ts index 9f786a4..2189d89 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from 'vitest/config' -// Two projects: the Solid tests need vite-plugin-solid's JSX transform, which -// must not rewrite the React `.tsx` tests. The solid project lives with its -// substrate (packages/solid/vitest.config.ts). +// One project per JSX flavor: the Solid and Vue tests need their framework's +// JSX transform, which must not rewrite the React `.tsx` tests. Each lives +// with its substrate (packages//vitest.config.ts). export default defineConfig({ test: { projects: [ @@ -20,12 +20,14 @@ export default defineConfig({ 'scripts/templates/**', 'packages/native/**', 'packages/solid/**', + 'packages/vue/**', '**/.worktrees/**', '**/.claude/**', ], }, }, './packages/solid/vitest.config.ts', + './packages/vue/vitest.config.ts', ], }, })