@@ -72,6 +72,13 @@ export class ThemePickerElement extends LitElement {
7272 private lastIsOpen : boolean | null = null
7373 private lastTheme : ThemeId | null = null
7474
75+ private tooltipPortal : HTMLDivElement | null = null
76+ private tooltipPortalContent : HTMLDivElement | null = null
77+ private tooltipActiveButton : HTMLButtonElement | null = null
78+ private tooltipRafId : number | null = null
79+
80+ private readonly tooltipId = 'theme-picker-tooltip'
81+
7582 private readonly emblaUpdateHandler = ( ) => this . updateEmblaNavState ( )
7683
7784 // Track View Transitions
@@ -98,6 +105,7 @@ export class ThemePickerElement extends LitElement {
98105 override disconnectedCallback ( ) : void {
99106 super . disconnectedCallback ( )
100107 this . teardownEmbla ( )
108+ this . teardownTooltipPortal ( )
101109 }
102110
103111 /**
@@ -127,6 +135,7 @@ export class ThemePickerElement extends LitElement {
127135 }
128136
129137 this . bindEvents ( )
138+ this . setupTooltipPortal ( )
130139 this . setViewTransitionHandlers ( )
131140 this . isInitialized = true
132141 this . setAttribute ( 'data-theme-picker-ready' , 'true' )
@@ -423,11 +432,191 @@ export class ThemePickerElement extends LitElement {
423432 this . handleClose ( )
424433 }
425434 } )
435+
436+ // Tooltip (hover/focus) uses a portal outside overflow-hidden containers.
437+ // Use capture for pointer events so we can react early and avoid flicker.
438+ this . addEventListener (
439+ 'pointerover' ,
440+ event => {
441+ const target = event . target
442+ if ( ! ( target instanceof HTMLElement ) ) return
443+ const button = target . closest < HTMLButtonElement > ( '[data-theme]' )
444+ if ( ! button ) return
445+ this . showTooltipForThemeButton ( button )
446+ } ,
447+ true
448+ )
449+
450+ this . addEventListener (
451+ 'pointerout' ,
452+ event => {
453+ const relatedTarget = ( event as PointerEvent ) . relatedTarget
454+ if ( relatedTarget instanceof HTMLElement ) {
455+ const stillInside = relatedTarget . closest ( '[data-theme]' )
456+ if ( stillInside && stillInside === this . tooltipActiveButton ) return
457+ }
458+
459+ this . hideTooltip ( )
460+ } ,
461+ true
462+ )
463+
464+ this . addEventListener (
465+ 'focusin' ,
466+ event => {
467+ const target = event . target
468+ if ( ! ( target instanceof HTMLElement ) ) return
469+ const button = target . closest < HTMLButtonElement > ( '[data-theme]' )
470+ if ( ! button ) return
471+ this . showTooltipForThemeButton ( button )
472+ } ,
473+ true
474+ )
475+
476+ this . addEventListener (
477+ 'focusout' ,
478+ ( ) => {
479+ this . hideTooltip ( )
480+ } ,
481+ true
482+ )
483+
484+ window . addEventListener ( 'scroll' , ( ) => this . updateTooltipPosition ( ) , true )
485+ window . addEventListener ( 'resize' , ( ) => this . updateTooltipPosition ( ) , true )
486+ } catch ( error ) {
487+ handleScriptError ( error , context )
488+ }
489+ }
490+
491+ /**
492+ * Create a tooltip portal outside overflow-hidden containers.
493+ * This allows the tooltip to float like a modal without affecting layout.
494+ */
495+ private setupTooltipPortal ( ) : void {
496+ const context = { scriptName : 'ThemePickerElement' , operation : 'setupTooltipPortal' }
497+ addScriptBreadcrumb ( context )
498+
499+ try {
500+ if ( typeof document === 'undefined' ) return
501+ if ( this . tooltipPortal ) return
502+
503+ const portal = document . createElement ( 'div' )
504+ portal . setAttribute ( 'data-theme-tooltip-portal' , 'true' )
505+ portal . setAttribute ( 'id' , this . tooltipId )
506+ portal . setAttribute ( 'role' , 'tooltip' )
507+ portal . className =
508+ 'fixed left-0 top-0 z-(--z-modal) pointer-events-none opacity-0 transition-opacity duration-150 ease-out'
509+
510+ const content = document . createElement ( 'div' )
511+ content . className =
512+ 'rounded-md border border-trim bg-content px-3 py-2 text-xs text-content-inverse shadow-lg'
513+
514+ portal . append ( content )
515+ document . body . append ( portal )
516+
517+ this . tooltipPortal = portal
518+ this . tooltipPortalContent = content
426519 } catch ( error ) {
427520 handleScriptError ( error , context )
428521 }
429522 }
430523
524+ private teardownTooltipPortal ( ) : void {
525+ if ( this . tooltipRafId !== null && typeof cancelAnimationFrame === 'function' ) {
526+ cancelAnimationFrame ( this . tooltipRafId )
527+ }
528+
529+ this . tooltipRafId = null
530+
531+ if ( this . tooltipPortal ) {
532+ this . tooltipPortal . remove ( )
533+ }
534+
535+ this . tooltipPortal = null
536+ this . tooltipPortalContent = null
537+ this . tooltipActiveButton = null
538+ }
539+
540+ private showTooltipForThemeButton ( button : HTMLButtonElement ) : void {
541+ if ( ! this . tooltipPortal || ! this . tooltipPortalContent ) return
542+
543+ const tooltipSource = button . querySelector < HTMLElement > ( '[data-theme-tooltip]' )
544+ if ( ! tooltipSource ) {
545+ this . hideTooltip ( )
546+ return
547+ }
548+
549+ const tooltipText = tooltipSource . textContent ?. trim ( )
550+ if ( ! tooltipText ) {
551+ this . hideTooltip ( )
552+ return
553+ }
554+
555+ this . tooltipPortalContent . textContent = tooltipText
556+ this . tooltipActiveButton = button
557+
558+ // Make it accessible when visible.
559+ button . setAttribute ( 'aria-describedby' , this . tooltipId )
560+
561+ this . updateTooltipPosition ( )
562+ this . tooltipPortal . style . opacity = '1'
563+
564+ this . scheduleTooltipPositionUpdates ( )
565+ }
566+
567+ private hideTooltip ( ) : void {
568+ if ( ! this . tooltipPortal ) return
569+
570+ if ( this . tooltipActiveButton ?. getAttribute ( 'aria-describedby' ) === this . tooltipId ) {
571+ this . tooltipActiveButton . removeAttribute ( 'aria-describedby' )
572+ }
573+
574+ this . tooltipActiveButton = null
575+ this . tooltipPortal . style . opacity = '0'
576+
577+ if ( this . tooltipRafId !== null && typeof cancelAnimationFrame === 'function' ) {
578+ cancelAnimationFrame ( this . tooltipRafId )
579+ }
580+
581+ this . tooltipRafId = null
582+ }
583+
584+ /**
585+ * Position the tooltip below the active theme item.
586+ * Width matches the item width; clamped to viewport to avoid off-screen rendering.
587+ */
588+ private updateTooltipPosition ( ) : void {
589+ if ( ! this . tooltipPortal || ! this . tooltipActiveButton ) return
590+
591+ const rect = this . tooltipActiveButton . getBoundingClientRect ( )
592+
593+ const viewportPadding = 8
594+ const gap = 4
595+ const maxLeft = Math . max ( viewportPadding , window . innerWidth - viewportPadding - rect . width )
596+ const left = Math . min ( Math . max ( rect . left , viewportPadding ) , maxLeft )
597+
598+ this . tooltipPortal . style . left = `${ left } px`
599+ this . tooltipPortal . style . top = `${ rect . bottom + gap } px`
600+ this . tooltipPortal . style . width = `${ rect . width } px`
601+ }
602+
603+ /**
604+ * Embla uses transforms; scheduling position updates keeps the tooltip attached while scrolling.
605+ */
606+ private scheduleTooltipPositionUpdates ( ) : void {
607+ if ( this . tooltipRafId !== null ) return
608+ if ( typeof requestAnimationFrame !== 'function' ) return
609+
610+ const loop = ( ) => {
611+ this . tooltipRafId = null
612+ if ( ! this . tooltipActiveButton ) return
613+ this . updateTooltipPosition ( )
614+ this . tooltipRafId = requestAnimationFrame ( loop )
615+ }
616+
617+ this . tooltipRafId = requestAnimationFrame ( loop )
618+ }
619+
431620 /**
432621 * Set up View Transition handlers
433622 */
0 commit comments