# Facet Events API

> Public browser events for facet controls, result updates, response metadata, and Map synchronization.

<!-- Sources: client/src/domains/facets/events.ts; client/src/domains/facets/service/facet-controller.ts; client/src/domains/facets/service/facet-response-applier.ts; client/src/components/select-facet/events.ts; client/src/components/search-select-facet/runtime.ts; client/src/domains/facets/facets/checkbox-list-facet.ts; client/src/domains/facets/facets/radio-list-facet.ts; client/src/components/map-address-facet/runtime.ts; client/src/components/map-radius-facet/runtime.ts; client/src/components/map-facet/runtime.ts -->

# Facet Events API

Facet events are browser `CustomEvent` objects for observing facet controls, customizing authored result markup, and reacting to completed result updates. Listen on a component root when you own one instance, or use event delegation on `document` when several facets share an integration.

The events are documented as observation contracts, not as a command API. Do not dispatch synthetic copies to operate a facet. For backward compatibility, some component `change` event names are also consumed by OME's internal adapters, so synthetic dispatch can trigger legacy internal behavior; that behavior is unsupported and must not be used as an integration contract. Propagation methods are not a control API either.

## Which Event Should I Use?

| Goal                                                                                    | Event                                                                                                              |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Observe a Select opening or closing.                                                    | `ome:select:open` or `ome:select:close`                                                                            |
| Observe a Search Select opening, closing, or receiving query text.                      | `ome:search-select:open`, `ome:search-select:close`, or `ome:search-select:input`                                  |
| React to a value committed directly through one control.                                | The component-specific `change` event, `ome:map-address-change`, `ome:map-radius-change`, or `ome:map:bbox-change` |
| Release resources that belong to result content about to be removed.                    | `ome:facet:target-will-update`                                                                                     |
| Change links, text, classes, attributes, or widgets in newly inserted authored results. | `ome:facet:target-content-updated`                                                                                 |
| Run work after the complete synchronous target update has settled.                      | `ome:facet:target-updated`                                                                                         |
| Observe count, pagination, or Map POI response metadata.                                | `ome:facet:response`                                                                                               |
| Observe creation of a Map Facet instance.                                               | `ome:map:initialized`                                                                                              |

Use the Facet Target lifecycle for integrations that care about result changes from any cause, including Reset Facet, pagination, Load More, and URL-driven requests. Control events describe the control itself; they do not guarantee that a request or target update follows.

## Shared Event Rules

Public events dispatched from a facet or Map component root use this DOM configuration:

```js
{
  bubbles: true,
  composed: true,
  cancelable: false,
}
```

This means:

- `event.target` is the component root or physical Facet Target that emitted the event.
- A listener on `document` can use event delegation across component instances.
- Calling `preventDefault()` cannot cancel OME behavior.
- Do not use `stopPropagation()` or `stopImmediatePropagation()` to control OME. An intentionally earlier `stopImmediatePropagation()` can prevent other observers from receiving a DOM event and is unsupported.
- Event handlers run synchronously. Returning a promise or using `await` does not pause OME.

`ome:facet:response` is the exception to the root dispatch rule: it is dispatched directly on `document`. It remains composed and non-cancelable, but bubbling has no practical extra reach from that target.

### Stable semantic fields

New public fields use snake_case and contain serializable semantic data. Control events share:

```ts
interface FacetControlDetail {
	readonly target_id: string;
	readonly facet_id: string;
}
```

- `target_id` identifies the logical result scope that the control affects.
- `facet_id` identifies the filter or query-clause key, such as `category_name`, `geo_radius`, or `geo_bbox`.
- `event.target` identifies the exact component instance. You do not need a DOM reference inside `event.detail`.
- A cleared scalar value is `null` in the stable fields.

Existing camelCase fields and DOM-element references are retained as legacy compatibility fields. They are documented separately below and are not used in new examples.

## Interaction Events

Interaction events report transient UI state. They do not mean that the selected value or result content changed.

| Event                     | Dispatch target           | When it fires                      | Stable detail                                                                |
| ------------------------- | ------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
| `ome:select:open`         | Select Facet root.        | Its popup opens.                   | Control fields, selection snapshot, `mode`, `state: "open"`, and `reason`.   |
| `ome:select:close`        | Select Facet root.        | Its popup closes.                  | Control fields, selection snapshot, `mode`, `state: "closed"`, and `reason`. |
| `ome:search-select:open`  | Search Select Facet root. | Its popup opens.                   | Select fields plus `query`.                                                  |
| `ome:search-select:close` | Search Select Facet root. | Its popup closes.                  | Select fields plus `query`.                                                  |
| `ome:search-select:input` | Search Select Facet root. | Its internal search query changes. | Control fields, selection snapshot, `mode`, `query`, and `previous_query`.   |

Select and Search Select open/close reasons are:

```ts
type FacetInteractionReason =
	| "trigger"
	| "item-select"
	| "escape"
	| "outside-click"
	| "tab"
	| "another-control"
	| "input-focus"
	| "input-change";
```

Only Search Select uses `input-focus` and `input-change`. `another-control` means that another overlay handled by the same choice runtime opened: another Select-style UIChoice for Select, or another Search Select for Search Select. Keep a fallback branch when switching on `reason`, because future versions can add another semantic reason.

## Value Change Events

A value change event reports a value committed directly through that control by the user.

These events are not emitted for initial HTML or URL hydration, option-availability synchronization, dependent Map synchronization, or as one event per control during a global Reset Facet action. Observe the Facet Target lifecycle when the integration cares about the resulting content rather than the direct control interaction.

| Event                      | Dispatch target           | Stable detail                                                         |
| -------------------------- | ------------------------- | --------------------------------------------------------------------- |
| `ome:select:change`        | Select Facet root.        | Selection snapshot and `mode`.                                        |
| `ome:search-select:change` | Search Select Facet root. | Selection snapshot, `mode`, and `query`.                              |
| `ome:checkbox-list:change` | Checkbox List Facet root. | `selected_values`, `previous_values`, `changed_value`, and `checked`. |
| `ome:radio-list:change`    | Radio List Facet root.    | Selection snapshot.                                                   |
| `ome:map-address-change`   | Map Address Facet root.   | Nested `address` snapshot or `null`.                                  |
| `ome:map-radius-change`    | Map Radius Facet root.    | Nested `radius` snapshot or `null`.                                   |
| `ome:map:bbox-change`      | Map Facet root.           | Numeric `bounds`, `center`, and `zoom`.                               |

### Select, Search Select, and Radio snapshots

Selection events use a common semantic snapshot:

```ts
interface FacetSelectionSnapshot {
	readonly selected_value: string | null;
	readonly previous_value: string | null;
	readonly selected_values: readonly string[];
	readonly previous_values: readonly string[];
}

type FacetSelectMode = "single" | "multiple";
```

Select and Search Select add `mode`. Search Select also includes its current `query`. Radio List exposes the arrays as well as the nullable scalar values, so one delegated selection handler can work across all three component families.

### Checkbox List payload

```ts
interface CheckboxListChangeDetail extends FacetControlDetail {
	readonly selected_values: readonly string[];
	readonly previous_values: readonly string[];
	readonly changed_value: string;
	readonly checked: boolean;
}
```

`changed_value` and `checked` describe the directly toggled item. The two arrays describe the complete state before and after that action.

### Map Address payload

```ts
interface MapAddressChangeDetail extends FacetControlDetail {
	readonly facet_id: "geo_radius";
	readonly address: {
		readonly label: string;
		readonly lat: number;
		readonly lng: number;
		readonly provider_id: string;
	} | null;
}
```

Selecting a suggestion supplies the human-readable label and numeric coordinates. Clearing that selection supplies `address: null`. Map Address and Map Radius share `facet_id: "geo_radius"` because together they form one proximity clause.

### Map Radius payload

