Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

- **Fetch:** add the `historyMode` option and keep the `src` separation on popstate ([#656](https://github.com/studiometa/ui/pull/656))
- **Fetch:** report `fetch.file-not-uploaded` when a file control cannot be sent as a file ([#656](https://github.com/studiometa/ui/pull/656))
- **Action:** add the reserved `mounted` pseudo-event ([#658](https://github.com/studiometa/ui/pull/658))
- **Track:** add `$event.<path>` placeholders, with `$detail.<path>` as the shortcut for `$event.detail.<path>` ([#658](https://github.com/studiometa/ui/pull/658))

### Changed

- ⚠️ **Fetch:** replace the `url` and `requestInit` event payload fields with one progressive lifecycle detail ([#657](https://github.com/studiometa/ui/pull/657))
- ⚠️ **Fetch:** drop the raw `Response` from the `fetch-response` payload ([#657](https://github.com/studiometa/ui/pull/657))
- **Track:** read the inherited context and both payload sources at dispatch time instead of caching them per mount cycle ([#658](https://github.com/studiometa/ui/pull/658))
- **Track:** rename `resolveDetailPlaceholders` to `resolveEventPlaceholders` ([#658](https://github.com/studiometa/ui/pull/658))

### Fixed

Expand Down
21 changes: 21 additions & 0 deletions packages/docs/reference/items/Action/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@ The `Target` component is a companion of the `Action` component that can be used

</llm-only>

### Running an action on mount

The reserved [`mounted`](./js-api.md#reserved-events) event runs the effect once, after the element and the components sharing it are mounted. It lets HTML drive an initial call without every component gaining its own option for it.

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/mounted/app.twig')"
:script="() => import('./stories/mounted/app.js?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/mounted/app.twig
<<< ./stories/mounted/app.js

:::

</llm-only>

### Listening to multiple events

The advanced HTML [option `on:<event>[.<modifier>]`](./js-api.md#on-event-modifier) can be used to listen to multiple events on a single `Action` component.
Expand Down
30 changes: 28 additions & 2 deletions packages/docs/reference/items/Action/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,32 @@ Modifiers can be chained with a `.` as separator:
```
<!-- prettier-ignore-end -->

#### Reserved events

`mounted` is a reserved name rather than a DOM event. It runs the effect once per mount cycle, after the current mount batch has settled, so the effect can target a component mounted on the same element:

<!-- prettier-ignore-start -->
```html {3}
<form
data-component="Fetch Action"
data-on:mounted="Fetch(#content-search) -> target.fetch()">
</form>
```
<!-- prettier-ignore-end -->

No listener is bound for it, so a lifecycle event bubbling from a descendant that mounts later never runs it again. Unmounting before the deferred effect runs cancels it, and remounting starts exactly one new one.

The effect receives `undefined` for its `event` argument, which is what the modifiers reading an event have to work with:

| Modifier | With `mounted` |
| ----------------------------- | ------------------------------------------------------- |
| `.debounce` / `.debounce<ms>` | Applies — the effect runs that many milliseconds later. |
| `.prevent` / `.stop` | Ignored — there is no event to cancel or to stop. |
| `.once` | Ignored — the effect already runs once per mount cycle. |
| `.capture` / `.passive` | Ignored — they configure a listener, and none is bound. |

Any other name binds a DOM event of that name.

### `target`

- Type: `string`
Expand Down Expand Up @@ -116,7 +142,7 @@ Defines a small piece of JavaScript executed in the context of the current targe

- `this` (`HTMLElement`): the current element
- `ctx` (`Record<name, Base>`): the current targeted component in an object with a uniq key being its name set in the static `config.name` property and the value being the component instance
- `event` (`Event`): the event that triggered the action
- `event` (`Event | undefined`): the event that triggered the action, `undefined` for [`mounted`](#reserved-events)
- `target` (`Base`): a direct reference to the current targeted component
- `action` (`Base`): a direct reference to the current action component
- `$el` (`HTMLElement`): a direct reference to the targeted element
Expand Down Expand Up @@ -223,7 +249,7 @@ The pattern described above with multiple components as targets is an advanced p
- Type: `string`
- Format: `[<name>[(<selector>)] -> ]<effect>`

Combines the [`on`](#on), [`target`](#target) and [`effect`](#effect) options into a single attribute. Attaches multiple events to a single `Action` component.
Combines the [`on`](#on), [`target`](#target) and [`effect`](#effect) options into a single attribute. Attaches multiple events to a single `Action` component. It reads the same event names and modifiers as the `on` option, [`mounted`](#reserved-events) included.

```html {3}
<button
Expand Down
4 changes: 4 additions & 0 deletions packages/docs/reference/items/Action/stories/mounted/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Transition } from '@studiometa/ui';

registerComponents(Action, Transition);
8 changes: 8 additions & 0 deletions packages/docs/reference/items/Action/stories/mounted/app.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div
data-component="Action Transition"
data-on:mounted="Transition.enter()"
data-option-enter-from="scale-50 opacity-10"
data-option-enter-active="transition duration-500"
data-option-enter-to="is-visible"
data-option-enter-keep
class="w-24 h-24 bg-green-400 dark:bg-green-600 rounded scale-50 opacity-10"></div>
27 changes: 24 additions & 3 deletions packages/docs/reference/items/Track/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ Wrap a section in `TrackContext` to provide data inherited by every descendant `

</llm-only>

### Custom events
### Event data

Track a `CustomEvent` emitted by third-party scripts and pull values from its `detail` with the `$detail.*` placeholder syntax:
Pull values off the event that triggered the dispatch with `$event.<path>` placeholders. `$event` is the whole event, so the same syntax reads a `CustomEvent` detail and a native event property; `$detail.<path>` is the shortcut for `$event.detail.<path>`.

The story below tracks a `CustomEvent` emitted by a third-party script:

<llm-exclude>
<PreviewPlayground
Expand All @@ -131,6 +133,25 @@ Track a `CustomEvent` emitted by third-party scripts and pull values from its `d

</llm-only>

And this one reads a native click, where the value lives on the element rather than in a detail:

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/basic/event-paths.twig')"
:script="() => import('./stories/basic/app.js?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/basic/event-paths.twig
<<< ./stories/basic/app.js

:::

</llm-only>

### Multiple events

Declare several `data-track:*` attributes on one element to track independent events, each with its own payload and modifiers:
Expand Down Expand Up @@ -169,5 +190,5 @@ The provider is chosen by the component name, so switching destinations is a one
`TrackShopify` uses the payload's `event` value as the published event name. Shopify recommends namespacing custom events (e.g. `my_app:add_to_cart`). To send to another destination, extend `Track` and override its [`dispatch()`](./js-api.md#providers) method.

::: warning
Payloads are serialised into the DOM (attribute or `<script>`), so they are visible in the page source. Never put personal data (emails, names, user IDs) in a tracking payload — resolve sensitive values at runtime via a `CustomEvent` and `$detail.*` instead, and gate `TrackShopify` on the visitor's analytics consent where required.
Payloads are serialised into the DOM (attribute or `<script>`), so they are visible in the page source. Never put personal data (emails, names, user IDs) in a tracking payload — resolve sensitive values at runtime via a `CustomEvent` and `$event.detail.*` instead, and gate `TrackShopify` on the visitor's analytics consent where required.
:::
43 changes: 39 additions & 4 deletions packages/docs/reference/items/Track/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ The dispatched payload is deep-merged from the following sources, in increasing

Later sources win on conflicting keys. **Arrays are replaced, not concatenated**, so a more specific layer fully overrides a list (e.g. GA4 `ecommerce.items`) from a broader one.

All three sources are read when the event fires, never cached at mount. A partial DOM update that rewrites a payload script, a `data-option-payload` attribute or an ancestor `TrackContext` therefore changes what the next dispatch sends, with no remount.

```html
<div
data-component="Track"
Expand All @@ -86,15 +88,48 @@ Later sources win on conflicting keys. **Arrays are replaced, not concatenated**

Malformed JSON (in an attribute value or a `<script>` ref) is skipped safely; a warning is logged when the element has `data-option-log`.

### Custom event data
### Event data

A payload value starting with `$event.` or `$detail.` is a placeholder: it is replaced by the value found at that path on the event that triggered the dispatch.

For a `CustomEvent`, resolve values from its `detail` with `$detail.<path>` placeholders, or merge the full detail with the `.detail` modifier:
- `$event.<path>` resolves against the whole event, so it reaches native properties as well as a `CustomEvent` detail.
- `$detail.<path>` is the shortcut for `$event.detail.<path>`.

```html
<!-- Pull specific fields -->
<!-- A CustomEvent detail, written both ways -->
<div data-track:form-submitted='{"event": "lead", "email": "$event.detail.email"}'></div>
<div data-track:form-submitted='{"event": "lead", "email": "$detail.email"}'></div>

<!-- Merge the whole detail -->
<!-- A native event -->
<button
data-type="cta"
data-track:click='{"event": "cta_click", "type": "$event.target.dataset.type"}'></button>
```

A path walks objects and arrays, and a numeric segment reads an array index. Placeholders nested inside objects and arrays are resolved too, which is what a GA4 `ecommerce.items` list needs:

```html
<div
data-track:add-to-cart='{"event": "add_to_cart", "ecommerce": {"items": [{"item_id": "$detail.id"}]}}'></div>
```

A path naming data the event does not carry resolves to `undefined`, and so does every placeholder of an event that carries no event object at all, such as `mounted`.

The resolver knows nothing about who emitted the event, so any component publishing plain data in its detail is readable from markup. A [`Fetch`](/reference/items/Fetch/) on the same element announces its request and its response that way:

```html
<form
data-component="Fetch Track"
data-track:fetch-update-after='{
"event": "content_search_results",
"result_count": "$event.detail.response.headers.x-search-result-count",
"genre": "$event.detail.request.searchParams.genre.0"
}'></form>
```

The `.detail` modifier is the other way to consume a `CustomEvent`: it merges the whole `event.detail` into the payload instead of resolving placeholders. It applies to a `CustomEvent` only, since a native event carries no detail to merge.

```html
<div data-track:form-submitted.detail='{"event": "lead"}'></div>
```

Expand Down
31 changes: 31 additions & 0 deletions packages/docs/reference/items/Track/stories/basic/event-paths.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<div class="space-y-4">
<p class="text-sm text-gray-600">
This example reads a native <code>click</code> with <code>$event.target.*</code>. The value comes
from the clicked element, not from a <code>CustomEvent</code> detail.
</p>

<div class="flex gap-2">
<button
data-component="Track"
data-plan="starter"
data-track:click='{"event": "plan_selected", "plan": "$event.target.dataset.plan", "type": "$event.type"}'
class="px-4 py-2 bg-blue-400 text-white rounded hover:bg-blue-500">
Starter
</button>

<button
data-component="Track"
data-plan="business"
data-track:click='{"event": "plan_selected", "plan": "$event.target.dataset.plan", "type": "$event.type"}'
class="px-4 py-2 bg-blue-400 text-white rounded hover:bg-blue-500">
Business
</button>
</div>

<div>
<p class="text-sm font-medium text-gray-700 mb-2">dataLayer:</p>
<pre
data-debug-datalayer
class="p-3 bg-gray-100 rounded text-xs overflow-auto max-h-48 text-gray-800">[]</pre>
</div>
</div>
Loading
Loading