@sarimarcus/content-sites-ui 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +74 -1
- package/package.json +8 -3
- package/src/affiliate/EmeraldLoader.astro +40 -0
- package/src/affiliate/index.ts +4 -1
- package/src/affiliate/resources.ts +7 -0
- package/src/affiliate/travelpayoutsStyler.ts +557 -0
- package/src/layout/AsyncStylesheet.astro +14 -0
- package/src/layout/ConsentAnalytics.astro +121 -0
- package/src/layout/DocumentShell.astro +171 -0
- package/src/layout/analytics.ts +151 -0
- package/src/layout/index.ts +7 -0
- package/src/layout/resources.ts +19 -0
- package/src/navigation/MobileNavLink.astro +38 -0
- package/src/navigation/MobileNavSection.astro +73 -0
- package/src/navigation/NavDropdown.astro +84 -0
- package/src/navigation/NavMenuLink.astro +53 -0
- package/src/navigation/SiteFooter.astro +105 -0
- package/src/navigation/SiteHeader.astro +115 -0
- package/src/navigation/activePath.ts +11 -0
- package/src/navigation/footer.ts +19 -0
- package/src/navigation/index.ts +10 -2
- package/src/navigation/navigation.ts +155 -0
- package/src/navigation/tones.ts +33 -0
- package/src/navigation/types.ts +33 -0
- package/src/search/SearchSuggestions.astro +26 -0
- package/src/search/index.ts +5 -0
- package/src/search/renderResult.ts +117 -0
- package/src/styles/consent.css +274 -0
- package/src/styles/search.css +629 -0
- package/src/styles/theme.css +5 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One row of a desktop dropdown panel. With an icon: an icon box, the label and an optional description; without:
|
|
4
|
+
* a compact label clamped to two lines. `emphasis` paints the label in the tone (hub links such as "All guides");
|
|
5
|
+
* `iconStyle="tile"` sets the icon on a tinted square. The row marks itself aria-current when `active`.
|
|
6
|
+
*/
|
|
7
|
+
import { Icon } from '../primitives/index.ts';
|
|
8
|
+
import { TONES } from './tones.ts';
|
|
9
|
+
import type { NavLink, NavTone } from './types.ts';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
link: NavLink;
|
|
13
|
+
tone?: NavTone;
|
|
14
|
+
active?: boolean;
|
|
15
|
+
size?: 'md' | 'sm';
|
|
16
|
+
align?: 'start' | 'center';
|
|
17
|
+
emphasis?: boolean;
|
|
18
|
+
iconStyle?: 'plain' | 'tile';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { link, tone = 'primary', active = false, size = 'md', align = 'start', emphasis = false, iconStyle = 'plain' } = Astro.props;
|
|
22
|
+
const t = TONES[tone];
|
|
23
|
+
const hasIcon = Boolean(link.icon);
|
|
24
|
+
const ring = `focus-visible:ring-2 ${t.ring} focus-visible:ring-offset-2`;
|
|
25
|
+
const rowClass = hasIcon
|
|
26
|
+
? `flex ${align === 'center' ? 'items-center' : 'items-start'} gap-3 ${size === 'sm' ? 'p-2.5' : 'p-3'} rounded-lg hover:bg-muted transition-colors group ${ring}`
|
|
27
|
+
: `block p-2.5 rounded-lg hover:bg-muted transition-colors group ${ring}`;
|
|
28
|
+
const boxClass = iconStyle === 'tile'
|
|
29
|
+
? `w-8 h-8 rounded-lg ${t.tile} flex items-center justify-center shrink-0 transition-colors`
|
|
30
|
+
: `${size === 'sm' ? 'w-5 h-5' : 'w-6 h-6'} flex items-center justify-center shrink-0${size === 'sm' && align === 'start' ? ' mt-0.5' : ''}`;
|
|
31
|
+
const iconClass = `${size === 'sm' && iconStyle === 'plain' ? 'w-3.5 h-3.5' : 'w-4 h-4'} ${t.icon}`;
|
|
32
|
+
const labelClass = [
|
|
33
|
+
emphasis ? `font-semibold ${t.text} ${t.groupStrongHover}` : `font-medium text-foreground ${t.groupHoverText}`,
|
|
34
|
+
'transition-colors block text-sm',
|
|
35
|
+
hasIcon ? '' : 'leading-snug line-clamp-2',
|
|
36
|
+
].join(' ').trim();
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<a href={link.href} aria-current={active ? 'page' : undefined} class:list={[rowClass, active && `bg-muted font-semibold ${t.text}`]}>
|
|
40
|
+
{hasIcon && (
|
|
41
|
+
<div class={boxClass}>
|
|
42
|
+
<Icon name={link.icon!} class={iconClass} />
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
{hasIcon ? (
|
|
46
|
+
<div>
|
|
47
|
+
<span class={labelClass}>{link.label}</span>
|
|
48
|
+
{link.description && <span class="text-xs text-muted-foreground">{link.description}</span>}
|
|
49
|
+
</div>
|
|
50
|
+
) : (
|
|
51
|
+
<span class={labelClass}>{link.label}</span>
|
|
52
|
+
)}
|
|
53
|
+
</a>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The site footer shell: a brand slot, one column per FooterSection, an optional `bottom` slot, then the copyright
|
|
4
|
+
* line and the labelled Legal `<nav>` closing with the cookie-preferences button. The button carries
|
|
5
|
+
* `data-cookie-preferences` instead of an inline handler; footer.ts calls `window.openCookiePreferences()`, which
|
|
6
|
+
* the site's consent loader defines. Defaults paint the `inverse` surface; the class props restyle each part.
|
|
7
|
+
*/
|
|
8
|
+
import { Icon } from '../primitives/index.ts';
|
|
9
|
+
import type { FooterSection, NavLink, SiteIdentity } from './types.ts';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
identity: SiteIdentity;
|
|
13
|
+
sections?: FooterSection[];
|
|
14
|
+
legal?: NavLink[];
|
|
15
|
+
cookieLabel?: string;
|
|
16
|
+
/** Shown between legal items (aria-hidden), e.g. `|`. */
|
|
17
|
+
separator?: string;
|
|
18
|
+
year?: number;
|
|
19
|
+
class?: string;
|
|
20
|
+
gridClass?: string;
|
|
21
|
+
brandClass?: string;
|
|
22
|
+
headingClass?: string;
|
|
23
|
+
listClass?: string;
|
|
24
|
+
linkClass?: string;
|
|
25
|
+
/** Copyright and legal text. */
|
|
26
|
+
smallClass?: string;
|
|
27
|
+
dividerClass?: string;
|
|
28
|
+
legalClass?: string;
|
|
29
|
+
/** Focus ring shared by every footer control. */
|
|
30
|
+
ringClass?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
identity,
|
|
35
|
+
sections = [],
|
|
36
|
+
legal = [],
|
|
37
|
+
cookieLabel = 'Cookie preferences',
|
|
38
|
+
separator,
|
|
39
|
+
year = new Date().getFullYear(),
|
|
40
|
+
class: className = 'bg-inverse text-inverse-muted',
|
|
41
|
+
gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-3 gap-8 lg:gap-10',
|
|
42
|
+
brandClass = 'col-span-2 lg:col-span-2',
|
|
43
|
+
headingClass = 'flex items-center gap-2 text-sm font-medium text-inverse-foreground uppercase tracking-wider mb-4',
|
|
44
|
+
listClass,
|
|
45
|
+
linkClass = 'block py-3 text-sm text-inverse-muted/80 hover:text-inverse-foreground transition-colors',
|
|
46
|
+
smallClass = 'text-xs text-inverse-muted/60',
|
|
47
|
+
dividerClass = 'border-inverse-muted/10',
|
|
48
|
+
legalClass = 'gap-x-5 gap-y-2',
|
|
49
|
+
ringClass = 'rounded focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-inverse',
|
|
50
|
+
} = Astro.props;
|
|
51
|
+
|
|
52
|
+
const legalItemClass = `${smallClass} hover:text-inverse-foreground transition-colors ${ringClass}`;
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
<footer class={className}>
|
|
56
|
+
<div class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
|
|
57
|
+
<div class={gridClass}>
|
|
58
|
+
<div class={brandClass}>
|
|
59
|
+
<slot name="brand" />
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{sections.map((section) => (
|
|
63
|
+
<div>
|
|
64
|
+
<h3 class={headingClass}>
|
|
65
|
+
{section.icon && <Icon name={section.icon} class="w-4 h-4" />}
|
|
66
|
+
{section.title}
|
|
67
|
+
</h3>
|
|
68
|
+
<ul class={listClass}>
|
|
69
|
+
{section.links.map((link) => (
|
|
70
|
+
<li>
|
|
71
|
+
<a href={link.href} class={`${linkClass} ${ringClass}`}>{link.label}</a>
|
|
72
|
+
</li>
|
|
73
|
+
))}
|
|
74
|
+
</ul>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class:list={['mt-12 pt-8 border-t', dividerClass]}>
|
|
80
|
+
<slot name="bottom" />
|
|
81
|
+
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
|
|
82
|
+
<p class={smallClass}>
|
|
83
|
+
© {year} {identity.name}. All rights reserved.
|
|
84
|
+
</p>
|
|
85
|
+
<nav class:list={['flex flex-wrap items-center justify-center sm:justify-end', legalClass]} aria-label="Legal">
|
|
86
|
+
{legal.map((link) => (
|
|
87
|
+
<>
|
|
88
|
+
<a href={link.href} class={legalItemClass}>{link.label}</a>
|
|
89
|
+
{separator && <span class={`${smallClass} opacity-40`} aria-hidden="true">{separator}</span>}
|
|
90
|
+
</>
|
|
91
|
+
))}
|
|
92
|
+
<button type="button" class={`${legalItemClass} cursor-pointer bg-transparent border-0 p-0`} data-cookie-preferences>
|
|
93
|
+
{cookieLabel}
|
|
94
|
+
</button>
|
|
95
|
+
</nav>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</footer>
|
|
100
|
+
|
|
101
|
+
<script>
|
|
102
|
+
import { initCookiePreferences } from './footer.ts';
|
|
103
|
+
|
|
104
|
+
initCookiePreferences();
|
|
105
|
+
</script>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The site header shell: a fixed `<header>` holding the labelled main `<nav>`, a logo slot, the desktop and mobile
|
|
4
|
+
* action slots and the mobile-menu toggle, then the mobile panel as a sibling of the header (the header's
|
|
5
|
+
* backdrop-blur would otherwise become the containing block of the fixed panel). The site composes every link,
|
|
6
|
+
* panel and logo; this component owns the landmarks, the toggle's ARIA and the controller.
|
|
7
|
+
*
|
|
8
|
+
* Slots: `logo`, `desktop` (inside the `lg:flex` bar), `actions` (after the desktop bar, every viewport),
|
|
9
|
+
* `mobile-actions` (before the toggle, below `lg`), `mobile` (the panel content).
|
|
10
|
+
* `searchPath` (site-relative, e.g. `/search`) turns on the Ctrl/Cmd+K shortcut to `<searchPath>?focus=1`.
|
|
11
|
+
* The ids default to the ones the sites reserve (`main-nav`, `mobile-menu`, `mobile-menu-btn`). The panel sits outside
|
|
12
|
+
* `<nav>`, so it carries its own `data-pagefind-ignore`.
|
|
13
|
+
*/
|
|
14
|
+
import { Icon } from '../primitives/index.ts';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Background of the header bar and the mobile panel. */
|
|
19
|
+
surfaceClass?: string;
|
|
20
|
+
/** Replaces the header's whole class list (layout included); prefer surfaceClass. */
|
|
21
|
+
class?: string;
|
|
22
|
+
toggleClass?: string;
|
|
23
|
+
openLabel?: string;
|
|
24
|
+
closeLabel?: string;
|
|
25
|
+
searchPath?: string;
|
|
26
|
+
id?: string;
|
|
27
|
+
panelId?: string;
|
|
28
|
+
toggleId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
label = 'Main navigation',
|
|
33
|
+
surfaceClass = 'bg-background/95 backdrop-blur-md',
|
|
34
|
+
class: className = `fixed top-0 left-0 right-0 z-50 transition-[background-color,backdrop-filter,border-color,box-shadow] duration-300 border-b border-border ${surfaceClass}`,
|
|
35
|
+
toggleClass = 'p-2 min-h-[44px] min-w-[44px] flex items-center justify-center rounded-lg transition-colors text-foreground hover:bg-muted cursor-pointer focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
|
36
|
+
openLabel = 'Open menu',
|
|
37
|
+
closeLabel = 'Close menu',
|
|
38
|
+
searchPath,
|
|
39
|
+
id = 'main-nav',
|
|
40
|
+
panelId = 'mobile-menu',
|
|
41
|
+
toggleId = 'mobile-menu-btn',
|
|
42
|
+
} = Astro.props;
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<header
|
|
46
|
+
id={id}
|
|
47
|
+
class:list={['site-header', className]}
|
|
48
|
+
data-site-nav
|
|
49
|
+
data-search-path={searchPath}
|
|
50
|
+
data-search-base={searchPath ? import.meta.env.BASE_URL : undefined}
|
|
51
|
+
>
|
|
52
|
+
<nav class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8" aria-label={label}>
|
|
53
|
+
<div class="flex h-16 items-center justify-between">
|
|
54
|
+
<slot name="logo" />
|
|
55
|
+
<div class="hidden lg:flex items-center gap-1">
|
|
56
|
+
<slot name="desktop" />
|
|
57
|
+
</div>
|
|
58
|
+
<slot name="actions" />
|
|
59
|
+
<div class="lg:hidden flex items-center gap-1">
|
|
60
|
+
<slot name="mobile-actions" />
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
id={toggleId}
|
|
64
|
+
class:list={['site-header-toggle', toggleClass]}
|
|
65
|
+
aria-label={openLabel}
|
|
66
|
+
aria-expanded="false"
|
|
67
|
+
aria-controls={panelId}
|
|
68
|
+
data-mobile-menu-toggle
|
|
69
|
+
data-open-label={openLabel}
|
|
70
|
+
data-close-label={closeLabel}
|
|
71
|
+
>
|
|
72
|
+
<Icon name="menu" class="w-6 h-6 menu-open-icon" />
|
|
73
|
+
<Icon name="x" class="w-6 h-6 menu-close-icon" />
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</nav>
|
|
78
|
+
</header>
|
|
79
|
+
|
|
80
|
+
<div id={panelId} class:list={['lg:hidden hidden fixed inset-x-0 top-16 bottom-0 z-50 overflow-y-auto overscroll-contain', surfaceClass]} data-pagefind-ignore>
|
|
81
|
+
<div class="flex flex-col pt-2 pb-12 border-t border-border relative">
|
|
82
|
+
<slot name="mobile" />
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
.site-header {
|
|
88
|
+
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.site-header:global(.mobile-menu-open) {
|
|
92
|
+
box-shadow: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
:global(body.mobile-menu-open) {
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* The icons are Icon components, which scoped selectors do not reach. */
|
|
100
|
+
.site-header-toggle :global(.menu-close-icon),
|
|
101
|
+
.site-header-toggle[aria-expanded='true'] :global(.menu-open-icon) {
|
|
102
|
+
display: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.site-header-toggle[aria-expanded='true'] :global(.menu-close-icon) {
|
|
106
|
+
display: block;
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
109
|
+
|
|
110
|
+
<script>
|
|
111
|
+
import { initNavigation } from './navigation.ts';
|
|
112
|
+
|
|
113
|
+
initNavigation();
|
|
114
|
+
document.addEventListener('astro:page-load', initNavigation);
|
|
115
|
+
</script>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `href` is the current section: the page itself or a page below it, on a path-segment boundary
|
|
3
|
+
* (`/plan` matches `/plan` and `/plan/x`, not `/planning`). The home page matches only itself.
|
|
4
|
+
* Both sides are normalised page paths (core/url normalizePagePath, url()).
|
|
5
|
+
*/
|
|
6
|
+
export function isActivePath(currentPath: string, href: string): boolean {
|
|
7
|
+
const target = href.replace(/\/+$/, '');
|
|
8
|
+
const current = currentPath.replace(/\/+$/, '');
|
|
9
|
+
if (target === '') return current === '';
|
|
10
|
+
return current === target || current.startsWith(`${target}/`);
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The footer's cookie-preferences button. One delegated listener per page, so any number of buttons work and a
|
|
2
|
+
// re-run binds nothing twice. The consent loader owns window.openCookiePreferences.
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
openCookiePreferences?: () => void;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let bound = false;
|
|
11
|
+
|
|
12
|
+
export function initCookiePreferences(): void {
|
|
13
|
+
if (bound) return;
|
|
14
|
+
bound = true;
|
|
15
|
+
document.addEventListener('click', (event) => {
|
|
16
|
+
const target = event.target;
|
|
17
|
+
if (target instanceof Element && target.closest('[data-cookie-preferences]')) window.openCookiePreferences?.();
|
|
18
|
+
});
|
|
19
|
+
}
|
package/src/navigation/index.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-ui/navigation.
|
|
2
|
-
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-ui/navigation: site chrome shells. Sites compose every link, panel and
|
|
2
|
+
// logo; these own landmarks, ARIA state and the bundled controllers.
|
|
3
|
+
export { default as SiteHeader } from './SiteHeader.astro';
|
|
4
|
+
export { default as NavDropdown } from './NavDropdown.astro';
|
|
5
|
+
export { default as MobileNavSection } from './MobileNavSection.astro';
|
|
6
|
+
export { default as SiteFooter } from './SiteFooter.astro';
|
|
7
|
+
export { default as NavMenuLink } from './NavMenuLink.astro';
|
|
8
|
+
export { default as MobileNavLink } from './MobileNavLink.astro';
|
|
9
|
+
export { isActivePath } from './activePath.ts';
|
|
10
|
+
export type { SiteIdentity, NavLink, NavGroup, FooterSection, NavTone } from './types.ts';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Site header controller: dropdowns (click, hover with a close delay, ArrowDown/ArrowUp/Escape, outside click,
|
|
2
|
+
// focus leaving), the mobile menu (toggle, Escape from anywhere, focus into the panel on open), the single-open
|
|
3
|
+
// mobile accordion and the Ctrl/Cmd+K search shortcut. Every root is bound once; document listeners are bound once
|
|
4
|
+
// per page however many headers it holds, so re-running it on astro:page-load is safe.
|
|
5
|
+
|
|
6
|
+
const CLOSE_DELAY = 150;
|
|
7
|
+
let documentBound = false;
|
|
8
|
+
|
|
9
|
+
const roots = () => Array.from(document.querySelectorAll<HTMLElement>('[data-site-nav]'));
|
|
10
|
+
const dropdownButtons = (scope: ParentNode = document) =>
|
|
11
|
+
Array.from(scope.querySelectorAll<HTMLButtonElement>('[data-nav-dropdown-button]'));
|
|
12
|
+
const controlled = (el: Element) => {
|
|
13
|
+
const id = el.getAttribute('aria-controls');
|
|
14
|
+
return id ? document.getElementById(id) : null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function setDropdown(button: HTMLButtonElement, open: boolean): void {
|
|
18
|
+
if (open) dropdownButtons().forEach((other) => other !== button && other.setAttribute('aria-expanded', 'false'));
|
|
19
|
+
button.setAttribute('aria-expanded', String(open));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setMobileMenu(root: HTMLElement, toggle: HTMLButtonElement, open: boolean, focus: boolean): void {
|
|
23
|
+
const panel = controlled(toggle);
|
|
24
|
+
if (!panel) return;
|
|
25
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
26
|
+
toggle.setAttribute('aria-label', (open ? toggle.dataset.closeLabel : toggle.dataset.openLabel) ?? '');
|
|
27
|
+
panel.classList.toggle('hidden', !open);
|
|
28
|
+
root.classList.toggle('mobile-menu-open', open);
|
|
29
|
+
document.body.classList.toggle('mobile-menu-open', open);
|
|
30
|
+
if (!focus) return;
|
|
31
|
+
if (open) panel.querySelector<HTMLElement>('button, a')?.focus();
|
|
32
|
+
else toggle.focus();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bindDropdown(dropdown: HTMLElement): void {
|
|
36
|
+
const button = dropdown.querySelector<HTMLButtonElement>('[data-nav-dropdown-button]');
|
|
37
|
+
const menu = button && controlled(button);
|
|
38
|
+
if (!button || !menu) return;
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
// A pointer that hovered the dropdown open then clicks the trigger means "open", not "toggle closed".
|
|
41
|
+
let openedByHover = false;
|
|
42
|
+
|
|
43
|
+
button.addEventListener('click', () => {
|
|
44
|
+
const open = openedByHover || button.getAttribute('aria-expanded') !== 'true';
|
|
45
|
+
openedByHover = false;
|
|
46
|
+
setDropdown(button, open);
|
|
47
|
+
});
|
|
48
|
+
button.addEventListener('keydown', (event) => {
|
|
49
|
+
if (event.key === 'Escape') {
|
|
50
|
+
setDropdown(button, false);
|
|
51
|
+
button.focus();
|
|
52
|
+
} else if (event.key === 'ArrowDown') {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
setDropdown(button, true);
|
|
55
|
+
menu.querySelector<HTMLElement>('a')?.focus();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
menu.addEventListener('keydown', (event) => {
|
|
59
|
+
const links = Array.from(menu.querySelectorAll<HTMLElement>('a'));
|
|
60
|
+
const index = links.indexOf(document.activeElement as HTMLElement);
|
|
61
|
+
if (event.key === 'ArrowDown') {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
(links[index + 1] ?? links[0])?.focus();
|
|
64
|
+
} else if (event.key === 'ArrowUp') {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
if (index <= 0) button.focus();
|
|
67
|
+
else links[index - 1]?.focus();
|
|
68
|
+
} else if (event.key === 'Escape') {
|
|
69
|
+
setDropdown(button, false);
|
|
70
|
+
button.focus();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
dropdown.addEventListener('mouseenter', () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
openedByHover = button.getAttribute('aria-expanded') !== 'true';
|
|
76
|
+
setDropdown(button, true);
|
|
77
|
+
});
|
|
78
|
+
dropdown.addEventListener('mouseleave', () => {
|
|
79
|
+
openedByHover = false;
|
|
80
|
+
timer = setTimeout(() => setDropdown(button, false), CLOSE_DELAY);
|
|
81
|
+
});
|
|
82
|
+
dropdown.addEventListener('focusout', (event) => {
|
|
83
|
+
const next = event.relatedTarget;
|
|
84
|
+
if (next instanceof Node && dropdown.contains(next)) return;
|
|
85
|
+
button.setAttribute('aria-expanded', 'false');
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bindAccordion(panel: HTMLElement): void {
|
|
90
|
+
const buttons = Array.from(panel.querySelectorAll<HTMLButtonElement>('[data-mobile-section-button]'));
|
|
91
|
+
buttons.forEach((button) => {
|
|
92
|
+
button.addEventListener('click', () => {
|
|
93
|
+
const open = button.getAttribute('aria-expanded') !== 'true';
|
|
94
|
+
if (open) {
|
|
95
|
+
buttons.forEach((other) => {
|
|
96
|
+
if (other === button) return;
|
|
97
|
+
other.setAttribute('aria-expanded', 'false');
|
|
98
|
+
controlled(other)?.classList.add('hidden');
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
button.setAttribute('aria-expanded', String(open));
|
|
102
|
+
controlled(button)?.classList.toggle('hidden', !open);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function searchPathOf(root: HTMLElement): string | null {
|
|
108
|
+
const path = root.dataset.searchPath;
|
|
109
|
+
if (!path) return null;
|
|
110
|
+
const compiled = (root.dataset.searchBase ?? '/').replace(/\/$/, '');
|
|
111
|
+
return (window.location.pathname.indexOf(compiled) === 0 ? compiled : '') + path;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function bindDocument(): void {
|
|
115
|
+
if (documentBound) return;
|
|
116
|
+
documentBound = true;
|
|
117
|
+
|
|
118
|
+
document.addEventListener('click', (event) => {
|
|
119
|
+
const target = event.target;
|
|
120
|
+
if (!(target instanceof Element) || target.closest('[data-nav-dropdown]')) return;
|
|
121
|
+
dropdownButtons().forEach((button) => button.setAttribute('aria-expanded', 'false'));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
document.addEventListener('keydown', (event) => {
|
|
125
|
+
if (event.key === 'Escape') {
|
|
126
|
+
roots().forEach((root) => {
|
|
127
|
+
const toggle = root.querySelector<HTMLButtonElement>('[data-mobile-menu-toggle]');
|
|
128
|
+
if (toggle?.getAttribute('aria-expanded') === 'true') setMobileMenu(root, toggle, false, true);
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!(event.metaKey || event.ctrlKey) || event.key !== 'k') return;
|
|
133
|
+
const searchPath = roots().map(searchPathOf).find(Boolean);
|
|
134
|
+
if (!searchPath) return;
|
|
135
|
+
// On the search page itself the page input owns the shortcut.
|
|
136
|
+
const current = window.location.pathname;
|
|
137
|
+
if (current === searchPath || current === `${searchPath}/`) return;
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
window.location.assign(`${searchPath}?focus=1`);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function initNavigation(): void {
|
|
144
|
+
bindDocument();
|
|
145
|
+
roots().forEach((root) => {
|
|
146
|
+
if (root.hasAttribute('data-site-nav-ready')) return;
|
|
147
|
+
root.setAttribute('data-site-nav-ready', '');
|
|
148
|
+
root.querySelectorAll<HTMLElement>('[data-nav-dropdown]').forEach(bindDropdown);
|
|
149
|
+
const toggle = root.querySelector<HTMLButtonElement>('[data-mobile-menu-toggle]');
|
|
150
|
+
const panel = toggle && controlled(toggle);
|
|
151
|
+
if (!toggle || !panel) return;
|
|
152
|
+
toggle.addEventListener('click', () => setMobileMenu(root, toggle, toggle.getAttribute('aria-expanded') !== 'true', true));
|
|
153
|
+
bindAccordion(panel);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NavTone } from './types.ts';
|
|
2
|
+
|
|
3
|
+
// Literal class strings per tone, so Tailwind's scanner sees every utility the navigation components emit.
|
|
4
|
+
export const TONES: Record<NavTone, {
|
|
5
|
+
text: string;
|
|
6
|
+
hoverText: string;
|
|
7
|
+
groupHoverText: string;
|
|
8
|
+
strongHover: string;
|
|
9
|
+
groupStrongHover: string;
|
|
10
|
+
ring: string;
|
|
11
|
+
icon: string;
|
|
12
|
+
iconDim: string;
|
|
13
|
+
tile: string;
|
|
14
|
+
}> = {
|
|
15
|
+
primary: {
|
|
16
|
+
text: 'text-primary', hoverText: 'hover:text-primary', groupHoverText: 'group-hover:text-primary',
|
|
17
|
+
strongHover: 'hover:text-primary-strong', groupStrongHover: 'group-hover:text-primary-strong',
|
|
18
|
+
ring: 'focus-visible:ring-primary', icon: 'text-primary', iconDim: 'text-primary/60',
|
|
19
|
+
tile: 'bg-primary/10 group-hover:bg-primary/20',
|
|
20
|
+
},
|
|
21
|
+
secondary: {
|
|
22
|
+
text: 'text-secondary', hoverText: 'hover:text-secondary', groupHoverText: 'group-hover:text-secondary',
|
|
23
|
+
strongHover: 'hover:text-secondary-strong', groupStrongHover: 'group-hover:text-secondary-strong',
|
|
24
|
+
ring: 'focus-visible:ring-secondary', icon: 'text-secondary', iconDim: 'text-secondary/60',
|
|
25
|
+
tile: 'bg-secondary/10 group-hover:bg-secondary/20',
|
|
26
|
+
},
|
|
27
|
+
accent: {
|
|
28
|
+
text: 'text-accent', hoverText: 'hover:text-accent', groupHoverText: 'group-hover:text-accent',
|
|
29
|
+
strongHover: 'hover:text-accent-strong', groupStrongHover: 'group-hover:text-accent-strong',
|
|
30
|
+
ring: 'focus-visible:ring-accent', icon: 'text-accent', iconDim: 'text-accent/60',
|
|
31
|
+
tile: 'bg-accent/10 group-hover:bg-accent/20',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Public contracts for site chrome. Sites own every value: labels, hrefs, icons, logos and copy.
|
|
2
|
+
|
|
3
|
+
/** Who the site is, as the header, footer and copyright line name it. */
|
|
4
|
+
export interface SiteIdentity {
|
|
5
|
+
name: string;
|
|
6
|
+
tagline?: string;
|
|
7
|
+
homeHref?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NavLink {
|
|
11
|
+
label: string;
|
|
12
|
+
href: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
/** Lucide icon name for the ui Icon primitive. */
|
|
15
|
+
icon?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NavGroup {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
icon?: string;
|
|
22
|
+
links: NavLink[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FooterSection {
|
|
26
|
+
title: string;
|
|
27
|
+
/** Lucide icon name shown before the heading. */
|
|
28
|
+
icon?: string;
|
|
29
|
+
links: NavLink[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The colour role a navigation item is tinted with. */
|
|
33
|
+
export type NavTone = 'primary' | 'secondary' | 'accent';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** The empty-state suggestion chips of a search page; each button carries the query it runs (data-search-suggest). */
|
|
3
|
+
interface Props {
|
|
4
|
+
label: string;
|
|
5
|
+
suggestions: { label: string; query: string }[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { label, suggestions } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<p class="search-suggestions__label">{label}</p>
|
|
12
|
+
<ul class="search-suggestions">
|
|
13
|
+
{
|
|
14
|
+
suggestions.map((s) => (
|
|
15
|
+
<li>
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
class="search-chip search-chip--suggest"
|
|
19
|
+
data-search-suggest={s.query}
|
|
20
|
+
>
|
|
21
|
+
{s.label}
|
|
22
|
+
</button>
|
|
23
|
+
</li>
|
|
24
|
+
))
|
|
25
|
+
}
|
|
26
|
+
</ul>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-ui/search: search-page building blocks. The site keeps its page, its
|
|
2
|
+
// type taxonomy, its copy and its Pagefind index.
|
|
3
|
+
export { default as SearchSuggestions } from './SearchSuggestions.astro';
|
|
4
|
+
export { appendResults } from './renderResult.ts';
|
|
5
|
+
export type { PagefindResultFragment, SearchTaxonomy, SearchTypeMeta } from './renderResult.ts';
|