From d95126d07eb1cc5afc73fc552850f90a75aee686 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 28 Jan 2026 21:33:12 +0100 Subject: [PATCH 1/6] Add Track component for declarative analytics tracking Implements #495 - A generic, declarative component to standardize analytics tracking compatible with GTM/dataLayer, GA4, Segment, and custom backends. Features: - Track component with data-on:* event syntax (click, submit, view, mounted, etc.) - TrackContext component for hierarchical context data merging - Event modifiers (.prevent, .stop, .once, .debounce, .throttle, etc.) - IntersectionObserver-based impression tracking (view event) - CustomEvent support with $detail.* placeholder syntax - Configurable dispatcher via setTrackDispatcher() - Default dispatcher pushes to window.dataLayer (GTM) Co-authored-by: Claude --- packages/docs/components/Track/examples.md | 262 +++++++++++++++ packages/docs/components/Track/index.md | 159 +++++++++ packages/docs/components/Track/js-api.md | 236 +++++++++++++ .../components/Track/stories/basic/app.js | 14 + .../components/Track/stories/basic/click.twig | 9 + .../Track/stories/basic/context.twig | 31 ++ .../Track/stories/basic/custom-event.twig | 24 ++ .../Track/stories/basic/mounted.twig | 10 + .../Track/stories/basic/multiple.twig | 12 + .../components/Track/stories/basic/view.twig | 32 ++ packages/tests/Track/Track.spec.ts | 316 ++++++++++++++++++ packages/tests/Track/TrackContext.spec.ts | 123 +++++++ packages/tests/Track/TrackEvent.spec.ts | 171 ++++++++++ packages/tests/index.spec.ts | 4 + packages/ui/Track/Track.ts | 134 ++++++++ packages/ui/Track/TrackContext.ts | 40 +++ packages/ui/Track/TrackEvent.ts | 208 ++++++++++++ packages/ui/Track/dispatcher.ts | 56 ++++ packages/ui/Track/index.ts | 3 + packages/ui/index.ts | 1 + 20 files changed, 1845 insertions(+) create mode 100644 packages/docs/components/Track/examples.md create mode 100644 packages/docs/components/Track/index.md create mode 100644 packages/docs/components/Track/js-api.md create mode 100644 packages/docs/components/Track/stories/basic/app.js create mode 100644 packages/docs/components/Track/stories/basic/click.twig create mode 100644 packages/docs/components/Track/stories/basic/context.twig create mode 100644 packages/docs/components/Track/stories/basic/custom-event.twig create mode 100644 packages/docs/components/Track/stories/basic/mounted.twig create mode 100644 packages/docs/components/Track/stories/basic/multiple.twig create mode 100644 packages/docs/components/Track/stories/basic/view.twig create mode 100644 packages/tests/Track/Track.spec.ts create mode 100644 packages/tests/Track/TrackContext.spec.ts create mode 100644 packages/tests/Track/TrackEvent.spec.ts create mode 100644 packages/ui/Track/Track.ts create mode 100644 packages/ui/Track/TrackContext.ts create mode 100644 packages/ui/Track/TrackEvent.ts create mode 100644 packages/ui/Track/dispatcher.ts create mode 100644 packages/ui/Track/index.ts diff --git a/packages/docs/components/Track/examples.md b/packages/docs/components/Track/examples.md new file mode 100644 index 00000000..0fa950d6 --- /dev/null +++ b/packages/docs/components/Track/examples.md @@ -0,0 +1,262 @@ +--- +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..c0c18cd4 --- /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-on:click`: + + + + + + +:::code-group + +<<< ./stories/basic/click.twig +<<< ./stories/basic/app.js + +::: + + + +### Page Load Tracking + +Use `data-on: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-on: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..4b7b2000 --- /dev/null +++ b/packages/docs/components/Track/js-api.md @@ -0,0 +1,236 @@ +--- +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-on:[.]` 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 +const track = document.querySelector('[data-component="Track"]').__base__; +track.dispatch({ event: 'custom_event', value: 123 }); +``` + +## GDPR / Consent + +Consent is handled at the GTM/CMP (Consent Management Platform) level: + +1. Track pushes data to `window.dataLayer[]` (just a JavaScript array) +2. GTM reads the dataLayer and fires tags based on consent triggers (e.g., Axeptio) +3. No data is sent to analytics platforms until consent is given + +This approach keeps the Track component simple and consent-agnostic. 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..1b03db8a --- /dev/null +++ b/packages/docs/components/Track/stories/basic/app.js @@ -0,0 +1,14 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Track, TrackContext } from '@studiometa/ui'; + +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..6fa776d2 --- /dev/null +++ b/packages/docs/components/Track/stories/basic/click.twig @@ -0,0 +1,9 @@ + + +

+ Open your browser's console and check `window.dataLayer` after clicking the button. +

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..7ddc59b6 --- /dev/null +++ b/packages/docs/components/Track/stories/basic/context.twig @@ -0,0 +1,31 @@ +
+ +
+

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

+ +
+ + + +
+ +

+ Clicking "Add to Cart" dispatches:
+ { page_type: "product", product_id: "SKU123", product_name: "Example Product", action: "add_to_cart" } +

+
+
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..1d52e2dd --- /dev/null +++ b/packages/docs/components/Track/stories/basic/custom-event.twig @@ -0,0 +1,24 @@ +
+

+ This example shows how to track CustomEvents from third-party scripts. +

+ +
+ +

Simulated third-party form

+ + +
+ +

+ The Track component extracts email and source from event.detail using the $detail.* syntax. +

+
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..8dad9bff --- /dev/null +++ b/packages/docs/components/Track/stories/basic/mounted.twig @@ -0,0 +1,10 @@ + + + +

