|
| 1 | +<script setup lang="ts" generic="T extends { id: string; label: string }"> |
| 2 | +// Sticky left nav + stacked, always-visible content sections, with the nav |
| 3 | +// link highlighted for whichever section the reader has scrolled to. Shared by |
| 4 | +// /product ("Find the product for how you work") and /integrations/opcua |
| 5 | +// ("What is OPC UA"). |
| 6 | +// |
| 7 | +// Deliberately not @nuxt/ui's UContentToc/useScrollspy: that tracks active |
| 8 | +// headings via IntersectionObserver on the heading elements themselves, which |
| 9 | +// works for dense docs TOCs but breaks here - when the gap between sections |
| 10 | +// (padding + divider) is wider than the heading, scrolling through that gap |
| 11 | +// leaves no heading intersecting and the indicator sticks on the previous |
| 12 | +// section. Walking sections in DOM order and taking whichever one's top has |
| 13 | +// crossed the viewport's vertical midpoint (overwriting on each match, so the |
| 14 | +// deepest/furthest-scrolled section wins) doesn't have that gap. |
| 15 | +// |
| 16 | +// Generic over T (not just { id, label }) so a scoped slot can read whatever |
| 17 | +// extra fields a caller's item carries (e.g. /product's heading/image/slug) |
| 18 | +// without a cast. |
| 19 | +const props = withDefaults(defineProps<{ |
| 20 | + items: T[] |
| 21 | + ariaLabel?: string |
| 22 | + gapClass?: string |
| 23 | + dividerClass?: string |
| 24 | + // Viewport width at which the nav switches from stacked-above-content to |
| 25 | + // beside it. 'lg' is /product's original width (its item content is |
| 26 | + // wider - text + image per step); opcua's items are text-only and have |
| 27 | + // room to go side-by-side sooner, at 'md'. Literal class strings below |
| 28 | + // (not a template-interpolated breakpoint) since Tailwind's build-time |
| 29 | + // scanner only picks up classes it can see written out in full. |
| 30 | + breakpoint?: 'md' | 'lg' |
| 31 | +}>(), { |
| 32 | + ariaLabel: undefined, |
| 33 | + gapClass: 'gap-16', |
| 34 | + dividerClass: 'pt-16 border-t border-gray-200', |
| 35 | + breakpoint: 'lg', |
| 36 | +}) |
| 37 | +
|
| 38 | +const rowClass = computed(() => props.breakpoint === 'md' ? 'flex flex-col md:flex-row gap-10' : 'flex flex-col lg:flex-row gap-10') |
| 39 | +const navClass = computed(() => props.breakpoint === 'md' ? 'hidden md:block md:w-44 shrink-0' : 'hidden lg:block lg:w-44 shrink-0') |
| 40 | +
|
| 41 | +const active = ref(props.items[0]?.id ?? '') |
| 42 | +const stepRefs = ref<Record<string, HTMLElement | null>>({}) |
| 43 | +
|
| 44 | +function updateActive () { |
| 45 | + const steps = Object.entries(stepRefs.value).filter((entry): entry is [string, HTMLElement] => !!entry[1]) |
| 46 | + if (!steps.length) return |
| 47 | + const mid = window.innerHeight / 2 |
| 48 | + let current = steps[0][0] |
| 49 | + for (const [id, el] of steps) { |
| 50 | + if (el.getBoundingClientRect().top <= mid) current = id |
| 51 | + } |
| 52 | + active.value = current |
| 53 | +} |
| 54 | +
|
| 55 | +let ticking = false |
| 56 | +function onScroll () { |
| 57 | + if (!ticking) { ticking = true; requestAnimationFrame(() => { ticking = false; updateActive() }) } |
| 58 | +} |
| 59 | +
|
| 60 | +onMounted(() => { |
| 61 | + window.addEventListener('scroll', onScroll, { passive: true }) |
| 62 | + window.addEventListener('resize', onScroll, { passive: true }) |
| 63 | + updateActive() |
| 64 | +}) |
| 65 | +
|
| 66 | +onUnmounted(() => { |
| 67 | + window.removeEventListener('scroll', onScroll) |
| 68 | + window.removeEventListener('resize', onScroll) |
| 69 | +}) |
| 70 | +</script> |
| 71 | + |
| 72 | +<template> |
| 73 | + <div :class="rowClass"> |
| 74 | + <nav :class="navClass" :aria-label="ariaLabel"> |
| 75 | + <ul class="sticky top-24 flex flex-col border-l border-gray-200"> |
| 76 | + <li v-for="item in items" :key="item.id"> |
| 77 | + <a |
| 78 | + :href="`#${item.id}`" |
| 79 | + class="block py-[0.6rem] pl-5 -ml-px border-l-2 font-medium transition-colors duration-200" |
| 80 | + :class="active === item.id ? 'text-indigo-600 border-indigo-600' : 'border-transparent text-gray-500 hover:text-indigo-600'" |
| 81 | + >{{ item.label }}</a> |
| 82 | + </li> |
| 83 | + </ul> |
| 84 | + </nav> |
| 85 | + |
| 86 | + <div class="flex-1 min-w-0 flex flex-col" :class="gapClass"> |
| 87 | + <div |
| 88 | + v-for="(item, index) in items" |
| 89 | + :id="item.id" |
| 90 | + :key="item.id" |
| 91 | + :ref="(el) => { stepRefs[item.id] = el as HTMLElement | null }" |
| 92 | + class="scroll-mt-24" |
| 93 | + :class="index > 0 ? dividerClass : ''" |
| 94 | + > |
| 95 | + <slot :name="item.id" :item="item" :index="index" /> |
| 96 | + </div> |
| 97 | + </div> |
| 98 | + </div> |
| 99 | +</template> |
0 commit comments