@escape-game-over/atlas 0.1.26 → 0.1.28

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.
@@ -46,8 +46,8 @@ finding them.
46
46
  analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`) renders the
47
47
  site's markup as its children, skips itself — script included — when the
48
48
  analytics need no permission, and remembers, expires and applies the answer. The
49
- markup spreads `consent.accept` and `consent.decline` on its buttons, and
50
- `consent.reopen` on the control that brings it back.
49
+ markup marks its buttons `data-consent="grant"` and `data-consent="deny"`,
50
+ and the control that brings it back `data-consent-reopen hidden`.
51
51
 
52
52
  **Browser code has one import path: `@escape-game-over/atlas/client`**, which
53
53
  re-exports every module above. None of it touches the DOM at import, so an
@@ -73,15 +73,15 @@ runs when the element enters the page, `signal` ends what it registered when the
73
73
  element leaves, and what it returns is the undo for everything else.
74
74
 
75
75
  ```ts
76
- export const thing = element("go-thing", ({ host, signal }) => {
77
- ref(host, "[data-thing-button]").addEventListener("click", open, { signal }); // ends with the visit
78
- return loop.attach(host); // and so does this
76
+ export const thing = element("go-thing", ({ root, signal }) => {
77
+ ref(root, "[data-thing-button]").addEventListener("click", open, { signal }); // ends with the visit
78
+ return loop.attach(root); // and so does this
79
79
  });
80
80
  ```
81
81
 
82
82
  An element can enter the page more than once — moving it in the DOM runs the
83
83
  undo and then the function again — so it has to be able to run twice. State that
84
- must survive a move goes in a `WeakMap` keyed by `host`, which is what the
84
+ must survive a move goes in a `WeakMap` keyed by `root`, which is what the
85
85
  carousel example does with its index.
86
86
 
87
87
  **The trap this exists for is view transitions.** A bundled `<script src>` is an
@@ -98,50 +98,46 @@ handled. Without a re-`attach` there is simply nothing wired.
98
98
  ## `filters`
99
99
 
100
100
  A list already rendered by the server, narrowed in the browser and mirrored in
101
- the URL. The caller declares fields; the item shape follows from them.
101
+ the URL. The caller declares fields and hands over its own items — elements,
102
+ records, whatever it holds — with how to read each one's values.
102
103
 
103
104
  ```ts
104
105
  const list = filters({
105
- fields: {
106
- q: { kind: "text" },
107
- category: { kind: "choice" },
108
- featured: { kind: "flag" },
106
+ fields: { q: "text", category: "choice", featured: "flag" },
107
+ items: rows,
108
+ values: (row) => ({
109
+ q: data(row, "text"),
110
+ category: data(row, "category"),
111
+ featured: row.hasAttribute("data-featured"),
112
+ }),
113
+ onChange: ({ matched }) => {
114
+ for (const row of rows) row.hidden = !matched.has(row);
109
115
  },
110
- items: entries.map((entry) => ({
111
- key: entry.id,
112
- values: {
113
- q: `${entry.title} ${entry.summary}`,
114
- category: entry.category,
115
- featured: entry.featured,
116
- },
117
- })),
118
- onChange: ({ matched, state }) => { /* the project's DOM writes, all of them */ },
119
116
  });
120
117
 
121
118
  const detach = list.attach();
122
119
  ```
123
120
 
124
- Fields are plain data rather than `text()` / `choice()` / `flag()` builders:
125
- three exported names that general — `text` above all — in a module a project
126
- imports by name, to return exactly this object. `values` is typed *from*
121
+ A field is its kind, and its URL parameter is its own name — to rename the
122
+ parameter, rename the field. `{ kind: "text", url: false }` keeps a field out of
123
+ the URL, for a list whose page owns the URL itself. `values` is typed *from*
127
124
  `fields`, so a `flag` demands a boolean and a mismatch is a compile error rather
128
- than an item that silently never matches.
125
+ than an item that silently never matches. It is read once per item, at
126
+ construction. `matched` is a set of the caller's own items, so there is no key
127
+ to invent, carry in the markup and map back.
129
128
 
130
129
  | Kind | State | Item value | Matches when | In the URL | History |
131
130
  | -------- | --------------- | --------------- | ------------------------------------------------ | ------------------------------- | ------- |
132
131
  | `text` | the query | searchable text | every folded query word appears; empty keeps all | `?q=…`, dropped if empty | replace |
133
132
  | `choice` | one value, `""` | one, or several | the item holds the chosen one; `""` keeps all | `?category=…`, dropped if empty | push |
134
- | `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured=1`, absent when off | push |
133
+ | `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured`, absent when off | push |
135
134
 
136
135
  **A `choice` item may hold several values.** The state stays one — the tab strip
137
136
  picks one category, the URL carries one — but an item can sit in more than one
138
137
  and then answers to any of them:
139
138
 
140
139
  ```ts
141
- items: [
142
- { key: "burgos", values: { category: "city" } },
143
- { key: "avila", values: { category: ["walk", "city"] } },
144
- ]
140
+ values: (room) => ({ category: data(room, "categories").split(" ") }),
145
141
  ```
146
142
 
147
143
  An escape room is adventure *and* sci-fi; a film is a comedy *and* a drama. This
@@ -175,8 +171,6 @@ pass. What is deliberately absent is one field holding several selected values;
175
171
  that is a `choices` kind, and it forces a URL encoding decision (repeated
176
172
  parameters or comma-joined) that nothing has needed yet.
177
173
 
178
- `param` defaults to the field's name. `param: false` keeps a field state-only, out of the URL.
179
-
180
174
  ### Facet counts: `matchedWithout`
181
175
 
182
176
  A dropdown that says how many results each option would give needs a different
@@ -191,7 +185,7 @@ onChange({ matched }) {
191
185
  for (const option of countryOptions) {
192
186
  const value = option.dataset.choice;
193
187
  const count = rows.filter(
194
- (row) => rest.has(row.key) && row.country === value
188
+ (row) => rest.has(row) && row.country === value
195
189
  ).length;
196
190
  // …the project writes `count` wherever its design puts it
197
191
  }
@@ -236,21 +230,12 @@ instead of flashing the whole list back. Nothing writes to the URL before
236
230
  `attach`, because `attach` is what reads it — a `set` on an unattached instance
237
231
  would overwrite state nobody had loaded.
238
232
 
239
- A set flag is written `1` and an unset one is **absent** rather than `=0`. One
240
- spelling for off keeps the URL short and leaves a single form to parse; the cost
241
- is that a hand-written `?featured=0` reads as off, which is the answer it would have
242
- got anyway. `1` and not `true` because that is what the existing lists already
243
- emit, and links people have shared should keep resolving to the view they named.
244
-
245
- ### Two things it refuses at construction
246
-
247
- Both are silent otherwise:
248
-
249
- - **Two items under one key.** `matched` could not say which of them matched.
250
- - **Two fields on one URL parameter.** Each write erases the other's value, which
251
- reads as a filter that will not stay set.
233
+ A flag is on by being there, as an HTML boolean attribute is: written as the bare
234
+ `?featured`, and **absent** when off. Reading asks only whether the name is
235
+ present, so `?featured=1` from an older link still reads as on — and so does a
236
+ hand-written `?featured=false`, just as `hidden="false"` still hides.
252
237
 
253
- And a `set` to the value already held announces nothing and pushes nothing, so
238
+ A `set` to the value already held announces nothing and pushes nothing, so
254
239
  re-clicking the current tab does not add a history step to walk back through.
255
240
 
256
241
  ## `filters-view`
@@ -275,9 +260,9 @@ const list = filters({
275
260
  });
276
261
  const detach = list.attach();
277
262
  const unbind = searchBox(list, "q", {
278
- input: one<HTMLInputElement>("[data-filter-search]"),
279
- clear: one("[data-filter-clear]"),
280
- empty: one("[data-filter-empty]"),
263
+ input: ref<HTMLInputElement>(root, "[data-filter-search]"),
264
+ clear: ref(root, "[data-filter-clear]"),
265
+ empty: ref(root, "[data-filter-empty]"),
281
266
  });
282
267
  ```
283
268
 
@@ -369,9 +354,9 @@ behaviour and runs it for as long as the element is on the page, and `ref` and
369
354
 
370
355
  ```ts
371
356
  // faq.ts — imported by the template for the tag, and loaded by the page's script
372
- export const faq = element("go-faq", ({ host, signal }) => {
373
- const input = ref<HTMLInputElement>(host, "[data-faq-search]");
374
- for (const row of refs<HTMLDetailsElement>(host, "[data-faq-row]")) {
357
+ export const faq = element("go-faq", ({ root, signal }) => {
358
+ const input = ref<HTMLInputElement>(root, "[data-faq-search]");
359
+ for (const row of refs<HTMLDetailsElement>(root, "[data-faq-row]")) {
375
360
  row.dataset.key;
376
361
  }
377
362
  });
@@ -422,7 +407,7 @@ attribute, and the script writes only that run:
422
407
  ```
423
408
 
424
409
  ```ts
425
- for (const each of refs(host, "[data-faq-count]")) each.textContent = String(hits.size);
410
+ for (const each of refs(root, "[data-faq-count]")) each.textContent = String(hits.size);
426
411
  ```
427
412
 
428
413
  Marks are parsed before placeholders are filled, so a run filled with `""`
@@ -565,9 +550,11 @@ right properties is a faithful stand-in.
565
550
  that `ref` throws naming the root and the selector.
566
551
  - `tests/element.test.ts` stubs `customElements` to upgrade on `define`, as a
567
552
  browser does, and pins what `connect` is handed.
553
+ - `tests/consent.test.ts` fakes `localStorage`, with a switch that makes it
554
+ throw, and the Google hook, and pins the six-month expiry to the day.
568
555
 
569
- `carousel` and `consent` have none, and `element` has no lifecycle test — moves
570
- and teardown. They need a real DOM and
556
+ `carousel` has none, nor does the consent banner's element, and `element` has
557
+ no lifecycle test — moves and teardown. They need a real DOM and
571
558
  this package carries no environment for one; adding `happy-dom` as a dev
572
559
  dependency and setting `environment` in `vitest.config.ts` is what that would
573
560
  take.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -72,12 +72,9 @@ export function analyticsScripts(
72
72
  /**
73
73
  * Whether this deployment loads anything a visitor has to be asked about.
74
74
  *
75
- * The build-time twin of `consentApplies()` in `astro/consent.ts`, which asks
76
- * the same question in the browser by looking for the global the Google tag
77
- * defines. That one cannot answer it early enough to be useful: a consent
78
- * banner's script has to be downloaded, parsed and run before it can report
79
- * that there was nothing to consent to — three requests to learn that none of
80
- * them were needed. Asked here, a project leaves the banner out of the HTML.
75
+ * The one place that question is answered, and at build time: a project leaves
76
+ * the banner — script and all — out of the HTML, rather than shipping a script
77
+ * to find out in the browser that there was nothing to ask.
81
78
  *
82
79
  * Vendor by vendor rather than `analytics.google !== undefined`, because that
83
80
  * is not the question. A `google` block carrying no ids emits no tag and sets
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  /**
3
3
  * The consent banner's behaviour; the site supplies its markup and copy as the
4
- * children, with `consent.accept` and `consent.decline` on its buttons.
4
+ * children, with `data-consent="grant"` and `data-consent="deny"` on its
5
+ * buttons.
5
6
  *
6
7
  * Renders nothing — and ships no script — when this site's analytics need no
7
8
  * permission. Otherwise the banner starts hidden and shows only to a visitor
8
- * with no answer on record, or when `consent.reopen` is pressed.
9
+ * with no answer on record, or when a `data-consent-reopen` control is pressed.
9
10
  */
10
11
  import type { HTMLAttributes } from "astro/types";
11
12
  import { type AnalyticsSettings, consentRequired } from "../analytics/index.ts";
@@ -2,11 +2,13 @@
2
2
  /** Internal to `ConsentBanner.astro`, so its script ships only where it renders. */
3
3
  import type { HTMLAttributes } from "astro/types";
4
4
  import AtlasElement from "./AtlasElement.astro";
5
- import { consentBanner } from "./consent.ts";
5
+ import { consentBanner } from "./consent-element.ts";
6
6
 
7
7
  type Props = HTMLAttributes<"div">;
8
8
  ---
9
9
 
10
+ <script src="./consent-element.ts" />
11
+
10
12
  <AtlasElement
11
13
  of={consentBanner}
12
14
  {...Astro.props}
@@ -16,52 +18,3 @@ type Props = HTMLAttributes<"div">;
16
18
  >
17
19
  <slot />
18
20
  </AtlasElement>
19
-
20
- <script>
21
- import {
22
- applyConsent,
23
- CONSENT_ANSWER,
24
- CONSENT_REOPEN,
25
- consentApplies,
26
- consentStore,
27
- } from "./consent.ts";
28
- import { element } from "./element.ts";
29
- import { refs } from "./ref.ts";
30
-
31
- element("atlas-consent", ({ host, signal }) => {
32
- // The head script left no consent hook: nothing here sets a cookie.
33
- if (!consentApplies()) return;
34
-
35
- const store = consentStore();
36
- const show = (): void => {
37
- host.hidden = false;
38
- };
39
-
40
- for (const button of refs(host, `[${CONSENT_ANSWER}]`)) {
41
- const choice = button.getAttribute(CONSENT_ANSWER);
42
- if (choice !== "granted" && choice !== "denied") {
43
- throw new Error(`${CONSENT_ANSWER} is "${choice}"`);
44
- }
45
- button.addEventListener(
46
- "click",
47
- () => {
48
- store.record(choice);
49
- host.hidden = true;
50
- },
51
- { signal }
52
- );
53
- }
54
-
55
- // Outside the banner — withdrawing must be as easy as granting.
56
- for (const button of refs(document, `[${CONSENT_REOPEN}]`)) {
57
- button.hidden = false;
58
- button.addEventListener("click", show, { signal });
59
- }
60
-
61
- // Applied, not re-recorded: recording stamps today's date, and an answer
62
- // renewed on every page view never expires.
63
- const stored = store.read();
64
- if (stored === undefined) show();
65
- else applyConsent(stored.choice);
66
- });
67
- </script>
@@ -11,15 +11,6 @@
11
11
 
12
12
  export * from "./background-video.ts";
13
13
  export * from "./carousel.ts";
14
- export {
15
- applyConsent,
16
- type ConsentChoice,
17
- type ConsentRecord,
18
- type ConsentStoreOptions,
19
- consent,
20
- consentApplies,
21
- consentStore,
22
- } from "./consent.ts";
23
14
  export {
24
15
  type Connect,
25
16
  type ElementContext,
@@ -0,0 +1,59 @@
1
+ import {
2
+ applyConsent,
3
+ type ConsentChoice,
4
+ readConsent,
5
+ recordConsent,
6
+ } from "./consent.ts";
7
+ import { element } from "./element.ts";
8
+ import { data, refs } from "./ref.ts";
9
+
10
+ /** What each button does, in the words Consent Mode records. */
11
+ const ACTIONS: Readonly<Record<string, ConsentChoice>> = {
12
+ grant: "granted",
13
+ deny: "denied",
14
+ };
15
+
16
+ /**
17
+ * The banner's behaviour. Its own module, and not re-exported from `client.ts`,
18
+ * so the script ships only where `<ConsentBanner>` renders.
19
+ *
20
+ * A site's markup marks the two buttons inside the banner `data-consent="grant"`
21
+ * and `data-consent="deny"`, and the control that brings it back — usually in
22
+ * the footer — `data-consent-reopen hidden`: it stays hidden until there is an
23
+ * answer to withdraw.
24
+ */
25
+ export const consentBanner = element("atlas-consent", ({ root, signal }) => {
26
+ const show = (): void => {
27
+ root.hidden = false;
28
+ };
29
+
30
+ for (const button of refs(root, "[data-consent]")) {
31
+ const action = data(button, "consent");
32
+ const choice = Object.hasOwn(ACTIONS, action)
33
+ ? ACTIONS[action]
34
+ : undefined;
35
+ if (choice === undefined) {
36
+ throw new Error(`data-consent is "${action}", not grant or deny`);
37
+ }
38
+ button.addEventListener(
39
+ "click",
40
+ () => {
41
+ recordConsent(choice);
42
+ root.hidden = true;
43
+ },
44
+ { signal }
45
+ );
46
+ }
47
+
48
+ // Outside the banner — withdrawing must be as easy as granting.
49
+ for (const button of refs(document, "[data-consent-reopen]")) {
50
+ button.hidden = false;
51
+ button.addEventListener("click", show, { signal });
52
+ }
53
+
54
+ // Applied, not re-recorded: recording stamps today's date, and an answer
55
+ // renewed on every page view never expires.
56
+ const stored = readConsent();
57
+ if (stored === undefined) show();
58
+ else applyConsent(stored.choice);
59
+ });
@@ -1,24 +1,4 @@
1
1
  import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
2
- import type { ElementTag } from "./element.ts";
3
-
4
- /** Internal to `ConsentBanner.astro`: the element, and what its script finds. */
5
- export const consentBanner: ElementTag<"atlas-consent"> = {
6
- tag: "atlas-consent",
7
- };
8
- export const CONSENT_ANSWER = "data-atlas-consent-answer";
9
- export const CONSENT_REOPEN = "data-atlas-consent-reopen";
10
-
11
- /**
12
- * What a site's consent markup spreads: the two answer buttons inside
13
- * `<ConsentBanner>`, and the control that brings it back — which belongs on
14
- * every page, usually the footer, and stays hidden until there is something to
15
- * withdraw.
16
- */
17
- export const consent = {
18
- accept: { [CONSENT_ANSWER]: "granted" },
19
- decline: { [CONSENT_ANSWER]: "denied" },
20
- reopen: { [CONSENT_REOPEN]: "", hidden: "" },
21
- };
22
2
 
23
3
  /**
24
4
  * The browser half of consent: remembering an answer, expiring it, and handing
@@ -57,104 +37,63 @@ export interface ConsentRecord {
57
37
  * is no way to expire it, and no way to answer "when did this visitor agree?",
58
38
  * which is a question only ever asked when somebody is already unhappy.
59
39
  */
60
- const DEFAULT_MONTHS = 6;
40
+ const MONTHS = 6;
61
41
 
62
42
  /**
63
- * Where the answer is kept.
64
- *
65
- * `localStorage` is already scoped to an origin, so this does not need to
66
- * identify the site — what it needs is to not collide with something else on
67
- * the page, and a bare `"consent"` is exactly the key a third-party consent
68
- * tool or chat widget would reach for. The prefix matches
69
- * `CONSENT_UPDATE_GLOBAL`, so one concept has one name on both sides.
70
- *
71
- * Overridable for the case the default cannot cover: two deployments sharing
72
- * one origin — `example.com/rome` and `example.com/bucharest` — where one key
73
- * would mean one answer for both. See `consentStore`.
43
+ * The `localStorage` key the answer is kept under. Prefixed to keep clear of the
44
+ * bare `"consent"` a third-party widget would reach for.
74
45
  */
75
- const DEFAULT_KEY = "__consent";
76
-
77
- export interface ConsentStoreOptions {
78
- /** Overrides `DEFAULT_KEY`. See it for the one case that needs this. */
79
- readonly key?: string;
80
- /** Overrides the six-month window. See `DEFAULT_MONTHS`. */
81
- readonly months?: number;
82
- }
46
+ const KEY = "atlas-consent";
83
47
 
84
48
  /**
85
- * Reading and writing an answer, bound to one key and one window.
49
+ * The answer this visitor gave, if it still counts.
86
50
  *
87
- * A factory rather than two functions each taking options, because the key and
88
- * the window have to *match* between them: a read that looked in one place and
89
- * a write that filled another would ask a visitor on every page while
90
- * faithfully recording each answer. Bound once, they cannot disagree.
51
+ * `undefined` for never asked, for an expired answer, and for anything
52
+ * unparseable — all three mean the same thing to a banner, and all three should
53
+ * ask rather than assume.
91
54
  */
92
- export function consentStore(options: ConsentStoreOptions = {}): {
93
- read(): ConsentRecord | undefined;
94
- record(choice: ConsentChoice): void;
95
- } {
96
- const key = options.key ?? DEFAULT_KEY;
97
- const months = options.months ?? DEFAULT_MONTHS;
98
-
99
- return {
100
- /**
101
- * The answer this visitor gave, if it still counts.
102
- *
103
- * `undefined` for never asked, for an expired answer, and for anything
104
- * unparseable — all three mean the same thing to a banner, and all
105
- * three should ask rather than assume. Storage hand-edited, or written
106
- * by an older version of this code, lands in the third case by design.
107
- */
108
- read(): ConsentRecord | undefined {
109
- let raw: string | null = null;
110
- try {
111
- raw = localStorage.getItem(key);
112
- } catch {
113
- // Private browsing, or storage disabled. Nothing was
114
- // remembered, so nothing is assumed.
115
- return undefined;
116
- }
117
- if (raw === null) return undefined;
118
-
119
- try {
120
- const parsed = JSON.parse(raw) as Partial<ConsentRecord>;
121
- if (parsed.choice !== "granted" && parsed.choice !== "denied") {
122
- return undefined;
123
- }
124
- if (typeof parsed.at !== "string") return undefined;
125
-
126
- const expiry = new Date(parsed.at);
127
- if (Number.isNaN(expiry.getTime())) return undefined;
128
- expiry.setMonth(expiry.getMonth() + months);
129
- if (expiry < new Date()) return undefined;
130
-
131
- return { choice: parsed.choice, at: parsed.at };
132
- } catch {
133
- return undefined;
134
- }
135
- },
55
+ export function readConsent(): ConsentRecord | undefined {
56
+ let raw: string | null = null;
57
+ try {
58
+ raw = localStorage.getItem(KEY);
59
+ } catch {
60
+ // Private browsing, or storage disabled. Nothing was remembered, so
61
+ // nothing is assumed.
62
+ return undefined;
63
+ }
64
+ if (raw === null) return undefined;
65
+
66
+ try {
67
+ const parsed = JSON.parse(raw) as Partial<ConsentRecord>;
68
+ if (parsed.choice !== "granted" && parsed.choice !== "denied") {
69
+ return undefined;
70
+ }
71
+ if (typeof parsed.at !== "string") return undefined;
72
+
73
+ const expiry = new Date(parsed.at);
74
+ if (Number.isNaN(expiry.getTime())) return undefined;
75
+ expiry.setMonth(expiry.getMonth() + MONTHS);
76
+ if (expiry < new Date()) return undefined;
77
+
78
+ return { choice: parsed.choice, at: parsed.at };
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
136
83
 
137
- /**
138
- * Remembers an answer, dated now, and tells Google about it.
139
- *
140
- * One call rather than two, because the two must not come apart: an
141
- * answer stored but never applied leaves the visitor consented in name
142
- * only, and one applied but never stored asks them again next page.
143
- */
144
- record(choice: ConsentChoice): void {
145
- const record: ConsentRecord = {
146
- choice,
147
- at: new Date().toISOString(),
148
- };
149
- try {
150
- localStorage.setItem(key, JSON.stringify(record));
151
- } catch {
152
- // Unable to remember it, which is a worse experience and not a
153
- // wrong one — the choice still applies to this page.
154
- }
155
- applyConsent(choice);
156
- },
157
- };
84
+ /**
85
+ * Remembers an answer, dated now, and tells Google about it — one call, so the
86
+ * two cannot come apart.
87
+ */
88
+ export function recordConsent(choice: ConsentChoice): void {
89
+ const record: ConsentRecord = { choice, at: new Date().toISOString() };
90
+ try {
91
+ localStorage.setItem(KEY, JSON.stringify(record));
92
+ } catch {
93
+ // Unable to remember it, which is a worse experience and not a wrong
94
+ // one — the choice still applies to this page.
95
+ }
96
+ applyConsent(choice);
158
97
  }
159
98
 
160
99
  /**
@@ -174,12 +113,3 @@ export function applyConsent(choice: ConsentChoice): void {
174
113
  )[CONSENT_UPDATE_GLOBAL];
175
114
  update?.(choice);
176
115
  }
177
-
178
- /** Whether this deployment has a Google tag to consent to at all. */
179
- export function consentApplies(): boolean {
180
- return (
181
- (window as unknown as Record<string, unknown>)[
182
- CONSENT_UPDATE_GLOBAL
183
- ] !== undefined
184
- );
185
- }
@@ -12,7 +12,8 @@ export const ROOT_ATTRIBUTE = "data-atlas-root";
12
12
  type Undo = void | (() => void);
13
13
 
14
14
  export interface ElementContext {
15
- readonly host: HTMLElement;
15
+ /** The element itself: where `ref` and `refs` search from. */
16
+ readonly root: HTMLElement;
16
17
  /** Aborted when the element leaves, taking its listeners with it. */
17
18
  readonly signal: AbortSignal;
18
19
  }
@@ -33,8 +34,8 @@ export interface ElementTag<Tag extends string = string> {
33
34
  *
34
35
  * ```ts
35
36
  * // faq.ts — imported by the template for the tag, and by the page's script
36
- * export const faq = element("go-faq", ({ host, signal }) => {
37
- * const input = ref<HTMLInputElement>(host, "[data-faq-search]");
37
+ * export const faq = element("go-faq", ({ root, signal }) => {
38
+ * const input = ref<HTMLInputElement>(root, "[data-faq-search]");
38
39
  * …
39
40
  * });
40
41
  * ```
@@ -48,7 +49,7 @@ export interface ElementTag<Tag extends string = string> {
48
49
  *
49
50
  * - **An element can enter the page more than once.** Moving it runs the undo
50
51
  * and `connect` again; Astro's `ClientRouter` does so on every navigation.
51
- * State that must survive belongs in a `WeakMap` keyed by `host`.
52
+ * State that must survive belongs in a `WeakMap` keyed by `root`.
52
53
  * - **The page's script must stay a bundled module** (a plain `<script>`), so
53
54
  * the element has its children when it upgrades.
54
55
  * - **Failures show in dev.** A `connect` that throws leaves that one element
@@ -115,7 +116,7 @@ function define(tag: string, connect: Connect): void {
115
116
  );
116
117
  }
117
118
  this.#undo =
118
- connect({ host: this, signal: ac.signal }) ?? undefined;
119
+ connect({ root: this, signal: ac.signal }) ?? undefined;
119
120
  } catch (error) {
120
121
  reportDevError(this.localName, error);
121
122
  }
@@ -10,21 +10,18 @@
10
10
  * on a clear button, an empty message or a section with nothing left. No class,
11
11
  * style or `aria`: that is where the choices live.
12
12
  *
13
- * Two notes for the markup:
14
- *
15
- * - **`hidden` needs help in a grid.** Any author rule setting `display` beats
16
- * it. Tailwind v4's preflight ships `[hidden] { display: none !important }`;
17
- * without something like it these writes are inert and nothing filters.
18
- * - **A result count wants `aria-live="polite"`**, in the template.
13
+ * **`hidden` needs help in a grid.** Any author rule setting `display` beats it.
14
+ * Tailwind v4's preflight ships `[hidden] { display: none !important }`; without
15
+ * something like it these writes are inert and nothing filters.
19
16
  *
20
17
  * See docs/client-scripts.md.
21
18
  */
22
19
 
23
- import type { FieldMap, FilterChange, Filters } from "./filters.ts";
20
+ import type { FieldMap, FilterChange, Filters, KindOf } from "./filters.ts";
24
21
 
25
22
  /** The `text` fields of `F`, so a search box cannot be pointed at a flag. */
26
23
  export type TextFieldOf<F extends FieldMap> = {
27
- [K in keyof F]: F[K] extends { kind: "text" } ? K : never;
24
+ [K in keyof F]: KindOf<F[K]> extends "text" ? K : never;
28
25
  }[keyof F];
29
26
 
30
27
  /**
@@ -38,12 +35,6 @@ export interface SearchBoxElements {
38
35
  readonly clear?: HTMLElement | null;
39
36
  /** The "nothing matched" message. Tracks the whole result set. */
40
37
  readonly empty?: HTMLElement | null;
41
- /**
42
- * How many matched, as digits and nothing else: the sentence around them is
43
- * translated, and not something to take apart in a browser. Give the count
44
- * its own element and let the copy sit beside it.
45
- */
46
- readonly count?: HTMLElement | null;
47
38
  }
48
39
 
49
40
  /**
@@ -54,20 +45,20 @@ export interface SearchBoxElements {
54
45
  * ```ts
55
46
  * const list = filters({ fields, items, onChange: … });
56
47
  * const detach = list.attach();
57
- * const unbind = searchBox(list, "q", { input, clear, empty, count });
48
+ * const unbind = searchBox(list, "q", { input, clear, empty });
58
49
  * ```
59
50
  */
60
- export function searchBox<F extends FieldMap>(
61
- list: Filters<F>,
51
+ export function searchBox<F extends FieldMap, T>(
52
+ list: Filters<F, T>,
62
53
  field: TextFieldOf<F>,
63
54
  elements: SearchBoxElements
64
55
  ): () => void {
65
- const { input, clear, empty, count } = elements;
56
+ const { input, clear, empty } = elements;
66
57
  // The narrowing `TextFieldOf` already did: inside a generic function
67
58
  // TypeScript cannot see that this field holds a string.
68
59
  const name = field as Parameters<typeof list.set>[0];
69
60
 
70
- const render = ({ state, matched }: FilterChange<F>): void => {
61
+ const render = ({ state, matched }: FilterChange<F, T>): void => {
71
62
  const query = String(state[name]);
72
63
  // Guarded by inequality: assigning `value` while someone types moves
73
64
  // the caret to the end, and this exists for state that moved without
@@ -75,7 +66,6 @@ export function searchBox<F extends FieldMap>(
75
66
  if (input != null && input.value !== query) input.value = query;
76
67
  if (clear != null) clear.hidden = query === "";
77
68
  if (empty != null) empty.hidden = matched.size > 0;
78
- if (count != null) count.textContent = String(matched.size);
79
69
  };
80
70
 
81
71
  render(list);
@@ -3,14 +3,9 @@
3
3
  *
4
4
  * ```ts
5
5
  * const list = filters({
6
- * fields: {
7
- * q: { kind: "text" },
8
- * category: { kind: "choice" },
9
- * },
10
- * items: entries.map((entry) => ({
11
- * key: entry.id,
12
- * values: { q: `${entry.title} ${entry.summary}`, category: entry.category },
13
- * })),
6
+ * fields: { q: "text", category: "choice" },
7
+ * items: entries,
8
+ * values: (entry) => ({ q: `${entry.title} ${entry.summary}`, category: entry.category }),
14
9
  * onChange: ({ matched }) => { /* every DOM write is the project's *\/ },
15
10
  * });
16
11
  *
@@ -40,19 +35,30 @@
40
35
  *
41
36
  * `flag` is not a tri-state: on, off and *either* is a `choice` with two values.
42
37
  */
43
- export interface Field {
44
- readonly kind: "text" | "choice" | "flag";
45
- /** The URL parameter. Defaults to the field's name; `false` keeps it out of the URL. */
46
- readonly param?: string | false;
47
- }
38
+ export type FieldKind = "text" | "choice" | "flag";
39
+
40
+ /**
41
+ * A field: its kind, which puts it in the URL under the field's own name, or
42
+ * `{ kind, url: false }` to keep it out. To rename the parameter, rename the
43
+ * field.
44
+ */
45
+ export type Field =
46
+ | FieldKind
47
+ | { readonly kind: FieldKind; readonly url: false };
48
+
49
+ /** A field's kind, from either spelling. */
50
+ export type KindOf<F extends Field> = F extends FieldKind
51
+ ? F
52
+ : F extends { readonly kind: infer K }
53
+ ? K
54
+ : never;
48
55
 
49
56
  /** The fields of one list, named by the caller. */
50
57
  export type FieldMap = Readonly<Record<string, Field>>;
51
58
 
52
59
  /** What one field contributes in the state: what was typed, picked or toggled. */
53
- export type StateValue<F extends Field> = F["kind"] extends "flag"
54
- ? boolean
55
- : string;
60
+ export type StateValue<F extends Field> =
61
+ KindOf<F> extends "flag" ? boolean : string;
56
62
 
57
63
  /**
58
64
  * What one field contributes on an item: what it *is*, where the state holds
@@ -61,11 +67,12 @@ export type StateValue<F extends Field> = F["kind"] extends "flag"
61
67
  * **A `choice` item may hold several values, and then answers to any of them** —
62
68
  * a room is adventure *and* sci-fi, while the chips still pick one.
63
69
  */
64
- export type ItemValue<F extends Field> = F["kind"] extends "flag"
65
- ? boolean
66
- : F["kind"] extends "choice"
67
- ? string | readonly string[]
68
- : string;
70
+ export type ItemValue<F extends Field> =
71
+ KindOf<F> extends "flag"
72
+ ? boolean
73
+ : KindOf<F> extends "choice"
74
+ ? string | readonly string[]
75
+ : string;
69
76
 
70
77
  /** Every field's value for one item, derived from the field declaration. */
71
78
  export type ItemValues<F extends FieldMap> = {
@@ -77,33 +84,31 @@ export type FilterState<F extends FieldMap> = {
77
84
  readonly [K in keyof F]: StateValue<F[K]>;
78
85
  };
79
86
 
80
- export interface FilterItem<F extends FieldMap> {
81
- /** Unique across the list: a duplicate makes `matched` ambiguous, and is refused. */
82
- readonly key: string;
83
- readonly values: ItemValues<F>;
84
- }
85
-
86
- export interface FilterChange<F extends FieldMap> {
87
+ export interface FilterChange<F extends FieldMap, T> {
87
88
  readonly state: FilterState<F>;
88
89
  /**
89
- * The keys that survive every field at once. A set, so a caller asks about
90
- * the items it already holds, and `size` is the count a "showing N" wants.
90
+ * The items that survive every field at once — the caller's own, so it asks
91
+ * `matched.has(row)` of what it already holds, and `size` is the count a
92
+ * "showing N" wants.
91
93
  */
92
- readonly matched: ReadonlySet<string>;
94
+ readonly matched: ReadonlySet<T>;
93
95
  }
94
96
 
95
- export interface FiltersOptions<F extends FieldMap> {
97
+ export interface FiltersOptions<F extends FieldMap, T> {
96
98
  readonly fields: F;
97
99
  /**
98
100
  * The full list, every time — this filters, it does not paginate. Fixed for
99
101
  * the instance's lifetime; a list whose contents change is a new instance.
102
+ * Anything: elements, records, whatever the caller already holds.
100
103
  */
101
- readonly items: readonly FilterItem<F>[];
104
+ readonly items: readonly T[];
105
+ /** Each item's value for every field. Read once per item, at construction. */
106
+ values(item: T): ItemValues<F>;
102
107
  /**
103
108
  * Called whenever the state moves, and once on `attach` with whatever the
104
109
  * URL already said. Never called for a `set` that changed nothing.
105
110
  */
106
- onChange(change: FilterChange<F>): void;
111
+ onChange(change: FilterChange<F, T>): void;
107
112
  }
108
113
 
109
114
  export interface SetOptions {
@@ -117,11 +122,11 @@ export interface SetOptions {
117
122
  readonly history?: "push" | "replace";
118
123
  }
119
124
 
120
- export interface Filters<F extends FieldMap> {
125
+ export interface Filters<F extends FieldMap, T> {
121
126
  readonly state: FilterState<F>;
122
- readonly matched: ReadonlySet<string>;
127
+ readonly matched: ReadonlySet<T>;
123
128
  /**
124
- * The keys that survive every field *except* this one: what a facet asks of
129
+ * The items that survive every field *except* this one: what a facet asks of
125
130
  * its own options, so each can say what picking it would give. Counted
126
131
  * against `matched` instead, a facet that is already set could only ever
127
132
  * offer its own value.
@@ -129,7 +134,7 @@ export interface Filters<F extends FieldMap> {
129
134
  * The same matcher as `matched`, so a count cannot drift from the list it
130
135
  * counts. Computed on first ask and kept until the state moves.
131
136
  */
132
- matchedWithout(field: keyof F): ReadonlySet<string>;
137
+ matchedWithout(field: keyof F): ReadonlySet<T>;
133
138
  set<K extends keyof F>(
134
139
  field: K,
135
140
  value: StateValue<F[K]>,
@@ -141,7 +146,7 @@ export interface Filters<F extends FieldMap> {
141
146
  * Also called on every change, after `onChange`. Returns the undo. For a
142
147
  * helper like `searchBox` that keeps its own part of the page in step.
143
148
  */
144
- subscribe(listener: (change: FilterChange<F>) => void): () => void;
149
+ subscribe(listener: (change: FilterChange<F, T>) => void): () => void;
145
150
  /**
146
151
  * Reads the URL, applies it, and starts listening — returns the undo.
147
152
  *
@@ -164,61 +169,38 @@ function fold(value: string): string {
164
169
  return value.normalize("NFD").replace(DIACRITICS, "").toLowerCase().trim();
165
170
  }
166
171
 
167
- /**
168
- * How a set flag is spelled in the query string, and the only spelling read back
169
- * as set: an unset flag is *absent* rather than `=0`. `1` and not `true`
170
- * because that is what shared links already carry.
171
- */
172
- const FLAG_ON = "1";
173
-
174
- export function filters<const F extends FieldMap>(
175
- options: FiltersOptions<F>
176
- ): Filters<F> {
172
+ export function filters<const F extends FieldMap, T>(
173
+ options: FiltersOptions<F, T>
174
+ ): Filters<F, T> {
177
175
  const { fields, items, onChange } = options;
178
176
  const names = Object.keys(fields) as (keyof F & string)[];
179
177
 
180
- // Checks types cannot express, and both failures are otherwise silent: a
181
- // duplicate key makes `matched` ambiguous, and two fields sharing a
182
- // parameter means each URL write erases the other's value.
183
- const keys = new Set<string>();
184
- for (const item of items) {
185
- if (keys.has(item.key)) {
186
- throw new Error(`Two filter items share the key "${item.key}".`);
187
- }
188
- keys.add(item.key);
189
- }
190
- // Field name → URL parameter, for the fields that are in the URL.
191
- const params = new Map<string, string>();
192
- const taken = new Set<string>();
193
- for (const name of names) {
194
- const given = fields[name]?.param;
195
- if (given === false) continue;
196
- const param = given ?? name;
197
- if (taken.has(param)) {
198
- throw new Error(
199
- `Two filter fields share the URL parameter "${param}".`
200
- );
201
- }
202
- taken.add(param);
203
- params.set(name, param);
204
- }
178
+ const kindOf = (name: keyof F & string): FieldKind | undefined => {
179
+ const field = fields[name];
180
+ return typeof field === "string" ? field : field?.kind;
181
+ };
205
182
 
206
- // Folded once at construction: doing it per item per keystroke is the
207
- // difference between filtering as you type and stuttering.
208
- const haystacks = new Map<string, Map<string, string>>();
209
- for (const item of items) {
210
- const perField = new Map<string, string>();
183
+ // The fields in the URL, each under its own name.
184
+ const inUrl = names.filter((name) => {
185
+ const field = fields[name];
186
+ return typeof field === "string" || field?.url !== false;
187
+ });
188
+
189
+ // Each item's values read once, and its text folded once: doing it per item
190
+ // per keystroke is the difference between filtering as you type and
191
+ // stuttering.
192
+ const entries = items.map((item) => {
193
+ const values = options.values(item);
194
+ const haystacks = new Map<string, string>();
211
195
  for (const name of names) {
212
- if (fields[name]?.kind !== "text") continue;
213
- perField.set(name, fold(item.values[name] as string));
196
+ if (kindOf(name) !== "text") continue;
197
+ haystacks.set(name, fold(values[name] as string));
214
198
  }
215
- haystacks.set(item.key, perField);
216
- }
199
+ return { item, values, haystacks };
200
+ });
217
201
 
218
202
  const empty = (name: keyof F & string): StateValue<F[typeof name]> =>
219
- (fields[name]?.kind === "flag" ? false : "") as StateValue<
220
- F[typeof name]
221
- >;
203
+ (kindOf(name) === "flag" ? false : "") as StateValue<F[typeof name]>;
222
204
 
223
205
  const blank = (): FilterState<F> =>
224
206
  Object.fromEntries(
@@ -226,13 +208,13 @@ export function filters<const F extends FieldMap>(
226
208
  ) as FilterState<F>;
227
209
 
228
210
  let state = blank();
229
- let matched: ReadonlySet<string> = new Set(items.map((item) => item.key));
211
+ let matched: ReadonlySet<T> = new Set(items);
230
212
 
231
213
  /** Each `text` field's query, folded and split once per state. */
232
214
  let queries = new Map<string, readonly string[]>();
233
215
 
234
216
  /** `matchedWithout` answers for the current state. Emptied when it moves. */
235
- const without = new Map<string, ReadonlySet<string>>();
217
+ const without = new Map<string, ReadonlySet<T>>();
236
218
 
237
219
  /**
238
220
  * Whether the URL is this instance's to write. An instance that was built
@@ -246,7 +228,7 @@ export function filters<const F extends FieldMap>(
246
228
  * a flag that is off all keep every item — the flag above all.
247
229
  */
248
230
  const narrows = (name: keyof F & string): boolean => {
249
- const kind = fields[name]?.kind;
231
+ const kind = kindOf(name);
250
232
  if (kind === "text") return (queries.get(name) ?? []).length > 0;
251
233
  if (kind === "choice") return state[name] !== "";
252
234
  return state[name] === true;
@@ -256,17 +238,20 @@ export function filters<const F extends FieldMap>(
256
238
  * Whether one item survives every field but `except`. The one matcher, so a
257
239
  * facet count and the list it counts cannot disagree about what a match is.
258
240
  */
259
- function passes(item: FilterItem<F>, except?: keyof F & string): boolean {
241
+ function passes(
242
+ entry: (typeof entries)[number],
243
+ except?: keyof F & string
244
+ ): boolean {
260
245
  for (const name of names) {
261
246
  if (name === except || !narrows(name)) continue;
262
- const kind = fields[name]?.kind;
247
+ const kind = kindOf(name);
263
248
  if (kind === "text") {
264
249
  // Every token must appear, rather than the query as one run: a
265
250
  // haystack is several things joined, and matching it whole made
266
251
  // the joining order load-bearing — "kettle kitchen" found
267
252
  // nothing while "kettle" worked.
268
253
  const tokens = queries.get(name) ?? [];
269
- const haystack = haystacks.get(item.key)?.get(name) ?? "";
254
+ const haystack = entry.haystacks.get(name) ?? "";
270
255
  if (!tokens.every((token) => haystack.includes(token))) {
271
256
  return false;
272
257
  }
@@ -274,12 +259,12 @@ export function filters<const F extends FieldMap>(
274
259
  // An item may sit in several categories while the control picks
275
260
  // one — see `ItemValue`.
276
261
  const wanted = state[name] as string;
277
- const held = item.values[name];
262
+ const held = entry.values[name];
278
263
  const holds = Array.isArray(held)
279
264
  ? held.includes(wanted)
280
265
  : held === wanted;
281
266
  if (!holds) return false;
282
- } else if (item.values[name] !== true) {
267
+ } else if (entry.values[name] !== true) {
283
268
  return false;
284
269
  }
285
270
  }
@@ -289,15 +274,15 @@ export function filters<const F extends FieldMap>(
289
274
  function recompute(): void {
290
275
  queries = new Map();
291
276
  for (const name of names) {
292
- if (fields[name]?.kind !== "text") continue;
277
+ if (kindOf(name) !== "text") continue;
293
278
  const folded = fold(state[name] as string);
294
279
  queries.set(name, folded === "" ? [] : folded.split(/\s+/));
295
280
  }
296
281
  without.clear();
297
282
 
298
- const next = new Set<string>();
299
- for (const item of items) {
300
- if (passes(item)) next.add(item.key);
283
+ const next = new Set<T>();
284
+ for (const entry of entries) {
285
+ if (passes(entry)) next.add(entry.item);
301
286
  }
302
287
  matched = next;
303
288
  }
@@ -306,18 +291,19 @@ export function filters<const F extends FieldMap>(
306
291
  const search = new URLSearchParams(window.location.search);
307
292
  const next: Record<string, string | boolean> = {};
308
293
  for (const name of names) {
309
- const field = fields[name];
310
- const param = params.get(name);
311
- if (field === undefined) continue;
312
- if (param === undefined) {
294
+ if (!inUrl.includes(name)) {
313
295
  // A state-only field is not in the URL, so the URL has nothing
314
296
  // to say about it. Clearing it here wiped a search mid-typing
315
297
  // whenever another parameter on the page moved.
316
298
  next[name] = state[name];
317
299
  continue;
318
300
  }
319
- const raw = search.get(param);
320
- next[name] = field.kind === "flag" ? raw === FLAG_ON : (raw ?? "");
301
+ // A flag is on by being there, as an HTML boolean attribute is:
302
+ // `?featured`, and `?featured=1` from an older link, alike.
303
+ next[name] =
304
+ kindOf(name) === "flag"
305
+ ? search.has(name)
306
+ : (search.get(name) ?? "");
321
307
  }
322
308
  state = next as FilterState<F>;
323
309
  }
@@ -331,17 +317,23 @@ export function filters<const F extends FieldMap>(
331
317
  // attributed the first time someone typed in the search box. Its own
332
318
  // are deleted first, then rewritten, so one state gives one URL.
333
319
  const search = new URLSearchParams(window.location.search);
334
- for (const param of params.values()) search.delete(param);
335
- for (const [name, param] of params) {
336
- if (fields[name]?.kind === "flag") {
337
- if (state[name] === true) search.set(param, FLAG_ON);
320
+ for (const name of inUrl) search.delete(name);
321
+ const rest = search.toString();
322
+ const parts = rest === "" ? [] : [rest];
323
+ for (const name of inUrl) {
324
+ // Joined by hand because `URLSearchParams` always writes `=`, and a
325
+ // flag that is on is its bare name.
326
+ if (kindOf(name) === "flag") {
327
+ if (state[name] === true) parts.push(encodeURIComponent(name));
338
328
  continue;
339
329
  }
340
330
  const value = (state[name] as string).trim();
341
- if (value !== "") search.set(param, value);
331
+ if (value !== "") {
332
+ parts.push(new URLSearchParams({ [name]: value }).toString());
333
+ }
342
334
  }
343
335
 
344
- const query = search.toString();
336
+ const query = parts.join("&");
345
337
  // The bare path when nothing is left, rather than a trailing `?`.
346
338
  const url = query === "" ? window.location.pathname : `?${query}`;
347
339
  // `null` on a pushed step, so Astro's `ClientRouter` leaves its popstate
@@ -352,9 +344,9 @@ export function filters<const F extends FieldMap>(
352
344
 
353
345
  /** One field's value as the matcher and the URL will read it. */
354
346
  const normalized = (name: keyof F & string, value: unknown): unknown =>
355
- fields[name]?.kind === "text" ? (value as string).trim() : value;
347
+ kindOf(name) === "text" ? (value as string).trim() : value;
356
348
 
357
- const listeners = new Set<(change: FilterChange<F>) => void>();
349
+ const listeners = new Set<(change: FilterChange<F, T>) => void>();
358
350
  const announce = (): void => {
359
351
  onChange({ state, matched });
360
352
  for (const listener of listeners) listener({ state, matched });
@@ -376,9 +368,9 @@ export function filters<const F extends FieldMap>(
376
368
  const cached = without.get(name);
377
369
  if (cached !== undefined) return cached;
378
370
 
379
- const rest = new Set<string>();
380
- for (const item of items) {
381
- if (passes(item, name)) rest.add(item.key);
371
+ const rest = new Set<T>();
372
+ for (const entry of entries) {
373
+ if (passes(entry, name)) rest.add(entry.item);
382
374
  }
383
375
  without.set(name, rest);
384
376
  return rest;
@@ -398,7 +390,7 @@ export function filters<const F extends FieldMap>(
398
390
 
399
391
  state = { ...state, [name]: value };
400
392
  recompute();
401
- const fallback = fields[name]?.kind === "text" ? "replace" : "push";
393
+ const fallback = kindOf(name) === "text" ? "replace" : "push";
402
394
  writeUrl(setOptions?.history ?? fallback);
403
395
  announce();
404
396
  },