zumly 0.92.5 → 0.97.0

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.
@@ -1,4 +1,5 @@
1
1
  import { showViewContent } from '../view-visibility.js'
2
+ import { disposeView } from '../view-lifecycle.js'
2
3
 
3
4
  /**
4
5
  * Shared helpers for Zumly transition drivers.
@@ -129,11 +130,15 @@ export function applyZoomOutLastState (element, backwardState) {
129
130
  * @param {HTMLElement} canvas - The canvas container
130
131
  */
131
132
  export function removeViewFromCanvas (element, canvas) {
133
+ disposeView(element)
132
134
  try {
133
135
  if (canvas) canvas.removeChild(element)
134
136
  } catch (e) {
135
137
  try {
136
- if (element.parentElement) canvas.removeChild(element.parentElement)
138
+ if (element?.parentElement && canvas) {
139
+ disposeView(element.parentElement)
140
+ canvas.removeChild(element.parentElement)
141
+ }
137
142
  } catch (e2) {
138
143
  // Element already removed or re-parented — safe to ignore.
139
144
  }
@@ -211,10 +216,10 @@ export const SAFETY_BUFFER_MS = 150
211
216
  *
212
217
  * @param {function} cleanup - The actual cleanup + onComplete work
213
218
  * @param {number} timeoutMs - Safety timeout duration
214
- * @returns {{ finish: function, safetyTimer: number }}
219
+ * @returns {{ finish: function, extend: function, safetyTimer: number }}
215
220
  *
216
221
  * @example
217
- * const { finish, safetyTimer } = createFinishGuard(() => {
222
+ * const { finish, extend } = createFinishGuard(() => {
218
223
  * cancelAnimations()
219
224
  * applyFinalState()
220
225
  * onComplete()
@@ -224,10 +229,13 @@ export const SAFETY_BUFFER_MS = 150
224
229
  * animation.onfinish = finish
225
230
  *
226
231
  * // The safetyTimer ensures finish() runs even if onfinish never fires.
232
+ * // Call extend(ms) when the animation actually starts to re-arm the
233
+ * // deadline from that moment (styles flush on the next render frame,
234
+ * // which can lag far behind the JS call on heavy views).
227
235
  */
228
236
  export function createFinishGuard (cleanup, timeoutMs) {
229
237
  let completed = false
230
- const safetyTimer = setTimeout(() => {
238
+ let safetyTimer = setTimeout(() => {
231
239
  if (!completed) { completed = true; cleanup() }
232
240
  }, timeoutMs)
233
241
 
@@ -238,6 +246,13 @@ export function createFinishGuard (cleanup, timeoutMs) {
238
246
  clearTimeout(safetyTimer)
239
247
  cleanup()
240
248
  },
249
+ extend (ms) {
250
+ if (completed) return
251
+ clearTimeout(safetyTimer)
252
+ safetyTimer = setTimeout(() => {
253
+ if (!completed) { completed = true; cleanup() }
254
+ }, ms)
255
+ },
241
256
  safetyTimer
242
257
  }
243
258
  }
@@ -263,10 +278,10 @@ export function parseMatrixString (mStr) {
263
278
  )
264
279
  if (!m) return identityMatrix()
265
280
  return {
266
- a: parseFloat(m[1]) || 1,
281
+ a: parseFloat(m[1]),
267
282
  b: parseFloat(m[2]) || 0,
268
283
  c: parseFloat(m[3]) || 0,
269
- d: parseFloat(m[4]) || 1,
284
+ d: parseFloat(m[4]),
270
285
  e: parseFloat(m[5]) || 0,
271
286
  f: parseFloat(m[6]) || 0,
272
287
  }
@@ -15,10 +15,10 @@ import {
15
15
  applyZoomOutLastState,
16
16
  removeViewFromCanvas,
17
17
  runLateralInstant,
18
- readComputedMatrix,
19
18
  interpolateMatrix,
20
19
  matrixToString,
21
20
  } from './driver-helpers.js'
21
+ import { readTransitionMatrix } from './transform-matrix.js'
22
22
 
23
23
  export function runTransition (spec, onComplete) {
24
24
  const animate = getMotionAnimate()
@@ -131,14 +131,14 @@ function computeMatrixPairs (currentView, previousView, lastView, currentStage,
131
131
  if (direction === 'forward') {
132
132
  return {
133
133
  el,
134
- from: readComputedMatrix(el, backward.origin, backward.transform),
135
- to: readComputedMatrix(el, backward.origin, forward.transform),
134
+ from: readTransitionMatrix(el, backward.origin, backward.transform),
135
+ to: readTransitionMatrix(el, backward.origin, forward.transform),
136
136
  }
137
137
  } else {
138
138
  return {
139
139
  el,
140
- from: readComputedMatrix(el, forward.origin, forward.transform),
141
- to: readComputedMatrix(el, forward.origin, backward.transform),
140
+ from: readTransitionMatrix(el, forward.origin, forward.transform),
141
+ to: readTransitionMatrix(el, forward.origin, backward.transform),
142
142
  }
143
143
  }
144
144
  })
@@ -0,0 +1,34 @@
1
+ import { identityMatrix, readComputedMatrix } from './driver-helpers.js'
2
+
3
+ // Zumly emits translate(px, px) followed by an optional uniform scale. These
4
+ // matrices are independent of layout and transform-origin: the browser applies
5
+ // the origin separately when it paints the matrix. Keep other CSS syntax on the
6
+ // computed-style path, including an empty inline value that can expose author CSS.
7
+ const number = '[-+]?(?:\\d*\\.\\d+|\\d+)(?:[eE][-+]?\\d+)?'
8
+ const length = `(${number})(px)?`
9
+ const zumlyTransform = new RegExp(`^(?:translate\\(\\s*${length}\\s*,\\s*${length}\\s*\\)\\s*)?(?:scale\\(\\s*(${number})\\s*\\))?$`)
10
+
11
+ function parseZumlyMatrix (transformStr) {
12
+ if (typeof transformStr !== 'string') return null
13
+ const value = transformStr.trim()
14
+ if (value === 'none') return identityMatrix()
15
+ if (!value) return null
16
+ const match = value.match(zumlyTransform)
17
+ if (!match) return null
18
+ const tx = Number(match[1] ?? 0)
19
+ const ty = Number(match[3] ?? 0)
20
+ const scale = Number(match[5] ?? 1)
21
+ // CSS permits unitless zero lengths, but not other unitless translations.
22
+ if ((tx !== 0 && !match[2]) || (ty !== 0 && !match[4])) return null
23
+ if (![tx, ty, scale].every(Number.isFinite)) return null
24
+ return { a: scale, b: 0, c: 0, d: scale, e: tx, f: ty }
25
+ }
26
+
27
+ /** Apply a driver state, resolving engine transforms without layout reads. */
28
+ export function readTransitionMatrix (element, origin, transformStr) {
29
+ const matrix = parseZumlyMatrix(transformStr)
30
+ if (!matrix) return readComputedMatrix(element, origin, transformStr)
31
+ element.style.transformOrigin = origin
32
+ element.style.transform = transformStr
33
+ return matrix
34
+ }
@@ -0,0 +1,70 @@
1
+ /** Cleanup belongs to a resolved view instance, never to a cached template. */
2
+ // Drivers can be imported from a separate package entry point, so a module-local
3
+ // WeakMap would not see registrations made by the bundled core. A non-enumerable
4
+ // symbol property shares the scope through the node and is not copied by cloneNode.
5
+ const lifecycleKey = Symbol.for('zumly.viewLifecycles')
6
+
7
+ function runCleanup (cleanup) {
8
+ try {
9
+ const result = cleanup()
10
+ if (result && typeof result.then === 'function') {
11
+ result.catch(error => console.error('Zumly: view cleanup failed:', error)) // eslint-disable-line no-console
12
+ }
13
+ } catch (error) {
14
+ console.error('Zumly: view cleanup failed:', error) // eslint-disable-line no-console
15
+ }
16
+ }
17
+
18
+ /** Internal scope: accepts registrations before or after resolution finishes. */
19
+ export function createViewLifecycle () {
20
+ let disposed = false
21
+ const callbacks = []
22
+ const lifecycle = {
23
+ onCleanup (cleanup) {
24
+ if (typeof cleanup !== 'function') throw new TypeError('Zumly: onCleanup expects a function')
25
+ if (disposed) runCleanup(cleanup)
26
+ else callbacks.push(cleanup)
27
+ },
28
+ attach (node) {
29
+ let entries = node[lifecycleKey]
30
+ if (!entries) {
31
+ entries = new Set()
32
+ Object.defineProperty(node, lifecycleKey, { value: entries, configurable: true })
33
+ }
34
+ entries.add(lifecycle)
35
+ },
36
+ dispose () {
37
+ if (disposed) return
38
+ disposed = true
39
+ for (const cleanup of callbacks.splice(0).reverse()) runCleanup(cleanup)
40
+ }
41
+ }
42
+ return lifecycle
43
+ }
44
+
45
+ /**
46
+ * Dispose a view and registered descendants once. Call before permanent removal,
47
+ * or for an unused async result; temporary detach/keepAlive must not call this.
48
+ * Async cleanup is started here, with rejections handled, but is not awaited.
49
+ */
50
+ export function disposeView (node) {
51
+ if (!node) return
52
+ // Snapshot only registered nodes before callbacks can mutate the subtree.
53
+ // A TreeWalker keeps the old reverse document order without allocating an
54
+ // array/NodeList containing every element of a large component.
55
+ const nodes = []
56
+ if (node[lifecycleKey]) nodes.push(node)
57
+ const document = node.ownerDocument || node
58
+ const walker = document.createTreeWalker(node, 1) // NodeFilter.SHOW_ELEMENT
59
+ let descendant
60
+ while ((descendant = walker.nextNode())) {
61
+ if (descendant[lifecycleKey]) nodes.push(descendant)
62
+ }
63
+ for (let i = nodes.length - 1; i >= 0; i--) {
64
+ const child = nodes[i]
65
+ const entries = child[lifecycleKey]
66
+ if (!entries) continue
67
+ for (const lifecycle of entries) lifecycle.dispose()
68
+ delete child[lifecycleKey]
69
+ }
70
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Centralized `content-visibility` handling for view layers during zoom prep / teardown.
3
+ * Single source of truth — .z-view.hide only handles opacity; this module controls content-visibility.
4
+ */
5
+
6
+ // Shared across the bundled engine and separately imported driver helpers.
7
+ const visibilityKey = Symbol.for('zumly.viewVisibility')
8
+
9
+ function rememberVisibility (element) {
10
+ if (element[visibilityKey]) return
11
+ Object.defineProperty(element, visibilityKey, {
12
+ configurable: true,
13
+ value: {
14
+ value: element.style.getPropertyValue('content-visibility'),
15
+ priority: element.style.getPropertyPriority('content-visibility')
16
+ }
17
+ })
18
+ }
19
+
20
+ /** @param {HTMLElement | null | undefined} element */
21
+ export function hideViewContent (element) {
22
+ if (!element) return
23
+ rememberVisibility(element)
24
+ element.style.contentVisibility = 'hidden'
25
+ }
26
+
27
+ /**
28
+ * @param {HTMLElement | null | undefined} element
29
+ */
30
+ export function showViewContent (element) {
31
+ if (!element) return
32
+ rememberVisibility(element)
33
+ element.style.contentVisibility = 'visible'
34
+ }
35
+
36
+ /** Release the temporary transition override, preserving the host's CSS policy. */
37
+ export function restoreViewContent (element) {
38
+ const original = element?.[visibilityKey]
39
+ if (!original) return
40
+ if (original.value) element.style.setProperty('content-visibility', original.value, original.priority)
41
+ else element.style.removeProperty('content-visibility')
42
+ delete element[visibilityKey]
43
+ }
package/types/zumly.d.ts CHANGED
@@ -12,7 +12,9 @@ export interface ViewContext {
12
12
  /** The componentContext from Zumly constructor options. */
13
13
  context: Map<string, unknown> | Record<string, unknown>
14
14
  /** Data attributes from the trigger element (e.g. data-id="42" → props.id). */
15
- props: Record<string, string>
15
+ props: Record<string, unknown>
16
+ /** Register component unmount or resource cleanup, run once when this view is discarded. */
17
+ onCleanup(callback: () => void | Promise<void>): void
16
18
  }
17
19
 
18
20
  /** A function that receives context and returns a view. */
@@ -53,7 +55,7 @@ export interface TransitionSpec {
53
55
  slideDeltaX?: number
54
56
  slideDeltaY?: number
55
57
  /** When true, driver should not remove the outgoing view from DOM (lateral keepAlive). */
56
- keepAlive?: boolean
58
+ keepAlive?: boolean | 'visible'
57
59
  }
58
60
 
59
61
  /** Custom driver function signature. */
@@ -136,7 +138,7 @@ export interface DepthNavOptions {
136
138
  export interface InputsOptions {
137
139
  /** Enable wheel zoom-out. Default: true. */
138
140
  wheel?: boolean
139
- /** Enable keyboard navigation (arrow keys). Default: true. */
141
+ /** Enable keyboard navigation (Enter/Space on triggers, arrow keys for back). Default: true. */
140
142
  keyboard?: boolean
141
143
  /** Enable click/mouseup navigation. Default: true. */
142
144
  click?: boolean
@@ -193,7 +195,7 @@ export interface ZumlyOptions {
193
195
  /** Depth navigation UI. true = default (back button, bottom-left), false = disabled. */
194
196
  depthNav?: boolean | DepthNavOptions
195
197
  /** Input types to enable/disable. All enabled by default. */
196
- inputs?: InputsOptions
198
+ inputs?: boolean | InputsOptions
197
199
  /** Enable deferred rendering (view content inserted after zoom animation). */
198
200
  deferred?: boolean
199
201
  }
@@ -278,27 +280,28 @@ export class Zumly {
278
280
  getCurrentViewName(): string | null
279
281
 
280
282
  /**
281
- * Navigate to a view by name. Unified API for depth and lateral navigation.
283
+ * Navigate to a view by name. Resolves after the transition completes.
284
+ * Calls made while another navigation is loading/animating are ignored.
282
285
  */
283
286
  goTo(viewName: string, options?: GoToOptions): Promise<void>
284
287
 
285
288
  /**
286
289
  * Programmatic zoom to a named view (depth navigation).
287
- * Uses a centered synthetic trigger for the transition.
290
+ * Uses a centered synthetic trigger for the transition. Resolves after animation.
288
291
  */
289
292
  zoomTo(viewName: string, options?: ZoomToOptions): Promise<void>
290
293
 
291
294
  /** Zoom into the view indicated by a trigger element with data-to="viewName". */
292
295
  zoomIn(el: HTMLElement): Promise<void>
293
296
 
294
- /** Zoom out one level. No-op at root. */
295
- zoomOut(): void
297
+ /** Zoom out one level. Resolves after animation; no-op at root. */
298
+ zoomOut(): Promise<void>
296
299
 
297
300
  /**
298
301
  * Navigate back. Pops lateral history first, then zooms out.
299
- * Returns a Promise when navigating laterally.
302
+ * Resolves after the navigation completes.
300
303
  */
301
- back(): Promise<void> | void
304
+ back(): Promise<void>
302
305
  }
303
306
 
304
307
  export default Zumly