Skip to content

feat(useDelay): add new composable#224

Merged
johnleider merged 10 commits into
masterfrom
feat/use-delay
May 1, 2026
Merged

feat(useDelay): add new composable#224
johnleider merged 10 commits into
masterfrom
feat/use-delay

Conversation

@johnleider

Copy link
Copy Markdown
Member

Summary

  • Adds useDelay — schedules deferred open / close transitions with configurable delays. Composes useTimer for the underlying lifecycle and adds direction tracking plus promise-based resolution.
  • Widens useTimer.duration to accept MaybeRefOrGetter<number> so the timer can be driven by a reactive source. Strict superset; existing plain-number consumers are unaffected.
  • Aligns the composables Import Section Order rule in .claude/rules/composables.md with what perfectionist/sort-imports actually enforces.

API

const delay = useDelay({
  openDelay: 700,
  closeDelay: 150,
  onChange: isOpening => { isVisible.value = isOpening },
})

delay.start(true)                       // schedule open
delay.start(false, { minDelay: 500 })   // schedule close, floor 500ms
delay.pause()
delay.resume()
delay.stop()

delay.isActive   // Readonly<Ref<boolean>>
delay.isPaused   // Readonly<Ref<boolean>>
delay.remaining  // Readonly<Ref<number>>
delay.isOpening  // Readonly<Ref<boolean>>

Notes

  • Zero-delay path (ms <= 0) fires synchronously; preserves behavior for instant-focus / skip-window callers
  • Maturity entry added at preview, since: null (set on first release)
  • 14 existing useTimer consumers verified unaffected by the duration widening

`duration` is now read fresh on every `start()` via `toValue`, so
consumers can pass a ref or getter and the timer picks up the new
value without recreating the instance. Plain `number` continues to
work — the change is a strict superset.

Enables `useDelay` to compose `useTimer` directly with a reactive
duration that switches between `openDelay` / `closeDelay` based on
direction.
Schedules deferred open and close transitions with configurable
delays. Composes `useTimer` for the underlying lifecycle and adds
direction tracking plus promise-based resolution.

API:
- `start(isOpening, { minDelay? })` — schedule a transition; restarts
  if already running and resolves any prior promise with the new
  direction
- `stop` / `pause` / `resume` — forwarded from the underlying timer
- `isActive` / `isPaused` / `remaining` / `isOpening` — reactive state

Covers tooltip, popover, and overlay use cases that previously
required ad-hoc timer logic. Zero-delay paths fire synchronously to
preserve the behavior of pre-existing callers (e.g. instant focus
opens).
The documented import-section order didn't match what
`perfectionist/sort-imports` actually enforces. Update the canonical
example, the section list, and the checklist to reflect the real
order: Components, Composables, Adapters, Utilities (Vue lives here),
Transformers, Types, Globals (#v0/constants/globals — sorted last as
the `internal` group, with an author-added section comment for
consistency).

Run `pnpm lint:fix`; never hand-author the order.
Addresses bugs and pattern violations surfaced by /inspect --thorough:

- Resolve `pendingResolve` on `stop()` and `onScopeDispose` so awaiters
  never hang
- Resolve the promise before invoking `onChange` so a thrown callback
  doesn't strand the resolution
- Settle `previousResolve` outside the new Promise executor to remove
  a re-entrancy window in the synchronous zero-delay path
- Wrap forwarded `isActive` / `isPaused` / `remaining` with
  `shallowReadonly()` so the runtime matches the declared
  `Readonly<Ref<>>` contract
- Narrow `openDelay` / `closeDelay` to `MaybeRefOrGetter<number>`;
  drop the `Number(raw) || 0` coercion that masked NaN inputs
- Extract `UseDelayStartOptions` interface and rename the `start()`
  options arg to `options` per project convention
- Demote internal `minDelay` from a `shallowRef` to a plain `let`
- Add `@example` JSDoc on `UseDelayReturn` showing destructure usage

Docs:
- Simplify the Mermaid lifecycle diagram (remove dangling `Pending` node)
- Add a `## FAQ` section per the composable page structure
- Expand the Examples block with multi-paragraph prose and a File/Role
  table; clarify the scope-disposal resolution semantic
- Reorder sections so FAQ precedes `<DocsApi />`
- Reactivity table references the new `UseDelayStartOptions` interface

Example:
- Drop the dead `target.value === 0` ternary
@pkg-pr-new

pkg-pr-new Bot commented Apr 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

commit: 0bbaa35

- pendingResolve → resolver
- resolveDuration → duration (enables { duration } shorthand to useTimer)
- previousResolve → previous
Aligns useDelay with the established callback-position precedent across
8 sibling composables (useTimer, useRaf, useEventListener,
useMutationObserver, useIntersectionObserver, useResizeObserver,
useClickOutside, useHotkey). Burying onChange inside the options bag
made useDelay the only outlier; the new shape matches Vuetify's
useDelay and the broader v0 muscle memory.

Before:
  useDelay({ openDelay: 300, closeDelay: 200, onChange: v => ... })

After:
  useDelay(v => ..., { openDelay: 300, closeDelay: 200 })

Codifies the rule in .claude/rules/composables.md as
\`Callback Argument Position\`.
Progress.Root is form-stateful (v-model + ARIA progressbar) and was
crashing here with a getter-set-on-readonly error against the docs
build. The countdown bar is decorative — no form value, no a11y
progressbar role needed — so a plain div is the right fit. Button.Root
remains since native button → v0 Button is the correct form-control
swap.

Also updates the Examples prose from "800 ms / 600 ms" to
"2000 ms / 1500 ms" to match the actual example.
The ::: faq container parses each ??? question as a separate item,
but only when a blank line separates the question from its answer.
Without the blank line, the answer collapses into the question text
and the dropdown renders empty.
@johnleider johnleider self-assigned this May 1, 2026
@johnleider johnleider added this to the v0.2.x milestone May 1, 2026
31 tests across 11 categories:

- basic lifecycle (open/close fire, promise resolution, no-callback path,
  default zero-delay)
- reactive delays (number, ref, getter; mid-flight changes don't affect
  in-flight)
- minDelay floor (greater-than, less-than, reset between calls)
- re-entrancy (previous promise resolves with new direction; in-flight
  timer cancels on restart)
- stop (cancels delay, resolves pending with current isOpening, no
  callback fired, no-op when idle)
- pause / resume (preserves remaining, resumes from pause point,
  isOpening unchanged through cycle)
- synchronous fire (zero delay, negative delay, cancels in-flight on
  back-to-back start)
- isOpening tracking (direction reflects start arg, readonly contract)
- callback throw safety (promise still resolves)
- auto-cleanup (timer cleared on scope dispose, pending promise settled)
- remaining tracking (decreases over time, 0 after fire)

Zero stderr output. Vue fake-timers + effectScope for lifecycle tests.
@johnleider johnleider changed the title feat(useDelay): add open/close delay composable feat(useDelay): add new composable May 1, 2026
@johnleider johnleider merged commit 6d3af16 into master May 1, 2026
16 of 17 checks passed
@johnleider johnleider deleted the feat/use-delay branch May 1, 2026 15:02
@johnleider johnleider modified the milestones: v0.2.x, v1.0.0 Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant