diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js
new file mode 100644
index 0000000000..ebbafa19bc
--- /dev/null
+++ b/assets/source/js/matomoTracking.js
@@ -0,0 +1,150 @@
+const matomoTrackingSelector = [
+ "button",
+ "[data-matomo-category]",
+].join(", ");
+
+const defaultTrackingCategory = "UI Interaction";
+const defaultTrackingAction = "Click";
+const handleMatomoTrackingDomReady = () => normalizeMatomoTrackableElements();
+
+/**
+ * Normalize whitespace in tracking labels.
+ *
+ * @param {string | null | undefined} value The label to normalize.
+ * @returns {string} The normalized label.
+ */
+export function normalizeTrackingLabel(value) {
+ return (value || "").replace(/\s+/g, " ").trim();
+}
+
+/**
+ * Derive a Matomo tracking name for a CTA element.
+ *
+ * @param {Element} element The element to derive a name from.
+ * @returns {string} The derived tracking name.
+ */
+export function getMatomoTrackingName(element) {
+ return normalizeTrackingLabel(
+ element.getAttribute("data-matomo-name") ||
+ element.getAttribute("aria-label") ||
+ element.getAttribute("title") ||
+ element.innerText ||
+ element.textContent,
+ );
+}
+
+/**
+ * Apply default Matomo data attributes unless they are explicitly set.
+ *
+ * @param {HTMLElement} element The element to normalize.
+ * @returns {void}
+ */
+export function applyMatomoTrackingAttributes(element) {
+ const trackingName = getMatomoTrackingName(element);
+
+ if (!element.dataset.matomoCategory) {
+ element.dataset.matomoCategory = defaultTrackingCategory;
+ }
+
+ if (!element.dataset.matomoAction) {
+ element.dataset.matomoAction = defaultTrackingAction;
+ }
+
+ if (!element.dataset.matomoName && trackingName) {
+ element.dataset.matomoName = trackingName;
+ }
+}
+
+/**
+ * Find a trackable CTA element from an event target.
+ *
+ * @param {EventTarget | null} target The event target.
+ * @returns {HTMLElement | null} The closest trackable element.
+ */
+export function getMatomoTrackableElement(target) {
+ if (!(target instanceof Element)) {
+ return null;
+ }
+
+ const element = target.closest(matomoTrackingSelector);
+
+ return element instanceof HTMLElement ? element : null;
+}
+
+/**
+ * Normalize trackable elements in the current DOM.
+ *
+ * @returns {void}
+ */
+export function normalizeMatomoTrackableElements() {
+ document
+ .querySelectorAll(matomoTrackingSelector)
+ .forEach((element) =>
+ applyMatomoTrackingAttributes(element),
+ );
+}
+
+/**
+ * Handle delegated click tracking for Matomo.
+ *
+ * @param {MouseEvent} event The click event.
+ * @returns {void}
+ */
+export function handleMatomoTrackingClick(event) {
+ const trackableElement = getMatomoTrackableElement(event.target);
+
+ if (!trackableElement) {
+ return;
+ }
+
+ applyMatomoTrackingAttributes(trackableElement);
+
+ const trackingQueue = window._paq;
+
+ if (!trackingQueue || typeof trackingQueue.push !== "function") {
+ return;
+ }
+
+ const trackingName = trackableElement.dataset.matomoName || "";
+
+ if (!trackingName) {
+ return;
+ }
+
+ trackingQueue.push([
+ "trackEvent",
+ trackableElement.dataset.matomoCategory || defaultTrackingCategory,
+ trackableElement.dataset.matomoAction || defaultTrackingAction,
+ trackingName,
+ ]);
+}
+
+/**
+ * Initialize global Matomo CTA tracking.
+ *
+ * @returns {() => void} Cleanup function for removing tracking listeners.
+ */
+export function initializeMatomoTracking() {
+ let hasDomReadyListener = false;
+
+ document.addEventListener("click", handleMatomoTrackingClick);
+
+ if (document.readyState === "loading") {
+ hasDomReadyListener = true;
+ document.addEventListener(
+ "DOMContentLoaded",
+ handleMatomoTrackingDomReady,
+ { once: true },
+ );
+ } else {
+ normalizeMatomoTrackableElements();
+ }
+
+ return () => {
+ document.removeEventListener("click", handleMatomoTrackingClick);
+
+ if (hasDomReadyListener) {
+ document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady);
+ }
+ };
+}
diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js
new file mode 100644
index 0000000000..decc872d4d
--- /dev/null
+++ b/assets/source/js/matomoTracking.test.js
@@ -0,0 +1,152 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ getMatomoTrackableElement,
+ handleMatomoTrackingClick,
+ initializeMatomoTracking,
+ normalizeMatomoTrackableElements,
+} from "./matomoTracking";
+
+describe("matomoTracking", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ window._paq = [];
+ });
+
+ afterEach(() => {
+ delete window._paq;
+ });
+
+ it("normalizes trackable elements with derived aria-label names", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ normalizeMatomoTrackableElements();
+
+ const button = document.querySelector("button");
+
+ expect(button.dataset.matomoCategory).toBe("UI Interaction");
+ expect(button.dataset.matomoAction).toBe("Click");
+ expect(button.dataset.matomoName).toBe("Open menu");
+ });
+
+ it("preserves explicitly provided matomo attributes", () => {
+ document.body.innerHTML = `
+
+ Read more
+
+ `;
+
+ normalizeMatomoTrackableElements();
+
+ const link = document.querySelector("a");
+
+ expect(link.dataset.matomoCategory).toBe("Custom category");
+ expect(link.dataset.matomoAction).toBe("Open");
+ expect(link.dataset.matomoName).toBe("Custom name");
+ });
+
+ it("finds the closest trackable element for nested CTA content", () => {
+ document.body.innerHTML = `
+
+ Read more
+
+ `;
+
+ const target = document.querySelector(".c-button__label-text");
+ const trackableElement = getMatomoTrackableElement(target);
+
+ expect(trackableElement).toBe(document.querySelector("a"));
+ });
+
+ it("tracks clicks for delegated button-like links", () => {
+ document.body.innerHTML = `
+
+ Read more
+
+ `;
+
+ initializeMatomoTracking();
+
+ const target = document.querySelector(".c-button__label-text");
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(window._paq).toEqual([
+ ["trackEvent", "UI Interaction", "Click", "Read more"],
+ ]);
+ expect(document.querySelector("a").dataset.matomoName).toBe("Read more");
+ });
+
+ it("does not track links without matomo tracking attributes", () => {
+ document.body.innerHTML = `
+
+ Read more
+
+ `;
+
+ initializeMatomoTracking();
+
+ document
+ .querySelector(".c-button__label-text")
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(window._paq).toEqual([]);
+ });
+
+ it("tracks dynamically injected buttons through delegated listeners", () => {
+ initializeMatomoTracking();
+
+ const button = document.createElement("button");
+ button.textContent = "Dynamic CTA";
+ document.body.appendChild(button);
+
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(window._paq).toEqual([
+ ["trackEvent", "UI Interaction", "Click", "Dynamic CTA"],
+ ]);
+ expect(button.dataset.matomoName).toBe("Dynamic CTA");
+ });
+
+ it("returns a cleanup function that removes delegated tracking", () => {
+ document.body.innerHTML = '';
+
+ const cleanup = initializeMatomoTracking();
+ cleanup();
+
+ document
+ .querySelector("button")
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(window._paq).toEqual([]);
+ });
+
+ it("does not push events when the tracking queue is unavailable", () => {
+ document.body.innerHTML = '';
+ delete window._paq;
+
+ expect(() =>
+ handleMatomoTrackingClick({
+ target: document.querySelector("button"),
+ }),
+ ).not.toThrow();
+ });
+});
diff --git a/assets/source/js/municipio.js b/assets/source/js/municipio.js
index 0fdc96c177..88f8b0c216 100644
--- a/assets/source/js/municipio.js
+++ b/assets/source/js/municipio.js
@@ -6,6 +6,7 @@ import { initializeCollapsibleSearch } from './collapsibleSearch';
import { initializeSessionManager } from './sessionManager';
import { initializeHashHighlightManager } from './hashHighlightManager';
import { initializeHashUpdateManager } from './hashUpdateManager';
+import { initializeMatomoTracking } from './matomoTracking';
import { initPostsListAsync } from './postsList';
import { initializeWpApiSettingsNonceRefresh } from './restApi/wpApiSettings';
@@ -20,4 +21,5 @@ initializeComments();
initializeCollapsibleSearch();
initializeHashHighlightManager();
initializeHashUpdateManager(8 * 10);
-initPostsListAsync();
\ No newline at end of file
+initializeMatomoTracking();
+initPostsListAsync();
diff --git a/library/Controller/BaseController.php b/library/Controller/BaseController.php
index 6211e09aea..d66d24b051 100644
--- a/library/Controller/BaseController.php
+++ b/library/Controller/BaseController.php
@@ -448,6 +448,13 @@ function ($attributes) {
);
}
+ $this->wpService->addFilter(
+ 'ComponentLibrary/Component/Attribute',
+ [$this, 'filterMatomoTrackingAttributes'],
+ 20,
+ 1,
+ );
+
//Wordpress hooks
$this->data['hook'] = (object) array(
'innerLoopStart' => $this->hook('inner_loop_start'),
@@ -511,6 +518,48 @@ private function getCurrentUrl(array $queryParam = []): string
return urldecode($permalink ?? '');
}
+ /**
+ * Add default Matomo tracking attributes to rendered component links and buttons.
+ *
+ * @param array $attributes The component attributes.
+ *
+ * @return array The modified component attributes.
+ */
+ public function filterMatomoTrackingAttributes(array $attributes): array
+ {
+ if (!$this->shouldAddMatomoTrackingAttributes($attributes)) {
+ return $attributes;
+ }
+
+ if (empty($attributes['data-matomo-category'])) {
+ $attributes['data-matomo-category'] = 'UI Interaction';
+ }
+
+ if (empty($attributes['data-matomo-action'])) {
+ $attributes['data-matomo-action'] = 'Click';
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Determine whether a component should receive default Matomo tracking attributes.
+ *
+ * @param array $attributes The component attributes.
+ *
+ * @return bool True when Matomo tracking attributes should be added.
+ */
+ private function shouldAddMatomoTrackingAttributes(array $attributes): bool
+ {
+ $buttonTypes = ['button', 'submit', 'reset'];
+
+ return !empty($attributes['href'])
+ || in_array($attributes['type'] ?? null, $buttonTypes, true)
+ || ($attributes['role'] ?? null) === 'button'
+ || !empty($attributes['data-open'])
+ || !empty($attributes['aria-controls']);
+ }
+
/**
* Get the emblem to use
*