feat: update useSnapPoints to improve drawer snapping behavior and add new features
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
This commit is contained in:
@@ -4,6 +4,7 @@ import { computed } from 'vue';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
const DEFAULT_DELAY = 500;
|
||||
const DEFAULT_THRESHOLD = 10;
|
||||
@@ -220,6 +221,11 @@ export function onLongPress(
|
||||
useEventListener(elementRef, ['pointerup', 'pointerleave'], onRelease, listenerOptions),
|
||||
];
|
||||
|
||||
// The listeners above self-dispose with the scope, but a delay timer armed
|
||||
// by a press that never released would outlive the component and fire the
|
||||
// handler against a dead scope.
|
||||
tryOnScopeDispose(clear);
|
||||
|
||||
return (): void => {
|
||||
clear();
|
||||
cleanups.forEach(stop => stop());
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { createApp, inject, onUnmounted, reactive, ref } from 'vue';
|
||||
import type { InjectionKey } from 'vue';
|
||||
import { runWithApp } from './index';
|
||||
|
||||
interface Settings {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
const SettingsKey: InjectionKey<Settings> = Symbol('DemoSettings');
|
||||
|
||||
// Imagine this is your main.ts: the app provides DI values and is registered
|
||||
// once with `app.use(activeAppPlugin)`. The demo keeps a standalone app and
|
||||
// passes it explicitly so it does not touch the docs application.
|
||||
const app = createApp({ render: () => null });
|
||||
const settings = reactive<Settings>({ volume: 50 });
|
||||
app.provide(SettingsKey, settings);
|
||||
|
||||
onUnmounted(() => app.unmount());
|
||||
|
||||
// A plain module-level function — no setup, no injection context. With
|
||||
// `runWithApp` it can still resolve `inject()` against the app.
|
||||
function readVolumeFromOutside() {
|
||||
return runWithApp(() => inject(SettingsKey)!.volume, app);
|
||||
}
|
||||
|
||||
const snapshot = ref<number>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-stack max-w-sm">
|
||||
<div class="demo-card p-4 flex flex-col gap-3">
|
||||
<span class="demo-label">App-provided state</span>
|
||||
|
||||
<label class="flex items-center gap-3 text-sm text-fg">
|
||||
<span class="text-xs text-fg-muted w-14">Volume</span>
|
||||
<input
|
||||
v-model.number="settings.volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
class="flex-1 accent-accent cursor-pointer"
|
||||
>
|
||||
<span class="font-mono text-xs tabular-nums text-fg-muted w-8 text-right">{{ settings.volume }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="demo-card p-4 flex flex-col gap-3">
|
||||
<span class="demo-label">Plain function, outside any component</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition hover:bg-accent-hover active:scale-[0.98] cursor-pointer"
|
||||
@click="snapshot = readVolumeFromOutside()"
|
||||
>
|
||||
runWithApp(() => inject(SettingsKey))
|
||||
</button>
|
||||
|
||||
<p class="font-mono text-xs tabular-nums text-fg-muted">
|
||||
{{ snapshot === undefined ? 'not read yet' : `injected volume: ${snapshot}` }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-fg-subtle">
|
||||
The function reading the value has no injection context of its own —
|
||||
<span class="font-mono text-fg-muted">runWithApp</span> wraps it in
|
||||
<span class="font-mono text-fg-muted">app.runWithContext</span> so
|
||||
<span class="font-mono text-fg-muted">inject()</span> resolves app-level provides.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp, defineComponent, h, inject, provide } from 'vue';
|
||||
import type { App, InjectionKey } from 'vue';
|
||||
import { activeAppPlugin, getActiveApp, injectWithApp, runWithApp, setActiveApp } from '.';
|
||||
import { VueToolsError } from '@/utils';
|
||||
|
||||
const key: InjectionKey<string> = Symbol('TestKey');
|
||||
|
||||
function makeApp(setup?: () => void) {
|
||||
return createApp(defineComponent({
|
||||
setup() {
|
||||
setup?.();
|
||||
return () => h('div');
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function mountApp(app: App) {
|
||||
app.mount(document.createElement('div'));
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActiveApp(undefined);
|
||||
});
|
||||
|
||||
describe(setActiveApp, () => {
|
||||
it('registers the app and returns it for chaining', () => {
|
||||
const app = makeApp();
|
||||
|
||||
expect(getActiveApp()).toBeUndefined();
|
||||
expect(setActiveApp(app)).toBe(app);
|
||||
expect(getActiveApp()).toBe(app);
|
||||
});
|
||||
|
||||
it('clears the registration with undefined', () => {
|
||||
setActiveApp(makeApp());
|
||||
setActiveApp(undefined);
|
||||
|
||||
expect(getActiveApp()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getActiveApp, () => {
|
||||
it('prefers the current instance app over the registered one', () => {
|
||||
const other = makeApp();
|
||||
setActiveApp(other);
|
||||
|
||||
let captured: App | undefined;
|
||||
const app = mountApp(makeApp(() => {
|
||||
captured = getActiveApp();
|
||||
}));
|
||||
|
||||
expect(captured).toBe(app);
|
||||
expect(captured).not.toBe(other);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe(runWithApp, () => {
|
||||
it('resolves app-level provides through the active app', () => {
|
||||
const app = makeApp();
|
||||
app.provide(key, 'from app');
|
||||
setActiveApp(app);
|
||||
|
||||
expect(runWithApp(() => inject(key))).toBe('from app');
|
||||
});
|
||||
|
||||
it('uses an explicitly passed app over the active one', () => {
|
||||
const active = makeApp();
|
||||
active.provide(key, 'active');
|
||||
setActiveApp(active);
|
||||
|
||||
const explicit = makeApp();
|
||||
explicit.provide(key, 'explicit');
|
||||
|
||||
expect(runWithApp(() => inject(key), explicit)).toBe('explicit');
|
||||
});
|
||||
|
||||
it('returns the function result', () => {
|
||||
setActiveApp(makeApp());
|
||||
|
||||
expect(runWithApp(() => 42)).toBe(42);
|
||||
});
|
||||
|
||||
it('throws when no app is available', () => {
|
||||
expect(() => runWithApp(() => inject(key))).toThrow(VueToolsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(injectWithApp, () => {
|
||||
it('resolves app-level provides outside of setup', () => {
|
||||
const app = makeApp();
|
||||
app.provide(key, 'from app');
|
||||
setActiveApp(app);
|
||||
|
||||
expect(injectWithApp(key)).toBe('from app');
|
||||
});
|
||||
|
||||
it('behaves like inject inside setup, component provides win', () => {
|
||||
const app = makeApp();
|
||||
app.provide(key, 'app level');
|
||||
setActiveApp(app);
|
||||
|
||||
let fromParent: string | undefined;
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
fromParent = injectWithApp(key);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
const host = createApp(defineComponent({
|
||||
setup() {
|
||||
provide(key, 'component level');
|
||||
return () => h(Child);
|
||||
},
|
||||
}));
|
||||
host.provide(key, 'host app level');
|
||||
mountApp(host);
|
||||
|
||||
expect(fromParent).toBe('component level');
|
||||
|
||||
host.unmount();
|
||||
});
|
||||
|
||||
it('falls back to the default value when the key is not provided', () => {
|
||||
setActiveApp(makeApp());
|
||||
|
||||
expect(injectWithApp(key, 'fallback')).toBe('fallback');
|
||||
expect(injectWithApp(key, () => 'factory', true)).toBe('factory');
|
||||
});
|
||||
|
||||
it('returns the default value when no app is available at all', () => {
|
||||
expect(injectWithApp(key, 'fallback')).toBe('fallback');
|
||||
expect(injectWithApp(key, () => 'factory', true)).toBe('factory');
|
||||
});
|
||||
|
||||
it('does not call a function default unless treated as factory', () => {
|
||||
const fn = vi.fn(() => 'value');
|
||||
const injected = injectWithApp<() => string>(Symbol('FnKey'), fn);
|
||||
|
||||
expect(injected).toBe(fn);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when there is no context, no app and no default', () => {
|
||||
expect(() => injectWithApp(key)).toThrow(VueToolsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(activeAppPlugin, () => {
|
||||
it('registers the app on install', () => {
|
||||
const app = makeApp().use(activeAppPlugin);
|
||||
|
||||
expect(getActiveApp()).toBe(app);
|
||||
});
|
||||
|
||||
it('clears the registration when the app unmounts', () => {
|
||||
const app = mountApp(makeApp().use(activeAppPlugin));
|
||||
|
||||
app.unmount();
|
||||
|
||||
expect(getActiveApp()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the registration when a stale app unmounts', () => {
|
||||
const first = mountApp(makeApp().use(activeAppPlugin));
|
||||
const second = makeApp().use(activeAppPlugin);
|
||||
|
||||
first.unmount();
|
||||
|
||||
expect(getActiveApp()).toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { getCurrentInstance, hasInjectionContext, inject } from 'vue';
|
||||
import type { App, InjectionKey, Plugin } from 'vue';
|
||||
import { VueToolsError } from '@/utils';
|
||||
|
||||
type InjectDefaults<Value> = [defaultValue?: Value | (() => Value), treatDefaultAsFactory?: boolean];
|
||||
|
||||
let activeApp: App | undefined;
|
||||
|
||||
/**
|
||||
* @name setActiveApp
|
||||
* @category State
|
||||
* @description Registers the Vue app instance used by `getActiveApp`, `runWithApp` and `injectWithApp`
|
||||
* outside of component context. Pass `undefined` to clear the registration.
|
||||
*
|
||||
* The registration is module-global (one slot per JS realm). On the client this is exactly
|
||||
* what you want; on the server create one app per request and prefer passing the app
|
||||
* explicitly to `runWithApp` instead of relying on the global slot, otherwise concurrent
|
||||
* requests may observe each other's app.
|
||||
*
|
||||
* @param {App | undefined} app The app to register, or `undefined` to clear
|
||||
* @returns {App | undefined} The same app, for chaining
|
||||
*
|
||||
* @example
|
||||
* // main.ts
|
||||
* const app = createApp(App);
|
||||
* setActiveApp(app);
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function setActiveApp(app: App | undefined) {
|
||||
activeApp = app;
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getActiveApp
|
||||
* @category State
|
||||
* @description Returns the closest Vue app instance: the current component's app when called
|
||||
* during setup (or anywhere `getCurrentInstance` works), otherwise the app registered via
|
||||
* `setActiveApp` / `activeAppPlugin`.
|
||||
*
|
||||
* @returns {App | undefined} The resolved app, or `undefined` when none is available
|
||||
*
|
||||
* @example
|
||||
* const app = getActiveApp();
|
||||
* app?.config.globalProperties;
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function getActiveApp(): App | undefined {
|
||||
return getCurrentInstance()?.appContext.app ?? activeApp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name runWithApp
|
||||
* @category State
|
||||
* @description Runs a function inside `app.runWithContext`, so `inject` (and everything built
|
||||
* on it) resolves app-level provides even outside of component setup — in router guards,
|
||||
* store actions, event handlers or timers.
|
||||
*
|
||||
* The app defaults to `getActiveApp()`; pass one explicitly to target a specific app
|
||||
* (recommended for SSR, where apps are created per request).
|
||||
*
|
||||
* @param {Function} fn The function to run with the app as injection context
|
||||
* @param {App} [app] The app to use instead of the active one
|
||||
* @returns The return value of `fn`
|
||||
* @throws {VueToolsError} when no app is registered and none is passed
|
||||
*
|
||||
* @example
|
||||
* router.beforeEach(() => {
|
||||
* const auth = runWithApp(() => inject(AuthKey));
|
||||
* });
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function runWithApp<Result>(fn: () => Result, app: App | undefined = getActiveApp()): Result {
|
||||
if (!app)
|
||||
throw new VueToolsError('runWithApp: no active Vue app, install activeAppPlugin or call setActiveApp first');
|
||||
|
||||
return app.runWithContext(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name injectWithApp
|
||||
* @category State
|
||||
* @description Drop-in replacement for `inject` that also works outside of component setup.
|
||||
* Inside an injection context it behaves exactly like `inject` (component-level provides
|
||||
* win); outside it resolves app-level provides through the active app. When no app is
|
||||
* available it falls back to the provided default value, or throws if there is none.
|
||||
*
|
||||
* @param {InjectionKey | string} key The injection key
|
||||
* @param {any} [defaultValue] The value (or factory) to fall back to when the key is not provided
|
||||
* @param {boolean} [treatDefaultAsFactory] Call `defaultValue` as a factory, like `inject`
|
||||
* @returns The injected value
|
||||
* @throws {VueToolsError} when called with no injection context, no active app and no default value
|
||||
*
|
||||
* @example
|
||||
* const theme = injectWithApp(ThemeKey, 'light');
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function injectWithApp<Value>(key: InjectionKey<Value> | string): Value | undefined;
|
||||
export function injectWithApp<Value>(key: InjectionKey<Value> | string, defaultValue: Value, treatDefaultAsFactory?: false): Value;
|
||||
export function injectWithApp<Value>(key: InjectionKey<Value> | string, defaultValue: Value | (() => Value), treatDefaultAsFactory: true): Value;
|
||||
export function injectWithApp<Value>(key: InjectionKey<Value> | string, ...defaults: InjectDefaults<Value>): Value | undefined {
|
||||
// spread `defaults` as-is: `inject` distinguishes a missing default from an
|
||||
// explicit `undefined` one via `arguments.length`
|
||||
const doInject = () => (inject as (...args: [typeof key, ...InjectDefaults<Value>]) => Value | undefined)(key, ...defaults);
|
||||
|
||||
if (hasInjectionContext())
|
||||
return doInject();
|
||||
|
||||
const app = getActiveApp();
|
||||
|
||||
if (app)
|
||||
return app.runWithContext(doInject);
|
||||
|
||||
if (defaults.length > 0) {
|
||||
const [defaultValue, treatDefaultAsFactory] = defaults;
|
||||
|
||||
return treatDefaultAsFactory && typeof defaultValue === 'function'
|
||||
? (defaultValue as () => Value)()
|
||||
: defaultValue as Value;
|
||||
}
|
||||
|
||||
throw new VueToolsError('injectWithApp: no injection context and no active Vue app, install activeAppPlugin or call setActiveApp first');
|
||||
}
|
||||
|
||||
/**
|
||||
* @name activeAppPlugin
|
||||
* @category State
|
||||
* @description Vue plugin that registers the app as the active one and clears the
|
||||
* registration when the app unmounts (unless another app took over in the meantime).
|
||||
*
|
||||
* @example
|
||||
* // main.ts
|
||||
* createApp(App).use(activeAppPlugin).mount('#app');
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export const activeAppPlugin: Plugin = {
|
||||
install(app) {
|
||||
setActiveApp(app);
|
||||
|
||||
app.onUnmount(() => {
|
||||
if (activeApp === app)
|
||||
setActiveApp(undefined);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './activeApp';
|
||||
export * from './createSharedComposable';
|
||||
export * from './useAppSharedState';
|
||||
export * from './useAsyncState';
|
||||
@@ -11,6 +12,7 @@ export * from './useLastChanged';
|
||||
export * from './useManualRefHistory';
|
||||
export * from './useOffsetPagination';
|
||||
export * from './useRefHistory';
|
||||
export * from './useStateMachine';
|
||||
export * from './useStepper';
|
||||
export * from './useThrottledRefHistory';
|
||||
export * from './useToggle';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { useStateMachine } from './index';
|
||||
|
||||
// A media-player transport: the machine makes the button matrix declarative —
|
||||
// what each control does (and whether it's enabled) follows from the state.
|
||||
const { state, send, can, matches } = useStateMachine({
|
||||
initial: 'stopped',
|
||||
states: {
|
||||
stopped: { on: { PLAY: 'playing' } },
|
||||
playing: { on: { PAUSE: 'paused', STOP: 'stopped' } },
|
||||
paused: { on: { PLAY: 'playing', STOP: 'stopped' } },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-stack max-w-sm">
|
||||
<div class="demo-card p-4">
|
||||
<p class="demo-label">
|
||||
Media transport
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<span
|
||||
class="demo-badge"
|
||||
:class="matches('playing') ? 'text-emerald-600 dark:text-emerald-400' : ''"
|
||||
>
|
||||
{{ matches('playing') ? '▶' : matches('paused') ? '⏸' : '⏹' }} {{ state }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="demo-btn-primary flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||
:disabled="!can('PLAY')"
|
||||
@click="send('PLAY')"
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||
:disabled="!can('PAUSE')"
|
||||
@click="send('PAUSE')"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||
:disabled="!can('STOP')"
|
||||
@click="send('STOP')"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { computed } from 'vue';
|
||||
import { useStateMachine } from './index';
|
||||
|
||||
function trafficLight() {
|
||||
return useStateMachine({
|
||||
initial: 'red',
|
||||
states: {
|
||||
red: { on: { NEXT: 'green' } },
|
||||
green: { on: { NEXT: 'yellow' } },
|
||||
yellow: { on: { NEXT: 'red' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe(useStateMachine, () => {
|
||||
it('starts in the initial state', () => {
|
||||
const { state, matches } = trafficLight();
|
||||
|
||||
expect(state.value).toBe('red');
|
||||
expect(matches('red')).toBeTruthy();
|
||||
expect(matches('green')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('transitions on send and mirrors the state into the ref', () => {
|
||||
const { state, send } = trafficLight();
|
||||
|
||||
expect(send('NEXT')).toBe('green');
|
||||
expect(state.value).toBe('green');
|
||||
|
||||
send('NEXT');
|
||||
expect(state.value).toBe('yellow');
|
||||
});
|
||||
|
||||
it('ignores events without a matching transition', () => {
|
||||
const { state, send } = useStateMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'running' } },
|
||||
running: {},
|
||||
},
|
||||
});
|
||||
|
||||
send('START');
|
||||
expect(send('START')).toBe('running');
|
||||
expect(state.value).toBe('running');
|
||||
});
|
||||
|
||||
it('is reactive: computeds tracking state/matches/can re-evaluate', () => {
|
||||
const { send, matches, can, state } = trafficLight();
|
||||
|
||||
const isRed = computed(() => matches('red'));
|
||||
const label = computed(() => state.value.toUpperCase());
|
||||
const canAdvance = computed(() => can('NEXT'));
|
||||
|
||||
expect(isRed.value).toBeTruthy();
|
||||
expect(label.value).toBe('RED');
|
||||
expect(canAdvance.value).toBeTruthy();
|
||||
|
||||
send('NEXT');
|
||||
|
||||
expect(isRed.value).toBeFalsy();
|
||||
expect(label.value).toBe('GREEN');
|
||||
});
|
||||
|
||||
it('respects guards and exposes them through can()', () => {
|
||||
const { state, send, can } = useStateMachine({
|
||||
initial: 'locked',
|
||||
context: { coins: 0 },
|
||||
states: {
|
||||
locked: {
|
||||
on: {
|
||||
PUSH: { target: 'open', guard: ctx => ctx.coins > 0 },
|
||||
COIN: { target: 'locked', action: (ctx) => { ctx.coins++; } },
|
||||
},
|
||||
},
|
||||
open: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(can('PUSH')).toBeFalsy();
|
||||
send('PUSH');
|
||||
expect(state.value).toBe('locked');
|
||||
|
||||
send('COIN');
|
||||
expect(can('PUSH')).toBeTruthy();
|
||||
send('PUSH');
|
||||
expect(state.value).toBe('open');
|
||||
});
|
||||
|
||||
it('runs action, exit, and entry hooks in order', () => {
|
||||
const order: string[] = [];
|
||||
|
||||
const { send } = useStateMachine({
|
||||
initial: 'a',
|
||||
states: {
|
||||
a: {
|
||||
exit: () => order.push('exit:a'),
|
||||
on: { GO: { target: 'b', action: () => order.push('action') } },
|
||||
},
|
||||
b: {
|
||||
entry: () => order.push('entry:b'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
send('GO');
|
||||
expect(order).toEqual(['action', 'exit:a', 'entry:b']);
|
||||
});
|
||||
|
||||
it('settles on the final state when hooks send follow-up events', () => {
|
||||
const machine = useStateMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'transient' } },
|
||||
transient: {
|
||||
entry: () => machine.send('CONTINUE'),
|
||||
on: { CONTINUE: 'done' },
|
||||
},
|
||||
done: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(machine.send('START')).toBe('done');
|
||||
expect(machine.state.value).toBe('done');
|
||||
});
|
||||
|
||||
it('keeps can() reactive across context-mutating self-transitions', () => {
|
||||
const { send, can } = useStateMachine({
|
||||
initial: 'locked',
|
||||
context: { coins: 0 },
|
||||
states: {
|
||||
locked: {
|
||||
on: {
|
||||
PUSH: { target: 'open', guard: ctx => ctx.coins > 0 },
|
||||
COIN: { target: 'locked', action: (ctx) => { ctx.coins++; } },
|
||||
},
|
||||
},
|
||||
open: {},
|
||||
},
|
||||
});
|
||||
|
||||
const canPush = computed(() => can('PUSH'));
|
||||
|
||||
expect(canPush.value).toBeFalsy();
|
||||
|
||||
// Self-transition: the state string does not change, only the context.
|
||||
send('COIN');
|
||||
|
||||
expect(canPush.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the state ref in sync when a hook throws', () => {
|
||||
const { state, send } = useStateMachine({
|
||||
initial: 'a',
|
||||
states: {
|
||||
a: { on: { GO: 'b' } },
|
||||
b: { entry: () => { throw new Error('boom'); } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => send('GO')).toThrow('boom');
|
||||
expect(state.value).toBe('b');
|
||||
});
|
||||
|
||||
it('exposes the raw machine with its context', () => {
|
||||
const onEnter = vi.fn();
|
||||
const { machine, send } = useStateMachine({
|
||||
initial: 'off',
|
||||
context: { toggles: 0 },
|
||||
states: {
|
||||
off: { on: { TOGGLE: { target: 'on', action: (ctx) => { ctx.toggles++; } } } },
|
||||
on: { entry: onEnter },
|
||||
},
|
||||
});
|
||||
|
||||
send('TOGGLE');
|
||||
|
||||
expect(machine.context.toggles).toBe(1);
|
||||
expect(machine.current).toBe('on');
|
||||
expect(machine.matches('on')).toBeTruthy();
|
||||
expect(onEnter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import { StateMachine } from '@robonen/stdlib';
|
||||
import type { ExtractEvents, ExtractStates, SyncStateNodeConfig } from '@robonen/stdlib';
|
||||
import type { ShallowRef } from 'vue';
|
||||
|
||||
export interface UseStateMachineReturn<
|
||||
States extends string,
|
||||
Events extends string,
|
||||
Context,
|
||||
> {
|
||||
/** Reactive current state of the machine. */
|
||||
state: Readonly<ShallowRef<States>>;
|
||||
|
||||
/**
|
||||
* Send an event to the machine, potentially causing a transition.
|
||||
* Returns the state the machine settled on (entry/exit hooks may themselves
|
||||
* send events; the returned state is the final one).
|
||||
*/
|
||||
send: (event: Events) => States;
|
||||
|
||||
/** Reactive check: is the machine currently in `state`? */
|
||||
matches: (state: States) => boolean;
|
||||
|
||||
/** Reactive check: can `event` cause a transition from the current state? */
|
||||
can: (event: Events) => boolean;
|
||||
|
||||
/** The underlying stdlib machine (context access, non-reactive escape hatch). */
|
||||
machine: StateMachine<States, Events, Context>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useStateMachine
|
||||
* @category State
|
||||
* @description Reactive wrapper around the stdlib `StateMachine`: a type-safe
|
||||
* finite state machine whose current state is exposed as a shallow ref, so
|
||||
* templates and computeds can branch on `state`/`matches`/`can`.
|
||||
*
|
||||
* States, events, guards, and entry/exit hooks follow the stdlib
|
||||
* `createMachine` config verbatim — this composable only adds reactivity.
|
||||
*
|
||||
* @param {object} config Machine config: `initial`, optional `context`, and `states`
|
||||
* @returns {UseStateMachineReturn} Reactive state plus `send`/`matches`/`can` and the raw machine
|
||||
*
|
||||
* @example
|
||||
* const { state, send, can } = useStateMachine({
|
||||
* initial: 'idle',
|
||||
* states: {
|
||||
* idle: { on: { FETCH: 'loading' } },
|
||||
* loading: { on: { RESOLVE: 'idle', REJECT: 'failed' } },
|
||||
* failed: { on: { RETRY: 'loading' } },
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* send('FETCH'); // state.value === 'loading'
|
||||
* can('RETRY'); // false — reactive, usable in computeds/templates
|
||||
*
|
||||
* @since 0.2.0
|
||||
*/
|
||||
export function useStateMachine<
|
||||
const States extends Record<string, SyncStateNodeConfig<Context>>,
|
||||
Context,
|
||||
>(config: {
|
||||
initial: NoInfer<ExtractStates<States>>;
|
||||
context: Context;
|
||||
states: States;
|
||||
}): UseStateMachineReturn<ExtractStates<States>, ExtractEvents<States>, Context>;
|
||||
|
||||
export function useStateMachine<
|
||||
const States extends Record<string, SyncStateNodeConfig<undefined>>,
|
||||
>(config: {
|
||||
initial: NoInfer<ExtractStates<States>>;
|
||||
states: States;
|
||||
}): UseStateMachineReturn<ExtractStates<States>, ExtractEvents<States>, undefined>;
|
||||
|
||||
export function useStateMachine(config: {
|
||||
initial: string;
|
||||
context?: unknown;
|
||||
// Overload-implementation signature (mirrors stdlib `createMachine`): `any`
|
||||
// accepts every concrete `SyncStateNodeConfig<C>` — contravariant in `C` —
|
||||
// and `Context = undefined` keeps the invariant `StateMachine<..., Context>`
|
||||
// comparable with both public overloads.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
states: Record<string, SyncStateNodeConfig<any>>;
|
||||
}): UseStateMachineReturn<string, string, undefined> {
|
||||
const machine = new StateMachine(config.initial, config.states, config.context as undefined);
|
||||
|
||||
const state = shallowRef(machine.current);
|
||||
|
||||
// Bumped on EVERY send: a self-transition leaves the state string unchanged
|
||||
// (so `state` doesn't trigger) yet its action may mutate the context that
|
||||
// `can()` guards read.
|
||||
const epoch = shallowRef(0);
|
||||
|
||||
function send(event: string): string {
|
||||
// Mirror the settled state (not send's return value — entry/exit hooks may
|
||||
// send follow-up events) even when a hook throws: the machine has already
|
||||
// advanced by the time hooks run.
|
||||
try {
|
||||
machine.send(event);
|
||||
}
|
||||
finally {
|
||||
state.value = machine.current;
|
||||
epoch.value++;
|
||||
}
|
||||
|
||||
return machine.current;
|
||||
}
|
||||
|
||||
function matches(value: string): boolean {
|
||||
return state.value === value;
|
||||
}
|
||||
|
||||
function can(event: string): boolean {
|
||||
// Track the send epoch (it covers state changes too) so callers re-evaluate
|
||||
// after every transition, including context-mutating self-transitions.
|
||||
void epoch.value;
|
||||
|
||||
return machine.can(event);
|
||||
}
|
||||
|
||||
return { state, send, matches, can, machine };
|
||||
}
|
||||
Reference in New Issue
Block a user