@sprucelabs/spruce-heartwood-utils 38.16.1 → 38.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1105 -188
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,240 +1,1157 @@
|
|
|
1
|
-
|
|
1
|
+
# @sprucelabs/spruce-heartwood-utils
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tools for building Spruce skills that integrate with Heartwood — remote card loading, auto-logout control, animation primitives, and testing utilities.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Table of Contents
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
1. [Remote View Controllers](#remote-view-controllers)
|
|
10
|
+
2. [CardRegistrar](#cardregistrar)
|
|
11
|
+
3. [Types](#types)
|
|
12
|
+
4. [Theming](#theming)
|
|
13
|
+
5. [Plugins](#plugins)
|
|
14
|
+
6. [Test Utilities](#test-utilities)
|
|
15
|
+
7. [Device & Layout Utilities](#device--layout-utilities)
|
|
16
|
+
8. [Animation](#animation)
|
|
17
|
+
- [Quick Start](#quick-start)
|
|
18
|
+
- [Required CSS](#required-css)
|
|
19
|
+
- [System Architecture](#system-architecture)
|
|
20
|
+
- [AnimationEmitter](#animationemitter)
|
|
21
|
+
- [Settings](#settings)
|
|
22
|
+
- [queueShow — Show/Hide Queue](#queueshow--showhide-queue)
|
|
23
|
+
- [Sizer — Animated Height Container](#sizer--animated-height-container)
|
|
24
|
+
- [DelayedPlacer — Absolute Positioning](#delayedplacer--absolute-positioning)
|
|
25
|
+
- [sizeUtil — DOM Measurement](#sizeutil--dom-measurement)
|
|
26
|
+
- [How the Three Systems Coordinate](#how-the-three-systems-coordinate)
|
|
10
27
|
|
|
11
|
-
|
|
12
|
-
- **The primary UI layer** for Spruce XP
|
|
13
|
-
- **A view controller framework** built on `@sprucelabs/heartwood-view-controllers`
|
|
14
|
-
- **A skill management platform** that registers, loads, and serves view controllers dynamically
|
|
15
|
-
- **A comprehensive component library** with 40+ UI components
|
|
28
|
+
---
|
|
16
29
|
|
|
17
|
-
##
|
|
30
|
+
## Remote View Controllers
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
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.
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
### `RemoteViewControllerFactoryImpl`
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
class MySkillViewController extends AbstractSkillViewController {
|
|
25
|
-
private cardVc: CardViewController
|
|
36
|
+
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.
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
38
|
+
```ts
|
|
39
|
+
import {
|
|
40
|
+
RemoteViewControllerFactoryImpl,
|
|
41
|
+
RemoteViewControllerFactory,
|
|
42
|
+
} from '@sprucelabs/spruce-heartwood-utils'
|
|
43
|
+
```
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
**Injecting a mock factory in tests:**
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
protected async beforeEach() {
|
|
49
|
+
RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
|
|
50
|
+
MockRemoteViewControllerFactory.reset()
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Loading a remote controller by ID:**
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const vc = await remoteVcFactory.RemoteController(
|
|
58
|
+
'your-skill.some-card',
|
|
59
|
+
{ someOption: true }
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
### `RemoteViewControllerFactory` (interface)
|
|
66
|
+
|
|
67
|
+
The interface that both `RemoteViewControllerFactoryImpl` and `MockRemoteViewControllerFactory` implement. Use this type for any field that may hold the real factory or a test mock:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { RemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils'
|
|
71
|
+
|
|
72
|
+
private remoteVcFactory: RemoteViewControllerFactory
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
### `RemoteFactoryOptions`
|
|
78
|
+
|
|
79
|
+
Options passed to `RemoteViewControllerFactoryImpl.Factory()`.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { RemoteFactoryOptions } from '@sprucelabs/spruce-heartwood-utils'
|
|
83
|
+
|
|
84
|
+
interface RemoteFactoryOptions {
|
|
85
|
+
connectToApi: () => Promise<MercuryClient>
|
|
86
|
+
vcFactory: VcFactoryForRemoteFactory
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
### `VcFactoryForRemoteFactory`
|
|
93
|
+
|
|
94
|
+
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:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { VcFactoryForRemoteFactory } from '@sprucelabs/spruce-heartwood-utils'
|
|
98
|
+
|
|
99
|
+
private views: VcFactoryForRemoteFactory
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## CardRegistrar
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import {
|
|
110
|
+
CardRegistrar,
|
|
111
|
+
CardRegistrarOptions,
|
|
112
|
+
CardFetchOptions,
|
|
113
|
+
EachCardHandler,
|
|
114
|
+
} from '@sprucelabs/spruce-heartwood-utils'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `CardRegistrar.Registrar(options)`
|
|
118
|
+
|
|
119
|
+
Create a fresh registrar inside your skill view's `load()` so each load cycle gets a fresh client connection:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
private async loadContributedCards(options: SkillViewControllerLoadOptions) {
|
|
123
|
+
const client = await this.connectToApi()
|
|
124
|
+
|
|
125
|
+
const registrar = CardRegistrar.Registrar({
|
|
126
|
+
client,
|
|
127
|
+
eventName: 'your-skill.register-cards::v2021_02_11',
|
|
128
|
+
vcFactory: this.getVcFactory(),
|
|
129
|
+
vcIdsTransformer: (payload) => payload.vcIds,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
this.remoteCards = (await registrar.fetch()) as RemoteDashboardCard[]
|
|
133
|
+
this.triggerRender()
|
|
134
|
+
|
|
135
|
+
await Promise.all(this.remoteCards.map((vc) => vc.load?.(options)))
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### `CardRegistrarOptions<Contract, Name>`
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
interface CardRegistrarOptions<Contract, Name> {
|
|
143
|
+
client: MercuryClient
|
|
144
|
+
eventName: Name
|
|
145
|
+
vcFactory: VcFactoryForRemoteFactory
|
|
146
|
+
vcIdsTransformer: (payload: ResponsePayload<Contract, Name>) => string[]
|
|
42
147
|
}
|
|
43
148
|
```
|
|
44
149
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
yarn storybook
|
|
59
|
-
|
|
60
|
-
# Build static Storybook
|
|
61
|
-
yarn build-storybook
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
**44+ Story Categories:**
|
|
65
|
-
- UI Components: buttons, cards, dialogs, forms, icons, dropdowns
|
|
66
|
-
- Data Display: lists, tables, feeds, timelines
|
|
67
|
-
- Navigation: pager, tabs, progress navigation
|
|
68
|
-
- Calendars: day view, month view, event management
|
|
69
|
-
- Charts: bar, line, polar area (via Chart.js)
|
|
70
|
-
- Maps: location display and selection
|
|
71
|
-
- Advanced: conferencing, streaming, swipe interactions
|
|
72
|
-
|
|
73
|
-
Stories use the same view controller pattern as production code, making them excellent for testing and documentation.
|
|
74
|
-
|
|
75
|
-
### Events
|
|
76
|
-
|
|
77
|
-
**Skill Events:**
|
|
78
|
-
|
|
79
|
-
| Event | Description |
|
|
80
|
-
|-------|-------------|
|
|
81
|
-
| `heartwood.register-skill-views` | Register view controllers from other skills |
|
|
82
|
-
| `heartwood.get-skill-views` | Fetch registered views by namespace |
|
|
83
|
-
| `heartwood.list-views` | List all registered skill views |
|
|
84
|
-
| `heartwood.did-register-skill-views` | Notification after registration complete |
|
|
85
|
-
| `heartwood.upsert-theme` | Update skill theme |
|
|
86
|
-
| `heartwood.get-active-theme` | Get current theme |
|
|
87
|
-
|
|
88
|
-
**UI Events (SkillViewEmitter):**
|
|
89
|
-
|
|
90
|
-
| Event | Description |
|
|
91
|
-
|-------|-------------|
|
|
92
|
-
| `did-change-orientation` | Device orientation changed |
|
|
93
|
-
| `did-resize` | Window resized |
|
|
94
|
-
| `did-render` | View rendered |
|
|
95
|
-
| `will-change-route` | Before navigation |
|
|
96
|
-
| `did-render-dialog` | Dialog displayed |
|
|
97
|
-
| `did-keydown` | Keyboard input |
|
|
98
|
-
| `did-scroll` | Page scrolled |
|
|
99
|
-
| `connection-status-changed` | Mercury connection status |
|
|
100
|
-
|
|
101
|
-
### Component Library
|
|
102
|
-
|
|
103
|
-
**Forms (32+ field types):**
|
|
104
|
-
- Text, textarea, phone, email
|
|
105
|
-
- Select, checkbox, toggle
|
|
106
|
-
- Date, time, datetime, duration
|
|
107
|
-
- Address, ratings, color picker
|
|
108
|
-
- File upload, signature capture
|
|
109
|
-
|
|
110
|
-
**Layout Components:**
|
|
111
|
-
- Cards with headers, bodies, footers
|
|
112
|
-
- Grid layouts (one-col, two-col, big-left, big-right)
|
|
113
|
-
- Dialogs and modals
|
|
114
|
-
- Slide panels
|
|
115
|
-
- Tabs and accordions
|
|
116
|
-
|
|
117
|
-
**Data Components:**
|
|
118
|
-
- Lists with pagination
|
|
119
|
-
- Tables with sorting
|
|
120
|
-
- Feeds and activity streams
|
|
121
|
-
- Progress indicators
|
|
122
|
-
|
|
123
|
-
**Interactive Components:**
|
|
124
|
-
- Buttons and button groups
|
|
125
|
-
- Dropdowns and menus
|
|
126
|
-
- Calendars and date pickers
|
|
127
|
-
- Maps and location pickers
|
|
128
|
-
- Charts and graphs
|
|
129
|
-
|
|
130
|
-
### Theming
|
|
131
|
-
|
|
132
|
-
Heartwood supports per-namespace theming:
|
|
133
|
-
|
|
134
|
-
```typescript
|
|
135
|
-
// Apply theme programmatically
|
|
136
|
-
themeManager.setTheme({
|
|
137
|
-
name: 'My Theme',
|
|
138
|
-
colors: {
|
|
139
|
-
primary: '#00f0ff',
|
|
140
|
-
secondary: '#7b2fff'
|
|
150
|
+
### `registrar.fetch(options?)`
|
|
151
|
+
|
|
152
|
+
Emits the event and loads all returned VC IDs. Returns `AbstractViewController<Card>[]`.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// Basic fetch — wait for all cards
|
|
156
|
+
const cards = await registrar.fetch()
|
|
157
|
+
|
|
158
|
+
// Streaming — render each batch as it arrives
|
|
159
|
+
await registrar.fetch({
|
|
160
|
+
each: async (batch) => {
|
|
161
|
+
this.remoteCards.push(...(batch as RemoteDashboardCard[]))
|
|
162
|
+
this.triggerRender()
|
|
141
163
|
},
|
|
142
|
-
fonts: { ... },
|
|
143
|
-
borderRadius: '8px'
|
|
144
164
|
})
|
|
165
|
+
|
|
166
|
+
// With target — scope the event to an organization
|
|
167
|
+
const cards = await registrar.fetch({
|
|
168
|
+
target: { organizationId: 'org-123' },
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### `EachCardHandler`
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
type EachCardHandler = (vcs: AbstractViewController<any>[]) => Promise<void> | void
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### `CardFetchOptions<Contract, Name>`
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
type CardFetchOptions<Contract, Name> = {
|
|
182
|
+
each?: EachCardHandler
|
|
183
|
+
controllerOptionsHandler?: (vcId: string) => Record<string, any>
|
|
184
|
+
} & EmitPayload<Contract, Name>
|
|
145
185
|
```
|
|
146
186
|
|
|
147
|
-
|
|
187
|
+
---
|
|
148
188
|
|
|
149
|
-
|
|
189
|
+
## Types
|
|
150
190
|
|
|
151
|
-
|
|
191
|
+
### `RemoteDashboardCard`
|
|
152
192
|
|
|
153
|
-
|
|
154
|
-
// Navigate to a view
|
|
155
|
-
this.Router().redirect('my-skill.profile', { userId: '123' })
|
|
193
|
+
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.
|
|
156
194
|
|
|
157
|
-
|
|
195
|
+
```ts
|
|
196
|
+
import { RemoteDashboardCard } from '@sprucelabs/spruce-heartwood-utils'
|
|
197
|
+
|
|
198
|
+
interface RemoteDashboardCard extends ViewController<Card> {
|
|
199
|
+
load?(options: SkillViewControllerLoadOptions): Promise<void>
|
|
200
|
+
}
|
|
158
201
|
```
|
|
159
202
|
|
|
160
|
-
|
|
161
|
-
- View stack for back/forward navigation
|
|
162
|
-
- Route restrictions for protected views
|
|
163
|
-
- Deep linking support
|
|
203
|
+
**Typical pattern — store, render, and forward load args:**
|
|
164
204
|
|
|
165
|
-
|
|
205
|
+
```ts
|
|
206
|
+
private remoteCards: RemoteDashboardCard[] = []
|
|
166
207
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
208
|
+
// After fetch:
|
|
209
|
+
this.remoteCards = (await registrar.fetch()) as RemoteDashboardCard[]
|
|
210
|
+
await Promise.all(this.remoteCards.map((vc) => vc.load?.(options)))
|
|
170
211
|
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
212
|
+
// In render():
|
|
213
|
+
cards: [
|
|
214
|
+
this.myLocalCardVc.render(),
|
|
215
|
+
...this.remoteCards.map((c) => c.render()),
|
|
216
|
+
]
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Theming
|
|
222
|
+
|
|
223
|
+
### `loadActiveThemeForOrg(client, organizationId)`
|
|
224
|
+
|
|
225
|
+
Fetches the active Heartwood theme for an organization so your skill can apply the same visual style.
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { loadActiveThemeForOrg } from '@sprucelabs/spruce-heartwood-utils'
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Signature:**
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
async function loadActiveThemeForOrg(
|
|
235
|
+
client: MercuryClient,
|
|
236
|
+
organizationId: string
|
|
237
|
+
): Promise<SkillTheme>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Usage:**
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
const client = await this.connectToApi()
|
|
244
|
+
const theme = await loadActiveThemeForOrg(client, organizationId)
|
|
245
|
+
// pass theme to your view controller factory or apply to the UI
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Plugins
|
|
251
|
+
|
|
252
|
+
### `AutoLogoutPlugin`
|
|
253
|
+
|
|
254
|
+
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.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { AutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils'
|
|
258
|
+
|
|
259
|
+
const plugin = factory.BuildPlugin(AutoLogoutPlugin)
|
|
260
|
+
|
|
261
|
+
plugin.enableAutoLogout(300) // auto-logout after 300 seconds of inactivity
|
|
262
|
+
plugin.disableAutoLogout() // cancel a pending auto-logout
|
|
176
263
|
```
|
|
177
264
|
|
|
178
|
-
|
|
265
|
+
---
|
|
179
266
|
|
|
180
|
-
|
|
267
|
+
### `AutoLogoutViewPlugin` (interface)
|
|
181
268
|
|
|
182
|
-
|
|
183
|
-
// Get current scope
|
|
184
|
-
const { organizationId, locationId } = this.getScope()
|
|
269
|
+
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:
|
|
185
270
|
|
|
186
|
-
|
|
187
|
-
|
|
271
|
+
```ts
|
|
272
|
+
import { AutoLogoutViewPlugin } from '@sprucelabs/spruce-heartwood-utils'
|
|
273
|
+
|
|
274
|
+
interface AutoLogoutViewPlugin extends ViewControllerPlugin {
|
|
275
|
+
enableAutoLogout(durationSec: number): void
|
|
276
|
+
disableAutoLogout(): void
|
|
277
|
+
}
|
|
188
278
|
```
|
|
189
279
|
|
|
190
|
-
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
### `SpyAutoLogoutPlugin`
|
|
283
|
+
|
|
284
|
+
A test double that records calls to `enableAutoLogout` and `disableAutoLogout`. Swap it in during test setup to assert your skill calls the plugin correctly:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { SpyAutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils'
|
|
191
288
|
|
|
289
|
+
protected async beforeEach() {
|
|
290
|
+
const factory = this.views.getFactory()
|
|
291
|
+
factory.setPlugin('auto-logout', new SpyAutoLogoutPlugin())
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
@test()
|
|
295
|
+
protected async startsAutoLogoutAfterLoad() {
|
|
296
|
+
await this.load()
|
|
297
|
+
assert.isTrue(this.plugin.wasEnableAutoLogoutCalled)
|
|
298
|
+
assert.isEqual(this.plugin.durationSec, 300)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private get plugin() {
|
|
302
|
+
return this.views.getFactory().getPlugin('auto-logout') as SpyAutoLogoutPlugin
|
|
303
|
+
}
|
|
192
304
|
```
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
└── Dialog System
|
|
305
|
+
|
|
306
|
+
**Spy fields:**
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
class SpyAutoLogoutPlugin {
|
|
310
|
+
wasEnableAutoLogoutCalled: boolean
|
|
311
|
+
durationSec?: number
|
|
312
|
+
wasDisableAutoLogoutCalled: boolean
|
|
313
|
+
}
|
|
203
314
|
```
|
|
204
315
|
|
|
205
|
-
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Test Utilities
|
|
206
319
|
|
|
207
|
-
|
|
208
|
-
# Install dependencies
|
|
209
|
-
yarn
|
|
320
|
+
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.
|
|
210
321
|
|
|
211
|
-
|
|
212
|
-
yarn build.dev
|
|
322
|
+
### `remoteVcAssert`
|
|
213
323
|
|
|
214
|
-
|
|
215
|
-
yarn test
|
|
324
|
+
High-level assertion helper. Verifies that your skill view emits the expected registration event and that the returned cards are rendered.
|
|
216
325
|
|
|
217
|
-
|
|
218
|
-
|
|
326
|
+
```ts
|
|
327
|
+
import { remoteVcAssert } from '@sprucelabs/spruce-heartwood-utils'
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Basic assertion — confirm your skill view emits the event and renders cards:**
|
|
219
331
|
|
|
220
|
-
|
|
221
|
-
|
|
332
|
+
```ts
|
|
333
|
+
@test()
|
|
334
|
+
protected async registersCards() {
|
|
335
|
+
await remoteVcAssert.assertSkillViewRendersRemoteCards({
|
|
336
|
+
svc: this.vc,
|
|
337
|
+
fqen: 'your-skill.register-cards::v2021_02_11',
|
|
338
|
+
views: this.views,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
222
341
|
```
|
|
223
342
|
|
|
224
|
-
|
|
343
|
+
**With `shouldInvoke` — confirm each returned card has `load()` called on it:**
|
|
225
344
|
|
|
226
|
-
|
|
345
|
+
```ts
|
|
346
|
+
await remoteVcAssert.assertSkillViewRendersRemoteCards({
|
|
347
|
+
svc: this.vc,
|
|
348
|
+
fqen: 'your-skill.register-cards::v2021_02_11',
|
|
349
|
+
views: this.views,
|
|
350
|
+
shouldInvoke: {
|
|
351
|
+
methodName: 'load',
|
|
352
|
+
expectedParams: [this.views.getRouter().buildLoadOptions()],
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
```
|
|
227
356
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
357
|
+
**With `expectedTarget` / `expectedPayload` — confirm the event is scoped correctly:**
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
await remoteVcAssert.assertSkillViewRendersRemoteCards({
|
|
361
|
+
svc: this.vc,
|
|
362
|
+
fqen: 'your-skill.register-cards::v2021_02_11',
|
|
363
|
+
views: this.views,
|
|
364
|
+
expectedTarget: { organizationId: 'org-123' },
|
|
365
|
+
})
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
### `AssertRendersRemoteCardsOptions<Svc>`
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import { AssertRendersRemoteCardsOptions } from '@sprucelabs/spruce-heartwood-utils'
|
|
374
|
+
|
|
375
|
+
interface AssertRendersRemoteCardsOptions<Svc extends SkillViewController = SkillViewController> {
|
|
376
|
+
svc: Svc
|
|
377
|
+
fqen: EventNames
|
|
378
|
+
views: ViewFixture
|
|
379
|
+
expectedTarget?: Record<string, any>
|
|
380
|
+
expectedPayload?: Record<string, any>
|
|
381
|
+
loadArgs?: ArgsFromSvc<Svc>
|
|
382
|
+
shouldInvoke?: {
|
|
383
|
+
methodName: string
|
|
384
|
+
expectedParams?: any[]
|
|
234
385
|
}
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
### `fakeGetViews`
|
|
392
|
+
|
|
393
|
+
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.
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
import { fakeGetViews } from '@sprucelabs/spruce-heartwood-utils'
|
|
397
|
+
|
|
398
|
+
// Stub two remote views:
|
|
399
|
+
await fakeGetViews.fakeGetViews(['your-skill.card-a', 'your-skill.card-b'])
|
|
400
|
+
|
|
401
|
+
// Stub a view with a tracked method — stub exposes wasHit and passedParams:
|
|
402
|
+
await fakeGetViews.fakeGetViews(['your-skill.card'], 'load')
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
> **Note:** The export is the `heartwoodEventFaker` object. Call it as `fakeGetViews.fakeGetViews(...)`.
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
### `MockRemoteViewControllerFactory`
|
|
410
|
+
|
|
411
|
+
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.
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
import {
|
|
415
|
+
MockRemoteViewControllerFactory,
|
|
416
|
+
RemoteViewControllerFactoryImpl,
|
|
417
|
+
} from '@sprucelabs/spruce-heartwood-utils'
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**Setup — inject the mock before your skill view creates its factory:**
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
protected async beforeEach() {
|
|
424
|
+
RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
|
|
425
|
+
MockRemoteViewControllerFactory.reset()
|
|
426
|
+
|
|
427
|
+
MockRemoteViewControllerFactory.dropInRemoteController(
|
|
428
|
+
'your-skill.some-card',
|
|
429
|
+
YourCardViewController
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**Get the mock instance to run assertions:**
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
const mockFactory = MockRemoteViewControllerFactory.getInstance()
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Available assertions:**
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
// Assert a specific card was loaded:
|
|
444
|
+
mockFactory.assertFetchedRemoteController('your-skill.some-card')
|
|
445
|
+
|
|
446
|
+
// Assert it was loaded with specific constructor options:
|
|
447
|
+
mockFactory.assertFetchedRemoteController('your-skill.some-card', { organizationId: 'org-123' })
|
|
448
|
+
|
|
449
|
+
// Assert a card was NOT loaded:
|
|
450
|
+
mockFactory.assertDidNotFetchRemoteController('your-skill.other-card')
|
|
451
|
+
|
|
452
|
+
// Assert a skill view renders a specific remote card:
|
|
453
|
+
mockFactory.assertSkillViewRendersRemoteCard(this.vc, 'your-skill.some-card')
|
|
454
|
+
|
|
455
|
+
// Assert constructor options match exactly:
|
|
456
|
+
mockFactory.assertRemoteCardConstructorOptionsEqual('your-skill.some-card', { organizationId: 'org-123' })
|
|
457
|
+
|
|
458
|
+
// Assert views were loaded for a namespace:
|
|
459
|
+
mockFactory.assertLoadedViewsForNamespace('your-skill')
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**Check without throwing:**
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
if (mockFactory.hasController('your-skill.some-card')) { ... }
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
### `MockDroppedInControllers`
|
|
471
|
+
|
|
472
|
+
Type for the map of card IDs to VC classes held by `MockRemoteViewControllerFactory`:
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
import { MockDroppedInControllers } from '@sprucelabs/spruce-heartwood-utils'
|
|
476
|
+
|
|
477
|
+
type MockDroppedInControllers = Record<
|
|
478
|
+
string,
|
|
479
|
+
new (args: any) => ViewController<any>
|
|
480
|
+
>
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## Device & Layout Utilities
|
|
486
|
+
|
|
487
|
+
These utilities are not re-exported through the main barrel and must be imported directly from their build paths.
|
|
488
|
+
|
|
489
|
+
### `getDeviceOrientation()`
|
|
490
|
+
|
|
491
|
+
Returns `'landscape'` or `'portrait'` based on the current viewport dimensions. Use this to make layout decisions that depend on device orientation.
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
import { getDeviceOrientation } from '@sprucelabs/spruce-heartwood-utils/build/components/controlBars/getDeviceOrientation'
|
|
495
|
+
|
|
496
|
+
const orientation = getDeviceOrientation() // 'landscape' | 'portrait'
|
|
497
|
+
|
|
498
|
+
if (orientation === 'landscape') {
|
|
499
|
+
// wide layout
|
|
500
|
+
} else {
|
|
501
|
+
// tall layout
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
**Decision logic:**
|
|
506
|
+
|
|
507
|
+
```
|
|
508
|
+
if (clientWidth > clientHeight && clientWidth > 700) → 'landscape'
|
|
509
|
+
else → 'portrait'
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
**Reacting to changes** — do not poll. Subscribe to the orientation change event instead:
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
await emitter.on('did-change-orientation', async () => {
|
|
516
|
+
const orientation = getDeviceOrientation()
|
|
517
|
+
// update layout
|
|
235
518
|
})
|
|
236
519
|
```
|
|
237
520
|
|
|
238
|
-
|
|
521
|
+
**Caveats:** Browser-only. Synchronous. Each call reads the DOM live — no caching.
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
### `skillViewState`
|
|
526
|
+
|
|
527
|
+
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.
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
import { skillViewState } from '@sprucelabs/spruce-heartwood-utils/build/components/skillViews/skillViewState'
|
|
531
|
+
|
|
532
|
+
if (skillViewState.isFullScreen) {
|
|
533
|
+
// full-screen mode is active — skip height-dependent layout
|
|
534
|
+
}
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
**In tests — reset between cases:**
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
beforeEach(() => {
|
|
541
|
+
skillViewState.isFullScreen = false
|
|
542
|
+
})
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
**Caveats:** Plain object — not reactive. Reading it does not subscribe to changes. Must be reset between tests since it is a module-level singleton.
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## Animation
|
|
550
|
+
|
|
551
|
+
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.
|
|
552
|
+
|
|
553
|
+
### Quick Start
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
import {
|
|
557
|
+
// Visibility queue
|
|
558
|
+
useQueueShow, useShowNow,
|
|
559
|
+
showRightAway, hideRightAway,
|
|
560
|
+
queueShow, queueHide,
|
|
561
|
+
queueCallback, callbackImmediately,
|
|
562
|
+
clearPendingHideAndQueueShow, clearPendingShowAndQueueHide,
|
|
563
|
+
stopQueue,
|
|
564
|
+
// Layout components
|
|
565
|
+
Sizer, DelayedPlacer,
|
|
566
|
+
// Utilities
|
|
567
|
+
sizeUtil, Settings,
|
|
568
|
+
// Types
|
|
569
|
+
AnimationEmitter,
|
|
570
|
+
SizerProps,
|
|
571
|
+
DelayedPlacerProps,
|
|
572
|
+
} from '@sprucelabs/spruce-heartwood-utils'
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
### Required CSS
|
|
578
|
+
|
|
579
|
+
**Inside a Heartwood skill view:** all required CSS is already provided by Heartwood's stylesheet. No extra setup needed.
|
|
580
|
+
|
|
581
|
+
**Outside Heartwood (standalone use):** supply the following rules yourself.
|
|
582
|
+
|
|
583
|
+
The `queueShow` system toggles a `hidden` CSS class. Elements must start with `hidden` in their `className` and define their own transition:
|
|
584
|
+
|
|
585
|
+
```css
|
|
586
|
+
.hidden {
|
|
587
|
+
opacity: 0;
|
|
588
|
+
transform: translateY(4px);
|
|
589
|
+
pointer-events: none;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/* Transition goes on the element, not .hidden */
|
|
593
|
+
.your-element {
|
|
594
|
+
transition: opacity 200ms ease, transform 200ms ease;
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
`Sizer` uses `.sizer` and `.sizer__inner`:
|
|
599
|
+
|
|
600
|
+
```css
|
|
601
|
+
.sizer {
|
|
602
|
+
transition: height 500ms ease;
|
|
603
|
+
overflow: hidden;
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
`DelayedPlacer` uses `.placer`. Its child must be `position: absolute`:
|
|
608
|
+
|
|
609
|
+
```css
|
|
610
|
+
.placer {
|
|
611
|
+
position: relative;
|
|
612
|
+
}
|
|
613
|
+
.placer > * {
|
|
614
|
+
position: absolute;
|
|
615
|
+
}
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
---
|
|
619
|
+
|
|
620
|
+
### System Architecture
|
|
621
|
+
|
|
622
|
+
The animation system has three cooperating layers:
|
|
623
|
+
|
|
624
|
+
```
|
|
625
|
+
queueShow / queueHide — staggered CSS class toggling (show/hide via .hidden)
|
|
626
|
+
│
|
|
627
|
+
▼
|
|
628
|
+
Sizer — measures content height, sets style.height for smooth animation
|
|
629
|
+
│ emits did-resize-content
|
|
630
|
+
▼
|
|
631
|
+
DelayedPlacer — reads Sizer's placeholder position, writes absolute coordinates
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
Pass the **same emitter instance** to `Sizer` and `DelayedPlacer` when they are siblings so their events coordinate. Different emitter instances decouple them.
|
|
635
|
+
|
|
636
|
+
`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.
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
640
|
+
### AnimationEmitter
|
|
641
|
+
|
|
642
|
+
The interface `Sizer` and `DelayedPlacer` use to communicate layout-change events. You implement this and pass it as the `emitter` prop to coordinate components.
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
interface AnimationEmitter {
|
|
646
|
+
on(event: string, handler: () => void): Promise<void> | void
|
|
647
|
+
off(event: string, handler: () => void): Promise<void> | void
|
|
648
|
+
emit(event: string): Promise<void> | void
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
**Events:**
|
|
653
|
+
|
|
654
|
+
| Event | Who listens | Who emits | Meaning |
|
|
655
|
+
|---|---|---|---|
|
|
656
|
+
| `did-render` | Sizer, DelayedPlacer | your code | A React render cycle completed |
|
|
657
|
+
| `did-resize` | Sizer, DelayedPlacer | your code | Viewport or parent dimensions changed |
|
|
658
|
+
| `did-resize-content` | DelayedPlacer | Sizer | Sizer changed its measured height |
|
|
659
|
+
| `did-change-orientation` | DelayedPlacer | your code | Portrait ↔ landscape switch |
|
|
660
|
+
| `did-place-cards` | your listeners | DelayedPlacer | Placement calculation finished |
|
|
661
|
+
|
|
662
|
+
**Minimal implementation:**
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
class SimpleEmitter implements AnimationEmitter {
|
|
666
|
+
private listeners: Record<string, Array<() => void>> = {}
|
|
667
|
+
|
|
668
|
+
on(event: string, handler: () => void): void {
|
|
669
|
+
;(this.listeners[event] ??= []).push(handler)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
off(event: string, handler: () => void): void {
|
|
673
|
+
this.listeners[event] = (this.listeners[event] ?? []).filter(
|
|
674
|
+
(h) => h !== handler
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
emit(event: string): void {
|
|
679
|
+
for (const h of this.listeners[event] ?? []) h()
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
> Pass the **same function reference** to `on` and `off`. Inline arrow functions cannot be removed with `off`.
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
### Settings
|
|
689
|
+
|
|
690
|
+
A static class that controls animation behavior globally. The most important method for skill developers is `disableAnimations()` — call it in every test `beforeEach`.
|
|
691
|
+
|
|
692
|
+
```ts
|
|
693
|
+
class Settings {
|
|
694
|
+
// Disable all animations. Makes timeouts fire at 0ms, queue drains synchronously.
|
|
695
|
+
// Cannot be undone. Never call in production code.
|
|
696
|
+
static disableAnimations(): void
|
|
697
|
+
|
|
698
|
+
static getIsAnimationEnabled(): boolean
|
|
699
|
+
|
|
700
|
+
// Returns animation duration in ms: landscape=500, portrait=1000. Returns 0 if disabled.
|
|
701
|
+
static get animationDuration(): number
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
**In your test base class:**
|
|
706
|
+
|
|
707
|
+
```ts
|
|
708
|
+
protected async beforeEach() {
|
|
709
|
+
Settings.disableAnimations()
|
|
710
|
+
}
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
> **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.
|
|
714
|
+
|
|
715
|
+
---
|
|
716
|
+
|
|
717
|
+
### queueShow — Show/Hide Queue
|
|
718
|
+
|
|
719
|
+
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.
|
|
720
|
+
|
|
721
|
+
When animations are disabled (`Settings.disableAnimations()`), the entire queue drains synchronously — no real timers needed in tests.
|
|
722
|
+
|
|
723
|
+
**The queue is shared** across your entire skill view. `stopQueue()` stops all pending operations.
|
|
724
|
+
|
|
725
|
+
---
|
|
726
|
+
|
|
727
|
+
#### `queueShow(refOrNode, delay?, method?)`
|
|
728
|
+
|
|
729
|
+
Queues a show (removes `hidden`) on an element.
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
function queueShow(
|
|
733
|
+
refOrNode: React.RefObject<HTMLElement | null> | HTMLElement | null,
|
|
734
|
+
delay?: number, // default: 40ms between queue steps
|
|
735
|
+
method?: 'push' | 'unshift' // default: 'push' (end of queue)
|
|
736
|
+
): void
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
No-ops silently if the element is null, already visible, or already queued.
|
|
740
|
+
|
|
741
|
+
```tsx
|
|
742
|
+
// Standard pattern — queue on mount via ref callback:
|
|
743
|
+
<div className="your-card hidden" ref={(ref) => { ref && queueShow(ref) }} />
|
|
744
|
+
|
|
745
|
+
// Multiple elements — queue them all:
|
|
746
|
+
const items = containerRef.current?.querySelectorAll('.item') ?? []
|
|
747
|
+
for (const item of items) { queueShow(item as HTMLElement) }
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
---
|
|
751
|
+
|
|
752
|
+
#### `queueHide(refOrNode, delay?, method?)`
|
|
753
|
+
|
|
754
|
+
Queues a hide (adds `hidden`) on an element.
|
|
755
|
+
|
|
756
|
+
```ts
|
|
757
|
+
function queueHide(
|
|
758
|
+
refOrNode: React.RefObject<HTMLElement> | HTMLElement,
|
|
759
|
+
delay?: number,
|
|
760
|
+
method?: 'push' | 'unshift'
|
|
761
|
+
): void
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
queueHide(loadingSpinnerRef)
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
---
|
|
769
|
+
|
|
770
|
+
#### `showRightAway(refOrNode)`
|
|
771
|
+
|
|
772
|
+
Jumps to the **front** of the queue. Use when an element must appear before anything else already queued.
|
|
773
|
+
|
|
774
|
+
```ts
|
|
775
|
+
function showRightAway(
|
|
776
|
+
refOrNode: React.RefObject<HTMLElement | null> | HTMLElement
|
|
777
|
+
): void
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
```tsx
|
|
781
|
+
<div className="alert hidden" ref={(ref) => { ref && showRightAway(ref) }} />
|
|
782
|
+
|
|
783
|
+
<img onLoad={() => { imageRef && showRightAway(imageRef) }} />
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
---
|
|
787
|
+
|
|
788
|
+
#### `hideRightAway(refOrNode)`
|
|
789
|
+
|
|
790
|
+
Jumps a hide to the front of the queue.
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
function hideRightAway(refOrNode: React.RefObject<HTMLElement> | HTMLElement): void
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
```ts
|
|
797
|
+
hideRightAway(overlayRef.current)
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
---
|
|
239
801
|
|
|
240
|
-
|
|
802
|
+
#### `queueCallback(cb)`
|
|
803
|
+
|
|
804
|
+
Inserts an arbitrary callback at the end of the queue. Runs in sequence with show/hide steps.
|
|
805
|
+
|
|
806
|
+
```ts
|
|
807
|
+
function queueCallback(cb: () => void): void
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
```ts
|
|
811
|
+
// Trigger a re-render after queued animations complete:
|
|
812
|
+
queueCallback(() => this.triggerRender())
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
#### `callbackImmediately(cb)`
|
|
818
|
+
|
|
819
|
+
Inserts a callback at the front of the queue.
|
|
820
|
+
|
|
821
|
+
```ts
|
|
822
|
+
function callbackImmediately(cb: () => void): void
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
const enqueue = shouldPrioritize ? callbackImmediately : queueCallback
|
|
827
|
+
enqueue(() => this.handleReady())
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
---
|
|
831
|
+
|
|
832
|
+
#### `clearPendingHideAndQueueShow(refOrNode, delay?)`
|
|
833
|
+
|
|
834
|
+
Cancels a pending hide and queues a show for elements toggled back before their hide ran.
|
|
835
|
+
|
|
836
|
+
> Prefer `showRightAway()` for most toggle cases.
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
#### `clearPendingShowAndQueueHide(refOrNode, delay?)`
|
|
841
|
+
|
|
842
|
+
Cancels a pending show and queues a hide.
|
|
843
|
+
|
|
844
|
+
> Prefer `hideRightAway()` for most toggle cases.
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
#### `stopQueue()`
|
|
849
|
+
|
|
850
|
+
Stops the queue interval and abandons any remaining items. Call in test teardown to prevent queue state leaking between tests.
|
|
851
|
+
|
|
852
|
+
```ts
|
|
853
|
+
protected async afterEach() {
|
|
854
|
+
await super.afterEach()
|
|
855
|
+
stopQueue()
|
|
856
|
+
}
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
---
|
|
860
|
+
|
|
861
|
+
#### `useQueueShow(ref, delay?)` — React Hook
|
|
862
|
+
|
|
863
|
+
Calls `queueShow` on every render via `useEffect`. Use for elements that should fade in after each render.
|
|
864
|
+
|
|
865
|
+
```tsx
|
|
866
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
867
|
+
useQueueShow(ref)
|
|
868
|
+
|
|
869
|
+
return <div ref={ref} className="your-panel hidden">...</div>
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
#### `useShowNow(ref, delay?)` — React Hook
|
|
875
|
+
|
|
876
|
+
Like `useQueueShow` but places itself at the front of the queue on every render.
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
function useShowNow(ref: React.RefObject<HTMLElement>, delay?: number): void
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
---
|
|
883
|
+
|
|
884
|
+
### Sizer — Animated Height Container
|
|
885
|
+
|
|
886
|
+
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.
|
|
887
|
+
|
|
888
|
+
#### Props (`SizerProps`)
|
|
889
|
+
|
|
890
|
+
```ts
|
|
891
|
+
interface SizerProps {
|
|
892
|
+
children: any
|
|
893
|
+
|
|
894
|
+
// When false, renders children with no wrapper. Default: enabled.
|
|
895
|
+
isEnabled?: boolean
|
|
896
|
+
|
|
897
|
+
// CSS class on the outer .sizer div.
|
|
898
|
+
className?: string
|
|
899
|
+
|
|
900
|
+
// When true, starts at height 0 and defers first measurement by 100ms.
|
|
901
|
+
// Use for content that should animate in from nothing.
|
|
902
|
+
shouldStartAtZero?: boolean
|
|
903
|
+
|
|
904
|
+
// When true, keeps overflow hidden throughout. When explicitly false,
|
|
905
|
+
// overflow is always visible (adds force-show-overflow class).
|
|
906
|
+
shouldHideOverflow?: boolean
|
|
907
|
+
|
|
908
|
+
// Event emitter. Without one, Sizer only resizes on React re-renders,
|
|
909
|
+
// not in response to external layout events.
|
|
910
|
+
emitter?: AnimationEmitter
|
|
911
|
+
}
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
#### Ref API
|
|
915
|
+
|
|
916
|
+
```ts
|
|
917
|
+
sizer.current?.resize(): boolean // measure and apply new height; returns true if it changed
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
#### Emitter Events
|
|
921
|
+
|
|
922
|
+
| Event | Direction | Meaning |
|
|
923
|
+
|---|---|---|
|
|
924
|
+
| `did-render` | listened | Re-rendered; measure and resize |
|
|
925
|
+
| `did-resize` | listened | Viewport changed; measure and resize |
|
|
926
|
+
| `did-resize-content` | emitted | Height changed (fires after `Settings.animationDuration` ms) |
|
|
927
|
+
|
|
928
|
+
#### Usage
|
|
929
|
+
|
|
930
|
+
```tsx
|
|
931
|
+
// Simplest — just clip overflow during height transition:
|
|
932
|
+
<Sizer shouldHideOverflow>
|
|
933
|
+
{isExpanded && <YourExpandableContent />}
|
|
934
|
+
</Sizer>
|
|
935
|
+
|
|
936
|
+
// Animate in from zero height:
|
|
937
|
+
<Sizer emitter={emitter} shouldStartAtZero>
|
|
938
|
+
<YourCard />
|
|
939
|
+
</Sizer>
|
|
940
|
+
|
|
941
|
+
// Manually trigger resize when content changes outside React state:
|
|
942
|
+
const emitter = useMemo(() => new SimpleEmitter(), [])
|
|
943
|
+
const sizer = useRef<React.ElementRef<typeof Sizer>>(null)
|
|
944
|
+
|
|
945
|
+
<Sizer shouldHideOverflow ref={sizer} emitter={emitter}>
|
|
946
|
+
<YourDynamicContent
|
|
947
|
+
onContentChange={async () => {
|
|
948
|
+
if (sizer.current?.resize()) {
|
|
949
|
+
await emitter.emit('did-resize')
|
|
950
|
+
}
|
|
951
|
+
}}
|
|
952
|
+
/>
|
|
953
|
+
</Sizer>
|
|
954
|
+
|
|
955
|
+
// Staggered entrance with queueShow:
|
|
956
|
+
<Sizer emitter={emitter} shouldHideOverflow shouldStartAtZero>
|
|
957
|
+
<div className="your-item hidden" ref={(ref) => { ref && queueShow(ref) }}>
|
|
958
|
+
content
|
|
959
|
+
</div>
|
|
960
|
+
</Sizer>
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
> Do not nest `Sizer` inside another `Sizer` for the same content — they will conflict.
|
|
964
|
+
|
|
965
|
+
---
|
|
966
|
+
|
|
967
|
+
### DelayedPlacer — Absolute Positioning
|
|
968
|
+
|
|
969
|
+
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.
|
|
970
|
+
|
|
971
|
+
When `isEnabled={false}`, children render inline with no wrapper.
|
|
972
|
+
|
|
973
|
+
#### Props (`DelayedPlacerProps`)
|
|
974
|
+
|
|
975
|
+
```ts
|
|
976
|
+
interface DelayedPlacerProps {
|
|
977
|
+
children: any
|
|
978
|
+
|
|
979
|
+
// Required. When true, wraps in .placer and manages absolute positioning.
|
|
980
|
+
isEnabled: boolean
|
|
981
|
+
|
|
982
|
+
// Required. CSS class on the outer .placer div.
|
|
983
|
+
className: string
|
|
984
|
+
|
|
985
|
+
// Event emitter. Listens for layout changes to re-measure and re-place.
|
|
986
|
+
emitter?: AnimationEmitter
|
|
987
|
+
|
|
988
|
+
// Required. Return true when this skill view is focused and in the foreground.
|
|
989
|
+
// Placement is skipped when false to avoid measuring off-screen content.
|
|
990
|
+
// Pass () => true for standalone use outside Heartwood.
|
|
991
|
+
isFocused: () => boolean
|
|
992
|
+
}
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
#### Ref API
|
|
996
|
+
|
|
997
|
+
```ts
|
|
998
|
+
delayedPlacer.current?.placeRightAway(): void // re-measure and re-place immediately
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
#### Emitter Events
|
|
1002
|
+
|
|
1003
|
+
| Event | Direction | Meaning |
|
|
1004
|
+
|---|---|---|
|
|
1005
|
+
| `did-resize-content` | listened | Sizer changed height; re-place |
|
|
1006
|
+
| `did-resize` | listened | Viewport changed; re-place |
|
|
1007
|
+
| `did-render` | listened | Re-rendered; re-place |
|
|
1008
|
+
| `did-change-orientation` | listened | Device rotated; re-place |
|
|
1009
|
+
| `did-place-cards` | emitted | Placement finished (100ms after measuring) |
|
|
1010
|
+
|
|
1011
|
+
#### Usage
|
|
1012
|
+
|
|
1013
|
+
```tsx
|
|
1014
|
+
const placerRef = React.createRef<DelayedPlacer>()
|
|
1015
|
+
|
|
1016
|
+
// Re-place after content changes:
|
|
1017
|
+
private onContentChange() {
|
|
1018
|
+
setTimeout(() => { placerRef.current?.placeRightAway() }, 50)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
render() {
|
|
1022
|
+
return (
|
|
1023
|
+
<DelayedPlacer
|
|
1024
|
+
className="placer__card"
|
|
1025
|
+
isEnabled={this.isPlacementEnabled}
|
|
1026
|
+
emitter={this.emitter}
|
|
1027
|
+
ref={placerRef}
|
|
1028
|
+
isFocused={() => true}
|
|
1029
|
+
>
|
|
1030
|
+
{yourCard}
|
|
1031
|
+
</DelayedPlacer>
|
|
1032
|
+
)
|
|
1033
|
+
}
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
> `isEnabled`, `className`, and `isFocused` are all required. The child must be `position: absolute` in CSS for placement to have visual effect.
|
|
1037
|
+
|
|
1038
|
+
---
|
|
1039
|
+
|
|
1040
|
+
### sizeUtil — DOM Measurement
|
|
1041
|
+
|
|
1042
|
+
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.
|
|
1043
|
+
|
|
1044
|
+
```ts
|
|
1045
|
+
const sizeUtil = {
|
|
1046
|
+
bodyWidth(): number
|
|
1047
|
+
bodyHeight(): number
|
|
1048
|
+
|
|
1049
|
+
// Absolute position (walks offsetParent chain, accounts for scroll)
|
|
1050
|
+
getTop(node: HTMLElement): number
|
|
1051
|
+
getLeft(node: HTMLElement): number
|
|
1052
|
+
getBottom(node: HTMLElement): number
|
|
1053
|
+
getRight(node: HTMLElement): number
|
|
1054
|
+
getPosition(node: HTMLElement): { x: number; y: number }
|
|
1055
|
+
|
|
1056
|
+
// Local position relative to offsetParent
|
|
1057
|
+
getLocalTop(node: HTMLElement): number
|
|
1058
|
+
getLocalLeft(node: HTMLElement): number
|
|
1059
|
+
getLocalBottom(node: HTMLElement): number
|
|
1060
|
+
getLocalRight(node: HTMLElement): number
|
|
1061
|
+
|
|
1062
|
+
// Dimensions
|
|
1063
|
+
getWidth(node: HTMLElement): number
|
|
1064
|
+
getHeight(node: HTMLElement, shouldGetPreciseHeight?: boolean): number
|
|
1065
|
+
// true (default): getBoundingClientRect().height — sub-pixel, correct during transitions
|
|
1066
|
+
// false: offsetHeight — integer, cheaper
|
|
1067
|
+
|
|
1068
|
+
// Scroll
|
|
1069
|
+
getScrollWidth(node: HTMLElement): number
|
|
1070
|
+
getScrollHeight(node: HTMLElement): number
|
|
1071
|
+
getMaxScrollTop(node: HTMLElement): number
|
|
1072
|
+
getMaxScrollLeft(node: HTMLElement): number
|
|
1073
|
+
isScrolledAllTheWayRight(node: HTMLElement): boolean
|
|
1074
|
+
isScrolledAllTheWayLeft(node: HTMLElement): boolean
|
|
1075
|
+
|
|
1076
|
+
// Hit testing
|
|
1077
|
+
doesIntersect({ x, y, node }: { x: number; y: number; node: HTMLElement }): boolean
|
|
1078
|
+
}
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
`getPosition()` returns **absolute page coordinates** (distance from top-left of document), not viewport coordinates.
|
|
1082
|
+
|
|
1083
|
+
**Stubbing in tests** — `sizeUtil` is a plain object so individual methods are replaceable:
|
|
1084
|
+
|
|
1085
|
+
```ts
|
|
1086
|
+
sizeUtil.bodyWidth = () => 1200
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
> Call measurement methods after layout has settled — inside `componentDidMount`, after `setTimeout`, or in an emitter event handler.
|
|
1090
|
+
|
|
1091
|
+
---
|
|
1092
|
+
|
|
1093
|
+
### How the Three Systems Coordinate
|
|
1094
|
+
|
|
1095
|
+
A complete example showing all three systems working together in a card component:
|
|
1096
|
+
|
|
1097
|
+
```tsx
|
|
1098
|
+
import {
|
|
1099
|
+
Sizer, DelayedPlacer, queueShow, Settings, AnimationEmitter,
|
|
1100
|
+
} from '@sprucelabs/spruce-heartwood-utils'
|
|
1101
|
+
|
|
1102
|
+
class SimpleEmitter implements AnimationEmitter {
|
|
1103
|
+
private listeners: Record<string, Array<() => void>> = {}
|
|
1104
|
+
on(event: string, handler: () => void) { (this.listeners[event] ??= []).push(handler) }
|
|
1105
|
+
off(event: string, handler: () => void) {
|
|
1106
|
+
this.listeners[event] = (this.listeners[event] ?? []).filter(h => h !== handler)
|
|
1107
|
+
}
|
|
1108
|
+
emit(event: string) { for (const h of this.listeners[event] ?? []) h() }
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function YourCard({ isPlaced, isFocused }: { isPlaced: boolean; isFocused: () => boolean }) {
|
|
1112
|
+
const emitter = useMemo(() => new SimpleEmitter(), [])
|
|
1113
|
+
|
|
1114
|
+
return (
|
|
1115
|
+
<DelayedPlacer
|
|
1116
|
+
className="placer__card"
|
|
1117
|
+
isEnabled={isPlaced}
|
|
1118
|
+
emitter={emitter}
|
|
1119
|
+
isFocused={isFocused}
|
|
1120
|
+
>
|
|
1121
|
+
<Sizer emitter={emitter} shouldHideOverflow shouldStartAtZero>
|
|
1122
|
+
<div
|
|
1123
|
+
className="card hidden"
|
|
1124
|
+
ref={(ref) => { ref && queueShow(ref) }}
|
|
1125
|
+
>
|
|
1126
|
+
your content
|
|
1127
|
+
</div>
|
|
1128
|
+
</Sizer>
|
|
1129
|
+
</DelayedPlacer>
|
|
1130
|
+
)
|
|
1131
|
+
}
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
**What happens when content renders:**
|
|
1135
|
+
|
|
1136
|
+
```
|
|
1137
|
+
React render
|
|
1138
|
+
→ Sizer receives did-render → measures height → updates style.height
|
|
1139
|
+
→ Sizer emits did-resize-content (after animation duration)
|
|
1140
|
+
→ DelayedPlacer re-measures placeholder → updates style.left / style.top
|
|
1141
|
+
→ DelayedPlacer emits did-place-cards
|
|
1142
|
+
queueShow removes .hidden from each element with a 40ms stagger
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
**Test setup for animation components:**
|
|
1146
|
+
|
|
1147
|
+
```ts
|
|
1148
|
+
protected async beforeEach() {
|
|
1149
|
+
Settings.disableAnimations()
|
|
1150
|
+
skillViewState.isFullScreen = false
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
protected async afterEach() {
|
|
1154
|
+
await super.afterEach()
|
|
1155
|
+
stopQueue()
|
|
1156
|
+
}
|
|
1157
|
+
```
|