feat: update useSnapPoints to improve drawer snapping behavior and add new features
Publish to NPM / Check version changes and publish (push) Successful in 11m14s

This commit is contained in:
2026-08-03 21:11:47 +07:00
parent f444feb7b3
commit 85313c6046
37 changed files with 3216 additions and 461 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/vue",
"version": "0.1.0",
"version": "0.2.0",
"license": "Apache-2.0",
"description": "Collection of powerful tools for Vue",
"keywords": [
@@ -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(() =&gt; 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 };
}