npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sprucelabs/spruce-heartwood-utils

v38.17.9

Published

Heartwood Utilities

Readme

@sprucelabs/spruce-heartwood-utils

Heartwood integration toolkit for Spruce skills. Use this package to load remote cards, register and render cross-skill views, apply shared theme behavior, drive animation/layout primitives, and test the full flow with built-in mocks.


Installation

npm install @sprucelabs/spruce-heartwood-utils
yarn add @sprucelabs/spruce-heartwood-utils

This package is designed for use inside a Spruce skill or a React application.


Project Pitch

This module supports development in 2 ways.

  1. It provides first-party utilities for common tasks in Skill development — remote view controllers, card registration, theming, plugins, and test helpers.
  2. It offers animation and layout primitives for use in React.

@sprucelabs/spruce-heartwood-utils packages those into one module with explicit entrypoints so you can keep browser bundles clean while still getting first-class testing helpers.


What You Get

  • Remote view controller infrastructure: RemoteViewControllerFactoryImpl, CardRegistrar, shared remote VC types
  • Theming and plugins: loadActiveThemeForOrg, AutoLogoutPlugin
  • Test harness pieces: remoteVcAssert, fakeGetViews, MockRemoteViewControllerFactory, SpyAutoLogoutPlugin
  • Animation/layout primitives: Sizer, DelayedPlacer, queue/show helpers, emitters, settings utilities

Import Paths

  • @sprucelabs/spruce-heartwood-utils/web for browser-safe runtime imports (recommended for Vite/Bun/ESM apps)
  • @sprucelabs/spruce-heartwood-utils/testing for test helpers and mocks
  • @sprucelabs/spruce-heartwood-utils keeps the legacy mixed export surface for existing consumers

Get Started

Web Only

Use this when you are shipping runtime/browser code and do not need test helpers in that code path.

import {
    CardRegistrar,
    RemoteViewControllerFactoryImpl,
    AutoLogoutPlugin,
    loadActiveThemeForOrg,
    Sizer,
} from '@sprucelabs/spruce-heartwood-utils/web'

Tests Only

Use this in test suites where you need to fake remote views and assert event-driven card registration.

import {
    remoteVcAssert,
    fakeGetViews,
    MockRemoteViewControllerFactory,
    SpyAutoLogoutPlugin,
} from '@sprucelabs/spruce-heartwood-utils/testing'

Web + Tests

Use split imports so runtime code stays browser-safe while tests get all mocks/helpers.

import {
    CardRegistrar,
    RemoteViewControllerFactoryImpl,
    remoteVcAssert,
    MockRemoteViewControllerFactory,
} from '@sprucelabs/spruce-heartwood-utils/testing'

Remote View Controllers

When your skill needs to load and render cards that come from other skills at runtime, these are the types you work with. In tests, you swap in MockRemoteViewControllerFactory to control which cards appear without making real network calls.

RemoteViewControllerFactoryImpl

Fetches compiled ViewController source from the heartwood.get-skill-views event, evaluates it in a sandboxed context, and returns instantiated controllers. You will typically interact with this class in tests to inject a mock, not to instantiate it in production — Heartwood manages the factory lifecycle.

import {
    RemoteViewControllerFactoryImpl,
    RemoteViewControllerFactory,
} from '@sprucelabs/spruce-heartwood-utils/web'

Injecting a mock factory in tests:

protected async beforeEach() {
    RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
    MockRemoteViewControllerFactory.reset()
}

Loading a remote controller by ID:

const vc = await remoteVcFactory.RemoteController(
    'your-skill.some-card',
    { someOption: true }
)

RemoteViewControllerFactory (interface)

The interface that both RemoteViewControllerFactoryImpl and MockRemoteViewControllerFactory implement. Use this type for any field that may hold the real factory or a test mock:

import { RemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils/web'

private remoteVcFactory: RemoteViewControllerFactory

RemoteFactoryOptions

Options passed to RemoteViewControllerFactoryImpl.Factory().

import { RemoteFactoryOptions } from '@sprucelabs/spruce-heartwood-utils/web'

interface RemoteFactoryOptions {
    connectToApi: () => Promise<MercuryClient>
    vcFactory: VcFactoryForRemoteFactory
}

VcFactoryForRemoteFactory

A narrowed subset of ViewControllerFactory containing only what the remote factory needs. Use this type when accepting or storing a factory reference to avoid a full ViewControllerFactory dependency:

import { VcFactoryForRemoteFactory } from '@sprucelabs/spruce-heartwood-utils/web'

private views: VcFactoryForRemoteFactory

CardRegistrar

If your skill view needs to collect cards contributed by other skills — similar to a dashboard that aggregates content from across the platform — CardRegistrar handles the full lifecycle: emitting the event, receiving VC IDs from respondents, loading each card via the remote factory, and streaming results as they arrive.

import {
    CardRegistrar,
    CardRegistrarOptions,
    CardFetchOptions,
    EachCardHandler,
} from '@sprucelabs/spruce-heartwood-utils/web'

CardRegistrar.Registrar(options)

Create a fresh registrar inside your skill view's load() so each load cycle gets a fresh client connection:

private async loadContributedCards(options: SkillViewControllerLoadOptions) {
    const client = await this.connectToApi()

    const registrar = CardRegistrar.Registrar({
        client,
        eventName: 'your-skill.register-cards::v2021_02_11',
        vcFactory: this.getVcFactory(),
        vcIdsTransformer: (payload) => payload.vcIds,
    })

    this.remoteCards = (await registrar.fetch()) as RemoteDashboardCard[]
    this.triggerRender()

    await Promise.all(this.remoteCards.map((vc) => vc.load?.(options)))
}

CardRegistrarOptions<Contract, Name>

interface CardRegistrarOptions<Contract, Name> {
    client: MercuryClient
    eventName: Name
    vcFactory: VcFactoryForRemoteFactory
    vcIdsTransformer: (payload: ResponsePayload<Contract, Name>) => string[]
}

registrar.fetch(options?)

Emits the event and loads all returned VC IDs. Returns AbstractViewController<Card>[].

// Basic fetch — wait for all cards
const cards = await registrar.fetch()

// Streaming — render each batch as it arrives
await registrar.fetch({
    each: async (batch) => {
        this.remoteCards.push(...(batch as RemoteDashboardCard[]))
        this.triggerRender()
    },
})

// With target — scope the event to an organization
const cards = await registrar.fetch({
    target: { organizationId: 'org-123' },
})

EachCardHandler

type EachCardHandler = (vcs: AbstractViewController<any>[]) => Promise<void> | void

CardFetchOptions<Contract, Name>

type CardFetchOptions<Contract, Name> = {
    each?: EachCardHandler
    controllerOptionsHandler?: (vcId: string) => Record<string, any>
} & EmitPayload<Contract, Name>

Types

RemoteDashboardCard

The shape of a card loaded from another skill. Extends ViewController<Card> with an optional load() method so you can forward load arguments after the card is rendered.

import { RemoteDashboardCard } from '@sprucelabs/spruce-heartwood-utils/web'

interface RemoteDashboardCard extends ViewController<Card> {
    load?(options: SkillViewControllerLoadOptions): Promise<void>
}

Typical pattern — store, render, and forward load args:

private remoteCards: RemoteDashboardCard[] = []

// After fetch:
this.remoteCards = (await registrar.fetch()) as RemoteDashboardCard[]
await Promise.all(this.remoteCards.map((vc) => vc.load?.(options)))

// In render():
cards: [
    this.myLocalCardVc.render(),
    ...this.remoteCards.map((c) => c.render()),
]

Theming

loadActiveThemeForOrg(client, organizationId)

Fetches the active Heartwood theme for an organization so your skill can apply the same visual style.

import { loadActiveThemeForOrg } from '@sprucelabs/spruce-heartwood-utils/web'

Signature:

async function loadActiveThemeForOrg(
    client: MercuryClient,
    organizationId: string
): Promise<SkillTheme>

Usage:

const client = await this.connectToApi()
const theme = await loadActiveThemeForOrg(client, organizationId)
// pass theme to your view controller factory or apply to the UI

Plugins

AutoLogoutPlugin

Sends enableAutoLogout / disableAutoLogout commands to the native Heartwood device layer. Register it with your skill's ViewControllerFactory to give your skill views control over session timeout behavior.

import { AutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils/web'

const plugin = factory.BuildPlugin(AutoLogoutPlugin)

plugin.enableAutoLogout(300)   // auto-logout after 300 seconds of inactivity
plugin.disableAutoLogout()     // cancel a pending auto-logout

AutoLogoutViewPlugin (interface)

Use this type when you want to reference the plugin without coupling to the concrete class — for example, when declaring a field that holds either the real plugin or a spy:

import type { AutoLogoutViewPlugin } from '@sprucelabs/spruce-heartwood-utils/web'

// Field declaration — works with both AutoLogoutPlugin and SpyAutoLogoutPlugin:
private autoLogoutPlugin: AutoLogoutViewPlugin

The interface shape:

interface AutoLogoutViewPlugin extends ViewControllerPlugin {
    enableAutoLogout(durationSec: number): void
    disableAutoLogout(): void
}

SpyAutoLogoutPlugin

A test double that records calls to enableAutoLogout and disableAutoLogout. Swap it in during test setup to assert your skill calls the plugin correctly:

import { SpyAutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils/testing'

protected async beforeEach() {
    const factory = this.views.getFactory()
    factory.setPlugin('auto-logout', new SpyAutoLogoutPlugin())
}

@test()
protected async startsAutoLogoutAfterLoad() {
    await this.load()
    assert.isTrue(this.plugin.wasEnableAutoLogoutCalled)
    assert.isEqual(this.plugin.durationSec, 300)
}

private get plugin() {
    return this.views.getFactory().getPlugin('auto-logout') as SpyAutoLogoutPlugin
}

Spy fields:

class SpyAutoLogoutPlugin {
    wasEnableAutoLogoutCalled: boolean
    durationSec?: number
    wasDisableAutoLogoutCalled: boolean
}

Test Utilities

Use these in your skill's test suite to verify that your skill correctly registers cards with Heartwood and responds to dashboard events. The typical scenario: Heartwood emits an event asking for cards; your skill responds with VC IDs; Heartwood loads and renders those cards. These utilities let you assert that entire flow without making real network calls.

remoteVcAssert

High-level assertion helper. Verifies that your skill view emits the expected registration event and that the returned cards are rendered.

import { remoteVcAssert } from '@sprucelabs/spruce-heartwood-utils/testing'

Basic assertion — confirm your skill view emits the event and renders cards:

@test()
protected async registersCards() {
    await remoteVcAssert.assertSkillViewRendersRemoteCards({
        svc: this.vc,
        fqen: 'your-skill.register-cards::v2021_02_11',
        views: this.views,
    })
}

With shouldInvoke — confirm each returned card has load() called on it:

await remoteVcAssert.assertSkillViewRendersRemoteCards({
    svc: this.vc,
    fqen: 'your-skill.register-cards::v2021_02_11',
    views: this.views,
    shouldInvoke: {
        methodName: 'load',
        expectedParams: [this.views.getRouter().buildLoadOptions()],
    },
})

With expectedTarget / expectedPayload — confirm the event is scoped correctly:

await remoteVcAssert.assertSkillViewRendersRemoteCards({
    svc: this.vc,
    fqen: 'your-skill.register-cards::v2021_02_11',
    views: this.views,
    expectedTarget: { organizationId: 'org-123' },
})

AssertRendersRemoteCardsOptions<Svc>

import { AssertRendersRemoteCardsOptions } from '@sprucelabs/spruce-heartwood-utils/testing'

interface AssertRendersRemoteCardsOptions<Svc extends SkillViewController = SkillViewController> {
    svc: Svc
    fqen: EventNames
    views: ViewFixture
    expectedTarget?: Record<string, any>
    expectedPayload?: Record<string, any>
    loadArgs?: ArgsFromSvc<Svc>
    shouldInvoke?: {
        methodName: string
        expectedParams?: any[]
    }
}

fakeGetViews

Intercepts heartwood.get-skill-views::v2021_02_11 and returns stub ViewController source. Useful when you need manual control over what remote views are returned in a test — remoteVcAssert uses this internally, but you can call it directly for custom scenarios.

import { fakeGetViews } from '@sprucelabs/spruce-heartwood-utils/testing'

// Stub two remote views:
await fakeGetViews.fakeGetViews(['your-skill.card-a', 'your-skill.card-b'])

The optional second argument is the name of a method to instrument on each stub VC. When provided, each stub controller gets a function at that name that records whether it was called and what arguments it received:

await fakeGetViews.fakeGetViews(['your-skill.card'], 'load')

// After your code triggers the stub:
const stub = /* get rendered card controller from your vc */
assert.isTrue(stub.wasHit)
assert.isEqualDeep(stub.passedParams, [expectedArgs])

Note: The export is the heartwoodEventFaker object. Call it as fakeGetViews.fakeGetViews(...).


MockRemoteViewControllerFactory

A drop-in replacement for the real remote factory. Controls exactly which VC classes are returned for each card ID and provides assertion methods for verifying what your skill view loaded.

import {
    MockRemoteViewControllerFactory,
    RemoteViewControllerFactoryImpl,
} from '@sprucelabs/spruce-heartwood-utils/web'
import { MockDroppedInControllers } from '@sprucelabs/spruce-heartwood-utils/testing'

Setup — inject the mock before your skill view creates its factory:

protected async beforeEach() {
    RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
    MockRemoteViewControllerFactory.reset()

    MockRemoteViewControllerFactory.dropInRemoteController(
        'your-skill.some-card',
        YourCardViewController
    )
}

Get the mock instance to run assertions:

const mockFactory = MockRemoteViewControllerFactory.getInstance()

Available assertions:

// Assert a specific card was loaded:
mockFactory.assertFetchedRemoteController('your-skill.some-card')

// Assert it was loaded with specific constructor options:
mockFactory.assertFetchedRemoteController('your-skill.some-card', { organizationId: 'org-123' })

// Assert a card was NOT loaded:
mockFactory.assertDidNotFetchRemoteController('your-skill.other-card')

// Assert a skill view renders a specific remote card:
mockFactory.assertSkillViewRendersRemoteCard(this.vc, 'your-skill.some-card')

// Assert constructor options match exactly:
mockFactory.assertRemoteCardConstructorOptionsEqual('your-skill.some-card', { organizationId: 'org-123' })

// Assert views were loaded for a namespace:
mockFactory.assertLoadedViewsForNamespace('your-skill')

Check without throwing:

if (mockFactory.hasController('your-skill.some-card')) { ... }

MockDroppedInControllers

Type for the map of card IDs to VC classes held by MockRemoteViewControllerFactory:

import { MockDroppedInControllers } from '@sprucelabs/spruce-heartwood-utils/testing'

type MockDroppedInControllers = Record<
    string,
    new (args: any) => ViewController<any>
>

Device & Layout Utilities

These utilities are not re-exported through the main barrel and must be imported directly from their build paths.

getDeviceOrientation()

Returns 'landscape' or 'portrait' based on the current viewport dimensions. Use this to make layout decisions that depend on device orientation.

import { getDeviceOrientation } from '@sprucelabs/spruce-heartwood-utils/build/components/controlBars/getDeviceOrientation'

const orientation = getDeviceOrientation()  // 'landscape' | 'portrait'

if (orientation === 'landscape') {
    // wide layout
} else {
    // tall layout
}

Decision logic:

if (clientWidth > clientHeight && clientWidth > 700)  → 'landscape'
else                                                   → 'portrait'

Reacting to changes — do not poll. Subscribe to the orientation change event instead:

await emitter.on('did-change-orientation', async () => {
    const orientation = getDeviceOrientation()
    // update layout
})

Caveats: Browser-only. Synchronous. Each call reads the DOM live — no caching.


skillViewState

A singleton that reflects whether the currently active Heartwood skill view is in full-screen mode. Read this in your card components to skip height animations or adapt layout when the user is in full-screen.

import { skillViewState } from '@sprucelabs/spruce-heartwood-utils/build/components/skillViews/skillViewState'

if (skillViewState.isFullScreen) {
    // full-screen mode is active — skip height-dependent layout
}

In tests — reset between cases:

beforeEach(() => {
    skillViewState.isFullScreen = false
})

Caveats: Plain object — not reactive. Reading it does not subscribe to changes. Must be reset between tests since it is a module-level singleton.


Animation

These are the same animation and layout primitives that Heartwood uses in its own card views. Import them to get consistent stagger, height animation, and absolute-positioning behavior in your skill's components.

Quick Start

import {
    // Visibility queue
    useQueueShow, useShowNow,
    showRightAway, hideRightAway,
    queueShow, queueHide,
    queueCallback, callbackImmediately,
    clearPendingHideAndQueueShow, clearPendingShowAndQueueHide,
    stopQueue,
    // Layout components
    Sizer, DelayedPlacer,
    // Utilities
    sizeUtil, Settings,
    // Emitter — pass to Sizer and DelayedPlacer to coordinate layout events
    // Types
    AnimationEmitter,
    SizerProps,
    DelayedPlacerProps,
} from '@sprucelabs/spruce-heartwood-utils/web'

Required CSS

⚠ Silent failure warning: If CSS transitions are missing, animations will not play and no error will be thrown. Elements will simply snap instantly between states with no visual feedback. Every class listed below requires its own transition declaration — the system provides none.

Inside a Heartwood skill view: all required CSS is already provided by Heartwood's stylesheet. No extra setup needed.

Outside Heartwood (standalone use): supply every rule below yourself. Missing any one of them causes that animation to silently skip.


queueShow / queueHide — fade + slide transitions

queueShow removes the hidden class; queueHide adds it back. The hidden class sets the hidden state (opacity, position offset, pointer-events). The transition must be declared on the element itself, not on .hidden — if the transition is on .hidden it is removed at the same moment the class is removed and the browser never interpolates.

/* Hidden state — sets opacity, nudge, and pointer-events */
.hidden {
    opacity: 0;
    transform: translateY(4px);
    pointer-events: none;
}

/* Transition on the element — this is what the browser animates */
.your-element {
    transition: opacity 200ms ease, transform 200ms ease;
}

Elements must also start with hidden in their className so they are invisible until queueShow fires:

<div className="your-element hidden" ref={(ref) => { ref && queueShow(ref) }} />

Sizer — animated height

Sizer measures its content and writes style.height directly. The CSS transition on .sizer is what makes the height change animate smoothly. Without it the height jumps instantly.

.sizer {
    transition: height 500ms ease;
    overflow: hidden;
}

DelayedPlacer — absolute positioning

DelayedPlacer writes style.left / style.top on the child. The .placer wrapper must be position: relative so the child's absolute coordinates are relative to it.

.placer {
    position: relative;
}
.placer > * {
    position: absolute;
}

No transition is needed on .placer itself — placement jumps immediately to the measured position.


System Architecture

The animation system has three cooperating layers:

queueShow / queueHide          — staggered CSS class toggling (show/hide via .hidden)
       │
       ▼
Sizer                          — measures content height, sets style.height for smooth animation
       │  emits did-resize-content
       ▼
DelayedPlacer                  — reads Sizer's placeholder position, writes absolute coordinates

Pass the same emitter instance to Sizer and DelayedPlacer when they are siblings so their events coordinate. Different emitter instances decouple them.

Settings.disableAnimations() is a global switch that makes every timeout fire at 0ms and drains the queue synchronously — call it in your test beforeEach for deterministic tests.


AnimationEmitter

The interface Sizer and DelayedPlacer use to communicate layout-change events. Pass an instance as the emitter prop to coordinate components. The package exports SkillViewEmitter for this:

import { SkillViewEmitter } from '@sprucelabs/spruce-heartwood-utils/web'

const emitter = SkillViewEmitter.getInstance()

SkillViewEmitter implements all three methods (on, off, emit) needed by AnimationEmitter.

The AnimationEmitter interface, for reference:

interface AnimationEmitter {
    on(event: string, handler: () => void): Promise<void> | void
    off(event: string, handler: () => void): Promise<void> | void
    emit(event: string): Promise<void> | void
}

Quick Setup

  1. Install

    npm install @sprucelabs/spruce-heartwood-utils
  2. Create an emitter — one per component tree, stable across renders:

    import { SkillViewEmitter } from '@sprucelabs/spruce-heartwood-utils/web'
    
    const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])
  3. Wire to components and emit events — pass the emitter to Sizer and/or DelayedPlacer, then signal layout changes:

    // After a React render cycle completes:
    await emitter.emit('did-render')
    
    // After a viewport resize:
    await emitter.emit('did-resize')

Events:

| Event | Who listens | Who emits | Meaning | |---|---|---|---| | did-render | Sizer, DelayedPlacer | your code | A React render cycle completed | | did-resize | Sizer, DelayedPlacer | your code | Viewport or parent dimensions changed | | did-resize-content | DelayedPlacer | Sizer | Sizer changed its measured height | | did-change-orientation | DelayedPlacer | your code | Portrait ↔ landscape switch | | did-place-cards | your listeners | DelayedPlacer | Placement calculation finished |

Pass the same function reference to on and off. Inline arrow functions cannot be removed with off.


Settings

A static class that controls animation behavior globally. The most important method for skill developers is disableAnimations() — call it in every test beforeEach.

class Settings {
    // Disable all animations. Makes timeouts fire at 0ms, queue drains synchronously.
    // Cannot be undone. Never call in production code.
    static disableAnimations(): void

    static getIsAnimationEnabled(): boolean

    // Returns animation duration in ms: landscape=500, portrait=1000. Returns 0 if disabled.
    static get animationDuration(): number
}

In your test base class:

protected async beforeEach() {
    Settings.disableAnimations()
}

One-way door: there is no enableAnimations(). Each test file runs in its own module context so this is safe. Never call it in production.


queueShow — Show/Hide Queue

A singleton FIFO queue that staggers visibility transitions by toggling the CSS class hidden on DOM elements. Staggering class removals across a 40ms interval creates natural cascading fade-in effects.

When animations are disabled (Settings.disableAnimations()), the entire queue drains synchronously — no real timers needed in tests.

The queue is shared across your entire skill view. stopQueue() stops all pending operations.


queueShow(refOrNode, delay?, method?)

Queues a show (removes hidden) on an element.

function queueShow(
    refOrNode: React.RefObject<HTMLElement | null> | HTMLElement | null,
    delay?: number,              // default: 40ms between queue steps
    method?: 'push' | 'unshift' // default: 'push' (end of queue)
): void

No-ops silently if the element is null, already visible, or already queued.

// Standard pattern — queue on mount via ref callback:
<div className="your-card hidden" ref={(ref) => { ref && queueShow(ref) }} />

// Multiple elements — queue them all:
const items = containerRef.current?.querySelectorAll('.item') ?? []
for (const item of items) { queueShow(item as HTMLElement) }

queueHide(refOrNode, delay?, method?)

Queues a hide (adds hidden) on an element.

function queueHide(
    refOrNode: React.RefObject<HTMLElement> | HTMLElement,
    delay?: number,
    method?: 'push' | 'unshift'
): void
queueHide(loadingSpinnerRef)

showRightAway(refOrNode)

Jumps to the front of the queue. Use when an element must appear before anything else already queued.

function showRightAway(
    refOrNode: React.RefObject<HTMLElement | null> | HTMLElement
): void
<div className="alert hidden" ref={(ref) => { ref && showRightAway(ref) }} />

<img onLoad={() => { imageRef && showRightAway(imageRef) }} />

hideRightAway(refOrNode)

Jumps a hide to the front of the queue.

function hideRightAway(refOrNode: React.RefObject<HTMLElement> | HTMLElement): void
hideRightAway(overlayRef.current)

queueCallback(cb)

Inserts an arbitrary callback at the end of the queue. Runs in sequence with show/hide steps.

function queueCallback(cb: () => void): void
// Trigger a re-render after queued animations complete:
queueCallback(() => this.triggerRender())

callbackImmediately(cb)

Inserts a callback at the front of the queue.

function callbackImmediately(cb: () => void): void
const enqueue = shouldPrioritize ? callbackImmediately : queueCallback
enqueue(() => this.handleReady())

clearPendingHideAndQueueShow(refOrNode, delay?)

Cancels a pending hide and queues a show for elements toggled back before their hide ran.

function clearPendingHideAndQueueShow(
    refOrNode: React.RefObject<HTMLElement> | HTMLElement,
    delay?: number   // default: 40ms
): void

Prefer showRightAway() for most toggle cases.


clearPendingShowAndQueueHide(refOrNode, delay?)

Cancels a pending show and queues a hide.

function clearPendingShowAndQueueHide(
    refOrNode: React.RefObject<HTMLElement> | HTMLElement,
    delay?: number   // default: 40ms
): void

Prefer hideRightAway() for most toggle cases.


stopQueue()

Stops the queue interval and abandons any remaining items. Call in test teardown to prevent queue state leaking between tests.

protected async afterEach() {
    await super.afterEach()
    stopQueue()
}

useQueueShow(ref, delay?) — React Hook

Calls queueShow on every render via useEffect. Use for elements that should fade in after each render.

const ref = useRef<HTMLDivElement>(null)
useQueueShow(ref)

return <div ref={ref} className="your-panel hidden">...</div>

useShowNow(ref, delay?) — React Hook

Like useQueueShow but places itself at the front of the queue on every render. Use for elements that must appear before any other queued items — such as a primary heading that should always be visible before supporting content fades in.

function useShowNow(ref: React.RefObject<HTMLElement>, delay?: number): void
const ref = useRef<HTMLHeadingElement>(null)
useShowNow(ref)

return <h1 ref={ref} className="your-heading hidden">Title</h1>

Sizer — Animated Height Container

Wraps children in a div whose height is set via inline style to match the measured height of its content. Listens to did-render and did-resize events so it resizes whenever layout changes — enabling smooth CSS height transitions on content that would otherwise be height: auto. Emits did-resize-content so DelayedPlacer can re-place after a height change.

Quick Setup

  1. Install and add the required CSS

    npm install @sprucelabs/spruce-heartwood-utils
    .sizer {
        transition: height 500ms ease;
        overflow: hidden;
    }

    Inside a Heartwood skill view this CSS is already provided — skip this step.

  2. Wrap your content

    import { Sizer } from '@sprucelabs/spruce-heartwood-utils/web'
    
    const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])
    
    <Sizer emitter={emitter}>
        <YourContent />
    </Sizer>
  3. Signal layout changes — emit after renders and on viewport resize; Sizer measures content and updates style.height automatically:

    await emitter.emit('did-render')
    await emitter.emit('did-resize')

    In tests, call Settings.disableAnimations() in beforeEach so height changes apply synchronously.

