@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 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.29.3",
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.29.3"
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={placement ?? 'above-fold'}
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={placement ?? 'book-section'}
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
+ }
@@ -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="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card transition-colors hover:border-primary/40 hover:bg-primary/10 disabled:pointer-events-none disabled:opacity-25"
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="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card transition-colors hover:border-primary/40 hover:bg-primary/10 disabled:pointer-events-none disabled:opacity-25"
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 gap-2"></div>
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" aria-label={`Go to page ${prevPage}`} class={edgeBase}>
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-white hover:text-white`}
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" aria-label={`Go to page ${nextPage}`} class={edgeBase}>
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)`}>
@@ -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
- 'height:0.375rem;border-radius:9999px;transition:all 0.2s;border:0;cursor:pointer;padding:0;' +
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
- if (prevBtn) prevBtn.disabled = startEdge;
47
- if (nextBtn) nextBtn.disabled = endEdge;
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
- dot.style.width = active ? '1rem' : '0.375rem';
53
- dot.style.background = active ? ACTIVE : INACTIVE;
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
- prevBtn?.addEventListener('click', () => scrollToIndex(currentIndex - 1));
85
- nextBtn?.addEventListener('click', () => scrollToIndex(currentIndex + 1));
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 {
@@ -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={trustedHtml(il.figure)} caption={md(il.caption)} />}
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; :focus-within covers a keyboard visit before any script runs. */
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/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
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
- .nav-dropdown-btn[aria-expanded='true'] + .dropdown-menu,
65
- .nav-dropdown:focus-within .dropdown-menu {
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
- const legalItemClass = `${smallClass} hover:text-inverse-foreground transition-colors ${ringClass}`;
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
- roots().forEach((root) => {
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, so callers may pass inline markup
6
- * (already-parsed markdown from parseMarkdownInline) or plain text. Plain strings
7
- * are unaffected: at the time of the change the 9,923 item strings in content
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: string[];
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?: string;
29
+ footer?: TrustedHtml;
33
30
  headingLevel?: 'h2' | 'h3' | 'h4';
34
31
  class?: string;
35
32
  }
@@ -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 fragments = await Promise.all(nextHandles.map((h) => h.data()));
532
- cachedFragments.push(...fragments);
533
- appendResults(listEl, fragments, renderedCount, taxonomy);
534
- renderedCount += fragments.length;
535
- if (renderedCount >= cachedHandles.length) loadMoreBtn.hidden = true;
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="contentinfo" aria-label={ariaLabel}>
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