@sarimarcus/content-sites-ui 0.29.4 → 0.30.1
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/editorial/NotFoundPage.astro +4 -2
- package/src/editorial/StaticProsePage.astro +4 -2
- package/src/editorial/interludes/Interlude.astro +1 -1
- package/src/layout/ListingShell.astro +16 -6
- package/src/primitives/FeatureList.astro +7 -10
- package/src/search/searchPage.ts +31 -10
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.1 (2026-10-07)
|
|
6
|
+
|
|
7
|
+
- `./editorial`: `StaticProsePage` takes `proseClass`, the variant beside `prose` (default `prose-stone`) and `NotFoundPage` takes `searchBoxClass` (default: the current search-link classes), so a dark theme can pass its own prose variant and input tokens; default output is unchanged (SLA-2989).
|
|
8
|
+
- `./layout`: `ListingShell` takes `contained` (default `true`) and `pagefindIgnore` (default `true`); `false` drops the Container inside `<main>` for a main of full-bleed bands, and keeps a listing's `data-pagefind-body` bands indexed. `mainClass={null}` renders a class-less `<main>`. Default output is unchanged (SLA-2989).
|
|
9
|
+
- `./search`: with `?focus`, `initSearchPage` re-claims input focus every 300 ms for 3 s, stopping at the first pointer or key press, instead of once at 1.7 s and only from `<body>`; the deferred cookie banner moves focus to its own button, which the single reclaim missed. This reaches every site (SLA-2989).
|
|
10
|
+
|
|
11
|
+
## 0.30.0 (2026-10-07)
|
|
12
|
+
|
|
13
|
+
- `./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).
|
|
14
|
+
- `./editorial`: `Interlude` renders a stat-fact `figure` through `renderInline`, so `**80 titles**` is bold and `<20%` is escaped (SLA-2985).
|
|
15
|
+
- `./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).
|
|
16
|
+
|
|
17
|
+
### Breaking
|
|
18
|
+
|
|
19
|
+
- `./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).
|
|
20
|
+
|
|
5
21
|
## 0.29.4 (2026-10-07)
|
|
6
22
|
|
|
7
23
|
- `./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).
|
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.1",
|
|
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.1"
|
|
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. */
|
|
@@ -20,9 +20,11 @@ interface Props {
|
|
|
20
20
|
/** Accessible name of the section links. */
|
|
21
21
|
exploreAriaLabel?: string;
|
|
22
22
|
links: { href: string; label: string }[];
|
|
23
|
+
/** Classes of the search link; a dark theme passes its own border and background tokens. */
|
|
24
|
+
searchBoxClass?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links } = Astro.props;
|
|
27
|
+
const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links, searchBoxClass = 'group flex items-center gap-3 w-full max-w-sm mx-auto px-4 py-3 rounded-xl border border-foreground/20 bg-white/60 backdrop-blur-sm text-left hover:border-primary/40 hover:bg-white/80 transition-all no-underline' } = Astro.props;
|
|
26
28
|
---
|
|
27
29
|
|
|
28
30
|
<main class="relative min-h-[80vh] flex items-center justify-center overflow-hidden bg-muted">
|
|
@@ -74,7 +76,7 @@ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaL
|
|
|
74
76
|
<a
|
|
75
77
|
href={searchHref}
|
|
76
78
|
data-search-trigger
|
|
77
|
-
class=
|
|
79
|
+
class={searchBoxClass}
|
|
78
80
|
aria-label={searchAriaLabel}
|
|
79
81
|
>
|
|
80
82
|
<Icon name="search" class="w-4 h-4 text-muted-foreground shrink-0" />
|
|
@@ -18,9 +18,11 @@ interface Props {
|
|
|
18
18
|
eyebrow?: string;
|
|
19
19
|
/** Closing box under the prose: a short line and secondary buttons. */
|
|
20
20
|
cta?: { text: string; links: CtaLink[] };
|
|
21
|
+
/** Prose variant beside the base `prose` class; a dark theme passes its own. */
|
|
22
|
+
proseClass?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
const { heading, eyebrow, cta } = Astro.props;
|
|
25
|
+
const { heading, eyebrow, cta, proseClass = 'prose-stone' } = Astro.props;
|
|
24
26
|
---
|
|
25
27
|
|
|
26
28
|
<main class="py-16 lg:py-24">
|
|
@@ -28,7 +30,7 @@ const { heading, eyebrow, cta } = Astro.props;
|
|
|
28
30
|
{eyebrow && <p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>}
|
|
29
31
|
<h1 class="text-3xl sm:text-4xl font-medium font-display mb-8">{heading}</h1>
|
|
30
32
|
|
|
31
|
-
<div class=
|
|
33
|
+
<div class:list={['prose', proseClass, 'max-w-none']}>
|
|
32
34
|
<slot />
|
|
33
35
|
</div>
|
|
34
36
|
|
|
@@ -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
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* ListingShell — the frame of a listing hub: breadcrumb band, hero, optional full-width bands, <main> with a
|
|
4
|
-
* Container, and trailing bands, all inside one Pagefind-
|
|
4
|
+
* Container, and trailing bands, all inside one wrapper that is Pagefind-ignored by default (most listings are not
|
|
5
|
+
* indexed; `pagefindIgnore={false}` keeps a listing's own `data-pagefind-body` bands). The route keeps
|
|
5
6
|
* Layout and passes StructuredData to Layout directly, so its JSON-LD lands in <head>; everything else comes in by slot.
|
|
6
7
|
*/
|
|
7
8
|
import BreadcrumbBand from './BreadcrumbBand.astro';
|
|
8
9
|
import Container from './Container.astro';
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
11
|
-
|
|
12
|
+
/** `null` renders a class-less <main>. */
|
|
13
|
+
mainClass?: string | null;
|
|
14
|
+
/** `false` drops the Container inside <main>, for a main made of full-bleed bands that carry their own. */
|
|
15
|
+
contained?: boolean;
|
|
16
|
+
/** `false` keeps the page indexable, for a listing whose bands carry `data-pagefind-body` prose. */
|
|
17
|
+
pagefindIgnore?: boolean;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
const { mainClass = 'py-12 lg:py-20' } = Astro.props;
|
|
20
|
+
const { mainClass = 'py-12 lg:py-20', contained = true, pagefindIgnore = true } = Astro.props;
|
|
15
21
|
---
|
|
16
22
|
|
|
17
|
-
<div data-pagefind-ignore>
|
|
23
|
+
<div data-pagefind-ignore={pagefindIgnore ? '' : undefined}>
|
|
18
24
|
<BreadcrumbBand ignore={false}>
|
|
19
25
|
<slot name="breadcrumb" />
|
|
20
26
|
</BreadcrumbBand>
|
|
@@ -23,9 +29,13 @@ const { mainClass = 'py-12 lg:py-20' } = Astro.props;
|
|
|
23
29
|
<slot name="before-main" />
|
|
24
30
|
|
|
25
31
|
<main class={mainClass}>
|
|
26
|
-
|
|
32
|
+
{contained ? (
|
|
33
|
+
<Container>
|
|
34
|
+
<slot />
|
|
35
|
+
</Container>
|
|
36
|
+
) : (
|
|
27
37
|
<slot />
|
|
28
|
-
|
|
38
|
+
)}
|
|
29
39
|
</main>
|
|
30
40
|
|
|
31
41
|
<slot name="after-main" />
|
|
@@ -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) => {
|
|
@@ -580,12 +591,22 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
|
|
|
580
591
|
}
|
|
581
592
|
};
|
|
582
593
|
requestAnimationFrame(claimFocus);
|
|
583
|
-
//
|
|
584
|
-
//
|
|
594
|
+
// The deferred cookie banner can steal focus (to its own button, not <body>) up to ~2s after load. Re-claim it
|
|
595
|
+
// every 300 ms for 3 s, and stop as soon as the user interacts.
|
|
585
596
|
if (initial.focus) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
597
|
+
let tries = 0;
|
|
598
|
+
const stop = () => {
|
|
599
|
+
window.clearInterval(reclaim);
|
|
600
|
+
document.removeEventListener('pointerdown', stop, true);
|
|
601
|
+
document.removeEventListener('keydown', stop, true);
|
|
602
|
+
};
|
|
603
|
+
const reclaim = window.setInterval(() => {
|
|
604
|
+
tries += 1;
|
|
605
|
+
if (tries > 10) stop();
|
|
606
|
+
else claimFocus();
|
|
607
|
+
}, 300);
|
|
608
|
+
document.addEventListener('pointerdown', stop, true);
|
|
609
|
+
document.addEventListener('keydown', stop, true);
|
|
589
610
|
}
|
|
590
611
|
}
|
|
591
612
|
}
|