Props (SizerProps)

interface SizerProps {
    children: any

    // When false, renders children with no wrapper. Default: enabled.
    isEnabled?: boolean

    // CSS class on the outer .sizer div.
    className?: string

    // not in response to external layout events.
    emitter?: AnimationEmitter
}

Ref API

sizer.current?.resize(): boolean    // measure and apply new height; returns true if it changed
sizer.current?.showOverflow(): void // temporarily allow overflow (e.g. while a dropdown inside is open)
sizer.current?.hideOverflow(): void // re-apply overflow: hidden

Emitter Events

| Event | Direction | Meaning | |---|---|---| | did-render | listened | Re-rendered; measure and resize | | did-resize | listened | Viewport changed; measure and resize | | did-resize-content | emitted | Height changed (fires after Settings.animationDuration ms) |

Usage

import { Sizer } from '@sprucelabs/spruce-heartwood-utils/web'

// Simplest 
<Sizer>
    {isExpanded && <YourExpandableContent />}
</Sizer>

// Animate in from zero height:
<Sizer emitter={emitter}>
    <YourCard />
</Sizer>

// Manually trigger resize when content changes outside React state:
const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])
const sizer = useRef<React.ElementRef<typeof Sizer>>(null)

<Sizer ref={sizer} emitter={emitter}>
    <YourDynamicContent
        onContentChange={async () => {
            if (sizer.current?.resize()) {
                await emitter.emit('did-resize')
            }
        }}
    />
