From 5dd33891f44087932c9ad99e3d5bb2819b99dedd Mon Sep 17 00:00:00 2001 From: dr Date: Sun, 18 Feb 2024 16:22:39 +0100 Subject: [PATCH] Reimplement Turbo support using MutationObserver --- src/components/accordion/index.ts | 9 ++++- src/components/carousel/index.ts | 9 ++++- src/components/clipboard/index.ts | 10 +++-- src/components/collapse/index.ts | 10 +++-- src/components/dial/index.ts | 9 ++++- src/components/dismiss/index.ts | 9 ++++- src/components/drawer/index.ts | 9 ++++- src/components/dropdown/index.ts | 10 +++-- src/components/index.ts | 49 ++++++++++++++++------ src/components/input-counter/index.ts | 9 ++++- src/components/modal/index.ts | 15 ++++--- src/components/popover/index.ts | 9 ++++- src/components/tabs/index.ts | 9 ++++- src/components/tooltip/index.ts | 9 ++++- src/dom/observer.ts | 58 +++++++++++++++++++++++++++ src/dom/query.ts | 10 +++++ src/index.turbo.ts | 24 +---------- 17 files changed, 200 insertions(+), 67 deletions(-) create mode 100644 src/dom/observer.ts create mode 100644 src/dom/query.ts diff --git a/src/components/accordion/index.ts b/src/components/accordion/index.ts index 2ca35eb90..a663c12f3 100644 --- a/src/components/accordion/index.ts +++ b/src/components/accordion/index.ts @@ -3,6 +3,7 @@ import type { AccordionItem, AccordionOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { AccordionInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: AccordionOptions = { alwaysOpen: false, @@ -173,8 +174,8 @@ class Accordion implements AccordionInterface { } } -export function initAccordions() { - document.querySelectorAll('[data-accordion]').forEach(($accordionEl) => { +export function initAccordionsFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-accordion]').forEach(($accordionEl) => { const alwaysOpen = $accordionEl.getAttribute('data-accordion'); const activeClasses = $accordionEl.getAttribute('data-active-classes'); const inactiveClasses = $accordionEl.getAttribute( @@ -218,6 +219,10 @@ export function initAccordions() { }); } +export function initAccordions() { + initAccordionsFrom(document); +} + if (typeof window !== 'undefined') { window.Accordion = Accordion; window.initAccordions = initAccordions; diff --git a/src/components/carousel/index.ts b/src/components/carousel/index.ts index 5d3ccba82..84ccf77c6 100644 --- a/src/components/carousel/index.ts +++ b/src/components/carousel/index.ts @@ -8,6 +8,7 @@ import type { import type { InstanceOptions } from '../../dom/types'; import { CarouselInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: CarouselOptions = { defaultPosition: 0, @@ -296,8 +297,8 @@ class Carousel implements CarouselInterface { } } -export function initCarousels() { - document.querySelectorAll('[data-carousel]').forEach(($carouselEl) => { +export function initCarouselsFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-carousel]').forEach(($carouselEl) => { const interval = $carouselEl.getAttribute('data-carousel-interval'); const slide = $carouselEl.getAttribute('data-carousel') === 'slide' @@ -372,6 +373,10 @@ export function initCarousels() { }); } +export function initCarousels() { + initCarouselsFrom(document); +} + if (typeof window !== 'undefined') { window.Carousel = Carousel; window.initCarousels = initCarousels; diff --git a/src/components/clipboard/index.ts b/src/components/clipboard/index.ts index 1eadd3707..99da51ac7 100644 --- a/src/components/clipboard/index.ts +++ b/src/components/clipboard/index.ts @@ -3,6 +3,7 @@ import type { CopyClipboardOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { CopyClipboardInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: CopyClipboardOptions = { htmlEntities: false, @@ -140,9 +141,8 @@ class CopyClipboard implements CopyClipboardInterface { } } -export function initCopyClipboards() { - document - .querySelectorAll('[data-copy-to-clipboard-target]') +export function initCopyClipboardsFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-copy-to-clipboard-target]') .forEach(($triggerEl) => { const targetId = $triggerEl.getAttribute( 'data-copy-to-clipboard-target' @@ -185,6 +185,10 @@ export function initCopyClipboards() { }); } +export function initCopyClipboards() { + initCopyClipboardsFrom(document); +} + if (typeof window !== 'undefined') { window.CopyClipboard = CopyClipboard; window.initClipboards = initCopyClipboards; diff --git a/src/components/collapse/index.ts b/src/components/collapse/index.ts index 9cd8cd42b..4df049888 100644 --- a/src/components/collapse/index.ts +++ b/src/components/collapse/index.ts @@ -3,6 +3,7 @@ import type { CollapseOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { CollapseInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: CollapseOptions = { onCollapse: () => {}, @@ -115,9 +116,8 @@ class Collapse implements CollapseInterface { } } -export function initCollapses() { - document - .querySelectorAll('[data-collapse-toggle]') +export function initCollapsesFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-collapse-toggle]') .forEach(($triggerEl) => { const targetId = $triggerEl.getAttribute('data-collapse-toggle'); const $targetEl = document.getElementById(targetId); @@ -156,6 +156,10 @@ export function initCollapses() { }); } +export function initCollapses() { + initCollapsesFrom(document); +} + if (typeof window !== 'undefined') { window.Collapse = Collapse; window.initCollapses = initCollapses; diff --git a/src/components/dial/index.ts b/src/components/dial/index.ts index 4de46d02d..c21464bc0 100644 --- a/src/components/dial/index.ts +++ b/src/components/dial/index.ts @@ -3,6 +3,7 @@ import type { DialOptions, DialTriggerType } from './types'; import type { InstanceOptions } from '../../dom/types'; import { DialInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: DialOptions = { triggerType: 'hover', @@ -172,8 +173,8 @@ class Dial implements DialInterface { } } -export function initDials() { - document.querySelectorAll('[data-dial-init]').forEach(($parentEl) => { +export function initDialsFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-dial-init]').forEach(($parentEl) => { const $triggerEl = $parentEl.querySelector('[data-dial-toggle]'); if ($triggerEl) { @@ -206,6 +207,10 @@ export function initDials() { }); } +export function initDials() { + initDialsFrom(document); +} + if (typeof window !== 'undefined') { window.Dial = Dial; window.initDials = initDials; diff --git a/src/components/dismiss/index.ts b/src/components/dismiss/index.ts index e190a7075..60d6a6504 100644 --- a/src/components/dismiss/index.ts +++ b/src/components/dismiss/index.ts @@ -3,6 +3,7 @@ import type { DismissOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { DismissInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: DismissOptions = { transition: 'transition-opacity', @@ -88,8 +89,8 @@ class Dismiss implements DismissInterface { } } -export function initDismisses() { - document.querySelectorAll('[data-dismiss-target]').forEach(($triggerEl) => { +export function initDismissesFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-dismiss-target]').forEach(($triggerEl) => { const targetId = $triggerEl.getAttribute('data-dismiss-target'); const $dismissEl = document.querySelector(targetId); @@ -103,6 +104,10 @@ export function initDismisses() { }); } +export function initDismisses() { + initDismissesFrom(document); +} + if (typeof window !== 'undefined') { window.Dismiss = Dismiss; window.initDismisses = initDismisses; diff --git a/src/components/drawer/index.ts b/src/components/drawer/index.ts index 74b397be3..ad5830593 100644 --- a/src/components/drawer/index.ts +++ b/src/components/drawer/index.ts @@ -3,6 +3,7 @@ import type { DrawerOptions, PlacementClasses } from './types'; import type { InstanceOptions, EventListenerInstance } from '../../dom/types'; import { DrawerInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: DrawerOptions = { placement: 'left', @@ -313,8 +314,8 @@ class Drawer implements DrawerInterface { } } -export function initDrawers() { - document.querySelectorAll('[data-drawer-target]').forEach(($triggerEl) => { +export function initDrawersFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-drawer-target]').forEach(($triggerEl) => { // mandatory const drawerId = $triggerEl.getAttribute('data-drawer-target'); const $drawerEl = document.getElementById(drawerId); @@ -453,6 +454,10 @@ export function initDrawers() { }); } +export function initDrawers() { + initDrawersFrom(document); +} + if (typeof window !== 'undefined') { window.Drawer = Drawer; window.initDrawers = initDrawers; diff --git a/src/components/dropdown/index.ts b/src/components/dropdown/index.ts index cf8787d7b..d1d3af050 100644 --- a/src/components/dropdown/index.ts +++ b/src/components/dropdown/index.ts @@ -8,6 +8,7 @@ import type { DropdownOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { DropdownInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: DropdownOptions = { placement: 'bottom', @@ -319,9 +320,8 @@ class Dropdown implements DropdownInterface { } } -export function initDropdowns() { - document - .querySelectorAll('[data-dropdown-toggle]') +export function initDropdownsFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-dropdown-toggle]') .forEach(($triggerEl) => { const dropdownId = $triggerEl.getAttribute('data-dropdown-toggle'); const $dropdownEl = document.getElementById(dropdownId); @@ -372,6 +372,10 @@ export function initDropdowns() { }); } +export function initDropdowns() { + initDropdownsFrom(document); +} + if (typeof window !== 'undefined') { window.Dropdown = Dropdown; window.initDropdowns = initDropdowns; diff --git a/src/components/index.ts b/src/components/index.ts index a4d438c48..0cd769024 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,16 +1,34 @@ -import { initAccordions } from './accordion'; -import { initCarousels } from './carousel'; -import { initCopyClipboards } from './clipboard'; -import { initCollapses } from './collapse'; -import { initDials } from './dial'; -import { initDismisses } from './dismiss'; -import { initDrawers } from './drawer'; -import { initDropdowns } from './dropdown'; -import { initInputCounters } from './input-counter'; -import { initModals } from './modal'; -import { initPopovers } from './popover'; -import { initTabs } from './tabs'; -import { initTooltips } from './tooltip'; +import { initAccordions, initAccordionsFrom } from './accordion'; +import { initCarousels, initCarouselsFrom } from './carousel'; +import { initCopyClipboards, initCopyClipboardsFrom } from './clipboard'; +import { initCollapses, initCollapsesFrom } from './collapse'; +import { initDials, initDialsFrom } from './dial'; +import { initDismisses, initDismissesFrom } from './dismiss'; +import { initDrawers, initDrawersFrom } from './drawer'; +import { initDropdowns, initDropdownsFrom } from './dropdown'; +import { initInputCounters, initInputCountersFrom } from './input-counter'; +import { initModals, initModalsFrom } from './modal'; +import { initPopovers, initPopoversFrom } from './popover'; +import { initTabs, initTabsFrom } from './tabs'; +import { initTooltips, initTooltipsFrom } from './tooltip'; +import { observeFlowbite } from '../dom/observer'; + + +export function initFlowbiteFrom(subtree: Document | Element) { + initAccordionsFrom(subtree); + initCollapsesFrom(subtree); + initCarouselsFrom(subtree); + initDismissesFrom(subtree); + initDropdownsFrom(subtree); + initModalsFrom(subtree); + initDrawersFrom(subtree); + initTabsFrom(subtree); + initTooltipsFrom(subtree); + initPopoversFrom(subtree); + initDialsFrom(subtree); + initInputCountersFrom(subtree); + initCopyClipboardsFrom(subtree); +} export function initFlowbite() { initAccordions(); @@ -28,6 +46,11 @@ export function initFlowbite() { initCopyClipboards(); } +export function initAndObserveFlowbite() { + initFlowbite() + observeFlowbite() +} + if (typeof window !== 'undefined') { window.initFlowbite = initFlowbite; } diff --git a/src/components/input-counter/index.ts b/src/components/input-counter/index.ts index 2a56227c9..a23707b6a 100644 --- a/src/components/input-counter/index.ts +++ b/src/components/input-counter/index.ts @@ -3,6 +3,7 @@ import type { InputCounterOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { InputCounterInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: InputCounterOptions = { minValue: null, @@ -172,8 +173,8 @@ class InputCounter implements InputCounterInterface { } } -export function initInputCounters() { - document.querySelectorAll('[data-input-counter]').forEach(($targetEl) => { +export function initInputCountersFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-input-counter]').forEach(($targetEl) => { const targetId = $targetEl.id; const $incrementEl = document.querySelector( @@ -213,6 +214,10 @@ export function initInputCounters() { }); } +export function initInputCounters() { + initInputCountersFrom(document); +} + if (typeof window !== 'undefined') { window.InputCounter = InputCounter; window.initInputCounters = initInputCounters; diff --git a/src/components/modal/index.ts b/src/components/modal/index.ts index a943205bb..e7df4c7d7 100644 --- a/src/components/modal/index.ts +++ b/src/components/modal/index.ts @@ -3,6 +3,7 @@ import type { ModalOptions } from './types'; import type { InstanceOptions, EventListenerInstance } from '../../dom/types'; import { ModalInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: ModalOptions = { placement: 'center', @@ -266,9 +267,9 @@ class Modal implements ModalInterface { } } -export function initModals() { +export function initModalsFrom(subtree: Document | Element) { // initiate modal based on data-modal-target - document.querySelectorAll('[data-modal-target]').forEach(($triggerEl) => { + inclusiveQuerySelectorAll(subtree, '[data-modal-target]').forEach(($triggerEl) => { const modalId = $triggerEl.getAttribute('data-modal-target'); const $modalEl = document.getElementById(modalId); @@ -290,7 +291,7 @@ export function initModals() { }); // toggle modal visibility - document.querySelectorAll('[data-modal-toggle]').forEach(($triggerEl) => { + inclusiveQuerySelectorAll(subtree, '[data-modal-toggle]').forEach(($triggerEl) => { const modalId = $triggerEl.getAttribute('data-modal-toggle'); const $modalEl = document.getElementById(modalId); @@ -323,7 +324,7 @@ export function initModals() { }); // show modal on click if exists based on id - document.querySelectorAll('[data-modal-show]').forEach(($triggerEl) => { + inclusiveQuerySelectorAll(subtree, '[data-modal-show]').forEach(($triggerEl) => { const modalId = $triggerEl.getAttribute('data-modal-show'); const $modalEl = document.getElementById(modalId); @@ -356,7 +357,7 @@ export function initModals() { }); // hide modal on click if exists based on id - document.querySelectorAll('[data-modal-hide]').forEach(($triggerEl) => { + inclusiveQuerySelectorAll(subtree, '[data-modal-hide]').forEach(($triggerEl) => { const modalId = $triggerEl.getAttribute('data-modal-hide'); const $modalEl = document.getElementById(modalId); @@ -389,6 +390,10 @@ export function initModals() { }); } +export function initModals() { + initModalsFrom(document); +} + if (typeof window !== 'undefined') { window.Modal = Modal; window.initModals = initModals; diff --git a/src/components/popover/index.ts b/src/components/popover/index.ts index 3c66d29a8..13a01751b 100644 --- a/src/components/popover/index.ts +++ b/src/components/popover/index.ts @@ -8,6 +8,7 @@ import type { PopoverOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { PopoverInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: PopoverOptions = { placement: 'top', @@ -293,8 +294,8 @@ class Popover implements PopoverInterface { } } -export function initPopovers() { - document.querySelectorAll('[data-popover-target]').forEach(($triggerEl) => { +export function initPopoversFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-popover-target]').forEach(($triggerEl) => { const popoverID = $triggerEl.getAttribute('data-popover-target'); const $popoverEl = document.getElementById(popoverID); @@ -322,6 +323,10 @@ export function initPopovers() { }); } +export function initPopovers() { + initPopoversFrom(document); +} + if (typeof window !== 'undefined') { window.Popover = Popover; window.initPopovers = initPopovers; diff --git a/src/components/tabs/index.ts b/src/components/tabs/index.ts index f4c111bc0..b5b0f598a 100644 --- a/src/components/tabs/index.ts +++ b/src/components/tabs/index.ts @@ -3,6 +3,7 @@ import type { TabItem, TabsOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { TabsInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: TabsOptions = { defaultTabId: null, @@ -133,8 +134,8 @@ class Tabs implements TabsInterface { } } -export function initTabs() { - document.querySelectorAll('[data-tabs-toggle]').forEach(($parentEl) => { +export function initTabsFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-tabs-toggle]').forEach(($parentEl) => { const tabItems: TabItem[] = []; const activeClasses = $parentEl.getAttribute( 'data-tabs-active-classes' @@ -174,6 +175,10 @@ export function initTabs() { }); } +export function initTabs() { + initTabsFrom(document); +} + if (typeof window !== 'undefined') { window.Tabs = Tabs; window.initTabs = initTabs; diff --git a/src/components/tooltip/index.ts b/src/components/tooltip/index.ts index 7432912ab..12c1bef64 100644 --- a/src/components/tooltip/index.ts +++ b/src/components/tooltip/index.ts @@ -8,6 +8,7 @@ import type { TooltipOptions } from './types'; import type { InstanceOptions } from '../../dom/types'; import { TooltipInterface } from './interface'; import instances from '../../dom/instances'; +import { inclusiveQuerySelectorAll } from '../../dom/query'; const Default: TooltipOptions = { placement: 'top', @@ -282,8 +283,8 @@ class Tooltip implements TooltipInterface { } } -export function initTooltips() { - document.querySelectorAll('[data-tooltip-target]').forEach(($triggerEl) => { +export function initTooltipsFrom(subtree: Document | Element) { + inclusiveQuerySelectorAll(subtree, '[data-tooltip-target]').forEach(($triggerEl) => { const tooltipId = $triggerEl.getAttribute('data-tooltip-target'); const $tooltipEl = document.getElementById(tooltipId); @@ -309,6 +310,10 @@ export function initTooltips() { }); } +export function initTooltips() { + initTooltipsFrom(document); +} + if (typeof window !== 'undefined') { window.Tooltip = Tooltip; window.initTooltips = initTooltips; diff --git a/src/dom/observer.ts b/src/dom/observer.ts new file mode 100644 index 000000000..49250df9f --- /dev/null +++ b/src/dom/observer.ts @@ -0,0 +1,58 @@ +import instances from './instances'; +import { initFlowbiteFrom } from '../components'; + +function findIds(element: Element): string[] { + const ids: string[] = []; + element.querySelectorAll('[id]').forEach((e) => ids.push(e.id)); + element.id && ids.unshift(element.id); + return ids; +} + +function destroyAndRemoveInstances(ids: string[]) { + const lookup = new Set(ids); + + // No explicit types for Instances._instances + type AllInstances = { [component in InstancesComponent]: InstancesMap }; + type InstancesComponent = keyof (typeof instances)['_instances']; + type InstancesInterface = (typeof instances)['_instances'][InstancesComponent][string]; + type InstancesMap = { [id: string]: InstancesInterface }; + + const allInstances: AllInstances = instances.getAllInstances(); + + // No `Object.entries` in es2015 lib, must use `Object.keys` + for (const component of Object.keys(allInstances) as InstancesComponent[]) { + for (const id of Object.keys(allInstances[component])) { + if (lookup.has(id)) { + allInstances[component][id].destroyAndRemoveInstance(); + } + } + } +} + +export function observeFlowbite() { + const observer = new MutationObserver((mutationList, observer) => { + for (const mutation of mutationList) { + if (mutation.type !== 'childList') { + continue; + } + + // Destroy components from removed DOM elements + const ids: string[] = []; + mutation.removedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + ids.concat(findIds(node as Element)); + } + }); + destroyAndRemoveInstances(ids); + + // Initialize components from added DOM elements + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + initFlowbiteFrom(node as Element); + } + }); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); +} diff --git a/src/dom/query.ts b/src/dom/query.ts new file mode 100644 index 000000000..bfa95e443 --- /dev/null +++ b/src/dom/query.ts @@ -0,0 +1,10 @@ +export function inclusiveQuerySelectorAll( + parent: Document | Element, + selectors: string +): Element[] { + const elements = Array.from(parent.querySelectorAll(selectors)); + if ('matches' in parent && parent.matches(selectors)) { + elements.unshift(parent); + } + return elements; +} diff --git a/src/index.turbo.ts b/src/index.turbo.ts index 0504f1817..100ce9073 100644 --- a/src/index.turbo.ts +++ b/src/index.turbo.ts @@ -12,32 +12,12 @@ import Tabs from './components/tabs'; import Tooltip from './components/tooltip'; import InputCounter from './components/input-counter'; import CopyClipboard from './components/clipboard'; -import { initFlowbite } from './components/index'; +import { initAndObserveFlowbite } from './components'; import Events from './dom/events'; -// Since turbo maintainers refuse to add this event, we'll add it ourselves -// https://discuss.hotwired.dev/t/event-to-know-a-turbo-stream-has-been-rendered/1554/10 -const afterRenderEvent = new Event('turbo:after-stream-render'); -addEventListener('turbo:before-stream-render', (event: CustomEvent) => { - const originalRender = event.detail.render; - - event.detail.render = function (streamElement: Element) { - originalRender(streamElement); - document.dispatchEvent(afterRenderEvent); - }; -}); - -const turboLoadEvents = new Events('turbo:load', [initFlowbite]); +const turboLoadEvents = new Events('turbo:load', [initAndObserveFlowbite]); turboLoadEvents.init(); -const turboFrameLoadEvents = new Events('turbo:frame-load', [initFlowbite]); -turboFrameLoadEvents.init(); - -const turboStreamLoadEvents = new Events('turbo:after-stream-render', [ - initFlowbite, -]); -turboStreamLoadEvents.init(); - export default { Accordion, Carousel,