@sarimarcus/content-sites-ui 0.29.3 → 0.30.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 +16 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/affiliate/AffiliateComparisonTable.astro +7 -0
- package/src/affiliate/HotelAffiliateCTA.astro +12 -2
- package/src/affiliate/provider.ts +6 -0
- package/src/affiliate/types.ts +2 -0
- package/src/composed/CardCarousel.astro +7 -4
- package/src/composed/Pagination.astro +3 -3
- package/src/composed/TableOfContents.astro +1 -0
- package/src/composed/carousel.ts +19 -7
- package/src/composed/toc.ts +1 -0
- package/src/editorial/interludes/Interlude.astro +1 -1
- package/src/navigation/ArticlesNavMenu.astro +3 -5
- package/src/navigation/NavDropdown.astro +6 -7
- package/src/navigation/SiteFooter.astro +2 -1
- package/src/navigation/navigation.ts +13 -4
- package/src/primitives/FeatureList.astro +7 -10
- package/src/search/searchPage.ts +16 -5
- package/src/seo/ArticleMetadata.astro +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.30.0 (2026-10-07)
|
|
6
|
+
|
|
7
|
+
- `./affiliate`: `HotelAffiliateCTA` and `AffiliateComparisonTable` links carry `data-track-affiliate-provider` and `data-track-affiliate-placement` so booking clicks are attributed; both take `provider`, `ComparisonRow` takes a per-row one, and without either the provider is read from the link's host (Booking.com links only; any other host is left unattributed) (SLA-2985).
|
|
8
|
+
- `./editorial`: `Interlude` renders a stat-fact `figure` through `renderInline`, so `**80 titles**` is bold and `<20%` is escaped (SLA-2985).
|
|
9
|
+
- `./search`: the load-more button ignores clicks while a page is loading and drops fragments fetched for a superseded query, so a double click no longer repeats 12 results and a new query no longer receives stale ones (SLA-2985).
|
|
10
|
+
|
|
11
|
+
### Breaking
|
|
12
|
+
|
|
13
|
+
- `./primitives`: `FeatureList`'s `items` and `footer` are typed `TrustedHtml` (were `string`); wrap plain text with `textAsHtml()` and parsed markdown with `trustedHtml()`. Sites run `scripts/platform/migrations/feature-list-trusted.mjs` (`items` → `.map(textAsHtml)`, `footer` → `trustedHtml(…)`) (SLA-2985).
|
|
14
|
+
|
|
15
|
+
## 0.29.4 (2026-10-07)
|
|
16
|
+
|
|
17
|
+
- `./composed`: `Pagination`'s current page uses `text-primary-foreground` (was `text-white`, 1.8:1 on a light primary) and Previous/Next are named by their visible text; `TableOfContents`' hidden mini bar is `inert`; `CardCarousel`'s arrows use `aria-disabled`, so the focused arrow keeps focus at an edge, and arrows and dots have 44px targets (SLA-2984).
|
|
18
|
+
- `./seo`: `ArticleMetadata`'s byline is a labelled `group`, no longer a second `contentinfo` landmark inside `<main>` (SLA-2984).
|
|
19
|
+
- `./navigation`: `NavDropdown` opens only through `aria-expanded` (no `:focus-within`), so Escape and click-to-close stay closed while focus is inside, and drops `aria-haspopup`; the mobile menu closes and releases the body scroll lock when the viewport crosses `lg`; `SiteFooter`'s legal links are 44px tall (SLA-2984).
|
|
20
|
+
|
|
5
21
|
## 0.29.3 (2026-10-07)
|
|
6
22
|
|
|
7
23
|
- `./layout`: `ConsentAnalytics` builds the absolute `gtm.js` URL at render time and passes it to the inline script, which no longer holds a relative `'/gtm.js?id='` literal that Googlebot crawled as a 404 on each site (SLA-3001).
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callo
|
|
|
36
36
|
`rel="noopener noreferrer"`, unless `target` or `rel` is given. Attributes render as `href rel target class`,
|
|
37
37
|
then every other anchor attribute (`data-*`, `aria-*`, `slot`) in the order passed.
|
|
38
38
|
- `Callout`: a boxed note with an icon. `type` is `info` (default), `tip`, `warning` or `success`; `title` is optional.
|
|
39
|
-
- `FeatureList`: a checklist or a row of tags from `items` (rendered with `set:html`). `color` is `primary`
|
|
39
|
+
- `FeatureList`: a checklist or a row of tags from `items` and an optional `footer`, both `TrustedHtml` (rendered with `set:html`; wrap plain text with `textAsHtml()`). `color` is `primary`
|
|
40
40
|
(default), `secondary` or `accent`; `container` boxes it, `compact` tightens it, `headingLevel` sets the heading
|
|
41
41
|
(default `h2`), which renders only for a non-empty `title`; `footer` adds a line under a border.
|
|
42
42
|
- `Blockquote`: a centred pull-quote, `quote` plus an optional `source`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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",
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"@lucide/astro": "^1.23.0",
|
|
50
50
|
"astro": "^7.0.6",
|
|
51
51
|
"tailwindcss": "^4.1.18",
|
|
52
|
-
"@sarimarcus/content-sites-core": "0.
|
|
52
|
+
"@sarimarcus/content-sites-core": "0.30.0"
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import Icon from '../primitives/Icon.astro';
|
|
9
9
|
import Badge from '../primitives/Badge.astro';
|
|
10
10
|
import type { ComparisonRow, ComparisonTableLabels } from './types';
|
|
11
|
+
import { providerFromHref } from './provider';
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
rows: ComparisonRow[];
|
|
@@ -17,6 +18,7 @@ interface Props {
|
|
|
17
18
|
priceLabel?: string;
|
|
18
19
|
featureLabel?: string;
|
|
19
20
|
placement?: string;
|
|
21
|
+
provider?: string;
|
|
20
22
|
labels?: ComparisonTableLabels;
|
|
21
23
|
class?: string;
|
|
22
24
|
}
|
|
@@ -29,6 +31,7 @@ const {
|
|
|
29
31
|
priceLabel = 'Price',
|
|
30
32
|
featureLabel = 'Highlight',
|
|
31
33
|
placement = 'comparison-table',
|
|
34
|
+
provider,
|
|
32
35
|
labels = {},
|
|
33
36
|
class: className = '',
|
|
34
37
|
} = Astro.props;
|
|
@@ -118,6 +121,8 @@ const defaultCta = (external: boolean) => (external ? externalCta : internalCta)
|
|
|
118
121
|
data-track-event="affiliate_click"
|
|
119
122
|
data-track-cta-id={row.trackId}
|
|
120
123
|
data-track-cta-placement={placement}
|
|
124
|
+
data-track-affiliate-provider={row.provider ?? provider ?? providerFromHref(row.href)}
|
|
125
|
+
data-track-affiliate-placement={placement}
|
|
121
126
|
class={ctaAffiliate}
|
|
122
127
|
aria-label={externalAria(cta, row.name)}
|
|
123
128
|
>
|
|
@@ -188,6 +193,8 @@ const defaultCta = (external: boolean) => (external ? externalCta : internalCta)
|
|
|
188
193
|
data-track-event="affiliate_click"
|
|
189
194
|
data-track-cta-id={row.trackId}
|
|
190
195
|
data-track-cta-placement={placement}
|
|
196
|
+
data-track-affiliate-provider={row.provider ?? provider ?? providerFromHref(row.href)}
|
|
197
|
+
data-track-affiliate-placement={placement}
|
|
191
198
|
class={`${ctaAffiliate} w-full`}
|
|
192
199
|
aria-label={externalAria(cta, row.name)}
|
|
193
200
|
>
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { isBadgeLength } from '../primitives/badgeText';
|
|
14
14
|
import type { HotelCtaLabels } from './types';
|
|
15
15
|
import { templateLine } from './templateText';
|
|
16
|
+
import { providerFromHref } from './provider';
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
interface Props {
|
|
@@ -23,6 +24,7 @@ interface Props {
|
|
|
23
24
|
entityNoun?: string;
|
|
24
25
|
trackId?: string;
|
|
25
26
|
placement?: string;
|
|
27
|
+
provider?: string;
|
|
26
28
|
priceText?: string | null;
|
|
27
29
|
priceCompactMax?: number;
|
|
28
30
|
priceNoteSlot?: boolean;
|
|
@@ -45,6 +47,7 @@ const {
|
|
|
45
47
|
entityNoun = 'hotel',
|
|
46
48
|
trackId = 'hotel-affiliate-cta',
|
|
47
49
|
placement,
|
|
50
|
+
provider,
|
|
48
51
|
priceText = null,
|
|
49
52
|
priceCompactMax,
|
|
50
53
|
priceNoteSlot = false,
|
|
@@ -73,6 +76,9 @@ const {
|
|
|
73
76
|
officialSite = `Visit the ${entityNoun}'s official site`,
|
|
74
77
|
} = labels;
|
|
75
78
|
|
|
79
|
+
const trackProvider = provider ?? providerFromHref(href);
|
|
80
|
+
const placementAbove = placement ?? 'above-fold';
|
|
81
|
+
const placementBook = placement ?? 'book-section';
|
|
76
82
|
const ctaLabel = isSearchFallback ? searchCta : bookCta;
|
|
77
83
|
const subhead = isSearchFallback ? searchSubhead : bookSubhead;
|
|
78
84
|
const priceIsCompact = priceCompactMax === undefined || isBadgeLength(priceText, priceCompactMax);
|
|
@@ -89,7 +95,9 @@ const ctaClasses =
|
|
|
89
95
|
rel="sponsored noopener noreferrer"
|
|
90
96
|
data-track-event="affiliate_click"
|
|
91
97
|
data-track-cta-id={trackId}
|
|
92
|
-
data-track-cta-placement={
|
|
98
|
+
data-track-cta-placement={placementAbove}
|
|
99
|
+
data-track-affiliate-provider={trackProvider}
|
|
100
|
+
data-track-affiliate-placement={placementAbove}
|
|
93
101
|
class:list={[ctaClasses, 'sm:w-auto', className]}
|
|
94
102
|
>
|
|
95
103
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
@@ -142,7 +150,9 @@ const ctaClasses =
|
|
|
142
150
|
rel="sponsored noopener noreferrer"
|
|
143
151
|
data-track-event="affiliate_click"
|
|
144
152
|
data-track-cta-id={trackId}
|
|
145
|
-
data-track-cta-placement={
|
|
153
|
+
data-track-cta-placement={placementBook}
|
|
154
|
+
data-track-affiliate-provider={trackProvider}
|
|
155
|
+
data-track-affiliate-placement={placementBook}
|
|
146
156
|
class:list={[ctaClasses, 'mt-4']}
|
|
147
157
|
>
|
|
148
158
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The affiliate provider a link belongs to, read from its host; undefined for a host no provider is known for. */
|
|
2
|
+
export function providerFromHref(href: string | undefined): string | undefined {
|
|
3
|
+
const host = /^https?:\/\/([^/?#:]+)/i.exec(href ?? '')?.[1]?.toLowerCase();
|
|
4
|
+
if (!host) return undefined;
|
|
5
|
+
return host === 'booking.com' || host.endsWith('.booking.com') ? 'booking' : undefined;
|
|
6
|
+
}
|
package/src/affiliate/types.ts
CHANGED
|
@@ -72,6 +72,8 @@ export interface ComparisonRow {
|
|
|
72
72
|
sponsored?: boolean;
|
|
73
73
|
cta?: string;
|
|
74
74
|
trackId?: string;
|
|
75
|
+
/** Affiliate provider for click attribution; falls back to the table's `provider`. */
|
|
76
|
+
provider?: string;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
/** AffiliateComparisonTable copy, all with English defaults. */
|
|
@@ -51,6 +51,9 @@ const HeadingTag = headingTag;
|
|
|
51
51
|
// Ids must be unique per page AND stable across builds.
|
|
52
52
|
const containerId = deterministicId('cc', Astro.url.pathname, id, ariaLabel ?? heading);
|
|
53
53
|
const trackId = `${containerId}-t`;
|
|
54
|
+
// The ::before grows the 36px circle into a 44px touch target.
|
|
55
|
+
const arrowClass =
|
|
56
|
+
'relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card transition-colors before:absolute before:-inset-1 hover:border-primary/40 hover:bg-primary/10 aria-disabled:pointer-events-none aria-disabled:opacity-25';
|
|
54
57
|
---
|
|
55
58
|
|
|
56
59
|
<div id={id} data-carousel={containerId} class:list={['scroll-mt-28', className]}>
|
|
@@ -80,8 +83,8 @@ const trackId = `${containerId}-t`;
|
|
|
80
83
|
data-carousel-prev
|
|
81
84
|
type="button"
|
|
82
85
|
aria-label={`Previous, ${ariaLabel ?? heading ?? 'carousel'}`}
|
|
83
|
-
disabled
|
|
84
|
-
class=
|
|
86
|
+
aria-disabled="true"
|
|
87
|
+
class={arrowClass}
|
|
85
88
|
>
|
|
86
89
|
<Icon name="chevron-left" class="h-4 w-4" aria-hidden="true" />
|
|
87
90
|
</button>
|
|
@@ -89,7 +92,7 @@ const trackId = `${containerId}-t`;
|
|
|
89
92
|
data-carousel-next
|
|
90
93
|
type="button"
|
|
91
94
|
aria-label={`Next, ${ariaLabel ?? heading ?? 'carousel'}`}
|
|
92
|
-
class=
|
|
95
|
+
class={arrowClass}
|
|
93
96
|
>
|
|
94
97
|
<Icon name="chevron-right" class="h-4 w-4" aria-hidden="true" />
|
|
95
98
|
</button>
|
|
@@ -106,7 +109,7 @@ const trackId = `${containerId}-t`;
|
|
|
106
109
|
>
|
|
107
110
|
<slot />
|
|
108
111
|
</div>
|
|
109
|
-
<div data-carousel-dots class="mt-4 hidden justify-center
|
|
112
|
+
<div data-carousel-dots class="mt-4 hidden justify-center"></div>
|
|
110
113
|
</div>
|
|
111
114
|
|
|
112
115
|
<style is:global>
|
|
@@ -44,7 +44,7 @@ const edgeBase = `${linkBase} px-3`;
|
|
|
44
44
|
{totalPages > 1 && (
|
|
45
45
|
<nav aria-label="Pagination" class="mt-14 flex items-center justify-center gap-2 flex-wrap">
|
|
46
46
|
{prevPage ? (
|
|
47
|
-
<a href={href(prevPage)} rel="prev"
|
|
47
|
+
<a href={href(prevPage)} rel="prev" class={edgeBase}>
|
|
48
48
|
<Icon name="arrow-left" class="w-3.5 h-3.5" />
|
|
49
49
|
<span>Previous</span>
|
|
50
50
|
</a>
|
|
@@ -65,7 +65,7 @@ const edgeBase = `${linkBase} px-3`;
|
|
|
65
65
|
<span
|
|
66
66
|
aria-current="page"
|
|
67
67
|
aria-label={`Page ${item}, current page`}
|
|
68
|
-
class={`${numberBase} border-primary bg-primary text-
|
|
68
|
+
class={`${numberBase} border-primary bg-primary text-primary-foreground hover:text-primary-foreground`}
|
|
69
69
|
>
|
|
70
70
|
{item}
|
|
71
71
|
</span>
|
|
@@ -82,7 +82,7 @@ const edgeBase = `${linkBase} px-3`;
|
|
|
82
82
|
</div>
|
|
83
83
|
|
|
84
84
|
{nextPage ? (
|
|
85
|
-
<a href={href(nextPage)} rel="next"
|
|
85
|
+
<a href={href(nextPage)} rel="next" class={edgeBase}>
|
|
86
86
|
<span>Next</span>
|
|
87
87
|
<Icon name="arrow-right" class="w-3.5 h-3.5" />
|
|
88
88
|
</a>
|
|
@@ -71,6 +71,7 @@ const showMini = variant === 'card';
|
|
|
71
71
|
data-toc-section-selector={sectionSelector}
|
|
72
72
|
class="fixed top-16 left-0 right-0 z-40 translate-y-[-100%] opacity-0 pointer-events-none transition-[transform,opacity] duration-300 ease-out bg-background/95 backdrop-blur-sm border-b border-border shadow-sm"
|
|
73
73
|
aria-hidden="true"
|
|
74
|
+
inert
|
|
74
75
|
>
|
|
75
76
|
<div class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8 py-2.5">
|
|
76
77
|
<nav class="flex gap-2 overflow-x-auto scrollbar-hide" aria-label={`${ariaLabel} (sticky)`}>
|
package/src/composed/carousel.ts
CHANGED
|
@@ -17,18 +17,26 @@ function initCarousel(section: HTMLElement): void {
|
|
|
17
17
|
const dotsWrap = section.querySelector<HTMLElement>('[data-carousel-dots]');
|
|
18
18
|
let currentIndex = 0;
|
|
19
19
|
|
|
20
|
+
// Each dot is a 44px hit area around its 6px pill; the negative margin keeps the row 6px tall.
|
|
20
21
|
const dots: HTMLButtonElement[] = [];
|
|
22
|
+
const pills: HTMLElement[] = [];
|
|
21
23
|
if (dotsWrap) {
|
|
22
24
|
cards.forEach((_, i) => {
|
|
23
25
|
const dot = document.createElement('button');
|
|
24
26
|
dot.type = 'button';
|
|
25
27
|
dot.setAttribute('aria-label', `Go to item ${i + 1}`);
|
|
26
28
|
dot.style.cssText =
|
|
27
|
-
'
|
|
29
|
+
'display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;' +
|
|
30
|
+
'margin:-1.1875rem 0;border:0;padding:0;background:transparent;cursor:pointer;';
|
|
31
|
+
const pill = document.createElement('span');
|
|
32
|
+
pill.style.cssText =
|
|
33
|
+
'display:block;height:0.375rem;border-radius:9999px;transition:all 0.2s;' +
|
|
28
34
|
(i === 0 ? `width:1rem;background:${ACTIVE};` : `width:0.375rem;background:${INACTIVE};`);
|
|
35
|
+
dot.appendChild(pill);
|
|
29
36
|
dot.addEventListener('click', () => scrollToIndex(i));
|
|
30
37
|
dotsWrap.appendChild(dot);
|
|
31
38
|
dots.push(dot);
|
|
39
|
+
pills.push(pill);
|
|
32
40
|
});
|
|
33
41
|
}
|
|
34
42
|
|
|
@@ -43,14 +51,17 @@ function initCarousel(section: HTMLElement): void {
|
|
|
43
51
|
currentIndex = Math.max(0, Math.min(idx, cards.length - 1));
|
|
44
52
|
// Disabled state comes from the scroll position, not the card index: with several cards per view the track hits
|
|
45
53
|
// max scroll while currentIndex is still mid-list.
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
// aria-disabled, not disabled: disabling the focused arrow would drop focus to body.
|
|
55
|
+
prevBtn?.setAttribute('aria-disabled', String(startEdge));
|
|
56
|
+
nextBtn?.setAttribute('aria-disabled', String(endEdge));
|
|
48
57
|
// At the extremes the dot shows the first or last card, otherwise the trailing dots could never light up.
|
|
49
58
|
const dotIndex = endEdge ? cards.length - 1 : startEdge ? 0 : currentIndex;
|
|
50
59
|
dots.forEach((dot, i) => {
|
|
51
60
|
const active = i === dotIndex;
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
pills[i].style.width = active ? '1rem' : '0.375rem';
|
|
62
|
+
pills[i].style.background = active ? ACTIVE : INACTIVE;
|
|
63
|
+
if (active) dot.setAttribute('aria-current', 'true');
|
|
64
|
+
else dot.removeAttribute('aria-current');
|
|
54
65
|
});
|
|
55
66
|
}
|
|
56
67
|
|
|
@@ -81,8 +92,9 @@ function initCarousel(section: HTMLElement): void {
|
|
|
81
92
|
});
|
|
82
93
|
}, { passive: true });
|
|
83
94
|
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
const enabled = (btn: HTMLButtonElement) => btn.getAttribute('aria-disabled') !== 'true';
|
|
96
|
+
prevBtn?.addEventListener('click', () => enabled(prevBtn) && scrollToIndex(currentIndex - 1));
|
|
97
|
+
nextBtn?.addEventListener('click', () => enabled(nextBtn) && scrollToIndex(currentIndex + 1));
|
|
86
98
|
|
|
87
99
|
// Controls show only when the track overflows; re-checked whenever the track resizes.
|
|
88
100
|
function syncControls(): void {
|
package/src/composed/toc.ts
CHANGED
|
@@ -12,6 +12,7 @@ function setMiniVisible(mini: Element, visible: boolean): void {
|
|
|
12
12
|
mini.classList.toggle('opacity-100', visible);
|
|
13
13
|
mini.classList.toggle('pointer-events-auto', visible);
|
|
14
14
|
mini.setAttribute('aria-hidden', visible ? 'false' : 'true');
|
|
15
|
+
mini.toggleAttribute('inert', !visible);
|
|
15
16
|
document.dispatchEvent(new CustomEvent('toc:mini-visibility-change', { detail: { visible } }));
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -37,7 +37,7 @@ const { interlude: il, renderInline: md, labels, author, didYouKnowVariant, didY
|
|
|
37
37
|
/>
|
|
38
38
|
)}
|
|
39
39
|
|
|
40
|
-
{il.type === 'stat-fact' && <StatFact figure={
|
|
40
|
+
{il.type === 'stat-fact' && <StatFact figure={md(il.figure)} caption={md(il.caption)} />}
|
|
41
41
|
|
|
42
42
|
{il.type === 'evidence-context' && (
|
|
43
43
|
<EvidenceContext
|
|
@@ -125,16 +125,14 @@ const featureStyle = featureBg
|
|
|
125
125
|
)}
|
|
126
126
|
|
|
127
127
|
<style>
|
|
128
|
-
/* data-opened is set on the first open and kept
|
|
129
|
-
:global([data-nav-dropdown][data-opened]) .articles-nav-feature
|
|
130
|
-
:global(.nav-dropdown:focus-within) .articles-nav-feature {
|
|
128
|
+
/* data-opened is set on the first open and kept, so the image never unpaints mid-fade. */
|
|
129
|
+
:global([data-nav-dropdown][data-opened]) .articles-nav-feature {
|
|
131
130
|
background-image: var(--articles-nav-feature, none);
|
|
132
131
|
}
|
|
133
132
|
|
|
134
133
|
/* A var() holding an unsupported image-set() computes to none, so older engines get the 1x file instead. */
|
|
135
134
|
@supports not (background-image: image-set(url('x.webp') 1x)) {
|
|
136
|
-
:global([data-nav-dropdown][data-opened]) .articles-nav-feature
|
|
137
|
-
:global(.nav-dropdown:focus-within) .articles-nav-feature {
|
|
135
|
+
:global([data-nav-dropdown][data-opened]) .articles-nav-feature {
|
|
138
136
|
background-image: var(--articles-nav-feature-1x, none);
|
|
139
137
|
}
|
|
140
138
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* A desktop dropdown: the trigger button (aria-expanded/
|
|
4
|
-
* default slot. The panel opens on click, hover (closing after a short delay)
|
|
3
|
+
* A desktop dropdown: the trigger button (aria-expanded/controls) and the panel the site fills through the
|
|
4
|
+
* default slot. The panel opens on click, hover (closing after a short delay) and ArrowDown, never on focus alone; the
|
|
5
5
|
* SiteHeader controller drives it. `buttonClass` replaces the default trigger styling, which `tone` tints.
|
|
6
6
|
*/
|
|
7
7
|
import { Icon } from '../primitives/index.ts';
|
|
@@ -37,7 +37,6 @@ const triggerClass = buttonClass ?? [
|
|
|
37
37
|
class:list={['nav-dropdown-btn', triggerClass]}
|
|
38
38
|
aria-current={current ? 'page' : undefined}
|
|
39
39
|
aria-expanded="false"
|
|
40
|
-
aria-haspopup="true"
|
|
41
40
|
aria-controls={id}
|
|
42
41
|
data-nav-dropdown-button
|
|
43
42
|
>
|
|
@@ -61,16 +60,16 @@ const triggerClass = buttonClass ?? [
|
|
|
61
60
|
pointer-events: none;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
|
|
65
|
-
.nav-dropdown
|
|
63
|
+
/* visibility flips at once on open, so ArrowDown can focus a link in the same tick; closing still fades. */
|
|
64
|
+
.nav-dropdown-btn[aria-expanded='true'] + .dropdown-menu {
|
|
65
|
+
transition: opacity 200ms, visibility 0s, transform 200ms;
|
|
66
66
|
opacity: 1;
|
|
67
67
|
visibility: visible;
|
|
68
68
|
transform: translateY(0);
|
|
69
69
|
pointer-events: auto;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
.nav-dropdown-btn[aria-expanded='true'] :global(.dropdown-chevron)
|
|
73
|
-
.nav-dropdown:focus-within :global(.dropdown-chevron) {
|
|
72
|
+
.nav-dropdown-btn[aria-expanded='true'] :global(.dropdown-chevron) {
|
|
74
73
|
transform: rotate(180deg);
|
|
75
74
|
}
|
|
76
75
|
|
|
@@ -49,7 +49,8 @@ const {
|
|
|
49
49
|
ringClass = 'rounded focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-inverse',
|
|
50
50
|
} = Astro.props;
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// A real 44px box, not an overflowing hit area: wrapped rows would overlap and steal each other's taps.
|
|
53
|
+
const legalItemClass = `${smallClass} inline-flex min-h-11 items-center hover:text-inverse-foreground transition-colors ${ringClass}`;
|
|
53
54
|
---
|
|
54
55
|
|
|
55
56
|
<footer class={className}>
|
|
@@ -113,6 +113,13 @@ function searchPathOf(root: HTMLElement): string | null {
|
|
|
113
113
|
return (window.location.pathname.indexOf(compiled) === 0 ? compiled : '') + path;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
function closeOpenMobileMenus(refocus: boolean): void {
|
|
117
|
+
roots().forEach((root) => {
|
|
118
|
+
const toggle = root.querySelector<HTMLButtonElement>('[data-mobile-menu-toggle]');
|
|
119
|
+
if (toggle?.getAttribute('aria-expanded') === 'true') setMobileMenu(root, toggle, false, refocus);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
function bindDocument(): void {
|
|
117
124
|
if (documentBound) return;
|
|
118
125
|
documentBound = true;
|
|
@@ -125,10 +132,7 @@ function bindDocument(): void {
|
|
|
125
132
|
|
|
126
133
|
document.addEventListener('keydown', (event) => {
|
|
127
134
|
if (event.key === 'Escape') {
|
|
128
|
-
|
|
129
|
-
const toggle = root.querySelector<HTMLButtonElement>('[data-mobile-menu-toggle]');
|
|
130
|
-
if (toggle?.getAttribute('aria-expanded') === 'true') setMobileMenu(root, toggle, false, true);
|
|
131
|
-
});
|
|
135
|
+
closeOpenMobileMenus(true);
|
|
132
136
|
return;
|
|
133
137
|
}
|
|
134
138
|
if (!(event.metaKey || event.ctrlKey) || event.key !== 'k') return;
|
|
@@ -140,6 +144,11 @@ function bindDocument(): void {
|
|
|
140
144
|
event.preventDefault();
|
|
141
145
|
window.location.assign(`${searchPath}?focus=1`);
|
|
142
146
|
});
|
|
147
|
+
|
|
148
|
+
// Tailwind's lg: the panel hides itself past it, so the body scroll lock must go with it.
|
|
149
|
+
window.matchMedia('(min-width: 64rem)').addEventListener('change', (event) => {
|
|
150
|
+
if (event.matches) closeOpenMobileMenus(false);
|
|
151
|
+
});
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
export function initNavigation(): void {
|
|
@@ -2,34 +2,31 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* FeatureList — reusable list display for specialties, ingredients, features.
|
|
4
4
|
*
|
|
5
|
-
* `items[]` and `footer` render with set:html
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
* JSON contained no angle brackets and no entity-like ampersands, and the
|
|
9
|
-
* rendered list output was byte-identical across all 462 list items on the three
|
|
10
|
-
* active sites. Callers passing author-written markdown must parse it first —
|
|
11
|
-
* see the `checklist` branch of the site's interlude dispatcher.
|
|
5
|
+
* `items[]` and `footer` render with set:html and are typed TrustedHtml: parse author-written
|
|
6
|
+
* markdown first (see the `checklist` branch of the interlude dispatcher) or wrap plain
|
|
7
|
+
* text with `textAsHtml()`.
|
|
12
8
|
*
|
|
13
9
|
* The heading renders only for a non-empty `title`.
|
|
14
10
|
*
|
|
15
11
|
* Usage:
|
|
16
12
|
* <FeatureList title="Specialities" items={bar.specialties} icon="star" />
|
|
17
13
|
* <FeatureList title="House specialities" items={restaurant.specialties} variant="tags" color="accent" container />
|
|
18
|
-
* <FeatureList title="Main ingredients" items={dish.mainIngredients} icon="star" color="accent" container footer="<strong>Allergens:</strong> gluten, dairy" />
|
|
14
|
+
* <FeatureList title="Main ingredients" items={dish.mainIngredients} icon="star" color="accent" container footer={trustedHtml("<strong>Allergens:</strong> gluten, dairy")} />
|
|
19
15
|
*/
|
|
20
16
|
|
|
21
17
|
import Icon from './Icon.astro';
|
|
18
|
+
import type { TrustedHtml } from '../types';
|
|
22
19
|
|
|
23
20
|
interface Props {
|
|
24
21
|
title?: string;
|
|
25
|
-
items:
|
|
22
|
+
items: TrustedHtml[];
|
|
26
23
|
variant?: 'checklist' | 'tags';
|
|
27
24
|
color?: 'primary' | 'accent' | 'secondary';
|
|
28
25
|
icon?: string;
|
|
29
26
|
container?: boolean;
|
|
30
27
|
/** Tighter padding/margin for space-constrained contexts (e.g. article interludes). */
|
|
31
28
|
compact?: boolean;
|
|
32
|
-
footer?:
|
|
29
|
+
footer?: TrustedHtml;
|
|
33
30
|
headingLevel?: 'h2' | 'h3' | 'h4';
|
|
34
31
|
class?: string;
|
|
35
32
|
}
|
package/src/search/searchPage.ts
CHANGED
|
@@ -294,6 +294,7 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
|
|
|
294
294
|
|
|
295
295
|
writeURL(query, typeFilter, audienceFilter);
|
|
296
296
|
const myToken = ++searchToken;
|
|
297
|
+
loadMoreBtn.disabled = false;
|
|
297
298
|
|
|
298
299
|
if (!query) {
|
|
299
300
|
updateAudienceCounts(undefined); // back to corpus counts
|
|
@@ -514,6 +515,7 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
|
|
|
514
515
|
});
|
|
515
516
|
|
|
516
517
|
loadMoreBtn.addEventListener('click', async () => {
|
|
518
|
+
if (loadMoreBtn.disabled) return;
|
|
517
519
|
if (activeType) {
|
|
518
520
|
// All type-filtered fragments are already in cachedFragments. An audience
|
|
519
521
|
// filter needs no branch here: it narrowed cachedHandles server-side, so
|
|
@@ -528,11 +530,20 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
|
|
|
528
530
|
|
|
529
531
|
const nextHandles = cachedHandles.slice(renderedCount, renderedCount + pageSize);
|
|
530
532
|
if (nextHandles.length === 0) return;
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
533
|
+
const myToken = searchToken;
|
|
534
|
+
loadMoreBtn.disabled = true;
|
|
535
|
+
try {
|
|
536
|
+
const fragments = await Promise.all(nextHandles.map((h) => h.data()));
|
|
537
|
+
if (myToken !== searchToken) return;
|
|
538
|
+
cachedFragments.push(...fragments);
|
|
539
|
+
appendResults(listEl, fragments, renderedCount, taxonomy);
|
|
540
|
+
renderedCount += fragments.length;
|
|
541
|
+
if (renderedCount >= cachedHandles.length) loadMoreBtn.hidden = true;
|
|
542
|
+
} catch {
|
|
543
|
+
// leave the button visible so the click can be retried
|
|
544
|
+
} finally {
|
|
545
|
+
loadMoreBtn.disabled = false;
|
|
546
|
+
}
|
|
536
547
|
});
|
|
537
548
|
|
|
538
549
|
listEl.addEventListener('click', (e) => {
|
|
@@ -84,7 +84,7 @@ const grouped = bylineWhitespace === 'grouped';
|
|
|
84
84
|
const bylineLead = author ? (grouped ? ' ' : ' ') : (grouped ? '' : ' ');
|
|
85
85
|
---
|
|
86
86
|
|
|
87
|
-
<address class="not-italic mb-5" role="
|
|
87
|
+
<address class="not-italic mb-5" role="group" aria-label={ariaLabel}>
|
|
88
88
|
<div class="flex items-start gap-3">
|
|
89
89
|
{author && (author.image ? (
|
|
90
90
|
<Image
|