import cssText from '../styles/widget.css?inline' import { WIDGET_HOST_ID } from '../context/dom-utils' import type { MakeThisBetterConfig } from '../types' import type { WidgetBrandColors } from './brand-colors' export class ShadowContainer { // Click-away layers listen at different points in a mouse or touch sequence. // Keep discrete widget interactions private, but leave pointermove composed // because toolbar dragging intentionally tracks movement on document. private static readonly HOST_INTERACTION_EVENTS = [ 'pointerdown', 'pointerup', 'pointercancel', 'mousedown', 'mouseup', 'touchstart', 'touchend', 'touchcancel', 'click', ] as const // Readable so a component can mark the host with its own presence and have // :host(...) rules react — the only way CSS inside the shadow tree can be // told which components are currently mounted. readonly host: HTMLElement readonly root: ShadowRoot // Only a host we made is ours to take away. One the page supplied has to // outlive us, or a framework counting on it being there — Turbo pairs its // permanent elements by id — would find it gone after the first teardown. private readonly ownsHost: boolean // A borrowed host's inline style and theme attribute belong to the page. // Snapshot the whole attribute values, not just the properties we write: // cssText assignment below replaces everything the customer had inline, so // only the full original string can put it back. null means the attribute // did not exist, and restoring must remove it rather than leave ''. private readonly originalInlineStyle: string | null private readonly originalTheme: string | null constructor( theme: MakeThisBetterConfig['theme'] = 'auto', brandColors?: WidgetBrandColors, ) { // A page that renders its own host controls where the widget lives, and // that is the only way to survive a framework which replaces
// wholesale on navigation: Turbo carries an element marked // data-turbo-permanent across the swap, shadow root and all, so a // recording in progress is not thrown away by a link click. const provided = document.getElementById(WIDGET_HOST_ID) this.ownsHost = !provided this.host = provided ?? document.createElement('div') // Attach before any host mutation: a supplied element that cannot take a // shadow root fails right here, while the page's element is still exactly // as the page left it — there is no partially-styled host to unwind. this.root = this.host.shadowRoot ?? this.host.attachShadow({ mode: 'open' }) this.root.replaceChildren() this.originalInlineStyle = this.host.getAttribute('style') this.originalTheme = this.host.getAttribute('data-mtb-theme') this.host.id = WIDGET_HOST_ID this.host.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;overflow:visible;z-index:2147483646;' if (brandColors) { const rgb = [1, 3, 5].map((offset) => parseInt(brandColors.primary.slice(offset, offset + 2), 16)) const onPrimaryRgb = [1, 3, 5].map((offset) => parseInt(brandColors.onPrimary.slice(offset, offset + 2), 16)) this.host.style.setProperty('--mtb-brand-primary', brandColors.primary.toLowerCase()) this.host.style.setProperty('--mtb-brand-hover', brandColors.hover.toLowerCase()) this.host.style.setProperty('--mtb-brand-active', brandColors.active.toLowerCase()) this.host.style.setProperty('--mtb-brand-on-primary', brandColors.onPrimary.toLowerCase()) this.host.style.setProperty('--mtb-brand-rgb', rgb.join(', ')) this.host.style.setProperty('--mtb-brand-on-primary-rgb', onPrimaryRgb.join(', ')) this.host.style.setProperty('--mtb-brand-emphasis', brandColors.primary.toLowerCase()) this.host.style.setProperty('--mtb-brand-emphasis-rgb', rgb.join(', ')) this.host.style.setProperty('--mtb-brand-gradient-start', brandColors.primary.toLowerCase()) this.host.style.setProperty('--mtb-brand-gradient-end', brandColors.hover.toLowerCase()) } this.host.setAttribute('data-mtb-theme', theme) if (this.ownsHost) document.body.appendChild(this.host) for (const eventType of ShadowContainer.HOST_INTERACTION_EVENTS) { this.root.addEventListener(eventType, this.stopHostInteraction) } const style = document.createElement('style') style.textContent = cssText this.root.appendChild(style) } el