```ts
interface MapRadiusChangeDetail extends FacetControlDetail {
	readonly facet_id: "geo_radius";
	readonly radius: {
		readonly value: number;
		readonly unit: "mi" | "km";
	} | null;
}
```

### Map bounding-box payload

```ts
interface MapBboxChangeDetail extends FacetControlDetail {
	readonly facet_id: "geo_bbox";
	readonly bounds: {
		readonly south: number;
		readonly west: number;
		readonly north: number;
		readonly east: number;
	};
	readonly bbox: string;
	readonly center: {
		readonly lat: number;
		readonly lng: number;
	};
	readonly zoom: number;
}
```

The stable values are plain serializable values. `bbox` is the serialized `south,west,north,east` clause value; `bounds` avoids requiring consumers to parse it. The event does not expose a Leaflet bounds or Map instance.

## Facet Target Update Lifecycle

The lifecycle describes accepted dynamic content updates, not the request lifecycle:

```text
old authored content is present
  │
  ├─ ome:facet:target-will-update
  │
  ├─ replace or append content and reconcile fallback
  │
  ├─ ome:facet:target-content-updated  ← synchronous customization seam
  │
  ├─ OME hydration, response metadata, dependent Map sync,
  │  returned script scheduling, URL sync, and loading cleanup
  │
  └─ ome:facet:target-updated
```

All three events have the same detail:

```ts
interface FacetTargetLifecycleDetail {
	readonly target_id: string;
	readonly template_id: string;
	readonly update_mode: "replace" | "append";
}
```

| Event                              | DOM visible to the listener                                                                                                                       | Intended use                                                                                             |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `ome:facet:target-will-update`     | Existing result content is still present.                                                                                                         | Release third-party observers, widgets, or resources owned by the outgoing authored content.             |
| `ome:facet:target-content-updated` | Replacement or appended content and fallback state are already in the physical target. Dependent result views have not consumed that content yet. | Synchronously customize newly inserted authored result markup. This is the only supported mutation seam. |
| `ome:facet:target-updated`         | Synchronous OME target work is complete and the loading state is cleared.                                                                         | Observe or measure the settled update.                                                                   |

`update_mode: "replace"` means the response replaced the result content. `update_mode: "append"` means Load More or infinite scroll extended the existing result content. A content handler must be idempotent because it can run repeatedly against appended or replacement markup.

### Physical targets and logical targets

Several Facet Target elements can share one `target_id`. The lifecycle emits one event per physical Facet Target for each phase, with that element available as `event.target`. The response event is different: `ome:facet:response` emits once per logical `target_id` update. When those physical targets use different templates, its summary can combine fields from more than one template response; the precedence rules are documented below.

### Lifecycle limits

- A failed, invalid, aborted, or stale response does not begin the content lifecycle.
- A rare synchronous failure in a later OME hydration step can happen after `will-update` and `content-updated`. OME then restores the previous content and omits `target-updated`. There is no rollback event in v1, so customization handlers must be idempotent and release old integrations again on the next `will-update`.
- The initial server render does not emit these events. Run an enhancement function once for existing markup, then listen for dynamic `ome:facet:target-content-updated` events.
- These are content events, not a request lifecycle. They do not expose request start, network timing, request data, a correlation identifier, or cancellation.
- There is no rollback phase in v1. A future additive detail field can identify another update cause without changing `update_mode`.
- Handlers are synchronous and non-cancelable. OME does not wait for promises returned by a listener.
- `ome:facet:target-updated` confirms synchronous hydration and scheduling. It does not wait for returned module scripts or other async modules to finish executing.

## Supported Customization Boundary

During `ome:facet:target-content-updated`, you may synchronously change authored result markup that your site owns:

- update an authored link `href` or image `src`;
- change authored text;
- add or remove your own classes and attributes;
- initialize your own result widget or add your own authored element.

The following are unsupported:

