diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf11569..a1e2a18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **Track:** add component for declarative analytics tracking with `data-track:*` attributes ([#495](https://github.com/studiometa/ui/issues/495), [#497](https://github.com/studiometa/ui/pull/497), [d95126d](https://github.com/studiometa/ui/commit/d95126d), [51b5f69](https://github.com/studiometa/ui/commit/51b5f69)) - **ScrollAnimation:** add a `withScrollAnimationDebug` decorator ([#494](https://github.com/studiometa/ui/pull/494)) ### Fixed diff --git a/packages/docs/components/Track/examples.md b/packages/docs/components/Track/examples.md new file mode 100644 index 00000000..3c47a75d --- /dev/null +++ b/packages/docs/components/Track/examples.md @@ -0,0 +1,261 @@ +--- +title: Track Examples +--- + +# Examples + +## E-commerce Tracking + +### Product List View + +```twig +{# Track product list view on page load #} + +``` + +### Product Click + +```twig + + {{ product.name }} + +``` + +### Add to Cart + +```twig +
+ + +
+``` + +### Purchase Confirmation + +```twig +{# On thank you page #} + +``` + +## Navigation Tracking + +### Menu Click + +```twig + +``` + +### Footer Link + +```twig + +``` + +## Form Tracking + +### Form Submission + +```twig +
+ + +
+``` + +### Search Input (Debounced) + +```twig + +``` + +## Impression Tracking + +### Product Card Impression + +```twig +{% for product in products %} +
+ {# Product card content #} +
+{% endfor %} +``` + +### Banner Impression + +```twig +
+ {{ banner.title }} +
+``` + +## Video Tracking + +### Video Play + +```twig + +``` + +## Scroll Tracking + +### Scroll Depth (Throttled) + +```twig +
+ {# Page content #} +
+``` + +## Third-Party Integration + +### Custom Event from External Script + +```html + +
+``` + +## Multiple Events on Same Element + +```twig + + {{ product.name }} + +``` diff --git a/packages/docs/components/Track/index.md b/packages/docs/components/Track/index.md new file mode 100644 index 00000000..3fa60665 --- /dev/null +++ b/packages/docs/components/Track/index.md @@ -0,0 +1,159 @@ +--- +badges: [JS] +--- + +# Track + +The `Track` component provides declarative analytics tracking compatible with GTM/dataLayer, GA4, Segment, and custom backends. No custom JavaScript required - tracking is fully defined in HTML/Twig attributes. + +## Table of content + +- [Examples](./examples.md) +- [JS API](./js-api.md) + +## Usage + +Import the component and register it in your application: + +```js +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Track, TrackContext } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + Track, + TrackContext, + }, + }; +} + +createApp(App); +``` + +### Click Tracking + +Track user interactions with `data-track:click`: + + + + + + +:::code-group + +<<< ./stories/basic/click.twig +<<< ./stories/basic/app.js + +::: + + + +### Page Load Tracking + +Use `data-track:mounted` to dispatch tracking data when the component mounts (page load): + + + + + + +:::code-group + +<<< ./stories/basic/mounted.twig +<<< ./stories/basic/app.js + +::: + + + +### Impression Tracking + +Use `data-track:view` for IntersectionObserver-based impression tracking: + + + + + + +:::code-group + +<<< ./stories/basic/view.twig +<<< ./stories/basic/app.js + +::: + + + +### Hierarchical Context + +Use `TrackContext` to provide shared data that is merged into all child `Track` components: + + + + + + +:::code-group + +<<< ./stories/basic/context.twig +<<< ./stories/basic/app.js + +::: + + + +### Multiple Events + +Multiple events can be tracked on the same element: + + + + + + +:::code-group + +<<< ./stories/basic/multiple.twig +<<< ./stories/basic/app.js + +::: + + + +### Custom Events + +Listen to CustomEvents from third-party scripts and extract data from `event.detail`: + + + + + + +:::code-group + +<<< ./stories/basic/custom-event.twig +<<< ./stories/basic/app.js + +::: + + diff --git a/packages/docs/components/Track/js-api.md b/packages/docs/components/Track/js-api.md new file mode 100644 index 00000000..2cb82a05 --- /dev/null +++ b/packages/docs/components/Track/js-api.md @@ -0,0 +1,204 @@ +--- +title: Track JS API +--- + +# JS API + +## Options + +### `threshold` + +- Type: `number` +- Default: `0.5` + +The IntersectionObserver threshold used for the `view` event. A value of `0.5` means the tracking will trigger when 50% of the element is visible. + +```html +
+ Product Card +
+``` + +## Events + +Events are defined using the `data-track:[.]` syntax with a JSON payload. + +### DOM Events + +Any DOM event can be tracked: `click`, `submit`, `change`, `input`, `focus`, `blur`, `scroll`, `mouseenter`, `mouseleave`, etc. + +```html + +``` + +### Special Events + +#### `mounted` + +Dispatches tracking data immediately when the component mounts. Useful for page load data like ecommerce views. + +```html + +``` + +#### `view` + +Uses IntersectionObserver for impression tracking. The event fires when the element becomes visible based on the `threshold` option. + +```html +
+ Product Card +
+``` + +## Event Modifiers + +Modifiers can be chained using `.` as a separator: + +```html + + View Product + +``` + +### Available Modifiers + +| Modifier | Effect | +| -------------- | ------------------------------------------------------------ | +| `.prevent` | Calls `event.preventDefault()` | +| `.stop` | Calls `event.stopPropagation()` | +| `.once` | Track only once (removes listener after first trigger) | +| `.passive` | Registers a passive event listener | +| `.capture` | Registers the listener in capture phase | +| `.debounce` | Debounces the handler with a 300ms delay | +| `.debounce` | Debounces with custom delay (e.g., `.debounce500` for 500ms) | +| `.throttle` | Throttles the handler with a 16ms delay (~60fps) | +| `.throttle` | Throttles with custom delay (e.g., `.throttle100` for 100ms) | + +### Timing Modifiers Examples + +```html + + + + + + + +
+ +
+
+``` + +## Custom Events + +The Track component can listen to CustomEvents dispatched by other scripts. + +### Using `$detail.*` Placeholders + +Extract specific values from `event.detail` using the `$detail.*` syntax: + +```html +
+``` + +If the form dispatches: + +```js +element.dispatchEvent( + new CustomEvent('form-submitted', { + detail: { email: 'test@example.com', user: { name: 'John' } }, + }), +); +``` + +The tracking data will be: + +```json +{ "event": "form_submitted", "email": "test@example.com", "name": "John" } +``` + +## TrackContext + +The `TrackContext` component provides hierarchical context data that is merged into all child `Track` components. + +### Options + +#### `data` + +- Type: `object` +- Default: `{}` + +The context data to merge into child Track components. + +```html +
+ + +
+``` + +### Nested Contexts + +When Track is nested in multiple TrackContext components, it uses the data from the **closest parent** only: + +```html +
+
+ + +
+
+``` + +## Custom Dispatcher + +By default, Track pushes data to `window.dataLayer` (GTM). You can customize the dispatcher: + +```js +import { setTrackDispatcher } from '@studiometa/ui'; + +// Send to GA4 directly +setTrackDispatcher((data, event) => { + gtag('event', data.event, data); +}); + +// Send to multiple destinations +setTrackDispatcher((data) => { + window.dataLayer.push(data); + fetch('/api/analytics', { method: 'POST', body: JSON.stringify(data) }); +}); + +// Reset to default (dataLayer.push) +setTrackDispatcher(null); +``` + +## Methods + +### `dispatch(data, event?)` + +Manually dispatch tracking data. The data is merged with any parent TrackContext data. + +```js +import { getInstanceFromElement } from '@studiometa/js-toolkit'; +import { Track } from '@studiometa/ui'; + +const element = document.querySelector('[data-component="Track"]'); +const track = getInstanceFromElement(element, Track); +track.dispatch({ event: 'custom_event', value: 123 }); +``` diff --git a/packages/docs/components/Track/stories/basic/app.js b/packages/docs/components/Track/stories/basic/app.js new file mode 100644 index 00000000..233bbcea --- /dev/null +++ b/packages/docs/components/Track/stories/basic/app.js @@ -0,0 +1,27 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Track, TrackContext, setTrackDispatcher } from '@studiometa/ui'; + +// Custom dispatcher that also updates debug elements +setTrackDispatcher((data) => { + // Push to dataLayer (default behavior) + window.dataLayer = window.dataLayer || []; + window.dataLayer.push(data); + + // Update debug elements + const debugEl = document.querySelector('[data-debug-datalayer]'); + if (debugEl) { + debugEl.textContent = JSON.stringify(window.dataLayer, null, 2); + } +}); + +class App extends Base { + static config = { + name: 'App', + components: { + Track, + TrackContext, + }, + }; +} + +export default createApp(App); diff --git a/packages/docs/components/Track/stories/basic/click.twig b/packages/docs/components/Track/stories/basic/click.twig new file mode 100644 index 00000000..563bb2f2 --- /dev/null +++ b/packages/docs/components/Track/stories/basic/click.twig @@ -0,0 +1,13 @@ + + +
+

dataLayer:

+
[]
+
diff --git a/packages/docs/components/Track/stories/basic/context.twig b/packages/docs/components/Track/stories/basic/context.twig new file mode 100644 index 00000000..994d0b35 --- /dev/null +++ b/packages/docs/components/Track/stories/basic/context.twig @@ -0,0 +1,33 @@ +
+ +
+

+ All buttons inherit context data from the parent TrackContext. +

+ +
+ + + +
+ +
+

dataLayer:

+
[]
+
+
+
diff --git a/packages/docs/components/Track/stories/basic/custom-event.twig b/packages/docs/components/Track/stories/basic/custom-event.twig new file mode 100644 index 00000000..0f6ca961 --- /dev/null +++ b/packages/docs/components/Track/stories/basic/custom-event.twig @@ -0,0 +1,28 @@ +
+

+ This example shows how to track CustomEvent from third-party scripts + using the $detail.* syntax. +

+ +
+ +

Simulated third-party form

+ + +
+ +
+

dataLayer:

+
[]
+
+
diff --git a/packages/docs/components/Track/stories/basic/mounted.twig b/packages/docs/components/Track/stories/basic/mounted.twig new file mode 100644 index 00000000..a3f6a106 --- /dev/null +++ b/packages/docs/components/Track/stories/basic/mounted.twig @@ -0,0 +1,17 @@ + + + +

+ The page_view event was dispatched when this component mounted. +

+ +
+

dataLayer:

+
[]
+
diff --git a/packages/docs/components/Track/stories/basic/multiple.twig b/packages/docs/components/Track/stories/basic/multiple.twig new file mode 100644 index 00000000..dd970c5d --- /dev/null +++ b/packages/docs/components/Track/stories/basic/multiple.twig @@ -0,0 +1,21 @@ +
+ + Hover and Click Me + + +

+ This link tracks both hover (mouseenter) and click events. +

+ +
+

dataLayer:

+
[]
+
+
diff --git a/packages/docs/components/Track/stories/basic/view.twig b/packages/docs/components/Track/stories/basic/view.twig new file mode 100644 index 00000000..29e2cec0 --- /dev/null +++ b/packages/docs/components/Track/stories/basic/view.twig @@ -0,0 +1,41 @@ +
+

+ Scroll down to see the product cards. Each card tracks an impression when it becomes visible. +

+ +
+
+ ↓ Scroll down ↓ +
+ +
+ Product A - Impression tracked once when visible +
+ +
+ Product B - Impression tracked once when visible +
+ +
+ Product C - Impression tracked once when visible +
+ +
+
+ +
+

dataLayer:

+
[]
+
+
diff --git a/packages/tests/Track/Track.spec.ts b/packages/tests/Track/Track.spec.ts new file mode 100644 index 00000000..082b31a0 --- /dev/null +++ b/packages/tests/Track/Track.spec.ts @@ -0,0 +1,345 @@ +import { describe, it, vi, expect, beforeEach, afterEach, beforeAll } from 'vitest'; +import { Track, TrackContext, setTrackDispatcher } from '@studiometa/ui'; +import { + h, + mount, + destroy, + intersectionObserverBeforeAllCallback, + intersectionObserverAfterEachCallback, + mockIsIntersecting, +} from '#test-utils'; + +describe('The Track component', () => { + let dispatcherSpy: ReturnType; + + beforeAll(() => { + intersectionObserverBeforeAllCallback(); + }); + + beforeEach(() => { + // Reset dataLayer + window.dataLayer = []; + // Create spy for custom dispatcher + dispatcherSpy = vi.fn(); + }); + + afterEach(() => { + intersectionObserverAfterEachCallback(); + setTrackDispatcher(null); + }); + + it('should dispatch on click event', async () => { + const div = h('div', { + 'data-track:click': JSON.stringify({ event: 'cta_click', location: 'header' }), + }); + const track = new Track(div); + await mount(track); + + track.$el.dispatchEvent(new Event('click')); + + expect(window.dataLayer).toHaveLength(1); + expect(window.dataLayer![0]).toEqual({ event: 'cta_click', location: 'header' }); + + await destroy(track); + }); + + it('should dispatch on mounted event', async () => { + const div = h('div', { + 'data-track:mounted': JSON.stringify({ event: 'page_view', page: 'home' }), + }); + const track = new Track(div); + await mount(track); + + expect(window.dataLayer).toHaveLength(1); + expect(window.dataLayer![0]).toEqual({ event: 'page_view', page: 'home' }); + + await destroy(track); + }); + + it('should dispatch on view event with IntersectionObserver', async () => { + const div = h('div', { + 'data-track:view': JSON.stringify({ event: 'product_impression', id: '123' }), + }); + const track = new Track(div); + await mount(track); + + // Initially not intersecting + expect(window.dataLayer).toHaveLength(0); + + // Trigger intersection + await mockIsIntersecting(track.$el, true); + + expect(window.dataLayer).toHaveLength(1); + expect(window.dataLayer![0]).toEqual({ event: 'product_impression', id: '123' }); + + await destroy(track); + }); + + it('should dispatch only once with .once modifier on view event', async () => { + const div = h('div', { + 'data-track:view.once': JSON.stringify({ event: 'product_impression', id: '123' }), + }); + const track = new Track(div); + await mount(track); + + // Trigger intersection - should dispatch once then disconnect + await mockIsIntersecting(track.$el, true); + + expect(window.dataLayer).toHaveLength(1); + expect(window.dataLayer![0]).toEqual({ event: 'product_impression', id: '123' }); + + await destroy(track); + }); + + it('should support multiple events on same element', async () => { + const div = h('div', { + 'data-track:click': JSON.stringify({ event: 'click_event' }), + 'data-track:mouseenter': JSON.stringify({ event: 'hover_event' }), + }); + const track = new Track(div); + await mount(track); + + track.$el.dispatchEvent(new Event('click')); + track.$el.dispatchEvent(new Event('mouseenter')); + + expect(window.dataLayer).toHaveLength(2); + expect(window.dataLayer![0]).toEqual({ event: 'click_event' }); + expect(window.dataLayer![1]).toEqual({ event: 'hover_event' }); + + await destroy(track); + }); + + it('should use custom dispatcher when set', async () => { + setTrackDispatcher(dispatcherSpy); + + const div = h('div', { + 'data-track:click': JSON.stringify({ event: 'test_event' }), + }); + const track = new Track(div); + await mount(track); + + track.$el.dispatchEvent(new Event('click')); + + expect(dispatcherSpy).toHaveBeenCalledTimes(1); + expect(dispatcherSpy).toHaveBeenCalledWith( + { event: 'test_event' }, + expect.any(Event), + ); + // Default dataLayer should not be used + expect(window.dataLayer).toHaveLength(0); + + await destroy(track); + }); + + it('should merge context data from TrackContext parent', async () => { + const contextDiv = h('div', { + 'data-option-data': JSON.stringify({ page_type: 'product', product_id: '123' }), + }); + const trackDiv = h('div', { + 'data-track:click': JSON.stringify({ action: 'add_to_cart' }), + }); + contextDiv.appendChild(trackDiv); + + const context = new TrackContext(contextDiv); + const track = new Track(trackDiv); + await mount(context, track); + + track.$el.dispatchEvent(new Event('click')); + + expect(window.dataLayer).toHaveLength(1); + expect(window.dataLayer![0]).toEqual({ + page_type: 'product', + product_id: '123', + action: 'add_to_cart', + }); + + await destroy(track, context); + }); + + it('should apply .prevent modifier', async () => { + const div = h('div', { + 'data-track:click.prevent': JSON.stringify({ event: 'test' }), + }); + const track = new Track(div); + await mount(track); + + const event = new Event('click', { cancelable: true }); + const preventSpy = vi.spyOn(event, 'preventDefault'); + track.$el.dispatchEvent(event); + + expect(preventSpy).toHaveBeenCalledTimes(1); + + await destroy(track); + }); + + it('should apply .stop modifier', async () => { + const div = h('div', { + 'data-track:click.stop': JSON.stringify({ event: 'test' }), + }); + const track = new Track(div); + await mount(track); + + const event = new Event('click', { bubbles: true }); + const stopSpy = vi.spyOn(event, 'stopPropagation'); + track.$el.dispatchEvent(event); + + expect(stopSpy).toHaveBeenCalledTimes(1); + + await destroy(track); + }); + + it('should warn on invalid JSON', async () => { + const div = h('div', { + 'data-track:click': 'invalid json', + }); + const track = new Track(div); + // $warn is a getter, so we spy on it with 'get' + const warnFn = vi.fn(); + vi.spyOn(track, '$warn', 'get').mockReturnValue(warnFn); + await mount(track); + + expect(warnFn).toHaveBeenCalled(); + + await destroy(track); + }); + + it('should handle CustomEvent with $detail.* placeholders', async () => { + const div = h('div', { + 'data-track:form-submitted': JSON.stringify({ + event: 'form_submitted', + email: '$detail.email', + name: '$detail.user.name', + }), + }); + const track = new Track(div); + await mount(track); + + const customEvent = new CustomEvent('form-submitted', { + detail: { + email: 'test@example.com', + user: { name: 'John' }, + }, + }); + track.$el.dispatchEvent(customEvent); + + expect(window.dataLayer).toHaveLength(1); + expect(window.dataLayer![0]).toEqual({ + event: 'form_submitted', + email: 'test@example.com', + name: 'John', + }); + + await destroy(track); + }); + + it('should merge full event.detail with .detail modifier', async () => { + const div = h('div', { + 'data-track:custom-event.detail': JSON.stringify({ + event: 'custom_tracking', + source: 'component', + }), + }); + const track = new Track(div); + await mount(track); + + const customEvent = new CustomEvent('custom-event', { + detail: { + extra: 'data', + nested: { value: 123 }, + }, + }); + track.$el.dispatchEvent(customEvent); + + expect(window.dataLayer).toHaveLength(1); + expect(window.dataLayer![0]).toEqual({ + event: 'custom_tracking', + source: 'component', + extra: 'data', + nested: { value: 123 }, + }); + + await destroy(track); + }); + + it('should debounce events with default delay', async () => { + const div = h('div', { + 'data-track:input.debounce': JSON.stringify({ event: 'search_input' }), + }); + const track = new Track(div); + await mount(track); + + // Enable fake timers after mounting + vi.useFakeTimers(); + + // Trigger multiple events quickly + track.$el.dispatchEvent(new Event('input')); + track.$el.dispatchEvent(new Event('input')); + track.$el.dispatchEvent(new Event('input')); + + // Should not be called immediately + expect(window.dataLayer).toHaveLength(0); + + // Fast forward 150ms (less than default 300ms) + vi.advanceTimersByTime(150); + expect(window.dataLayer).toHaveLength(0); + + // Fast forward another 150ms (total 300ms) + vi.advanceTimersByTime(150); + expect(window.dataLayer).toHaveLength(1); + + vi.useRealTimers(); + await destroy(track); + }); + + it('should debounce events with custom delay', async () => { + const div = h('div', { + 'data-track:input.debounce500': JSON.stringify({ event: 'search_input' }), + }); + const track = new Track(div); + await mount(track); + + // Enable fake timers after mounting + vi.useFakeTimers(); + + track.$el.dispatchEvent(new Event('input')); + + // Should not be called after 300ms + vi.advanceTimersByTime(300); + expect(window.dataLayer).toHaveLength(0); + + // Should be called after 500ms total + vi.advanceTimersByTime(200); + expect(window.dataLayer).toHaveLength(1); + + vi.useRealTimers(); + await destroy(track); + }); + + it('should throttle events with default delay', async () => { + const div = h('div', { + 'data-track:scroll.throttle': JSON.stringify({ event: 'scroll_tracking' }), + }); + const track = new Track(div); + await mount(track); + + // Enable fake timers after mounting + vi.useFakeTimers(); + + // First event should fire immediately (throttle behavior) + track.$el.dispatchEvent(new Event('scroll')); + expect(window.dataLayer).toHaveLength(1); + + // Subsequent events within throttle window should be ignored + track.$el.dispatchEvent(new Event('scroll')); + track.$el.dispatchEvent(new Event('scroll')); + expect(window.dataLayer).toHaveLength(1); + + // After throttle delay, next event should fire + vi.advanceTimersByTime(16); + track.$el.dispatchEvent(new Event('scroll')); + expect(window.dataLayer).toHaveLength(2); + + vi.useRealTimers(); + await destroy(track); + }); +}); diff --git a/packages/tests/Track/TrackContext.spec.ts b/packages/tests/Track/TrackContext.spec.ts new file mode 100644 index 00000000..775afdb2 --- /dev/null +++ b/packages/tests/Track/TrackContext.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Track, TrackContext, setTrackDispatcher } from '@studiometa/ui'; +import { h, mount, destroy } from '#test-utils'; + +describe('The TrackContext component', () => { + beforeEach(() => { + window.dataLayer = []; + }); + + afterEach(() => { + setTrackDispatcher(null); + }); + + it('should provide context data to child Track components', async () => { + const contextDiv = h('div', { + 'data-option-data': JSON.stringify({ page: 'home' }), + }); + const trackDiv = h('div', { + 'data-track:click': JSON.stringify({ action: 'click' }), + }); + contextDiv.appendChild(trackDiv); + + const context = new TrackContext(contextDiv); + const track = new Track(trackDiv); + await mount(context, track); + + track.$el.dispatchEvent(new Event('click')); + + expect(window.dataLayer![0]).toEqual({ + page: 'home', + action: 'click', + }); + + await destroy(track, context); + }); + + it('should support nested TrackContext components', async () => { + const outerContext = h('div', { + 'data-option-data': JSON.stringify({ site: 'example.com' }), + }); + const innerContext = h('div', { + 'data-option-data': JSON.stringify({ page: 'product', product_id: '123' }), + }); + const trackDiv = h('div', { + 'data-track:click': JSON.stringify({ action: 'add_to_cart' }), + }); + + outerContext.appendChild(innerContext); + innerContext.appendChild(trackDiv); + + const outer = new TrackContext(outerContext); + const inner = new TrackContext(innerContext); + const track = new Track(trackDiv); + await mount(outer, inner, track); + + track.$el.dispatchEvent(new Event('click')); + + // Track should use closest parent context (inner), not outer + expect(window.dataLayer![0]).toEqual({ + page: 'product', + product_id: '123', + action: 'add_to_cart', + }); + + await destroy(track, inner, outer); + }); + + it('should work without TrackContext parent', async () => { + const trackDiv = h('div', { + 'data-track:click': JSON.stringify({ event: 'standalone' }), + }); + const track = new Track(trackDiv); + await mount(track); + + track.$el.dispatchEvent(new Event('click')); + + expect(window.dataLayer![0]).toEqual({ event: 'standalone' }); + + await destroy(track); + }); + + it('should allow Track data to override context data', async () => { + const contextDiv = h('div', { + 'data-option-data': JSON.stringify({ page: 'home', version: '1' }), + }); + const trackDiv = h('div', { + 'data-track:click': JSON.stringify({ page: 'override', action: 'click' }), + }); + contextDiv.appendChild(trackDiv); + + const context = new TrackContext(contextDiv); + const track = new Track(trackDiv); + await mount(context, track); + + track.$el.dispatchEvent(new Event('click')); + + expect(window.dataLayer![0]).toEqual({ + page: 'override', // Overridden by Track + version: '1', // From context + action: 'click', // From Track + }); + + await destroy(track, context); + }); + + it('should default to empty object when no data option provided', async () => { + const contextDiv = h('div'); + const trackDiv = h('div', { + 'data-track:click': JSON.stringify({ event: 'test' }), + }); + contextDiv.appendChild(trackDiv); + + const context = new TrackContext(contextDiv); + const track = new Track(trackDiv); + await mount(context, track); + + track.$el.dispatchEvent(new Event('click')); + + expect(window.dataLayer![0]).toEqual({ event: 'test' }); + + await destroy(track, context); + }); +}); diff --git a/packages/tests/Track/TrackEvent.spec.ts b/packages/tests/Track/TrackEvent.spec.ts new file mode 100644 index 00000000..4ba5f0c6 --- /dev/null +++ b/packages/tests/Track/TrackEvent.spec.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; +import { parseEventDefinition, resolveDetailPlaceholders } from '#private/Track/TrackEvent.js'; + +describe('parseEventDefinition', () => { + it('should parse simple event', () => { + const result = parseEventDefinition('click'); + expect(result).toEqual({ + event: 'click', + modifiers: [], + debounceDelay: 0, + throttleDelay: 0, + }); + }); + + it('should parse event with modifiers', () => { + const result = parseEventDefinition('click.prevent.stop'); + expect(result).toEqual({ + event: 'click', + modifiers: ['prevent', 'stop'], + debounceDelay: 0, + throttleDelay: 0, + }); + }); + + it('should parse event with default debounce', () => { + const result = parseEventDefinition('input.debounce'); + expect(result).toEqual({ + event: 'input', + modifiers: ['debounce'], + debounceDelay: 300, + throttleDelay: 0, + }); + }); + + it('should parse event with custom debounce delay', () => { + const result = parseEventDefinition('input.debounce500'); + expect(result).toEqual({ + event: 'input', + modifiers: ['debounce'], + debounceDelay: 500, + throttleDelay: 0, + }); + }); + + it('should parse event with default throttle', () => { + const result = parseEventDefinition('scroll.throttle'); + expect(result).toEqual({ + event: 'scroll', + modifiers: ['throttle'], + debounceDelay: 0, + throttleDelay: 16, + }); + }); + + it('should parse event with custom throttle delay', () => { + const result = parseEventDefinition('mousemove.throttle100'); + expect(result).toEqual({ + event: 'mousemove', + modifiers: ['throttle'], + debounceDelay: 0, + throttleDelay: 100, + }); + }); + + it('should parse event with multiple modifiers including timing', () => { + const result = parseEventDefinition('click.prevent.stop.debounce200'); + expect(result).toEqual({ + event: 'click', + modifiers: ['prevent', 'stop', 'debounce'], + debounceDelay: 200, + throttleDelay: 0, + }); + }); + + it('should parse all listener option modifiers', () => { + const result = parseEventDefinition('click.once.passive.capture'); + expect(result).toEqual({ + event: 'click', + modifiers: ['once', 'passive', 'capture'], + debounceDelay: 0, + throttleDelay: 0, + }); + }); +}); + +describe('resolveDetailPlaceholders', () => { + it('should resolve simple $detail.* placeholder', () => { + const data = { event: 'test', email: '$detail.email' }; + const detail = { email: 'test@example.com' }; + + const result = resolveDetailPlaceholders(data, detail); + + expect(result).toEqual({ + event: 'test', + email: 'test@example.com', + }); + }); + + it('should resolve nested $detail.* placeholder', () => { + const data = { event: 'test', name: '$detail.user.name' }; + const detail = { user: { name: 'John' } }; + + const result = resolveDetailPlaceholders(data, detail); + + expect(result).toEqual({ + event: 'test', + name: 'John', + }); + }); + + it('should resolve deeply nested $detail.* placeholder', () => { + const data = { city: '$detail.address.location.city' }; + const detail = { address: { location: { city: 'Paris' } } }; + + const result = resolveDetailPlaceholders(data, detail); + + expect(result).toEqual({ city: 'Paris' }); + }); + + it('should return undefined for missing paths', () => { + const data = { email: '$detail.missing' }; + const detail = { other: 'value' }; + + const result = resolveDetailPlaceholders(data, detail); + + expect(result).toEqual({ email: undefined }); + }); + + it('should return undefined when path traversal hits a non-object value', () => { + const data = { value: '$detail.foo.bar.baz' }; + const detail = { foo: 'primitive' }; // foo is a string, not an object + + const result = resolveDetailPlaceholders(data, detail); + + expect(result).toEqual({ value: undefined }); + }); + + it('should preserve non-placeholder values', () => { + const data = { event: 'test', static: 'value', number: 42 }; + const detail = {}; + + const result = resolveDetailPlaceholders(data, detail); + + expect(result).toEqual({ + event: 'test', + static: 'value', + number: 42, + }); + }); + + it('should resolve placeholders in nested objects', () => { + const data = { + event: 'test', + user: { + email: '$detail.email', + name: '$detail.name', + }, + }; + const detail = { email: 'test@example.com', name: 'John' }; + + const result = resolveDetailPlaceholders(data, detail); + + expect(result).toEqual({ + event: 'test', + user: { + email: 'test@example.com', + name: 'John', + }, + }); + }); + + it('should preserve arrays', () => { + const data = { items: ['a', 'b', 'c'] }; + const detail = {}; + + const result = resolveDetailPlaceholders(data, detail); + + expect(result).toEqual({ items: ['a', 'b', 'c'] }); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 151da8b6..b7c69dcc 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -64,8 +64,12 @@ test('components exports', () => { "Sticky", "Tabs", "Target", + "Track", + "TrackContext", "Transition", "animationScrollWithEase", + "getTrackDispatcher", + "setTrackDispatcher", "withDeprecation", "withScrollAnimationDebug", "withTransition", diff --git a/packages/ui/Track/DESIGN.md b/packages/ui/Track/DESIGN.md new file mode 100644 index 00000000..d1680493 --- /dev/null +++ b/packages/ui/Track/DESIGN.md @@ -0,0 +1,272 @@ +# Track & TrackContext — Enhanced Payload Design + +> Design spec for adding support for complex JSON payloads via ` +``` + +### Source 2: `data-option-track-*` attributes + +Flat key/value pairs. Values are always strings — no type coercion. + +```html +
+``` + +### Merge order + +Script tag content is the base, attributes are merged on top: + +```html + + +``` + +## Context Hierarchy + +`TrackContext` components form a hierarchy through **DOM nesting** (parent/child ancestors only). Siblings are ignored. + +When a `Track` dispatches, it walks up the DOM collecting all ancestor `TrackContext` payloads. The merge order is outermost first, innermost last (innermost wins on conflicts). + +```html +
+ + +
...
+ + +
+ + + + + +
+
+``` + +## Full Dispatch Flow + +When an event fires on a `Track` component: + +### Normal flow (no `.no-merge`) + +Merge priority (lowest → highest, each layer overrides the previous): + +| # | Layer | Source | +|---|-------|--------| +| 1 | Context chain | All ancestor `TrackContext` payloads (outermost → innermost) | +| 2 | Track script content | ` +``` + +### TrackContext via attributes + +```html +
+ +
+``` + +### TrackContext via script tag + +```html + +``` + +### Combined: script tag + attributes + +```html + + +``` + +### Multiple events with different payloads + +```html + + +``` + +### Empty `data-track:` (no inline JSON) + +```html + + +``` + +## Implementation Changes + +### Shared: `resolvePayload(element)` utility + +New function used by both `Track` and `TrackContext` to resolve their payload: + +```ts +function resolvePayload(el: HTMLElement): Record { + let data: Record = {}; + + // 1. Script tag content (if root is