diff --git a/.changeset/replace-existing-instance.md b/.changeset/replace-existing-instance.md new file mode 100644 index 000000000..cbf9f9c6b --- /dev/null +++ b/.changeset/replace-existing-instance.md @@ -0,0 +1,6 @@ +--- +"@tabler/core": patch +"@tabler/docs": patch +--- + +Fixed `new Tooltip()`, `new Popover()` and `new Dropdown()` on auto-initialised elements ignoring options and doubling listeners. diff --git a/core/js/src/bootstrap/base-component.ts b/core/js/src/bootstrap/base-component.ts index 8ff8c38ad..b4720efea 100644 --- a/core/js/src/bootstrap/base-component.ts +++ b/core/js/src/bootstrap/base-component.ts @@ -25,10 +25,12 @@ class BaseComponent extends Config { return } + const ctor = this.constructor as unknown as BaseComponentStatic + Data.get(resolved, ctor.DATA_KEY)?.dispose() + this._element = resolved this._config = this._getConfig(config) - const ctor = this.constructor as unknown as BaseComponentStatic Data.set(this._element, ctor.DATA_KEY, this) } @@ -43,7 +45,15 @@ class BaseComponent extends Config { } _queueCallback(callback: () => void, element: HTMLElement, isAnimated = true): void { - executeAfterTransition(callback, element, isAnimated) + executeAfterTransition( + () => { + if (this._element) { + callback() + } + }, + element, + isAnimated, + ) } _getConfig(config?: ComponentConfig): ComponentConfig { diff --git a/core/js/tests/unit/alert.spec.ts b/core/js/tests/unit/alert.spec.ts index 72d78a312..f78017c22 100644 --- a/core/js/tests/unit/alert.spec.ts +++ b/core/js/tests/unit/alert.spec.ts @@ -18,9 +18,8 @@ describe('Alert', () => { const alertEl = fixtureEl.querySelector('.alert')! const alertBySelector = new Alert('.alert') - const alertByElement = new Alert(alertEl) - expect(alertBySelector._element).toBe(alertEl) + const alertByElement = new Alert(alertEl) expect(alertByElement._element).toBe(alertEl) }) diff --git a/core/js/tests/unit/base-component.spec.ts b/core/js/tests/unit/base-component.spec.ts index e66466d74..3c55eefd5 100644 --- a/core/js/tests/unit/base-component.spec.ts +++ b/core/js/tests/unit/base-component.spec.ts @@ -79,6 +79,16 @@ describe('BaseComponent', () => { const inst = new DummyClass('#nonexistent') expect(inst._element).toBeUndefined() }) + + it('should dispose an existing instance on the same element', () => { + createInstance() + const spy = vi.spyOn(instance, 'dispose') + + const second = new DummyClass(element) + + expect(spy).toHaveBeenCalledOnce() + expect(DummyClass.getInstance(element)).toBe(second) + }) }) describe('dispose', () => { @@ -162,5 +172,14 @@ describe('BaseComponent', () => { element.dispatchEvent(new Event('transitionend')) expect(callback).toHaveBeenCalledOnce() }) + + it('should skip the callback when the instance is disposed before the transition ends', () => { + createInstance() + const callback = vi.fn() + instance._queueCallback(callback, element, true) + instance.dispose() + element.dispatchEvent(new Event('transitionend')) + expect(callback).not.toHaveBeenCalled() + }) }) }) diff --git a/core/js/tests/unit/button.spec.ts b/core/js/tests/unit/button.spec.ts index 9bd8626d7..e17914429 100644 --- a/core/js/tests/unit/button.spec.ts +++ b/core/js/tests/unit/button.spec.ts @@ -17,9 +17,8 @@ describe('Button', () => { fixtureEl.innerHTML = '' const buttonEl = fixtureEl.querySelector('[data-bs-toggle="button"]')! const buttonBySelector = new Button('[data-bs-toggle="button"]') - const buttonByElement = new Button(buttonEl) - expect(buttonBySelector._element).toBe(buttonEl) + const buttonByElement = new Button(buttonEl) expect(buttonByElement._element).toBe(buttonEl) }) diff --git a/core/js/tests/unit/carousel.spec.ts b/core/js/tests/unit/carousel.spec.ts index fa0ab685a..5c4a55397 100644 --- a/core/js/tests/unit/carousel.spec.ts +++ b/core/js/tests/unit/carousel.spec.ts @@ -39,9 +39,8 @@ describe('Carousel', () => { const carouselEl = fixtureEl.querySelector('#myCarousel')! const carouselBySelector = new Carousel('#myCarousel') - const carouselByElement = new Carousel(carouselEl) - expect(carouselBySelector._element).toBe(carouselEl) + const carouselByElement = new Carousel(carouselEl) expect(carouselByElement._element).toBe(carouselEl) }) diff --git a/core/js/tests/unit/collapse.spec.ts b/core/js/tests/unit/collapse.spec.ts index e251c6ee5..0cd1e1f1b 100644 --- a/core/js/tests/unit/collapse.spec.ts +++ b/core/js/tests/unit/collapse.spec.ts @@ -39,9 +39,8 @@ describe('Collapse', () => { const collapseEl = fixtureEl.querySelector('div.my-collapse')! const collapseBySelector = new Collapse('div.my-collapse') - const collapseByElement = new Collapse(collapseEl) - expect(collapseBySelector._element).toBe(collapseEl) + const collapseByElement = new Collapse(collapseEl) expect(collapseByElement._element).toBe(collapseEl) }) diff --git a/core/js/tests/unit/dropdown.spec.ts b/core/js/tests/unit/dropdown.spec.ts index c95b95c71..1ce1f11fc 100644 --- a/core/js/tests/unit/dropdown.spec.ts +++ b/core/js/tests/unit/dropdown.spec.ts @@ -52,9 +52,8 @@ describe('Dropdown', () => { const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')! const dropdownBySelector = new Dropdown('[data-bs-toggle="dropdown"]') - const dropdownByElement = new Dropdown(btnDropdown) - expect(dropdownBySelector._element).toBe(btnDropdown) + const dropdownByElement = new Dropdown(btnDropdown) expect(dropdownByElement._element).toBe(btnDropdown) }) diff --git a/core/js/tests/unit/scrollspy.spec.ts b/core/js/tests/unit/scrollspy.spec.ts index 4668695a7..ef2f183d0 100644 --- a/core/js/tests/unit/scrollspy.spec.ts +++ b/core/js/tests/unit/scrollspy.spec.ts @@ -67,9 +67,8 @@ describe('ScrollSpy', () => { const sSpyEl = fixtureEl.querySelector('.content')! const sSpyBySelector = new ScrollSpy('.content') - const sSpyByElement = new ScrollSpy(sSpyEl) - expect(sSpyBySelector._element).toBe(sSpyEl) + const sSpyByElement = new ScrollSpy(sSpyEl) expect(sSpyByElement._element).toBe(sSpyEl) }) diff --git a/core/js/tests/unit/tab.spec.ts b/core/js/tests/unit/tab.spec.ts index 2327dbd29..ba220201e 100644 --- a/core/js/tests/unit/tab.spec.ts +++ b/core/js/tests/unit/tab.spec.ts @@ -26,9 +26,8 @@ describe('Tab', () => { const tabEl = fixtureEl.querySelector('[href="#home"]')! const tabBySelector = new Tab('[href="#home"]') - const tabByElement = new Tab(tabEl) - expect(tabBySelector._element).toBe(tabEl) + const tabByElement = new Tab(tabEl) expect(tabByElement._element).toBe(tabEl) }) diff --git a/core/js/tests/unit/toast.spec.ts b/core/js/tests/unit/toast.spec.ts index 6736187e6..a728e30fb 100644 --- a/core/js/tests/unit/toast.spec.ts +++ b/core/js/tests/unit/toast.spec.ts @@ -31,9 +31,8 @@ describe('Toast', () => { const toastEl = fixtureEl.querySelector('.toast')! const toastBySelector = new Toast('.toast') - const toastByElement = new Toast(toastEl) - expect(toastBySelector._element).toBe(toastEl) + const toastByElement = new Toast(toastEl) expect(toastByElement._element).toBe(toastEl) }) diff --git a/core/js/tests/unit/tooltip.spec.ts b/core/js/tests/unit/tooltip.spec.ts index c6120cb5e..47700bd64 100644 --- a/core/js/tests/unit/tooltip.spec.ts +++ b/core/js/tests/unit/tooltip.spec.ts @@ -62,6 +62,24 @@ describe('Tooltip', () => { expect(Tooltip.getInstance(el)).toBe(tooltip) }) + it('should replace an existing instance and use the new config', () => { + fixtureEl.innerHTML = 'Trigger' + const el = fixtureEl.querySelector('a')! + const first = Tooltip.getOrCreateInstance(el, { placement: 'auto' }) + const firstEnter = vi.spyOn(first, '_enter') + + const second = new Tooltip(el, { placement: 'bottom', customClass: 'custom' }) + const secondEnter = vi.spyOn(second, '_enter') + + el.dispatchEvent(new Event('mouseover')) + + expect(Tooltip.getInstance(el)).toBe(second) + expect(second._config.customClass).toBe('custom') + expect(el.getAttribute('data-bs-original-title')).toBe('Tooltip title') + expect(firstEnter).not.toHaveBeenCalled() + expect(secondEnter).toHaveBeenCalledOnce() + }) + it('should fix title on construction', () => { fixtureEl.innerHTML = 'Trigger' const el = fixtureEl.querySelector('a')! diff --git a/docs/content/ui/components/dropdown.mdx b/docs/content/ui/components/dropdown.mdx index e3f3941f2..5a5822c1b 100644 --- a/docs/content/ui/components/dropdown.mdx +++ b/docs/content/ui/components/dropdown.mdx @@ -293,6 +293,15 @@ Tabler automatically initializes all elements with `data-bs-toggle="dropdown"` o +To use your own options, create the instance yourself. It replaces the one Tabler created, so only your options apply: + +```js +const el = document.getElementById('my-dropdown'); +new tabler.Dropdown(el, { autoClose: 'outside' }); +``` + +To read or control the existing instance without changing its options, use `tabler.Dropdown.getInstance(el)` or `tabler.Dropdown.getOrCreateInstance(el)`. + ## Accessibility - The trigger must be a `button`, or an `a` with an `href`. Bootstrap toggles `aria-expanded` on it for you, so do not set it by hand. diff --git a/docs/content/ui/components/popover.mdx b/docs/content/ui/components/popover.mdx index ad5ce12e5..fda668a43 100644 --- a/docs/content/ui/components/popover.mdx +++ b/docs/content/ui/components/popover.mdx @@ -55,6 +55,15 @@ Tabler automatically initializes all elements with `data-bs-toggle="popover"` on +To use your own options, create the instance yourself. It replaces the one Tabler created, so only your options apply: + +```js +const el = document.getElementById('my-popover'); +new tabler.Popover(el, { customClass: 'popover-wide', trigger: 'focus' }); +``` + +To read or control the existing instance without changing its options, use `tabler.Popover.getInstance(el)` or `tabler.Popover.getOrCreateInstance(el)`. + ## Accessibility - The trigger must be a `button` or a link with an `href`, so it can take focus. Bootstrap manages `aria-describedby` while the popover is open. diff --git a/docs/content/ui/components/tooltip.mdx b/docs/content/ui/components/tooltip.mdx index c301069cf..769c05edd 100644 --- a/docs/content/ui/components/tooltip.mdx +++ b/docs/content/ui/components/tooltip.mdx @@ -45,6 +45,15 @@ Tabler automatically initializes all elements with `data-bs-toggle="tooltip"` on +To use your own options, create the instance yourself. It replaces the one Tabler created, so only your options apply: + +```js +const el = document.getElementById('my-tooltip'); +new tabler.Tooltip(el, { customClass: 'tooltip-wide', delay: 300 }); +``` + +To read or control the existing instance without changing its options, use `tabler.Tooltip.getInstance(el)` or `tabler.Tooltip.getOrCreateInstance(el)`. + ## Accessibility - A tooltip only describes; it never replaces a name. The trigger still needs its own text or `aria-label`.