@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-utilsyarn add @sprucelabs/spruce-heartwood-utilsThis package is designed for use inside a Spruce skill or a React application.
Project Pitch
This module supports development in 2 ways.
- It provides first-party utilities for common tasks in Skill development — remote view controllers, card registration, theming, plugins, and test helpers.
- 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/webfor browser-safe runtime imports (recommended for Vite/Bun/ESM apps)@sprucelabs/spruce-heartwood-utils/testingfor test helpers and mocks@sprucelabs/spruce-heartwood-utilskeeps 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: RemoteViewControllerFactoryRemoteFactoryOptions
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: VcFactoryForRemoteFactoryCardRegistrar
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> | voidCardFetchOptions<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 UIPlugins
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-logoutAutoLogoutViewPlugin (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: AutoLogoutViewPluginThe 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
heartwoodEventFakerobject. Call it asfakeGetViews.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
transitiondeclaration — 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 coordinatesPass 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
Install
npm install @sprucelabs/spruce-heartwood-utilsCreate an emitter — one per component tree, stable across renders:
import { SkillViewEmitter } from '@sprucelabs/spruce-heartwood-utils/web' const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])Wire to components and emit events — pass the emitter to
Sizerand/orDelayedPlacer, 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
onandoff. Inline arrow functions cannot be removed withoff.
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)
): voidNo-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'
): voidqueueHide(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): voidhideRightAway(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): voidconst 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
): voidPrefer
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
): voidPrefer
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): voidconst 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
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.
Wrap your content
import { Sizer } from '@sprucelabs/spruce-heartwood-utils/web' const emitter = useMemo(() => SkillViewEmitter.getInstance(), []) <Sizer emitter={emitter}> <YourContent /> </Sizer>Signal layout changes — emit after renders and on viewport resize; Sizer measures content and updates
style.heightautomatically:await emitter.emit('did-render') await emitter.emit('did-resize')In tests, call
Settings.disableAnimations()inbeforeEachso 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: hiddenEmitter 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
Sizerinside anotherSizerfor 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
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.
Wrap your content —
isEnabled,className, andisFocusedare 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
() => trueforisFocusedwhen using outside Heartwood — placement is skipped when this returns false.Share the emitter with
Sizerwhen 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>DelayedPlaceremitsdid-place-cardswhen 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 immediatelyPlacer & 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, andisFocusedare all required. The child must beposition: absolutein 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 tests — sizeUtil is a plain object so individual methods are replaceable:
sizeUtil.bodyWidth = () => 1200Call measurement methods after layout has settled — inside
componentDidMount, aftersetTimeout, 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 staggerTest 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()
}