<header class="siteHeader -fixed" v-scroll-header>
<div class="container">
<div class="siteHeader__content">
<a class="siteHeader__logo" href="">
<svg viewBox="0 0 300 133" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="300" height="133" fill="currentColor"/>
</svg>
</a>
<nav class="siteHeader__utilityNav navigation">
<ul class="navigation__list">
<li class="navigation__item">
<p-openable class="navigation__button" label="Utility Dropdown" :close-on-blur="false">
<template #default>
<div class="navigation__dropdown dropdown">
<ul class="dropdown__list">
<li class="dropdown__item">
<a class="dropdown__link" href=""><span>Elit?</span></a>
</li>
<li class="dropdown__item">
<a class="dropdown__link" href=""><span>Dolor.</span></a>
</li>
<li class="dropdown__item">
<a class="dropdown__link" href=""><span>Adipisicing!</span></a>
</li>
<li class="dropdown__item">
<a class="dropdown__link" href=""><span>Amet</span></a>
</li>
<li class="dropdown__item">
<a class="dropdown__link" href=""><span>Adipisicing</span></a>
</li>
</ul>
</div>
</template>
</p-openable>
</li>
<li class="navigation__item">
<a class="navigation__link" href="#">Contact</a>
</li>
</ul>
</nav>
<div class="siteHeader__search search">
<svg class="search__icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256"><path d="M232.49,215.51,185,168a92.12,92.12,0,1,0-17,17l47.53,47.54a12,12,0,0,0,17-17ZM44,112a68,68,0,1,1,68,68A68.07,68.07,0,0,1,44,112Z"></path></svg>
<input class="search__input input" type="search" placeholder="Search">
</div>
<nav class="siteHeader__mobileNav mobileNavigation">
<p-openable class="mobileNavigation__menu button -circle -ghost" label="menu">
<template #toggle>
<svg class="button__icon icon"><use href="/main-icons-sprite.svg#bars" /></svg>
</template>
<template v-slot="{ click }">
<div class="mobileNavigation__pane" v-scrolllock>
<div class="mobileNavigation__title">
Menu
</div>
<ul class="mobileNavigation__list" v-directionals>
<li class="dropdown__item">
<p-openable class="mobileNavigation__button" name="submenu">
<template #toggle>
Submenu
<svg class="icon"><use href="/main-icons-sprite.svg#chevron-right" /></svg>
</template>
<template v-slot="{ click }">
<div class="mobileNavigation__pane">
<div class="mobileNavigation__title">
<div class="mobileNavigation__actions">
<button class="button -circle -ghost" @click.stopPropagation="click" tabindex="-1">
<svg class="icon"><use href="/main-icons-sprite.svg#chevron-left" /></svg>
</button>
</div>
Menu
</div>
<ul class="mobileNavigation__list" v-directionals>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Amet porro.</a></li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Ipsum dolorem</a></li>
</ul>
</div>
</template>
</p-openable>
</li>
<li class="dropdown__item">
<p-openable class="mobileNavigation__button" name="submenu">
<template #toggle>
STUFF
<svg class="icon"><use href="/main-icons-sprite.svg#chevron-right" /></svg>
</template>
<template v-slot="{ click }">
<div class="mobileNavigation__pane">
<div class="mobileNavigation__title">
<div class="mobileNavigation__actions">
<button class="button -circle -ghost" @click.stopPropagation="click" tabindex="-1">
<svg class="icon"><use href="/main-icons-sprite.svg#chevron-left" /></svg>
</button>
</div>
Menu
</div>
<ul class="mobileNavigation__list" v-directionals>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Amet porro.</a></li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Ipsum dolorem</a></li>
</ul>
</div>
</template>
</p-openable>
</li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Amet porro.</a></li>
<li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Ipsum dolorem</a></li>
<li class="mobileNavigation__item">
<a class="mobileNavigation__link" href="">Amet impedit cumque</a>
</li>
<li class="mobileNavigation__item">
<a class="mobileNavigation__link" href="">Consectetur vitae temporibus!</a>
</li>
<li class="mobileNavigation__item">
<a class="mobileNavigation__link" href="">Consectetur doloremque reiciendis</a>
</li>
<li class="mobileNavigation__item">
<a class="mobileNavigation__link" href="">Dolor pariatur rerum.</a>
</li>
<li class="mobileNavigation__item">
<a class="mobileNavigation__link" href="">Ipsum a officiis.</a>
</li>
<li class="mobileNavigation__item">
<a class="mobileNavigation__link" href="">Dolor unde debitis.</a>
</li>
<li class="mobileNavigation__item">
<a class="mobileNavigation__link" href="">Adipisicing delectus velit</a>
</li>
<li class="mobileNavigation__item">
<a class="mobileNavigation__link" href="">Amet aliquam voluptates</a>
</li>
</ul>
</div>
<div class="mobileNavigation__closeButton">
<button class="button -circle -ghost" @click="click">
<svg class="icon"><use href="/main-icons-sprite.svg#close" /></svg>
</button>
</div>
</template>
</p-openable>
</nav>
<nav class="siteHeader__primaryNav navigation" v-directionals>
<ul class="navigation__list">
<li class="navigation__item" v-directionals>
<p-openable class="navigation__button" label="Default Dropdown" :close-on-blur="false">
<template #default>
<div class="navigation__dropdown dropdown">
<ul class="dropdown__list">
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
</ul>
</div>
</template>
</p-openable>
</li>
<li class="navigation__item" v-directionals>
<p-openable class="navigation__button" label="Centered Dropdown" :close-on-blur="false">
<template #default>
<div class="navigation__dropdown dropdown -center">
<ul class="dropdown__list">
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
</ul>
</div>
</template>
</p-openable>
</li>
<li class="navigation__item -wide" v-directionals>
<p-openable class="navigation__button" label="Full-width Dropdown" :close-on-blur="false">
<template #default>
<div class="navigation__dropdown dropdown grid -columns-3">
<div class="cell">
<h3>Heading</h3>
<p>
Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
</p>
<ul class="dropdown__list">
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
</ul>
</div>
<div class="cell">
<h3>Heading</h3>
<p>
Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
</p>
<ul class="dropdown__list">
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
</ul>
</div>
<div class="cell">
<h3>Heading</h3>
<p>
Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
</p>
<ul class="dropdown__list">
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
</ul>
</div>
</div>
</template>
</p-openable>
</li>
<li class="navigation__item" v-directionals>
<p-openable class="navigation__button" label="Right Aligned Dropdown" :close-on-blur="false">
<template #default>
<div class="navigation__dropdown dropdown -rightAligned">
<ul class="dropdown__list">
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
<li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
</ul>
</div>
</template>
</p-openable>
</li>
</ul>
</nav>
</div>
</div>
</header>
<div style="min-height: 180vh" aria-hidden="true"></div>
@use '@/core' as *;
/**
* Drives `-fixed` compact ↔ expanded chrome in sync (one transition, no layout fights).
*/
@property --siteheader-compact {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/**
* `v-scroll-header` toggles `-active` on `-fixed` and `-hidden` on `-hideOnScroll`.
* Variant modifiers stay on the block; JS only toggles state classes.
*/
.siteHeader {
$b: &;
--gap: var(--root-gap, 16px);
--siteHeader-chrome-duration: 320ms;
--siteHeader-chrome-ease: cubic-bezier(0.33, 1, 0.68, 1);
--siteHeader-logo-max-height: calc(301px * 133 / 300);
--siteHeader-primaryNav-row-height: calc(3rem + 1rem * var(--root-line-height, 1.5));
background-color: transparent;
box-shadow: none;
/**
* Expanded bar only. Optional `--siteHeader-min-height-when-fixed` sets a floor
* when compact if a project needs it (default: content height only).
*/
--siteHeader-min-height: 12rem;
min-height: var(--siteHeader-min-height);
padding-block: var(--gap);
padding-inline: 0;
position: relative;
transition:
background-color var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease),
box-shadow var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease),
min-height var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease),
padding-block var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease);
&__content {
display: grid;
grid: 'logo search mobile-nav' auto / auto 1fr auto;
align-items: center;
gap: 0 var(--gap);
position: relative;
}
&__logo {
align-self: start;
color: var(--accent-color);
grid-area: logo;
max-width: 301px;
padding: 0.5rem 0;
width: 100%;
svg {
display: block;
height: auto;
width: 100%;
}
}
&__utilityNav {
display: none;
grid-area: utility-nav;
font-size: 0.75rem;
justify-self: end;
gap: 1.5rem;
}
&__primaryNav {
display: none;
grid-area: primary-nav;
justify-self: end;
}
&__search {
justify-self: end;
width: #{fluid-rems(8, 8, 16)};
}
@include at(md) {
&__content {
grid:
'logo utility-nav search' auto
'primary-nav primary-nav primary-nav' auto
/ auto 1fr auto;
}
&__primaryNav,
&__utilityNav {
display: grid;
justify-self: end;
}
&__mobileNav {
display: none;
}
}
@include at(lg) {
&__content {
grid:
'logo utility-nav search' auto
'logo spacer spacer' auto
'logo primary-nav primary-nav' auto
/ auto 1fr auto;
}
}
/**
* Sticky + `--siteheader-compact` keeps layout in flow and animates all chrome
* from one value so nav, logo, and bar height move together.
*/
&.-fixed {
--siteheader-compact: 0;
background-color: color-mix(in srgb, var(--root-background-color, #ffffff) calc(var(--siteheader-compact) * 100%), transparent);
min-height: calc((1 - var(--siteheader-compact)) * var(--siteHeader-min-height) + var(--siteheader-compact) * var(--siteHeader-min-height-when-fixed, 5.5rem));
padding-block: var(--gap);
position: sticky;
top: 0;
transition:
--siteheader-compact var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease),
box-shadow var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease);
z-index: 200;
#{$b}__content {
align-items: start;
}
#{$b}__logo {
align-self: end;
padding: 0;
svg {
max-height: calc(var(--siteHeader-primaryNav-row-height) + (var(--siteHeader-logo-max-height) - var(--siteHeader-primaryNav-row-height)) * (1 - var(--siteheader-compact)));
}
}
#{$b}__search {
font-size: calc(0.94rem + 0.06rem * (1 - var(--siteheader-compact)));
}
#{$b}__primaryNav {
.navigation__link,
.navigation__button {
font-size: calc(0.9rem + 0.1rem * (1 - var(--siteheader-compact)));
}
.navigation__item:has(.navigation__dropdown) .navigation__button {
font-size: 1rem;
}
}
#{$b}__mobileNav .mobileNavigation__menu {
font-size: calc(0.9rem + 0.1rem * (1 - var(--siteheader-compact)));
}
}
&.-fixed.-active {
--siteheader-compact: 1;
@include at(md) {
box-shadow: 0 0.125rem 0.75rem rgb(0 0 0 / 0.06);
}
}
/**
* Full-size pinned bar; `-hidden` slides off on scroll down.
*/
&.-hideOnScroll {
background-color: var(--root-background-color, #ffffff);
inset-inline: 0;
min-height: unset;
position: fixed;
top: 0;
transition: transform var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease);
width: 100%;
z-index: 200;
&.-hidden {
pointer-events: none;
transform: translateY(-100%);
}
}
/** Reserves space while pinned headers are visible (sibling follows header). */
& + &__spacer {
min-height: 0;
pointer-events: none;
}
&.-hideOnScroll:not(.-hidden) + &__spacer {
min-height: 9rem;
transition: min-height var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease);
}
&.-hideOnScroll.-hidden + &__spacer {
min-height: 0;
}
}
<script>
const openableGroups = {}
</script>
<script setup>
import { ref, nextTick, useTemplateRef } from 'vue'
import focusableElements from '../composables/FocusableElements.js'
const { closeOnBlur, refocus, label, name, openOnHover, hoverOrClick } = defineProps({
closeOnBlur: { type: Boolean, default: true },
refocus: { type: Boolean, default: true },
label: { type: String, default: '' },
name: { type: String, default: 'global' },
openOnHover: { type: Boolean, default: false },
hoverOrClick: { type: Boolean, default: false },
})
const emit = defineEmits(['open', 'close'])
const button = useTemplateRef('button')
const openable = useTemplateRef('openable')
const open = ref(false)
const targetOutside = evt => {
if (openable.value && !openable.value.contains(evt.target)) {
toggle()
}
}
const pressEscape = evt => {
if (evt.key === 'Escape') {
evt.stopPropagation()
toggle()
}
}
const updateGroup = open => {
if (!name) {
return
}
if (!(name in openableGroups)) {
openableGroups[name] = new Set()
}
if (open) {
openableGroups[name].forEach(t => t())
openableGroups[name].add(toggle)
} else {
openableGroups[name].delete(toggle)
}
}
const toggle = (evt, { noFocus = false } = {}) => {
evt?.stopPropagation()
emit(open.value ? 'close' : 'open')
open.value = !open.value
updateGroup(open.value)
nextTick(() => {
if (open.value) {
if (refocus && !noFocus) {
focusableElements(openable)?.[0].focus()
}
document.documentElement.addEventListener('click', targetOutside)
openable.value.addEventListener('keydown', pressEscape)
if (closeOnBlur) {
document.documentElement.addEventListener('focusin', targetOutside)
}
} else {
document.documentElement.removeEventListener('click', targetOutside)
openable.value.removeEventListener('keydown', pressEscape)
if (closeOnBlur) {
document.documentElement.removeEventListener('focusin', targetOutside)
}
if (refocus && !noFocus) {
focusableElements(button)?.[0].focus()
}
}
})
}
let timeout = null
const mouseover = () => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
if (!open.value) {
toggle(undefined, { noFocus: true })
}
}, 450)
}
const mouseout = () => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
if (open.value) {
toggle(undefined, { noFocus: true })
}
}, 450)
}
const keypress = evt => {
if (evt.key == ' ' || evt.key == 'Enter') {
toggle()
}
}
let bindings = { click: toggle }
if (openOnHover) {
bindings = { mouseover, mouseout, keypress }
}
if (hoverOrClick) {
bindings = { click: toggle, mouseover, mouseout }
}
</script>
<template>
<button v-bind="$attrs" ref="button" v-on="bindings">
<slot name="toggle" v-bind="bindings">
{{ label }}
</slot>
</button>
<div ref="openable" v-on="openOnHover || hoverOrClick ? { mouseover, mouseout } : {}">
<slot v-if="open" v-bind="bindings" />
</div>
</template>
import focusableElements from '../composables/FocusableElements.js'
/**
* Default key filters. These should be functions that 'filter out' what
* elements should be considered when a key is hit.
*/
const KEY_BINDINGS = {
ArrowRight: elements => elements.filter(({ x, top, bottom }) => x > 0 && top < 0 && bottom > 0),
ArrowLeft: elements => elements.filter(({ x, top, bottom }) => x < 0 && top < 0 && bottom > 0),
ArrowDown: elements => elements.filter(({ y, left, right }) => y > 0 && left < 0 && right > 0),
ArrowUp: elements => elements.filter(({ y, left, right }) => y < 0 && left < 0 && right > 0),
Home: elements => elements.length && elements.slice(0, 1),
End: elements => elements.length && elements.slice(-1),
}
/**
* Retrieves where the element is drawn on screen (client rects).
* Adds in x and y for the center of the element.
*/
const getElementRects = el => {
if (!el) {
console.error('Unable to determine location of element', el)
return null
}
const rects = el.getClientRects()[0]
if (!rects) {
return getElementRects(el.parentElement)
}
return {
bottom: rects.bottom,
height: rects.height,
left: rects.left,
right: rects.right,
top: rects.top,
width: rects.width,
x: rects.left + rects.width / 2,
y: rects.top + rects.height / 2,
}
}
/**
* Calls getElementRects for every element in nodeList, and adjust the
* values to be relative to origin for simpler follow up math. Also
* calculates the distance between the two elements' centers.
*/
const augmentElementRects = (nodeList, origin) => {
const elements = []
origin = getElementRects(origin)
nodeList.forEach(el => {
const rects = getElementRects(el)
if (rects === null) {
return
}
rects.bottom -= origin.y
rects.left -= origin.x
rects.right -= origin.x
rects.top -= origin.y
rects.x -= origin.x
rects.y -= origin.y
const distance = Math.sqrt(rects.x * rects.x + rects.y * rects.y)
elements.push({ el, ...rects, distance })
})
return elements
}
const findTarget = (el, key, root) => {
const elements = augmentElementRects(focusableElements(root), el)
if (key in KEY_BINDINGS) {
const { el } = KEY_BINDINGS[key](elements).reduce(
(closest, el) => {
return el.distance < closest.distance ? el : closest
},
{ distance: Infinity },
)
return el
}
return null
}
export default {
mounted(element) {
const handler = evt => {
const target = findTarget(evt.target, evt.key, element)
if (target) {
evt.preventDefault()
evt.stopPropagation()
target.focus()
}
}
element.addEventListener('keydown', handler)
},
}
export default {
mounted() {
document.documentElement.style.overflow = 'hidden'
},
unmounted() {
document.documentElement.style.overflow = ''
},
}