</Sizer>

// Staggered entrance with queueShow:
<Sizer emitter={emitter}>
    <div className="your-item hidden" ref={(ref) => { ref && queueShow(ref) }}>
        content
    </div>
</Sizer>

Do not nest Sizer inside another Sizer for the same content — they will conflict.


DelayedPlacer — Absolute Positioning

Positions a child element to match the location of its in-flow placeholder. Wraps children in a position: relative placeholder div, measures the placeholder's offsetLeft/offsetTop, and writes those values as style.left / style.top on the child. The deferred measurement lets the layout settle before placement, enabling cards and overlays to animate smoothly into position.

When isEnabled={false}, children render inline with no wrapper.

Quick Setup

  1. Install and add the required CSS

    npm install @sprucelabs/spruce-heartwood-utils
    .placer { position: relative; }
    .placer > * { position: absolute; }

    Inside a Heartwood skill view this CSS is already provided — skip this step.

  2. Wrap your contentisEnabled, className, and isFocused are all required:

    import { DelayedPlacer } from '@sprucelabs/spruce-heartwood-utils/web'
    
    const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])
    
    <DelayedPlacer
        className="placer__card"
        isEnabled={true}
        emitter={emitter}
        isFocused={() => true}
    >
        <YourCard />
    </DelayedPlacer>

    Pass () => true for isFocused when using outside Heartwood — placement is skipped when this returns false.

  3. Share the emitter with Sizer when they are siblings so height changes automatically trigger re-placement:

    <DelayedPlacer className="placer__card" isEnabled emitter={emitter} isFocused={isFocused}>
        <Sizer emitter={emitter}>
            <YourContent />
        </Sizer>
    </DelayedPlacer>

    DelayedPlacer emits did-place-cards when placement is complete. Listen for it if you need to react after cards settle.