- changing or removing OME component roots or OME-owned `data-ome-*` attributes;
- changing Facet Target identity, Map POI identity, or Map coordinates;
- mutating `event.detail`, the response summary, `pois`, or `selected_content`;
- treating `will-update` or `target-updated` as markup mutation hooks;
- delaying OME with async work or trying to cancel the lifecycle.

Use your own class or data attribute as the integration selector. This keeps the handler independent of OME runtime markup.

## Facet Response Event

`ome:facet:response` is an advanced, read-only summary dispatched on `document` once per logical accepted target update.

```ts
interface FacetResponseDetail {
	readonly target_id: string;
	readonly count: number;
	readonly pagination?: {
		readonly per_page: number;
		readonly offset: number;
		readonly current_page: number;
		readonly total_pages: number;
		readonly total_items: number;
		readonly has_prev_page: boolean;
		readonly has_next_page: boolean;
		readonly range_start: number;
		readonly range_end: number;
		readonly unbounded?: boolean;
	};
	readonly pois?: readonly {
		readonly value: string;
		readonly slug?: string;
		readonly lat: number;
		readonly lng: number;
		readonly hover_content?: string;
		readonly selected_content?: string;
	}[];
	readonly visible_values?: readonly string[];
	readonly map_poi_detail_mode?: "full" | "lightweight";
	readonly map_poi_detail_threshold?: number;
	readonly map_poi_result_count?: number;
	readonly map_poi_truncated?: boolean;
}
```

For a logical target backed by multiple template responses, OME builds the summary in registered target order:

- `count` and other base fields come from the first registered Facet Target template.
- `pagination` comes from the last template response in that order that contains pagination metadata.
- Map fields come from the first template response in that order that contains any Map payload.

The resulting fields can therefore describe different physical templates that share the same `target_id`. Treat the event as a logical-target summary rather than as one raw response. Pagination fields are absent when none of the template responses includes pagination metadata. Map fields are absent when none includes Map data.

The event fires after every physical target has received its new content, availability and pagination metadata have been applied, and the internal Map synchronization has run. It fires before `ome:facet:target-updated`, which additionally confirms returned-script scheduling, URL synchronization, and loading cleanup.

Use this event for analytics, diagnostics, or read-only synchronization. Do not mutate its detail to change results or Map POIs. Internal Map synchronization does not depend on listener order or on consumers modifying this public event; use `ome:facet:target-content-updated` for authored markup customization.

## Map Initialized Event

`ome:map:initialized` is dispatched from the Map Facet root after its runtime instance and initial POI state have been created.

```ts
interface MapInitializedDetail {
	readonly target_id: string;
}
```

It does not mean that map tiles or animations have finished, and it does not expose a Leaflet instance or a public Map control API.

## Example: Handle Selection Facets Together

```js
const selection_events = [
	"ome:select:change",
	"ome:search-select:change",
	"ome:radio-list:change",
];

for (const event_name of selection_events) {
	document.addEventListener(event_name, (event) => {
		if (!(event instanceof CustomEvent)) {
			return;
		}

		const { target_id, facet_id, selected_values } = event.detail;

		if (target_id === "products" && facet_id === "category_name") {
			console.log("Selected categories", selected_values);
		}
	});
}
```

## Example: Observe A Settled Target Update

```js
document.addEventListener("ome:facet:target-updated", (event) => {
	if (
		!(event instanceof CustomEvent) ||
		!(event.target instanceof HTMLElement)
	) {
		return;
	}

	if (event.detail.target_id !== "articles") {
		return;
	}

	console.log(
		"Rendered article cards",
		event.target.querySelectorAll(".article-card").length,
	);
});
```

## Example: Add `saddr` To Map POI Direction Links

Suppose the selected slot of each authored Map POI contains this link:

```html

	Get directions

```

Keep the selected address by logical target, then change the authored links only in the content-update seam:

