Skip to content

Commit 8a46b6e

Browse files
committed
Tweaks to ThemePicker stylings
1 parent ef83d18 commit 8a46b6e

4 files changed

Lines changed: 199 additions & 7 deletions

File tree

‎src/components/Brand/index.astro‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import styles from './index.module.css'
99
---
1010

1111
<a
12-
class:list={['flex items-stretch', styles.brandLink]}
12+
class:list={[
13+
"flex items-stretch relative focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:[inset:-6px] focus-visible:after:border-spotlight",
14+
styles.brandLink,
15+
]}
1316
href="/"
1417
rel="home"
1518
aria-label="Webstack Builders home"

‎src/components/ThemePicker/client/__tests__/index.spec.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,11 @@ describe('ThemePicker Component', () => {
292292

293293
const hues = Array.from(firstItem.querySelectorAll('.themepicker__hue')) as HTMLElement[]
294294
const expectedClasses = [
295-
'bg-primary',
296295
'bg-secondary',
297-
'bg-gray-offset',
298-
'bg-gray-offset',
299-
'bg-gray-700',
296+
'bg-spotlight',
297+
'bg-content-inverse',
298+
'bg-content-offset',
299+
'bg-content',
300300
]
301301

302302
expectedClasses.forEach((className, index) => {

‎src/components/ThemePicker/client/index.ts‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/

‎src/components/ThemePicker/index.astro‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const { closeComponent = 'close', label = 'Select theme' } = Astro.props
5757
]}
5858
>
5959
<button
60-
class="themepicker__selectBtn group block p-4 rounded-lg border border-page-base-offset w-full text-center bg-page-base-offset"
60+
class="themepicker__selectBtn group block p-4 rounded-lg border-2 border-secondary w-full text-center bg-page-base-offset relative focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:-inset-1.5 focus-visible:after:border-spotlight"
6161
aria-label={`select color theme ${theme.name}`}
6262
data-theme={theme.id}
6363
>
@@ -66,7 +66,7 @@ const { closeComponent = 'close', label = 'Select theme' } = Astro.props
6666
<span class="themepicker__themeName text-content">{theme.name}</span>
6767
{theme.description && (
6868
<span
69-
class="themepicker__tooltip absolute left-full top-full ml-1 mt-1 z-(--z-modal) max-w-xs whitespace-normal wrap-break-word rounded-md border border-trim bg-content-inverse-inverse px-2 py-1 text-xs text-content-inverse opacity-0 pointer-events-none group-hover:opacity-100 group-focus:opacity-100 group-focus-visible:opacity-100"
69+
class="themepicker__tooltip sr-only"
7070
data-theme-tooltip
7171
>
7272
{theme.description}

0 commit comments

Comments
 (0)