@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.
- package/docs/client-scripts.md +42 -55
- package/package.json +1 -1
- package/src/analytics/index.ts +3 -6
- package/src/astro/ConsentBanner.astro +3 -2
- package/src/astro/ConsentElement.astro +3 -50
- package/src/astro/client.ts +0 -9
- package/src/astro/consent-element.ts +59 -0
- package/src/astro/consent.ts +49 -119
- package/src/astro/element.ts +6 -5
- package/src/astro/filters-view.ts +10 -20
- package/src/astro/filters.ts +108 -116
package/docs/client-scripts.md
CHANGED
|
@@ -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
|
|
50
|
-
|
|
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", ({
|
|
77
|
-
ref(
|
|
78
|
-
return loop.attach(
|
|
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 `
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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:
|
|
279
|
-
clear:
|
|
280
|
-
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", ({
|
|
373
|
-
const input = ref<HTMLInputElement>(
|
|
374
|
-
for (const row of refs<HTMLDetailsElement>(
|
|
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(
|
|
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`
|
|
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
package/src/analytics/index.ts
CHANGED
|
@@ -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
|
|
76
|
-
* the
|
|
77
|
-
*
|
|
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
|
|
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
|
|
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>
|
package/src/astro/client.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/astro/consent.ts
CHANGED
|
@@ -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
|
|
40
|
+
const MONTHS = 6;
|
|
61
41
|
|
|
62
42
|
/**
|
|
63
|
-
*
|
|
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
|
|
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
|
-
*
|
|
49
|
+
* The answer this visitor gave, if it still counts.
|
|
86
50
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
}
|
package/src/astro/element.ts
CHANGED
|
@@ -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
|
-
|
|
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", ({
|
|
37
|
-
* const input = ref<HTMLInputElement>(
|
|
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 `
|
|
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({
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/src/astro/filters.ts
CHANGED
|
@@ -3,14 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ```ts
|
|
5
5
|
* const list = filters({
|
|
6
|
-
* fields: {
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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> =
|
|
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> =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
90
|
-
*
|
|
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<
|
|
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
|
|
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<
|
|
127
|
+
readonly matched: ReadonlySet<T>;
|
|
123
128
|
/**
|
|
124
|
-
* The
|
|
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<
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 (
|
|
213
|
-
|
|
196
|
+
if (kindOf(name) !== "text") continue;
|
|
197
|
+
haystacks.set(name, fold(values[name] as string));
|
|
214
198
|
}
|
|
215
|
-
|
|
216
|
-
}
|
|
199
|
+
return { item, values, haystacks };
|
|
200
|
+
});
|
|
217
201
|
|
|
218
202
|
const empty = (name: keyof F & string): StateValue<F[typeof name]> =>
|
|
219
|
-
(
|
|
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<
|
|
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<
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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<
|
|
299
|
-
for (const
|
|
300
|
-
if (passes(
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 !== "")
|
|
331
|
+
if (value !== "") {
|
|
332
|
+
parts.push(new URLSearchParams({ [name]: value }).toString());
|
|
333
|
+
}
|
|
342
334
|
}
|
|
343
335
|
|
|
344
|
-
const query =
|
|
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
|
-
|
|
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<
|
|
380
|
-
for (const
|
|
381
|
-
if (passes(
|
|
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 =
|
|
393
|
+
const fallback = kindOf(name) === "text" ? "replace" : "push";
|
|
402
394
|
writeUrl(setOptions?.history ?? fallback);
|
|
403
395
|
announce();
|
|
404
396
|
},
|