Props (DelayedPlacerProps)

interface DelayedPlacerProps {
    children: any

    // Required. When true, wraps in .placer and manages absolute positioning.
    isEnabled: boolean

    // Required. CSS class on the outer .placer div.
    className: string

    // Event emitter. Listens for layout changes to re-measure and re-place.
    emitter?: AnimationEmitter
}

Ref API

delayedPlacer.current?.placeRightAway(): void  // re-measure and re-place immediately

Placer & Sizer Together For Card Placement

When using DelayedPlacer and Sizer together, the typical pattern is to place Sizer inside DelayedPlacer so that height changes trigger placement updates:

<DelayedPlacer className="placer" isEnabled emitter={emitter} isFocused={isFocused}>
	<Sizer emitter={emitter}>
		<YourCard />
	</Sizer>
</DelayedPlacer>

Emitter Events

| Event | Direction | Meaning | |---|---|---| | did-resize-content | listened | Sizer changed height; re-place | | did-resize | listened | Viewport changed; re-place | | did-render | listened | Re-rendered; re-place | | did-change-orientation | listened | Device rotated; re-place | | did-place-cards | emitted | Placement finished (100ms after measuring) |

Usage

import React, { useRef, useMemo } from 'react'
import { DelayedPlacer } from '@sprucelabs/spruce-heartwood-utils/web'

