|
1 | 1 | <script lang="ts"> |
2 | | - import type { Snippet } from 'svelte'; |
| 2 | + import { browser } from '$app/environment'; |
| 3 | + import { tick, type Snippet } from 'svelte'; |
| 4 | + import type { Action } from 'svelte/action'; |
3 | 5 | import { PARTNER_LINK_WHY_TOOLTIP } from '$lib/brand.js'; |
4 | 6 |
|
5 | 7 | let { |
|
23 | 25 | children: Snippet; |
24 | 26 | } = $props(); |
25 | 27 |
|
| 28 | + const tipId = $derived(`partner-why-tip-${partner}`); |
| 29 | +
|
| 30 | + let whyOpen = $state(false); |
| 31 | + let whyWrapEl = $state<HTMLElement | null>(null); |
| 32 | + let tipPortalEl = $state<HTMLElement | null>(null); |
| 33 | + let layoutNarrow = $state(false); |
| 34 | +
|
| 35 | + const tipLayoutSmMq = '(min-width: 640px)'; |
| 36 | +
|
| 37 | + /** Escapes `overflow: hidden` ancestors (e.g. page shell) so the tooltip is not clipped. */ |
| 38 | + const appendToBody: Action<HTMLElement> = (node) => { |
| 39 | + document.body.appendChild(node); |
| 40 | + return { |
| 41 | + destroy() { |
| 42 | + node.remove(); |
| 43 | + } |
| 44 | + }; |
| 45 | + }; |
| 46 | +
|
| 47 | + $effect(() => { |
| 48 | + if (!browser) return; |
| 49 | + const mq = window.matchMedia(tipLayoutSmMq); |
| 50 | + layoutNarrow = !mq.matches; |
| 51 | + const onChange = () => { |
| 52 | + layoutNarrow = !mq.matches; |
| 53 | + }; |
| 54 | + mq.addEventListener('change', onChange); |
| 55 | + return () => mq.removeEventListener('change', onChange); |
| 56 | + }); |
| 57 | +
|
| 58 | + const showTipPortal = $derived(whyOpen && layoutNarrow); |
| 59 | +
|
| 60 | + function syncPartnerTipPortal() { |
| 61 | + if (!browser || !whyOpen || !layoutNarrow || !whyWrapEl || !tipPortalEl) return; |
| 62 | + const r = whyWrapEl.getBoundingClientRect(); |
| 63 | + const gap = 6; |
| 64 | + tipPortalEl.style.setProperty('top', `${r.bottom + gap}px`); |
| 65 | + tipPortalEl.style.setProperty('left', 'max(1rem, env(safe-area-inset-left, 0px))'); |
| 66 | + tipPortalEl.style.setProperty('right', 'max(1rem, env(safe-area-inset-right, 0px))'); |
| 67 | + tipPortalEl.style.setProperty('width', 'auto'); |
| 68 | + tipPortalEl.style.setProperty('margin-top', '0'); |
| 69 | + } |
| 70 | +
|
| 71 | + function toggleWhy(e: MouseEvent) { |
| 72 | + e.stopPropagation(); |
| 73 | + whyOpen = !whyOpen; |
| 74 | + } |
| 75 | +
|
| 76 | + $effect(() => { |
| 77 | + if (!browser || !whyOpen) return; |
| 78 | + function onDocClick(ev: MouseEvent) { |
| 79 | + const target = ev.target; |
| 80 | + if (!(target instanceof Node)) return; |
| 81 | + if (whyWrapEl?.contains(target)) return; |
| 82 | + const tipNode = document.getElementById(tipId); |
| 83 | + if (tipNode?.contains(target)) return; |
| 84 | + whyOpen = false; |
| 85 | + } |
| 86 | + queueMicrotask(() => { |
| 87 | + document.addEventListener('click', onDocClick); |
| 88 | + }); |
| 89 | + return () => document.removeEventListener('click', onDocClick); |
| 90 | + }); |
| 91 | +
|
| 92 | + $effect(() => { |
| 93 | + if (!browser || !whyOpen || !layoutNarrow) return; |
| 94 | +
|
| 95 | + tipPortalEl; |
| 96 | +
|
| 97 | + let cancelled = false; |
| 98 | + const run = () => { |
| 99 | + if (!cancelled) syncPartnerTipPortal(); |
| 100 | + }; |
| 101 | +
|
| 102 | + void tick().then(run); |
| 103 | +
|
| 104 | + window.addEventListener('resize', run); |
| 105 | + window.addEventListener('scroll', run, true); |
| 106 | +
|
| 107 | + return () => { |
| 108 | + cancelled = true; |
| 109 | + window.removeEventListener('resize', run); |
| 110 | + window.removeEventListener('scroll', run, true); |
| 111 | + if (tipPortalEl) tipPortalEl.style.cssText = ''; |
| 112 | + }; |
| 113 | + }); |
| 114 | +
|
26 | 115 | const linkClass = |
27 | 116 | 'inline-flex max-w-full items-center gap-1 text-sm font-medium text-primary-700 underline decoration-primary-700/40 underline-offset-2 hover:text-primary-800 hover:decoration-primary-800 dark:text-primary-400 dark:decoration-primary-400/50 dark:hover:text-primary-300'; |
28 | 117 | </script> |
|
38 | 127 | class="font-heading m-0 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 self-start text-base font-semibold leading-snug text-gray-900 sm:col-start-1 sm:row-start-1 sm:text-lg dark:text-white" |
39 | 128 | > |
40 | 129 | <span class="min-w-0">{title}</span> |
41 | | - <span class="relative inline-flex shrink-0 items-center"> |
| 130 | + <span class="group relative inline-flex shrink-0 items-center" bind:this={whyWrapEl}> |
42 | 131 | <button |
43 | 132 | type="button" |
44 | | - class="peer inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-gray-300/90 bg-gray-50/80 text-[10px] font-normal leading-none text-gray-400 transition-colors hover:border-gray-400 hover:bg-gray-100 hover:text-gray-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-gray-400 dark:border-gray-600 dark:bg-gray-800/60 dark:text-gray-500 dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300 dark:focus-visible:ring-gray-500" |
| 133 | + class="peer inline-flex h-4 w-4 shrink-0 touch-manipulation cursor-pointer items-center justify-center rounded-full border border-gray-300/90 bg-gray-50/80 text-[10px] font-normal leading-none text-gray-400 transition-colors hover:border-gray-400 hover:bg-gray-100 hover:text-gray-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-gray-400 dark:border-gray-600 dark:bg-gray-800/60 dark:text-gray-500 dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300 dark:focus-visible:ring-gray-500" |
45 | 134 | aria-label="Why is this here?" |
| 135 | + aria-expanded={whyOpen} |
| 136 | + aria-controls={tipId} |
| 137 | + onclick={toggleWhy} |
46 | 138 | > |
47 | 139 | ? |
48 | 140 | </button> |
49 | | - <span |
50 | | - role="tooltip" |
51 | | - class="pointer-events-none absolute top-full left-1/2 z-[60] mt-1.5 hidden w-max max-w-[min(24rem,calc(100vw-2rem))] -translate-x-1/2 whitespace-pre-line rounded-md bg-gray-900 px-3 py-2.5 text-left text-xs font-normal leading-snug text-white shadow-md peer-hover:block peer-focus-visible:block dark:bg-gray-700" |
52 | | - > |
53 | | - {PARTNER_LINK_WHY_TOOLTIP} |
54 | | - </span> |
| 141 | + {#if !showTipPortal} |
| 142 | + <span |
| 143 | + id={tipId} |
| 144 | + role="tooltip" |
| 145 | + class="pointer-events-auto absolute top-full left-1/2 z-[60] mt-1.5 w-max max-w-[min(24rem,calc(100vw-2rem))] -translate-x-1/2 whitespace-pre-line rounded-md bg-gray-900 px-3 py-2.5 text-left text-xs font-normal leading-snug text-white shadow-md dark:bg-gray-700 {whyOpen |
| 146 | + ? 'block' |
| 147 | + : 'hidden sm:group-hover:block sm:peer-focus-visible:block'}" |
| 148 | + > |
| 149 | + {PARTNER_LINK_WHY_TOOLTIP} |
| 150 | + </span> |
| 151 | + {/if} |
55 | 152 | </span> |
56 | 153 | </h3> |
57 | 154 | <div |
|
72 | 169 | </a> |
73 | 170 | </div> |
74 | 171 | </article> |
| 172 | + |
| 173 | +{#if showTipPortal} |
| 174 | + <div |
| 175 | + bind:this={tipPortalEl} |
| 176 | + use:appendToBody |
| 177 | + id={tipId} |
| 178 | + role="tooltip" |
| 179 | + class="pointer-events-auto fixed z-[100] box-border max-w-[min(24rem,calc(100vw-2rem))] whitespace-pre-line rounded-md bg-gray-900 px-3 py-2.5 text-left text-xs font-normal leading-snug text-white shadow-md dark:bg-gray-700" |
| 180 | + > |
| 181 | + {PARTNER_LINK_WHY_TOOLTIP} |
| 182 | + </div> |
| 183 | +{/if} |
0 commit comments