+ Check `window.dataLayer` - the page_view event was dispatched when this component mounted. +

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..d6c4a70f --- /dev/null +++ b/packages/docs/components/Track/stories/basic/multiple.twig @@ -0,0 +1,12 @@ + + Hover and Click Me + + +

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

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..8500e2ed --- /dev/null +++ b/packages/docs/components/Track/stories/basic/view.twig @@ -0,0 +1,32 @@ +
+

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

+ +
+
+ +
+ Product A - Impression tracked once when visible +
+ +
+ Product B - Impression tracked once when visible +
+ +
+ Product C - Impression tracked once when visible +
+ +
+
+
diff --git a/packages/tests/Track/Track.spec.ts b/packages/tests/Track/Track.spec.ts new file mode 100644 index 00000000..e01cc545 --- /dev/null +++ b/packages/tests/Track/Track.spec.ts @@ -0,0 +1,316 @@ +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-on: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-on: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-on: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-on: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-on:click': JSON.stringify({ event: 'click_event' }), + 'data-on: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-on: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-on: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-on: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-on: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-on: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-on: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 debounce events with default delay', async () => { + const div = h('div', { + 'data-on: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-on: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-on: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..6c9d487c --- /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-on: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-on: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-on: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-on: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-on: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..64cf3197 --- /dev/null +++ b/packages/tests/Track/TrackEvent.spec.ts @@ -0,0 +1,171 @@ +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 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/Track.ts b/packages/ui/Track/Track.ts new file mode 100644 index 00000000..ec727116 --- /dev/null +++ b/packages/ui/Track/Track.ts @@ -0,0 +1,134 @@ +import { Base, getClosestParent } from '@studiometa/js-toolkit'; +import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; +import { TrackContext } from './TrackContext.js'; +import { TrackEvent } from './TrackEvent.js'; +import { getTrackDispatcher } from './dispatcher.js'; + +export interface TrackProps extends BaseProps { + $options: { + threshold: number; + }; +} + +/** + * Track class. + * + * A declarative component for analytics tracking, compatible with GTM/dataLayer, + * GA4, Segment, and custom backends. + * + * @link https://ui.studiometa.dev/components/Track/ + * + * @example + * ```html + * + * + * + * + * + * + * + *
+ * Product Card + *
+ * ``` + */ +export class Track extends Base { + static config: BaseConfig = { + name: 'Track', + options: { + threshold: { + type: Number, + default: 0.5, + }, + }, + }; + + /** + * @private + */ + __trackEvents?: Set; + + /** + * Get all TrackEvent instances parsed from data-on:* attributes. + */ + get trackEvents(): Set { + if (this.__trackEvents) { + return this.__trackEvents; + } + + this.__trackEvents = new Set(); + + // Parse data-on:* attributes + for (let i = 0; i < this.$el.attributes.length; i++) { + const attr = this.$el.attributes[i]; + if (attr.name.startsWith('data-on:')) { + const eventDefinition = attr.name.slice(8); // Remove 'data-on:' + try { + const data = JSON.parse(attr.value); + this.__trackEvents.add(new TrackEvent(this, eventDefinition, data)); + } catch (err) { + this.$warn(`Invalid JSON in ${attr.name}:`, err); + } + } + } + + return this.__trackEvents; + } + + /** + * Get context data from closest TrackContext parent. + */ + get contextData(): Record { + const context = getClosestParent(this, TrackContext); + return context?.$options.data ?? {}; + } + + /** + * Dispatch tracking data (merged with context). + * + * @param data - The tracking data to dispatch + * @param event - The optional DOM event that triggered the dispatch + */ + dispatch(data: Record, event?: Event): void { + const dispatcher = getTrackDispatcher(); + const mergedData = { ...this.contextData, ...data }; + dispatcher(mergedData, event); + } + + /** + * Mounted lifecycle hook. + */ + mounted() { + for (const trackEvent of this.trackEvents) { + if (trackEvent.event === 'mounted') { + // Dispatch immediately on mount + this.dispatch(trackEvent.data); + } else if (trackEvent.event === 'view') { + // IntersectionObserver for impression tracking + trackEvent.attachViewEvent(); + } else { + // Standard DOM event + trackEvent.attachEvent(); + } + } + } + + /** + * Destroyed lifecycle hook. + */ + destroyed() { + for (const trackEvent of this.trackEvents) { + trackEvent.detachEvent(); + } + } +} diff --git a/packages/ui/Track/TrackContext.ts b/packages/ui/Track/TrackContext.ts new file mode 100644 index 00000000..b2011250 --- /dev/null +++ b/packages/ui/Track/TrackContext.ts @@ -0,0 +1,40 @@ +import { Base } from '@studiometa/js-toolkit'; +import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; + +export interface TrackContextProps extends BaseProps { + $options: { + data: Record; + }; +} + +/** + * TrackContext class. + * + * Provides hierarchical context data that is merged into child Track components. + * + * @example + * ```html + *
+ * + * + * + *
+ * ``` + */ +export class TrackContext extends Base { + static config: BaseConfig = { + name: 'TrackContext', + options: { + data: { + type: Object, + default: () => ({}), + }, + }, + }; +} diff --git a/packages/ui/Track/TrackEvent.ts b/packages/ui/Track/TrackEvent.ts new file mode 100644 index 00000000..488bec20 --- /dev/null +++ b/packages/ui/Track/TrackEvent.ts @@ -0,0 +1,208 @@ +import { debounce, throttle } from '@studiometa/js-toolkit/utils'; +import type { Track } from './Track.js'; + +export type Modifier = 'prevent' | 'stop' | 'once' | 'passive' | 'capture' | 'debounce' | 'throttle'; + +export interface ParsedEvent { + event: string; + modifiers: Modifier[]; + debounceDelay: number; + throttleDelay: number; +} + +/** + * Parse an event definition string into its components. + * + * @param eventDefinition - Event string like "click.prevent.stop" or "input.debounce300" + * @returns Parsed event with modifiers and timing delays + * + * @example + * ```ts + * parseEventDefinition('click.prevent.stop'); + * // { event: 'click', modifiers: ['prevent', 'stop'], debounceDelay: 0, throttleDelay: 0 } + * + * parseEventDefinition('input.debounce500'); + * // { event: 'input', modifiers: ['debounce'], debounceDelay: 500, throttleDelay: 0 } + * ``` + */ +export function parseEventDefinition(eventDefinition: string): ParsedEvent { + const [event, ...rawModifiers] = eventDefinition.split('.'); + + let debounceDelay = 0; + let throttleDelay = 0; + const modifiers: Modifier[] = []; + + for (const mod of rawModifiers) { + if (mod.startsWith('debounce')) { + modifiers.push('debounce'); + debounceDelay = parseInt(mod.replace('debounce', '') || '300', 10); + } else if (mod.startsWith('throttle')) { + modifiers.push('throttle'); + throttleDelay = parseInt(mod.replace('throttle', '') || '16', 10); + } else { + modifiers.push(mod as Modifier); + } + } + + return { event, modifiers, debounceDelay, throttleDelay }; +} + +/** + * Resolve `$detail.*` placeholders in tracking data with values from event.detail. + * + * @param data - The tracking data object + * @param detail - The event.detail object from a CustomEvent + * @returns New object with placeholders resolved + * + * @example + * ```ts + * resolveDetailPlaceholders( + * { event: 'form_submit', email: '$detail.email', name: '$detail.user.name' }, + * { email: 'test@example.com', user: { name: 'John' } } + * ); + * // { event: 'form_submit', email: 'test@example.com', name: 'John' } + * ``` + */ +export function resolveDetailPlaceholders( + data: Record, + detail: Record, +): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && value.startsWith('$detail.')) { + const path = value.slice(8); // Remove '$detail.' + result[key] = getNestedValue(detail, path); + } else if (value && typeof value === 'object' && !Array.isArray(value)) { + result[key] = resolveDetailPlaceholders(value as Record, detail); + } else { + result[key] = value; + } + } + + return result; +} + +/** + * Get a nested value from an object using a dot-separated path. + */ +function getNestedValue(obj: Record, path: string): unknown { + return path.split('.').reduce((current: unknown, key) => { + if (current && typeof current === 'object') { + return (current as Record)[key]; + } + return undefined; + }, obj); +} + +/** + * TrackEvent handles DOM event binding for the Track component. + */ +export class TrackEvent { + track: Track; + event: string; + modifiers: Modifier[]; + data: Record; + debounceDelay: number; + throttleDelay: number; + + private handler: EventListener; + private observer?: IntersectionObserver; + + constructor(track: Track, eventDefinition: string, data: Record) { + this.track = track; + this.data = data; + + const { event, modifiers, debounceDelay, throttleDelay } = parseEventDefinition(eventDefinition); + this.event = event; + this.modifiers = modifiers; + this.debounceDelay = debounceDelay; + this.throttleDelay = throttleDelay; + + // Build handler with timing modifiers + let handler: EventListener = (event: Event) => this.handleEvent(event); + + if (modifiers.includes('debounce')) { + handler = debounce(handler, debounceDelay) as EventListener; + } else if (modifiers.includes('throttle')) { + handler = throttle(handler, throttleDelay) as EventListener; + } + + this.handler = handler; + } + + /** + * Handle the DOM event and dispatch tracking data. + */ + handleEvent(event: Event) { + const { modifiers, data, track } = this; + + if (modifiers.includes('prevent')) { + event.preventDefault(); + } + + if (modifiers.includes('stop')) { + event.stopPropagation(); + } + + // Handle CustomEvent detail merging + let finalData = data; + if (event instanceof CustomEvent && event.detail) { + // Check for .detail modifier (merge full detail) + if (modifiers.includes('detail' as Modifier)) { + finalData = { ...data, ...event.detail }; + } else { + // Resolve $detail.* placeholders + finalData = resolveDetailPlaceholders(data, event.detail); + } + } + + track.dispatch(finalData, event); + } + + /** + * Attach the event listener for standard DOM events. + */ + attachEvent() { + const { event, modifiers, handler, track } = this; + + track.$el.addEventListener(event, handler, { + capture: modifiers.includes('capture'), + once: modifiers.includes('once'), + passive: modifiers.includes('passive'), + }); + } + + /** + * Attach IntersectionObserver for the "view" event. + */ + attachViewEvent() { + const { modifiers, track, data } = this; + const threshold = track.$options.threshold; + + this.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && entry.intersectionRatio >= threshold) { + track.dispatch(data); + + if (modifiers.includes('once')) { + this.observer?.disconnect(); + } + } + } + }, + { threshold }, + ); + + this.observer.observe(track.$el); + } + + /** + * Detach the event listener. + */ + detachEvent() { + this.track.$el.removeEventListener(this.event, this.handler); + this.observer?.disconnect(); + } +} diff --git a/packages/ui/Track/dispatcher.ts b/packages/ui/Track/dispatcher.ts new file mode 100644 index 00000000..4dacfc6e --- /dev/null +++ b/packages/ui/Track/dispatcher.ts @@ -0,0 +1,56 @@ +declare global { + interface Window { + dataLayer?: Record[]; + } +} + +export type TrackDispatcher = (data: Record, event?: Event) => void; + +let dispatcher: TrackDispatcher | null = null; + +/** + * Default dispatcher: push to dataLayer (GTM). + * GTM handles consent at tag level via Axeptio/CMP triggers. + */ +function defaultDispatcher(data: Record) { + if (typeof window !== 'undefined') { + window.dataLayer = window.dataLayer || []; + window.dataLayer.push(data); + } +} + +/** + * Set a custom dispatcher function. + * + * @param fn - The dispatcher function, or null to reset to default. + * + * @example + * ```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); + * ``` + */ +export function setTrackDispatcher(fn: TrackDispatcher | null): void { + dispatcher = fn; +} + +/** + * Get the current dispatcher function. + * @internal + */ +export function getTrackDispatcher(): TrackDispatcher { + return dispatcher ?? defaultDispatcher; +} diff --git a/packages/ui/Track/index.ts b/packages/ui/Track/index.ts new file mode 100644 index 00000000..8295d632 --- /dev/null +++ b/packages/ui/Track/index.ts @@ -0,0 +1,3 @@ +export * from './Track.js'; +export * from './TrackContext.js'; +export * from './dispatcher.js'; diff --git a/packages/ui/index.ts b/packages/ui/index.ts index c377ee75..99e72a6f 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -24,4 +24,5 @@ export * from './Sentinel/index.js'; export * from './Slider/index.js'; export * from './Sticky/index.js'; export * from './Tabs/index.js'; +export * from './Track/index.js'; export * from './Transition/index.js'; From f4c1c2dcd05b2072ca1c72f1ec19d15b4b32dd79 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 28 Jan 2026 22:16:14 +0100 Subject: [PATCH 2/6] Add dataLayer debug element to Track playground examples Co-authored-by: Claude --- packages/docs/components/Track/examples.md | 3 +- packages/docs/components/Track/js-api.md | 102 ++++++------------ .../components/Track/stories/basic/app.js | 15 ++- .../components/Track/stories/basic/click.twig | 12 ++- .../Track/stories/basic/context.twig | 16 +-- .../Track/stories/basic/custom-event.twig | 16 +-- .../Track/stories/basic/mounted.twig | 11 +- .../Track/stories/basic/multiple.twig | 31 ++++-- .../components/Track/stories/basic/view.twig | 17 ++- packages/ui/Track/TrackContext.ts | 2 + packages/ui/Track/TrackEvent.ts | 6 ++ packages/ui/Track/dispatcher.ts | 2 + 12 files changed, 129 insertions(+), 104 deletions(-) diff --git a/packages/docs/components/Track/examples.md b/packages/docs/components/Track/examples.md index 0fa950d6..182a31cd 100644 --- a/packages/docs/components/Track/examples.md +++ b/packages/docs/components/Track/examples.md @@ -244,8 +244,7 @@ title: Track Examples
-
+ data-on:form-success='{"event": "lead_generated", "source": "$detail.source", "email": "$detail.email"}'>
``` ## Multiple Events on Same Element diff --git a/packages/docs/components/Track/js-api.md b/packages/docs/components/Track/js-api.md index 4b7b2000..d4aaf1ce 100644 --- a/packages/docs/components/Track/js-api.md +++ b/packages/docs/components/Track/js-api.md @@ -31,9 +31,7 @@ Events are defined using the `data-on:[.]` syntax with a JSON p Any DOM event can be tracked: `click`, `submit`, `change`, `input`, `focus`, `blur`, `scroll`, `mouseenter`, `mouseleave`, etc. ```html - ``` @@ -48,8 +46,7 @@ Dispatches tracking data immediately when the component mounts. Useful for page + hidden> ``` #### `view` @@ -57,9 +54,7 @@ Dispatches tracking data immediately when the component mounts. Useful for page Uses IntersectionObserver for impression tracking. The event fires when the element becomes visible based on the `threshold` option. ```html -
+
Product Card
``` @@ -69,50 +64,39 @@ Uses IntersectionObserver for impression tracking. The event fires when the elem 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 | +| 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 the handler with a 16ms delay (~60fps) | | `.throttle` | Throttles with custom delay (e.g., `.throttle100` for 100ms) | ### Timing Modifiers Examples ```html - + - + -
- - -
+
+ +
+
``` ## Custom Events @@ -126,18 +110,21 @@ Extract specific values from `event.detail` using the `$detail.*` syntax: ```html
-
+ data-on:form-submitted='{"event": "form_submitted", "email": "$detail.email", "name": "$detail.user.name"}'> ``` If the form dispatches: + ```js -element.dispatchEvent(new CustomEvent('form-submitted', { - detail: { email: 'test@example.com', user: { name: 'John' } } -})); +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" } ``` @@ -159,12 +146,7 @@ The context data to merge into child Track components.
- - +
``` @@ -174,19 +156,11 @@ The context data to merge into child Track components. When Track is nested in multiple TrackContext components, it uses the data from the **closest parent** only: ```html -
- +
- - +
@@ -221,16 +195,10 @@ setTrackDispatcher(null); Manually dispatch tracking data. The data is merged with any parent TrackContext data. ```js -const track = document.querySelector('[data-component="Track"]').__base__; +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 }); ``` - -## GDPR / Consent - -Consent is handled at the GTM/CMP (Consent Management Platform) level: - -1. Track pushes data to `window.dataLayer[]` (just a JavaScript array) -2. GTM reads the dataLayer and fires tags based on consent triggers (e.g., Axeptio) -3. No data is sent to analytics platforms until consent is given - -This approach keeps the Track component simple and consent-agnostic. diff --git a/packages/docs/components/Track/stories/basic/app.js b/packages/docs/components/Track/stories/basic/app.js index 1b03db8a..233bbcea 100644 --- a/packages/docs/components/Track/stories/basic/app.js +++ b/packages/docs/components/Track/stories/basic/app.js @@ -1,5 +1,18 @@ import { Base, createApp } from '@studiometa/js-toolkit'; -import { Track, TrackContext } from '@studiometa/ui'; +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 = { diff --git a/packages/docs/components/Track/stories/basic/click.twig b/packages/docs/components/Track/stories/basic/click.twig index 6fa776d2..e93c8383 100644 --- a/packages/docs/components/Track/stories/basic/click.twig +++ b/packages/docs/components/Track/stories/basic/click.twig @@ -1,9 +1,13 @@ -

- Open your browser's console and check `window.dataLayer` after clicking the button. -

+
+

dataLayer:

+
[]
+
diff --git a/packages/docs/components/Track/stories/basic/context.twig b/packages/docs/components/Track/stories/basic/context.twig index 7ddc59b6..2912b615 100644 --- a/packages/docs/components/Track/stories/basic/context.twig +++ b/packages/docs/components/Track/stories/basic/context.twig @@ -4,28 +4,30 @@

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

-

- Clicking "Add to Cart" dispatches:
- { page_type: "product", product_id: "SKU123", product_name: "Example Product", action: "add_to_cart" } -

+
+

dataLayer:

+
[]
+
diff --git a/packages/docs/components/Track/stories/basic/custom-event.twig b/packages/docs/components/Track/stories/basic/custom-event.twig index 1d52e2dd..eaf9ef6f 100644 --- a/packages/docs/components/Track/stories/basic/custom-event.twig +++ b/packages/docs/components/Track/stories/basic/custom-event.twig @@ -1,6 +1,7 @@

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

-

Simulated third-party form

+

Simulated third-party form

-

- The Track component extracts email and source from event.detail using the $detail.* syntax. -

+
+

dataLayer:

+
[]
+
diff --git a/packages/docs/components/Track/stories/basic/mounted.twig b/packages/docs/components/Track/stories/basic/mounted.twig index 8dad9bff..d3a81593 100644 --- a/packages/docs/components/Track/stories/basic/mounted.twig +++ b/packages/docs/components/Track/stories/basic/mounted.twig @@ -5,6 +5,13 @@ hidden>
-

- Check `window.dataLayer` - the page_view event was dispatched when this component mounted. +

+ 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 index d6c4a70f..99fa4f79 100644 --- a/packages/docs/components/Track/stories/basic/multiple.twig +++ b/packages/docs/components/Track/stories/basic/multiple.twig @@ -1,12 +1,21 @@ - - Hover and Click Me - +
+ + Hover and Click Me + -

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

+

+ 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 index 8500e2ed..a878c6b1 100644 --- a/packages/docs/components/Track/stories/basic/view.twig +++ b/packages/docs/components/Track/stories/basic/view.twig @@ -4,29 +4,38 @@

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

dataLayer:

+
[]
+
diff --git a/packages/ui/Track/TrackContext.ts b/packages/ui/Track/TrackContext.ts index b2011250..d549e599 100644 --- a/packages/ui/Track/TrackContext.ts +++ b/packages/ui/Track/TrackContext.ts @@ -12,6 +12,8 @@ export interface TrackContextProps extends BaseProps { * * Provides hierarchical context data that is merged into child Track components. * + * @link https://ui.studiometa.dev/components/Track/js-api.html#trackcontext + * * @example * ```html *
, path: string): unknown { /** * TrackEvent handles DOM event binding for the Track component. + * + * @link https://ui.studiometa.dev/components/Track/js-api.html#events */ export class TrackEvent { track: Track; diff --git a/packages/ui/Track/dispatcher.ts b/packages/ui/Track/dispatcher.ts index 4dacfc6e..a312b566 100644 --- a/packages/ui/Track/dispatcher.ts +++ b/packages/ui/Track/dispatcher.ts @@ -22,6 +22,8 @@ function defaultDispatcher(data: Record) { /** * Set a custom dispatcher function. * + * @link https://ui.studiometa.dev/components/Track/js-api.html#custom-dispatcher + * * @param fn - The dispatcher function, or null to reset to default. * * @example From cf3d7c5f00aa03f0a18f5f05c333137f1cdeab31 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 29 Jan 2026 14:47:13 +0100 Subject: [PATCH 3/6] Add tests for missing Track coverage lines - Test getNestedValue returning undefined on non-object path traversal - Test .detail modifier for full event.detail merging Co-authored-by: Claude --- packages/tests/Track/Track.spec.ts | 29 +++++++++++++++++++++++++ packages/tests/Track/TrackEvent.spec.ts | 9 ++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/tests/Track/Track.spec.ts b/packages/tests/Track/Track.spec.ts index e01cc545..ce4819a2 100644 --- a/packages/tests/Track/Track.spec.ts +++ b/packages/tests/Track/Track.spec.ts @@ -232,6 +232,35 @@ describe('The Track component', () => { await destroy(track); }); + it('should merge full event.detail with .detail modifier', async () => { + const div = h('div', { + 'data-on: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-on:input.debounce': JSON.stringify({ event: 'search_input' }), diff --git a/packages/tests/Track/TrackEvent.spec.ts b/packages/tests/Track/TrackEvent.spec.ts index 64cf3197..4ba5f0c6 100644 --- a/packages/tests/Track/TrackEvent.spec.ts +++ b/packages/tests/Track/TrackEvent.spec.ts @@ -126,6 +126,15 @@ describe('resolveDetailPlaceholders', () => { 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 = {}; From bd765fe22bcbf2778ae069636375d45113839f9c Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 29 Jan 2026 14:57:30 +0100 Subject: [PATCH 4/6] Update changelog Co-authored-by: Claude --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf11569..3ab29c7c 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 ([#495](https://github.com/studiometa/ui/issues/495), [#497](https://github.com/studiometa/ui/pull/497), [d95126d](https://github.com/studiometa/ui/commit/d95126d)) - **ScrollAnimation:** add a `withScrollAnimationDebug` decorator ([#494](https://github.com/studiometa/ui/pull/494)) ### Fixed From 51b5f693f419a381bcbf29763b0def712d66830e Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Sat, 14 Feb 2026 12:26:34 +0000 Subject: [PATCH 5/6] Rename data-on:* to data-track:* to avoid conflict with Action component The Action component already uses data-on:* attributes with JS expression values. Using the same prefix for Track (with JSON values) would cause namespace collisions. Using data-track:* makes the API unambiguous. Co-authored-by: Claude --- packages/docs/components/Track/examples.md | 36 +++++++++---------- packages/docs/components/Track/index.md | 6 ++-- packages/docs/components/Track/js-api.md | 26 +++++++------- .../components/Track/stories/basic/click.twig | 2 +- .../Track/stories/basic/context.twig | 4 +-- .../Track/stories/basic/custom-event.twig | 2 +- .../Track/stories/basic/mounted.twig | 2 +- .../Track/stories/basic/multiple.twig | 4 +-- .../components/Track/stories/basic/view.twig | 6 ++-- packages/tests/Track/Track.spec.ts | 32 ++++++++--------- packages/tests/Track/TrackContext.spec.ts | 10 +++--- packages/ui/Track/Track.ts | 14 ++++---- packages/ui/Track/TrackContext.ts | 2 +- 13 files changed, 73 insertions(+), 73 deletions(-) diff --git a/packages/docs/components/Track/examples.md b/packages/docs/components/Track/examples.md index 182a31cd..3c47a75d 100644 --- a/packages/docs/components/Track/examples.md +++ b/packages/docs/components/Track/examples.md @@ -12,7 +12,7 @@ title: Track Examples {# Track product list view on page load #}
{ @@ -32,7 +32,7 @@ title: Track Examples + data-track:click='{{ { event: "link_click", link_text: "Contact Us" }|json_encode }}'> Contact Us @@ -148,7 +148,7 @@ title: Track Examples ```twig
``` @@ -176,7 +176,7 @@ title: Track Examples {% for product in products %}
+ data-track:play='{{ { event: "video_start", video_title: video.title }|json_encode }}' + data-track:pause='{{ { event: "video_pause", video_title: video.title }|json_encode }}' + data-track:ended='{{ { event: "video_complete", video_title: video.title }|json_encode }}'> ``` @@ -230,7 +230,7 @@ title: Track Examples ```twig
+ data-track:scroll.throttle200.passive='{{ { event: "scroll_depth" }|json_encode }}'> {# Page content #}
``` @@ -244,7 +244,7 @@ title: Track Examples
+ data-track:form-success='{"event": "lead_generated", "source": "$detail.source", "email": "$detail.email"}'>
``` ## Multiple Events on Same Element @@ -253,9 +253,9 @@ title: Track Examples + data-track:click='{{ { event: "select_item", item_id: product.sku }|json_encode }}' + data-track:auxclick='{{ { event: "select_item", item_id: product.sku, method: "new_tab" }|json_encode }}' + data-track:contextmenu='{{ { event: "select_item", item_id: product.sku, method: "context_menu" }|json_encode }}'> {{ product.name }} ``` diff --git a/packages/docs/components/Track/index.md b/packages/docs/components/Track/index.md index c0c18cd4..3fa60665 100644 --- a/packages/docs/components/Track/index.md +++ b/packages/docs/components/Track/index.md @@ -34,7 +34,7 @@ createApp(App); ### Click Tracking -Track user interactions with `data-on:click`: +Track user interactions with `data-track:click`: + data-track:view.once='{"event": "product_impression"}'> Product Card
``` ## Events -Events are defined using the `data-on:[.]` syntax with a JSON payload. +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 - ``` @@ -45,7 +45,7 @@ Dispatches tracking data immediately when the component mounts. Useful for page ```html ``` @@ -54,7 +54,7 @@ Dispatches tracking data immediately when the component mounts. Useful for page Uses IntersectionObserver for impression tracking. The event fires when the element becomes visible based on the `threshold` option. ```html -
+
Product Card
``` @@ -64,7 +64,7 @@ Uses IntersectionObserver for impression tracking. The event fires when the elem Modifiers can be chained using `.` as a separator: ```html - + View Product ``` @@ -87,15 +87,15 @@ Modifiers can be chained using `.` as a separator: ```html - + - + -
+
-
+
``` @@ -110,7 +110,7 @@ Extract specific values from `event.detail` using the `$detail.*` syntax: ```html + data-track:form-submitted='{"event": "form_submitted", "email": "$detail.email", "name": "$detail.user.name"}'> ``` If the form dispatches: @@ -146,7 +146,7 @@ The context data to merge into child Track components.
- +
``` @@ -160,7 +160,7 @@ When Track is nested in multiple TrackContext components, it uses the data from
- +
diff --git a/packages/docs/components/Track/stories/basic/click.twig b/packages/docs/components/Track/stories/basic/click.twig index e93c8383..563bb2f2 100644 --- a/packages/docs/components/Track/stories/basic/click.twig +++ b/packages/docs/components/Track/stories/basic/click.twig @@ -1,6 +1,6 @@ diff --git a/packages/docs/components/Track/stories/basic/context.twig b/packages/docs/components/Track/stories/basic/context.twig index 2912b615..994d0b35 100644 --- a/packages/docs/components/Track/stories/basic/context.twig +++ b/packages/docs/components/Track/stories/basic/context.twig @@ -10,14 +10,14 @@
diff --git a/packages/docs/components/Track/stories/basic/custom-event.twig b/packages/docs/components/Track/stories/basic/custom-event.twig index eaf9ef6f..0f6ca961 100644 --- a/packages/docs/components/Track/stories/basic/custom-event.twig +++ b/packages/docs/components/Track/stories/basic/custom-event.twig @@ -7,7 +7,7 @@

Simulated third-party form

diff --git a/packages/docs/components/Track/stories/basic/mounted.twig b/packages/docs/components/Track/stories/basic/mounted.twig index d3a81593..a3f6a106 100644 --- a/packages/docs/components/Track/stories/basic/mounted.twig +++ b/packages/docs/components/Track/stories/basic/mounted.twig @@ -1,7 +1,7 @@ diff --git a/packages/docs/components/Track/stories/basic/multiple.twig b/packages/docs/components/Track/stories/basic/multiple.twig index 99fa4f79..dd970c5d 100644 --- a/packages/docs/components/Track/stories/basic/multiple.twig +++ b/packages/docs/components/Track/stories/basic/multiple.twig @@ -2,8 +2,8 @@ Hover and Click Me diff --git a/packages/docs/components/Track/stories/basic/view.twig b/packages/docs/components/Track/stories/basic/view.twig index a878c6b1..29e2cec0 100644 --- a/packages/docs/components/Track/stories/basic/view.twig +++ b/packages/docs/components/Track/stories/basic/view.twig @@ -10,21 +10,21 @@
Product A - Impression tracked once when visible
Product B - Impression tracked once when visible
Product C - Impression tracked once when visible
diff --git a/packages/tests/Track/Track.spec.ts b/packages/tests/Track/Track.spec.ts index ce4819a2..082b31a0 100644 --- a/packages/tests/Track/Track.spec.ts +++ b/packages/tests/Track/Track.spec.ts @@ -30,7 +30,7 @@ describe('The Track component', () => { it('should dispatch on click event', async () => { const div = h('div', { - 'data-on:click': JSON.stringify({ event: 'cta_click', location: 'header' }), + 'data-track:click': JSON.stringify({ event: 'cta_click', location: 'header' }), }); const track = new Track(div); await mount(track); @@ -45,7 +45,7 @@ describe('The Track component', () => { it('should dispatch on mounted event', async () => { const div = h('div', { - 'data-on:mounted': JSON.stringify({ event: 'page_view', page: 'home' }), + 'data-track:mounted': JSON.stringify({ event: 'page_view', page: 'home' }), }); const track = new Track(div); await mount(track); @@ -58,7 +58,7 @@ describe('The Track component', () => { it('should dispatch on view event with IntersectionObserver', async () => { const div = h('div', { - 'data-on:view': JSON.stringify({ event: 'product_impression', id: '123' }), + 'data-track:view': JSON.stringify({ event: 'product_impression', id: '123' }), }); const track = new Track(div); await mount(track); @@ -77,7 +77,7 @@ describe('The Track component', () => { it('should dispatch only once with .once modifier on view event', async () => { const div = h('div', { - 'data-on:view.once': JSON.stringify({ event: 'product_impression', id: '123' }), + 'data-track:view.once': JSON.stringify({ event: 'product_impression', id: '123' }), }); const track = new Track(div); await mount(track); @@ -93,8 +93,8 @@ describe('The Track component', () => { it('should support multiple events on same element', async () => { const div = h('div', { - 'data-on:click': JSON.stringify({ event: 'click_event' }), - 'data-on:mouseenter': JSON.stringify({ event: 'hover_event' }), + 'data-track:click': JSON.stringify({ event: 'click_event' }), + 'data-track:mouseenter': JSON.stringify({ event: 'hover_event' }), }); const track = new Track(div); await mount(track); @@ -113,7 +113,7 @@ describe('The Track component', () => { setTrackDispatcher(dispatcherSpy); const div = h('div', { - 'data-on:click': JSON.stringify({ event: 'test_event' }), + 'data-track:click': JSON.stringify({ event: 'test_event' }), }); const track = new Track(div); await mount(track); @@ -136,7 +136,7 @@ describe('The Track component', () => { 'data-option-data': JSON.stringify({ page_type: 'product', product_id: '123' }), }); const trackDiv = h('div', { - 'data-on:click': JSON.stringify({ action: 'add_to_cart' }), + 'data-track:click': JSON.stringify({ action: 'add_to_cart' }), }); contextDiv.appendChild(trackDiv); @@ -158,7 +158,7 @@ describe('The Track component', () => { it('should apply .prevent modifier', async () => { const div = h('div', { - 'data-on:click.prevent': JSON.stringify({ event: 'test' }), + 'data-track:click.prevent': JSON.stringify({ event: 'test' }), }); const track = new Track(div); await mount(track); @@ -174,7 +174,7 @@ describe('The Track component', () => { it('should apply .stop modifier', async () => { const div = h('div', { - 'data-on:click.stop': JSON.stringify({ event: 'test' }), + 'data-track:click.stop': JSON.stringify({ event: 'test' }), }); const track = new Track(div); await mount(track); @@ -190,7 +190,7 @@ describe('The Track component', () => { it('should warn on invalid JSON', async () => { const div = h('div', { - 'data-on:click': 'invalid json', + 'data-track:click': 'invalid json', }); const track = new Track(div); // $warn is a getter, so we spy on it with 'get' @@ -205,7 +205,7 @@ describe('The Track component', () => { it('should handle CustomEvent with $detail.* placeholders', async () => { const div = h('div', { - 'data-on:form-submitted': JSON.stringify({ + 'data-track:form-submitted': JSON.stringify({ event: 'form_submitted', email: '$detail.email', name: '$detail.user.name', @@ -234,7 +234,7 @@ describe('The Track component', () => { it('should merge full event.detail with .detail modifier', async () => { const div = h('div', { - 'data-on:custom-event.detail': JSON.stringify({ + 'data-track:custom-event.detail': JSON.stringify({ event: 'custom_tracking', source: 'component', }), @@ -263,7 +263,7 @@ describe('The Track component', () => { it('should debounce events with default delay', async () => { const div = h('div', { - 'data-on:input.debounce': JSON.stringify({ event: 'search_input' }), + 'data-track:input.debounce': JSON.stringify({ event: 'search_input' }), }); const track = new Track(div); await mount(track); @@ -293,7 +293,7 @@ describe('The Track component', () => { it('should debounce events with custom delay', async () => { const div = h('div', { - 'data-on:input.debounce500': JSON.stringify({ event: 'search_input' }), + 'data-track:input.debounce500': JSON.stringify({ event: 'search_input' }), }); const track = new Track(div); await mount(track); @@ -317,7 +317,7 @@ describe('The Track component', () => { it('should throttle events with default delay', async () => { const div = h('div', { - 'data-on:scroll.throttle': JSON.stringify({ event: 'scroll_tracking' }), + 'data-track:scroll.throttle': JSON.stringify({ event: 'scroll_tracking' }), }); const track = new Track(div); await mount(track); diff --git a/packages/tests/Track/TrackContext.spec.ts b/packages/tests/Track/TrackContext.spec.ts index 6c9d487c..775afdb2 100644 --- a/packages/tests/Track/TrackContext.spec.ts +++ b/packages/tests/Track/TrackContext.spec.ts @@ -16,7 +16,7 @@ describe('The TrackContext component', () => { 'data-option-data': JSON.stringify({ page: 'home' }), }); const trackDiv = h('div', { - 'data-on:click': JSON.stringify({ action: 'click' }), + 'data-track:click': JSON.stringify({ action: 'click' }), }); contextDiv.appendChild(trackDiv); @@ -42,7 +42,7 @@ describe('The TrackContext component', () => { 'data-option-data': JSON.stringify({ page: 'product', product_id: '123' }), }); const trackDiv = h('div', { - 'data-on:click': JSON.stringify({ action: 'add_to_cart' }), + 'data-track:click': JSON.stringify({ action: 'add_to_cart' }), }); outerContext.appendChild(innerContext); @@ -67,7 +67,7 @@ describe('The TrackContext component', () => { it('should work without TrackContext parent', async () => { const trackDiv = h('div', { - 'data-on:click': JSON.stringify({ event: 'standalone' }), + 'data-track:click': JSON.stringify({ event: 'standalone' }), }); const track = new Track(trackDiv); await mount(track); @@ -84,7 +84,7 @@ describe('The TrackContext component', () => { 'data-option-data': JSON.stringify({ page: 'home', version: '1' }), }); const trackDiv = h('div', { - 'data-on:click': JSON.stringify({ page: 'override', action: 'click' }), + 'data-track:click': JSON.stringify({ page: 'override', action: 'click' }), }); contextDiv.appendChild(trackDiv); @@ -106,7 +106,7 @@ describe('The TrackContext component', () => { it('should default to empty object when no data option provided', async () => { const contextDiv = h('div'); const trackDiv = h('div', { - 'data-on:click': JSON.stringify({ event: 'test' }), + 'data-track:click': JSON.stringify({ event: 'test' }), }); contextDiv.appendChild(trackDiv); diff --git a/packages/ui/Track/Track.ts b/packages/ui/Track/Track.ts index ec727116..56b36b24 100644 --- a/packages/ui/Track/Track.ts +++ b/packages/ui/Track/Track.ts @@ -23,21 +23,21 @@ export interface TrackProps extends BaseProps { * * * * * * * *
+ * data-track:view.once='{"event": "product_impression", "id": "123"}'> * Product Card *
* ``` @@ -59,7 +59,7 @@ export class Track extends Base __trackEvents?: Set; /** - * Get all TrackEvent instances parsed from data-on:* attributes. + * Get all TrackEvent instances parsed from data-track:* attributes. */ get trackEvents(): Set { if (this.__trackEvents) { @@ -68,11 +68,11 @@ export class Track extends Base this.__trackEvents = new Set(); - // Parse data-on:* attributes + // Parse data-track:* attributes for (let i = 0; i < this.$el.attributes.length; i++) { const attr = this.$el.attributes[i]; - if (attr.name.startsWith('data-on:')) { - const eventDefinition = attr.name.slice(8); // Remove 'data-on:' + if (attr.name.startsWith('data-track:')) { + const eventDefinition = attr.name.slice(11); // Remove 'data-track:' try { const data = JSON.parse(attr.value); this.__trackEvents.add(new TrackEvent(this, eventDefinition, data)); diff --git a/packages/ui/Track/TrackContext.ts b/packages/ui/Track/TrackContext.ts index d549e599..f7d08f4f 100644 --- a/packages/ui/Track/TrackContext.ts +++ b/packages/ui/Track/TrackContext.ts @@ -22,7 +22,7 @@ export interface TrackContextProps extends BaseProps { * * * From be329a1162246fc9572785e30f55413d9d2806f8 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Sat, 14 Feb 2026 13:25:52 +0000 Subject: [PATCH 6/6] Update changelog with data-track:* attribute mention Co-authored-by: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab29c7c..a1e2a18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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 ([#495](https://github.com/studiometa/ui/issues/495), [#497](https://github.com/studiometa/ui/pull/497), [d95126d](https://github.com/studiometa/ui/commit/d95126d)) +- **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