function YourPanel({ isFocused }: { isFocused: () => boolean }) {
    const placerRef = useRef<React.ElementRef<typeof DelayedPlacer>>(null)
    const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])

    return (
        <DelayedPlacer
            className="placer__panel"
            isEnabled={true}
            emitter={emitter}
            ref={placerRef}
            isFocused={isFocused}
        >
            {yourContent}
        </DelayedPlacer>
    )
}

When you need to re-place after a content change that happens outside React state:

// Trigger placement immediately, bypassing the debounce:
placerRef.current?.placeRightAway()

isEnabled, className, and isFocused are all required. The child must be position: absolute in CSS for placement to have visual effect.


sizeUtil — DOM Measurement

A collection of DOM measurement helpers. Use these in your components when you need to measure element dimensions or positions the same way Heartwood does.

const sizeUtil = {
    bodyWidth(): number
    bodyHeight(): number

    // Absolute position (walks offsetParent chain, accounts for scroll)
    getTop(node: HTMLElement): number
    getLeft(node: HTMLElement): number
    getBottom(node: HTMLElement): number
    getRight(node: HTMLElement): number
    getPosition(node: HTMLElement): { x: number; y: number }

    // Local position relative to offsetParent
    getLocalTop(node: HTMLElement): number
    getLocalLeft(node: HTMLElement): number
    getLocalBottom(node: HTMLElement): number
    getLocalRight(node: HTMLElement): number

    // Dimensions
    getWidth(node: HTMLElement): number
    getHeight(node: HTMLElement, shouldGetPreciseHeight?: boolean): number
    //  true (default): getBoundingClientRect().height — sub-pixel, correct during transitions
    //  false: offsetHeight — integer, cheaper

    // Scroll
    getScrollWidth(node: HTMLElement): number
    getScrollHeight(node: HTMLElement): number
    getMaxScrollTop(node: HTMLElement): number
    getMaxScrollLeft(node: HTMLElement): number
    isScrolledAllTheWayRight(node: HTMLElement): boolean
    isScrolledAllTheWayLeft(node: HTMLElement): boolean

    // Hit testing
    doesIntersect({ x, y, node }: { x: number; y: number; node: HTMLElement }): boolean
}