```js
const start_addresses = new Map();

function update_direction_links(root, target_id) {
	const start_address = start_addresses.get(target_id) ?? null;

	for (const link of root.querySelectorAll("[data-directions-link]")) {
		if (!(link instanceof HTMLAnchorElement)) {
			continue;
		}

		const authored_href = link.getAttribute("href");
		if (!authored_href) {
			continue;
		}

		const url = new URL(authored_href, window.location.href);

		if (start_address === null) {
			url.searchParams.delete("saddr");
		} else {
			url.searchParams.set("saddr", start_address);
		}

		link.href = url.toString();
	}
}

document.addEventListener("ome:map-address-change", (event) => {
	if (!(event instanceof CustomEvent)) {
		return;
	}

	const { target_id, address } = event.detail;
	start_addresses.set(target_id, address?.label ?? null);
});

document.addEventListener("click", (event) => {
	if (!(event.target instanceof Element)) {
		return;
	}

	const reset = event.target.closest("[data-directions-address-reset]");
	if (!(reset instanceof HTMLElement)) {
		return;
	}

	const target_id = reset.dataset.directionsTargetId;
	if (target_id) {
		start_addresses.set(target_id, null);
	}
});

document.addEventListener("ome:facet:target-content-updated", (event) => {
	if (
		!(event instanceof CustomEvent) ||
		!(event.target instanceof HTMLElement)
	) {
		return;
	}

	update_direction_links(event.target, event.detail.target_id);
});

function enhance_existing_targets() {
	for (const target of document.querySelectorAll(".dealer-results")) {
		if (target instanceof HTMLElement) {
			update_direction_links(target, "dealers");
		}
	}
}

enhance_existing_targets();
```

Add the authored `.dealer-results` class to the dealer Facet Target. If the page also has a global Reset Facet, add your own `data-directions-address-reset` and `data-directions-target-id="dealers"` attributes to that authored reset control. Global Reset clears controls silently, so this optional click handler clears the integration's state before the next result update without depending on OME-owned attributes.

The helper runs once for the initial server-rendered DOM; the lifecycle listener handles every later replacement or append. Calling the function repeatedly is safe because `URL.searchParams.set()` replaces the existing value instead of duplicating it. Clearing Map Address directly or through the authored Reset integration stores `null`, and the next content update removes `saddr`.

Initial URL hydration does not emit a value change event. If another integration already knows an initial human-readable address, seed `start_addresses` before calling `enhance_existing_targets()`.

This recipe runs before the Map runtime consumes the new Map POI selected content. A `MutationObserver` and response-payload mutation are not needed.

## Example: Observe Response Counts

```js
document.addEventListener("ome:facet:response", (event) => {
	if (
		!(event instanceof CustomEvent) ||
		event.detail.target_id !== "products"
	) {
		return;
	}

	window.dataLayer?.push({
		event: "facet_results_updated",
		result_count: event.detail.count,
	});
});
```

## Example: Read Map Viewport State

```js
document.addEventListener("ome:map:bbox-change", (event) => {
	if (!(event instanceof CustomEvent)) {
		return;
	}

	const { bounds, center, zoom } = event.detail;
	console.log({ bounds, center, zoom });
});
```

## Legacy Compatibility Fields

Existing integrations can continue to receive the historical fields below. New integrations should use the semantic snake_case fields and `event.target` instead.

| Event family  | Legacy compatibility fields                                                                                                                                            |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Select        | `selectId`, `selectedValue`, `selectedValues`, `previousValue`, `previousValues`, `highlightedValue`, `rootElement`, `triggerElement`, `contentElement`, `itemElement` |
| Search Select | `searchSelectId`, `previousQuery`, the Select-style value fields, `rootElement`, `inputElement`, `triggerElement`, `contentElement`, `itemElement`                     |
| Radio List    | `selectedValue`, `previousValue`, `rootElement`, `itemElement`                                                                                                         |
| Map Address   | `target`, `label`, string `lat`, string `lng`, `providerId`                                                                                                            |
| Map Radius    | `target`, string `value`, string `unit`                                                                                                                                |

These fields are preserved for backwards compatibility, but their DOM references and presentation-oriented shapes should not be used as the basis of a new integration.
