@sarimarcus/content-sites-ui 0.15.0 → 0.16.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 +12 -0
- package/README.md +45 -1
- package/package.json +6 -3
- package/src/affiliate/select.ts +1 -1
- package/src/layout/AsyncStylesheet.astro +14 -0
- package/src/layout/index.ts +1 -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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.16.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- New `./navigation`: `SiteHeader` (fixed header, labelled main `<nav>`, logo/desktop/actions/mobile slots, mobile toggle, mobile panel after the header with `data-pagefind-ignore`, `surfaceClass`, Ctrl/Cmd+K → `searchPath?focus=1`), `NavDropdown` (click, hover with a 150 ms close delay, ArrowDown/ArrowUp/Escape, outside click, focus leaving; a click on a hover-opened dropdown keeps it open), `MobileNavSection` (single-open accordion, `aria-controls`), `NavMenuLink` / `MobileNavLink` (panel rows by `NavTone`), `SiteFooter` (sections, `<nav aria-label="Legal">`, cookie-preferences button through `data-cookie-preferences`, no inline handler), `isActivePath`, and the `SiteIdentity` / `NavLink` / `NavGroup` / `FooterSection` / `NavTone` contracts. One bundled controller, idempotent on `astro:page-load`, any number of headers per page; the toggle and chevron icons now switch state (SLA-2735).
|
|
8
|
+
- New `./search`: `appendResults` (Pagefind result cards from the site's type taxonomy; the excerpt sanitiser's `DOMParser` is created on first use, so the entry point is safe to import during SSR) and `SearchSuggestions` (SLA-2735).
|
|
9
|
+
- New `./search.css` and `./consent.css`: the search page and Silktide consent styles on theme roles, tuned per site by optional `--search-*` / `--consent-*` custom properties. Folds in the consent modal type hierarchy and toggle contrast (Dark Skies 5d13f8a), the SLA-2290 mobile banner compaction, and `.search-filters[hidden]` (SLA-2735).
|
|
10
|
+
- `./layout`: `AsyncStylesheet` (non-render-blocking sheet with a `<noscript>` fallback) (SLA-2735).
|
|
11
|
+
- `theme.css`: `inverse`, `inverse-foreground`, `inverse-muted` roles for the footer surface; sites must set them (SLA-2735).
|
|
12
|
+
|
|
13
|
+
## 0.15.1 (2026-10-01)
|
|
14
|
+
|
|
15
|
+
- `selectActivities` accepts activities without an `id` (the sites' activity type has it optional); 0.15.0 failed site type checks (SLA-2736).
|
|
16
|
+
|
|
5
17
|
## 0.15.0 (2026-10-01)
|
|
6
18
|
|
|
7
19
|
- New `./affiliate`: `AffiliateDisclosure` (provider key required, texts from the site), `AffiliateLink`, `InlineAffiliateCTA`, `StickyCTA` (inert when hidden, tracked, several bars per page, safe with blocked storage), `AmazonProductCard`/`Section`, `TravelpayoutsWidget` (hotel, activity, train, car), `AffiliateCard`, `AffiliateCardSection`, `AffiliateCarousel`, `AffiliateComparisonTable`, `HotelAffiliateCTA`. Links and widget sources arrive built by the site. `resources.ts` declares the hosts each widget kind loads (SLA-2736).
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @sarimarcus/content-sites-ui
|
|
2
2
|
|
|
3
3
|
Universal Astro UI shared by every content site. Named entry points, one per folder: `primitives`,
|
|
4
|
-
`composed`, `editorial`, `affiliate`, `seo`, `layout`, `navigation` and `types`. For example,
|
|
4
|
+
`composed`, `editorial`, `affiliate`, `seo`, `layout`, `navigation`, `search` and `types`. For example,
|
|
5
5
|
`import { Container } from '@sarimarcus/content-sites-ui/layout'`. `src/internal` is never exported,
|
|
6
6
|
and `src/styles` holds the Tailwind entry.
|
|
7
7
|
|
|
@@ -48,6 +48,50 @@ import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callo
|
|
|
48
48
|
Chip colour only through theme roles, including `-tint`, `-emphasis` and the `badge-on-image` / `chip-on-image`
|
|
49
49
|
roles, which each site sets to its own values.
|
|
50
50
|
|
|
51
|
+
## Navigation and search
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { SiteHeader, NavDropdown, MobileNavSection, SiteFooter, type NavLink, type FooterSection } from '@sarimarcus/content-sites-ui/navigation';
|
|
55
|
+
import { SearchSuggestions, appendResults } from '@sarimarcus/content-sites-ui/search';
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Shells for site chrome. The site composes every link, panel, logo and label; the shells own the landmarks, the ARIA
|
|
59
|
+
state and one bundled controller (idempotent on `astro:page-load`, any number of headers per page).
|
|
60
|
+
|
|
61
|
+
- `SiteHeader`: the fixed `<header>` with a labelled `<nav>`, slots `logo`, `desktop`, `actions`, `mobile-actions`
|
|
62
|
+
and `mobile`, the mobile-menu toggle, and the mobile panel rendered after the header (with `data-pagefind-ignore`).
|
|
63
|
+
`searchPath` turns on Ctrl/Cmd+K → `<searchPath>?focus=1`. `surfaceClass` sets the bar's and the panel's
|
|
64
|
+
background. Ids default to `main-nav`, `mobile-menu`, `mobile-menu-btn`.
|
|
65
|
+
- `NavDropdown`: trigger plus panel (default slot). Click, hover with a 150 ms close delay, ArrowDown/ArrowUp/Escape,
|
|
66
|
+
outside click and focus leaving. `tone` tints the default trigger; `buttonClass` replaces it.
|
|
67
|
+
- `MobileNavSection`: one section of the mobile panel's single-open accordion (its button's `aria-controls` names
|
|
68
|
+
its content).
|
|
69
|
+
- `NavMenuLink` / `MobileNavLink`: one row of a dropdown panel / of the mobile panel, from a `NavLink`, tinted by a
|
|
70
|
+
`NavTone`. `active` marks the row `aria-current="page"`; `emphasis` paints hub links in the tone.
|
|
71
|
+
- `isActivePath(currentPath, href)`: the page or a page below it, on a path-segment boundary.
|
|
72
|
+
- `SiteFooter`: brand slot, one column per `FooterSection`, a `bottom` slot, the copyright line and a
|
|
73
|
+
`<nav aria-label="Legal">` ending with the cookie-preferences button, which calls `window.openCookiePreferences()`
|
|
74
|
+
(the site's consent loader defines it). Defaults paint the `inverse` roles; class props restyle each part.
|
|
75
|
+
- `appendResults` renders Pagefind result cards from the site's type taxonomy; `SearchSuggestions` renders the
|
|
76
|
+
empty-state chips. The search page, its copy and its index stay in the site.
|
|
77
|
+
|
|
78
|
+
## Search and consent styles
|
|
79
|
+
|
|
80
|
+
`@sarimarcus/content-sites-ui/search.css` styles the search page markup (`appendResults`, `SearchSuggestions` and
|
|
81
|
+
the page's form, filters and states); `@sarimarcus/content-sites-ui/consent.css` styles the Silktide consent banner
|
|
82
|
+
and modal. Both read the theme roles, and optional `--search-*` / `--consent-*` custom properties, listed at the top
|
|
83
|
+
of each file, tune what differs per site. Load them as non-critical sheets with `AsyncStylesheet` (ui/layout):
|
|
84
|
+
|
|
85
|
+
```astro
|
|
86
|
+
---
|
|
87
|
+
import searchCssUrl from '@sarimarcus/content-sites-ui/search.css?url';
|
|
88
|
+
import { AsyncStylesheet } from '@sarimarcus/content-sites-ui/layout';
|
|
89
|
+
---
|
|
90
|
+
<AsyncStylesheet href={searchCssUrl} />
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A site's own additions to a sheet load after it.
|
|
94
|
+
|
|
51
95
|
## Tailwind
|
|
52
96
|
|
|
53
97
|
Tailwind v4 does not scan `node_modules`, so a site must import the package's source entry next to Tailwind:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,15 +33,18 @@
|
|
|
33
33
|
"./seo": "./src/seo/index.ts",
|
|
34
34
|
"./layout": "./src/layout/index.ts",
|
|
35
35
|
"./navigation": "./src/navigation/index.ts",
|
|
36
|
+
"./search": "./src/search/index.ts",
|
|
36
37
|
"./types": "./src/types/index.ts",
|
|
37
38
|
"./tailwind.css": "./src/styles/tailwind.css",
|
|
38
39
|
"./widths.css": "./src/styles/widths.css",
|
|
39
|
-
"./theme.css": "./src/styles/theme.css"
|
|
40
|
+
"./theme.css": "./src/styles/theme.css",
|
|
41
|
+
"./consent.css": "./src/styles/consent.css",
|
|
42
|
+
"./search.css": "./src/styles/search.css"
|
|
40
43
|
},
|
|
41
44
|
"peerDependencies": {
|
|
42
45
|
"@lucide/astro": "^1.23.0",
|
|
43
46
|
"astro": "^7.0.6",
|
|
44
47
|
"tailwindcss": "^4.1.18",
|
|
45
|
-
"@sarimarcus/content-sites-core": "0.
|
|
48
|
+
"@sarimarcus/content-sites-core": "0.16.0"
|
|
46
49
|
}
|
|
47
50
|
}
|
package/src/affiliate/select.ts
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A non-critical stylesheet that does not block rendering: loaded as `media="print"` and switched to `all` once it
|
|
4
|
+
* arrives, with a `<noscript>` fallback. Pass a URL from a `?url` import.
|
|
5
|
+
*/
|
|
6
|
+
interface Props {
|
|
7
|
+
href: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { href } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<link rel="stylesheet" href={href} media="print" onload="this.media='all'" />
|
|
14
|
+
<noscript><link rel="stylesheet" href={href} /></noscript>
|
package/src/layout/index.ts
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One link of the mobile panel. `emphasis` paints it in the tone (hub links); the icon is dimmed unless `iconStrong`
|
|
4
|
+
* or `emphasis`. The `leading` slot replaces the icon (a day-count badge, for instance).
|
|
5
|
+
*/
|
|
6
|
+
import { Icon } from '../primitives/index.ts';
|
|
7
|
+
import { TONES } from './tones.ts';
|
|
8
|
+
import type { NavLink, NavTone } from './types.ts';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
link: NavLink;
|
|
12
|
+
tone?: NavTone;
|
|
13
|
+
active?: boolean;
|
|
14
|
+
emphasis?: 'medium' | 'semibold';
|
|
15
|
+
iconStrong?: boolean;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { link, tone = 'primary', active = false, emphasis, iconStrong = Boolean(emphasis), class: className } = Astro.props;
|
|
20
|
+
const t = TONES[tone];
|
|
21
|
+
const WEIGHT = { medium: 'font-medium', semibold: 'font-semibold' } as const;
|
|
22
|
+
const look = emphasis
|
|
23
|
+
? `${WEIGHT[emphasis]} ${t.text} ${t.strongHover}`
|
|
24
|
+
: `text-muted-foreground ${t.hoverText}`;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<a
|
|
28
|
+
href={link.href}
|
|
29
|
+
aria-current={active ? 'page' : undefined}
|
|
30
|
+
class:list={[
|
|
31
|
+
`flex items-center gap-3 px-4 py-2.5 text-sm ${look} hover:bg-muted transition-colors focus-visible:ring-2 ${t.ring} focus-visible:ring-offset-2`,
|
|
32
|
+
active && !emphasis && `${t.text} font-medium`,
|
|
33
|
+
className,
|
|
34
|
+
]}
|
|
35
|
+
>
|
|
36
|
+
<slot name="leading">{link.icon && <Icon name={link.icon} class={`w-4 h-4 ${iconStrong ? t.icon : t.iconDim}`} />}</slot>
|
|
37
|
+
{link.label}
|
|
38
|
+
</a>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One section of the mobile menu's single-open accordion: a full-width button and the content the site fills
|
|
4
|
+
* through the default slot (its id, from deterministicId, is the button's aria-controls). Opening a section closes
|
|
5
|
+
* the others in the same panel (SiteHeader controller).
|
|
6
|
+
*/
|
|
7
|
+
import { deterministicId } from '@sarimarcus/content-sites-core/dom';
|
|
8
|
+
import { Icon } from '../primitives/index.ts';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
label: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
/** Classes of the leading icon (its colour, typically). */
|
|
14
|
+
iconClass?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
buttonClass?: string;
|
|
17
|
+
/** Mark the section button as the current page section (aria-current). */
|
|
18
|
+
current?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
label,
|
|
23
|
+
icon,
|
|
24
|
+
iconClass = 'w-4 h-4 text-primary',
|
|
25
|
+
class: className,
|
|
26
|
+
buttonClass = 'flex items-center justify-between w-full px-4 py-3 text-base font-medium text-foreground hover:bg-muted transition-colors cursor-pointer',
|
|
27
|
+
current = false,
|
|
28
|
+
} = Astro.props;
|
|
29
|
+
const contentId = deterministicId('mobile-nav', Astro.url.pathname, label);
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<div class:list={['mobile-section', className]} data-mobile-section>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
class:list={['mobile-section-btn', buttonClass]}
|
|
36
|
+
aria-current={current ? 'page' : undefined}
|
|
37
|
+
aria-expanded="false"
|
|
38
|
+
aria-controls={contentId}
|
|
39
|
+
data-mobile-section-button
|
|
40
|
+
>
|
|
41
|
+
<span class="flex items-center gap-2">
|
|
42
|
+
{icon && <Icon name={icon} class={iconClass} />}
|
|
43
|
+
{label}
|
|
44
|
+
</span>
|
|
45
|
+
<Icon name="chevron-down" class="w-4 h-4 transition-transform section-chevron" />
|
|
46
|
+
</button>
|
|
47
|
+
<div id={contentId} class="mobile-section-content hidden" data-mobile-section-content>
|
|
48
|
+
<slot />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<style>
|
|
53
|
+
.mobile-section-btn[aria-expanded='true'] :global(.section-chevron) {
|
|
54
|
+
transform: rotate(180deg);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.mobile-section-content {
|
|
58
|
+
display: block;
|
|
59
|
+
padding-bottom: 0.5rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.mobile-section-content.hidden {
|
|
63
|
+
display: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
.mobile-section :global(.section-chevron),
|
|
68
|
+
.mobile-section-content {
|
|
69
|
+
transition: none !important;
|
|
70
|
+
animation: none !important;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A desktop dropdown: the trigger button (aria-expanded/haspopup/controls) and the panel the site fills through the
|
|
4
|
+
* default slot. The panel opens on click, hover (closing after a short delay), ArrowDown and keyboard focus; the
|
|
5
|
+
* SiteHeader controller drives it. `buttonClass` replaces the default trigger styling, which `tone` tints.
|
|
6
|
+
*/
|
|
7
|
+
import { Icon } from '../primitives/index.ts';
|
|
8
|
+
import type { NavTone } from './types.ts';
|
|
9
|
+
import { TONES } from './tones.ts';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Id of the panel, referenced by the button's aria-controls. */
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: string;
|
|
16
|
+
tone?: NavTone;
|
|
17
|
+
active?: boolean;
|
|
18
|
+
align?: 'left' | 'center' | 'right';
|
|
19
|
+
buttonClass?: string;
|
|
20
|
+
/** Mark the trigger itself as the current page section (aria-current). */
|
|
21
|
+
current?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { id, label, icon, tone = 'primary', active = false, align = 'left', buttonClass, current = false } = Astro.props;
|
|
25
|
+
|
|
26
|
+
const t = TONES[tone];
|
|
27
|
+
const ALIGN = { left: 'left-0', center: 'left-1/2 -translate-x-1/2', right: 'right-0' } as const;
|
|
28
|
+
const triggerClass = buttonClass ?? [
|
|
29
|
+
`flex items-center gap-1.5 px-3 xl:gap-2 xl:px-4 py-2 text-base font-medium rounded-lg transition-colors hover:bg-muted focus-visible:ring-2 ${t.ring} focus-visible:ring-offset-2 cursor-pointer`,
|
|
30
|
+
active ? t.text : `text-muted-foreground ${t.hoverText}`,
|
|
31
|
+
].join(' ');
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<div class="nav-dropdown relative" data-nav-dropdown>
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
class:list={['nav-dropdown-btn', triggerClass]}
|
|
38
|
+
aria-current={current ? 'page' : undefined}
|
|
39
|
+
aria-expanded="false"
|
|
40
|
+
aria-haspopup="true"
|
|
41
|
+
aria-controls={id}
|
|
42
|
+
data-nav-dropdown-button
|
|
43
|
+
>
|
|
44
|
+
{icon && <Icon name={icon} class="hidden xl:block w-4 h-4" />}
|
|
45
|
+
{label}
|
|
46
|
+
<Icon name="chevron-down" class="w-3 h-3 transition-transform dropdown-chevron" />
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
<div id={id} class:list={['dropdown-menu absolute top-full mt-0 pt-2', ALIGN[align]]}>
|
|
50
|
+
<slot />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
/* Opened by the controller through aria-expanded, never by :hover, so crossing the gap does not flicker. */
|
|
56
|
+
.dropdown-menu {
|
|
57
|
+
opacity: 0;
|
|
58
|
+
visibility: hidden;
|
|
59
|
+
transform: translateY(4px);
|
|
60
|
+
transition: opacity 200ms, visibility 200ms, transform 200ms;
|
|
61
|
+
pointer-events: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.nav-dropdown-btn[aria-expanded='true'] + .dropdown-menu,
|
|
65
|
+
.nav-dropdown:focus-within .dropdown-menu {
|
|
66
|
+
opacity: 1;
|
|
67
|
+
visibility: visible;
|
|
68
|
+
transform: translateY(0);
|
|
69
|
+
pointer-events: auto;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.nav-dropdown-btn[aria-expanded='true'] :global(.dropdown-chevron),
|
|
73
|
+
.nav-dropdown:focus-within :global(.dropdown-chevron) {
|
|
74
|
+
transform: rotate(180deg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@media (prefers-reduced-motion: reduce) {
|
|
78
|
+
.dropdown-menu,
|
|
79
|
+
.nav-dropdown :global(.dropdown-chevron) {
|
|
80
|
+
transition: none !important;
|
|
81
|
+
animation: none !important;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -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';
|