From f213035b7db08d46a0c6f3d9037b053e3dd23a4d Mon Sep 17 00:00:00 2001 From: Rafikooo Date: Fri, 9 Oct 2026 16:26:09 +0200 Subject: [PATCH] fix(bootstrap-admin-ui): rebind tooltips after a live component re-render --- .../assets/scripts/bootstrap.js | 8 +- .../assets/scripts/tooltips.js | 22 ++++++ .../tests/JavaScript/tooltips.test.mjs | 78 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 src/BootstrapAdminUi/assets/scripts/tooltips.js create mode 100644 src/BootstrapAdminUi/tests/JavaScript/tooltips.test.mjs diff --git a/src/BootstrapAdminUi/assets/scripts/bootstrap.js b/src/BootstrapAdminUi/assets/scripts/bootstrap.js index 91c34245..8b0f89f5 100644 --- a/src/BootstrapAdminUi/assets/scripts/bootstrap.js +++ b/src/BootstrapAdminUi/assets/scripts/bootstrap.js @@ -1,5 +1,6 @@ /* eslint-env browser */ import * as bootstrap from 'bootstrap'; +import { initTooltips, initTooltipsOnLiveRender } from './tooltips'; // Fix dropdowns (() => { @@ -16,10 +17,7 @@ import * as bootstrap from 'bootstrap'; })(); // Initialize tooltips -(() => { - document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((tooltipTriggerEl) => { - let tooltip = new bootstrap.Tooltip(tooltipTriggerEl); - }); -})(); +initTooltips(document, bootstrap.Tooltip); +initTooltipsOnLiveRender(document, bootstrap.Tooltip); window.bootstrap = bootstrap; diff --git a/src/BootstrapAdminUi/assets/scripts/tooltips.js b/src/BootstrapAdminUi/assets/scripts/tooltips.js new file mode 100644 index 00000000..5f61aa19 --- /dev/null +++ b/src/BootstrapAdminUi/assets/scripts/tooltips.js @@ -0,0 +1,22 @@ +/* + * This file is part of the Sylius package. + * + * (c) Sylius Sp. z o.o. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export function initTooltips(root, Tooltip) { + root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((tooltipTriggerEl) => { + Tooltip.getInstance(tooltipTriggerEl)?.dispose(); + new Tooltip(tooltipTriggerEl); + }); +} + +// A live component re-render morphs its markup, so tooltips inside it end up unbound or showing a stale title +export function initTooltipsOnLiveRender(target, Tooltip) { + target.addEventListener('live:connect', (event) => { + event.detail.component.on('render:finished', (component) => initTooltips(component.element, Tooltip)); + }); +} diff --git a/src/BootstrapAdminUi/tests/JavaScript/tooltips.test.mjs b/src/BootstrapAdminUi/tests/JavaScript/tooltips.test.mjs new file mode 100644 index 00000000..36edc850 --- /dev/null +++ b/src/BootstrapAdminUi/tests/JavaScript/tooltips.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { initTooltips, initTooltipsOnLiveRender } from '../../assets/scripts/tooltips.js'; + +function fakeTooltip() { + const instances = new Map(); + + class Tooltip { + constructor(element) { + this.element = element; + this.disposed = false; + instances.set(element, this); + } + + dispose() { + this.disposed = true; + instances.delete(this.element); + } + + static getInstance(element) { + return instances.get(element) ?? null; + } + } + + return Tooltip; +} + +function root(...triggers) { + return { + querySelectorAll: (selector) => { + assert.equal(selector, '[data-bs-toggle="tooltip"]'); + + return triggers; + }, + }; +} + +test('binds a tooltip to every trigger', () => { + const Tooltip = fakeTooltip(); + const first = {}; + const second = {}; + + initTooltips(root(first, second), Tooltip); + + assert.ok(Tooltip.getInstance(first)); + assert.ok(Tooltip.getInstance(second)); +}); + +test('replaces a tooltip already bound to a trigger', () => { + const Tooltip = fakeTooltip(); + const trigger = {}; + initTooltips(root(trigger), Tooltip); + const stale = Tooltip.getInstance(trigger); + + initTooltips(root(trigger), Tooltip); + + assert.equal(stale.disposed, true); + assert.notEqual(Tooltip.getInstance(trigger), stale); +}); + +test('rebinds tooltips inside a live component after each render', () => { + const Tooltip = fakeTooltip(); + const listeners = {}; + const target = { addEventListener: (name, callback) => { listeners[name] = callback; } }; + const hooks = {}; + const component = { + element: root({}), + on: (name, callback) => { hooks[name] = callback; }, + }; + const freshTrigger = {}; + + initTooltipsOnLiveRender(target, Tooltip); + listeners['live:connect']({ detail: { component } }); + component.element = root(freshTrigger); + hooks['render:finished'](component); + + assert.ok(Tooltip.getInstance(freshTrigger)); +});