getPosition() returns absolute page coordinates (distance from top-left of document), not viewport coordinates.

Stubbing in testssizeUtil is a plain object so individual methods are replaceable:

sizeUtil.bodyWidth = () => 1200

Call measurement methods after layout has settled — inside componentDidMount, after setTimeout, or in an emitter event handler.


How the Three Systems Coordinate

A complete example showing all three systems working together in a card component:

import React, { useMemo } from 'react'
import {
    Sizer, DelayedPlacer, queueShow, Settings,
} from '@sprucelabs/spruce-heartwood-utils/web'

function YourCard({ isPlaced, isFocused }: { isPlaced: boolean; isFocused: () => boolean }) {
    const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])

    return (
        <DelayedPlacer
            className="placer__card"
            isEnabled={isPlaced}
            emitter={emitter}
            isFocused={isFocused}
        >
            <Sizer emitter={emitter}>
                <div
                    className="card hidden"
                    ref={(ref) => { ref && queueShow(ref) }}
                >
                    your content
                </div>
            </Sizer>
        </DelayedPlacer>
    )
}

What happens when content renders:

React render
    → Sizer receives did-render → measures height → updates style.height
    → Sizer emits did-resize-content (after animation duration)
        → DelayedPlacer re-measures placeholder → updates style.left / style.top
        → DelayedPlacer emits did-place-cards
queueShow removes .hidden from each element with a 40ms stagger

Test setup for animation components:

import { Settings, stopQueue } from '@sprucelabs/spruce-heartwood-utils/web'
import { skillViewState } from '@sprucelabs/spruce-heartwood-utils/build/components/skillViews/skillViewState'

protected async beforeEach() {
    Settings.disableAnimations()
    skillViewState.isFullScreen = false
}

protected async afterEach() {
    await super.afterEach()
    stopQueue()
}