@escape-game-over/atlas 0.1.23 → 0.1.25
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/README.md +29 -45
- package/bin/use-project.mjs +18 -13
- package/docs/NOT-BUILT.md +1 -1
- package/docs/client-scripts.md +73 -141
- package/docs/rich-text.md +25 -20
- package/package.json +5 -12
- package/src/analytics/google.ts +6 -6
- package/src/analytics/index.ts +4 -3
- package/src/analytics/tags.ts +14 -60
- package/src/analytics/umami.ts +8 -8
- package/src/astro/ConsentBanner.astro +25 -0
- package/src/astro/ConsentElement.astro +61 -0
- package/src/astro/Document.astro +44 -0
- package/src/astro/Image.astro +102 -0
- package/src/astro/MetaTags.astro +3 -26
- package/src/astro/RichText.astro +71 -0
- package/src/astro/Zoom.astro +61 -0
- package/src/astro/client.ts +19 -9
- package/src/astro/consent.ts +20 -0
- package/src/astro/dev-log.ts +8 -14
- package/src/astro/element.ts +111 -112
- package/src/astro/filters-view.ts +48 -64
- package/src/astro/filters.ts +46 -37
- package/src/astro/images.ts +27 -26
- package/src/astro/index.ts +2 -9
- package/src/astro/markup.ts +6 -6
- package/src/astro/site-routes.ts +10 -15
- package/src/config.ts +23 -36
- package/src/content/index.ts +2 -1
- package/src/content/marks.ts +13 -13
- package/src/content/rich.ts +58 -29
- package/src/hours.ts +48 -11
- package/src/i18n/define.ts +14 -74
- package/src/index.ts +41 -57
- package/src/jsonld/faq.ts +2 -1
- package/src/jsonld/node.ts +4 -14
- package/src/meta/index.ts +7 -13
- package/src/meta/share-image.ts +6 -19
- package/src/meta/tag.ts +1 -45
- package/src/money.ts +161 -6
- package/src/project.ts +84 -73
- package/src/routes/define.ts +8 -44
- package/src/routes/resolve.ts +1 -1
- package/src/site/api.ts +7 -33
- package/src/site/create.ts +6 -10
- package/src/site/define.ts +120 -0
- package/src/site/index.ts +2 -5
- package/src/site/page.ts +4 -2
- package/src/sitemap.ts +2 -35
- package/src/warn.ts +16 -17
- package/src/astro/dom.ts +0 -35
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Internal to `Image.astro`. No script: `command="show-modal"` opens the dialog
|
|
4
|
+
* and `closedby="any"` lets a click outside close it. The large image is lazy
|
|
5
|
+
* inside a closed dialog, so it is fetched on open.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getImage } from "astro:assets";
|
|
9
|
+
import type { ImageMetadata } from "astro";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
readonly src: ImageMetadata;
|
|
13
|
+
readonly alt: string;
|
|
14
|
+
readonly closeLabel: string;
|
|
15
|
+
/** On the `<dialog>`. */
|
|
16
|
+
readonly class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { src, alt, closeLabel, class: dialogClass } = Astro.props;
|
|
20
|
+
const large = await getImage({
|
|
21
|
+
src,
|
|
22
|
+
width: Math.min(src.width, 2048),
|
|
23
|
+
format: "webp",
|
|
24
|
+
});
|
|
25
|
+
// The file's URL carries Astro's content hash, so the id is the same every
|
|
26
|
+
// build. The same image zoomed twice on a page opening one dialog is harmless.
|
|
27
|
+
const id = `atlas-zoom${large.src.replace(/[^\w-]/g, "-")}`;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
commandfor={id}
|
|
33
|
+
command="show-modal"
|
|
34
|
+
style="padding:0;border:0;background:none;cursor:zoom-in"
|
|
35
|
+
>
|
|
36
|
+
<slot />
|
|
37
|
+
</button>
|
|
38
|
+
<dialog
|
|
39
|
+
id={id}
|
|
40
|
+
closedby="any"
|
|
41
|
+
class={dialogClass}
|
|
42
|
+
aria-label={alt}
|
|
43
|
+
>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
commandfor={id}
|
|
47
|
+
command="close"
|
|
48
|
+
aria-label={closeLabel}
|
|
49
|
+
>
|
|
50
|
+
×
|
|
51
|
+
</button>
|
|
52
|
+
<img
|
|
53
|
+
src={large.src}
|
|
54
|
+
width={large.attributes.width}
|
|
55
|
+
height={large.attributes.height}
|
|
56
|
+
alt={alt}
|
|
57
|
+
loading="lazy"
|
|
58
|
+
decoding="async"
|
|
59
|
+
style="display:block;max-width:100%;max-height:85vh;height:auto"
|
|
60
|
+
>
|
|
61
|
+
</dialog>
|
package/src/astro/client.ts
CHANGED
|
@@ -5,19 +5,29 @@
|
|
|
5
5
|
* import { filters, searchBox } from "@escape-game-over/atlas/client";
|
|
6
6
|
* ```
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* component's contract is the one thing both halves need, and it stays on
|
|
11
|
-
* `astro/markup`, which touches nothing.
|
|
8
|
+
* The only import path for browser code. Nothing here touches the DOM at
|
|
9
|
+
* import, so a component's contract can be imported from frontmatter too.
|
|
12
10
|
*/
|
|
13
11
|
|
|
14
12
|
export * from "./background-video.ts";
|
|
15
13
|
export * from "./carousel.ts";
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
export {
|
|
15
|
+
applyConsent,
|
|
16
|
+
type ConsentChoice,
|
|
17
|
+
type ConsentRecord,
|
|
18
|
+
type ConsentStoreOptions,
|
|
19
|
+
consent,
|
|
20
|
+
consentApplies,
|
|
21
|
+
consentStore,
|
|
22
|
+
} from "./consent.ts";
|
|
23
|
+
export { type Connect, type ElementRoles, element } from "./element.ts";
|
|
20
24
|
export * from "./filters.ts";
|
|
21
25
|
export * from "./filters-view.ts";
|
|
22
|
-
export
|
|
26
|
+
export {
|
|
27
|
+
type Component,
|
|
28
|
+
type Marked,
|
|
29
|
+
type Markup,
|
|
30
|
+
marker,
|
|
31
|
+
markup,
|
|
32
|
+
} from "./markup.ts";
|
|
23
33
|
export * from "./youtube.ts";
|
package/src/astro/consent.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
|
|
2
|
+
import { component, markup } from "./markup.ts";
|
|
3
|
+
|
|
4
|
+
/** Internal to `ConsentBanner.astro`: the element and the footer control. */
|
|
5
|
+
export const CONSENT_ROLES = {
|
|
6
|
+
answer: { choice: { kind: "choice", of: ["granted", "denied"] } },
|
|
7
|
+
} as const;
|
|
8
|
+
export const consentBanner = component("atlas-consent", CONSENT_ROLES);
|
|
9
|
+
export const consentReopen = markup("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: consentBanner.answer.attrs({ choice: "granted" }),
|
|
19
|
+
decline: consentBanner.answer.attrs({ choice: "denied" }),
|
|
20
|
+
reopen: { ...consentReopen.attrs(), hidden: "" },
|
|
21
|
+
};
|
|
2
22
|
|
|
3
23
|
/**
|
|
4
24
|
* The browser half of consent: remembering an answer, expiring it, and handing
|
package/src/astro/dev-log.ts
CHANGED
|
@@ -7,20 +7,9 @@
|
|
|
7
7
|
* least of all the person who broke it ten seconds ago. This puts the same
|
|
8
8
|
* message where it cannot be missed.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* and message strings
|
|
13
|
-
* instead of shipped and never called.
|
|
14
|
-
*
|
|
15
|
-
* ```ts
|
|
16
|
-
* if (import.meta.env.DEV) reportDevError(this.localName, error);
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* Written as the bare expression, without an optional chain: Vite substitutes
|
|
20
|
-
* `import.meta.env.DEV` with a literal, so the branch collapses and everything
|
|
21
|
-
* below is dropped. `import.meta.env?.DEV` is replaced as `import.meta.env` —
|
|
22
|
-
* an object literal whose property access a minifier has to fold rather than
|
|
23
|
-
* simply delete.
|
|
10
|
+
* In a production build it is a `console.error` and nothing else: Vite
|
|
11
|
+
* substitutes the bare `import.meta.env.DEV` with a literal, so the panel,
|
|
12
|
+
* styles and message strings are dead code and get dropped.
|
|
24
13
|
*
|
|
25
14
|
* Not a custom element, deliberately. It would need registering, it would
|
|
26
15
|
* collide with a project that registered the same name, and nothing ever
|
|
@@ -102,6 +91,11 @@ function panel(): HTMLElement {
|
|
|
102
91
|
* second bug underneath them.
|
|
103
92
|
*/
|
|
104
93
|
export function reportDevError(source: string, error: unknown): void {
|
|
94
|
+
if (!import.meta.env.DEV) {
|
|
95
|
+
console.error(`${source}:`, error);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
105
99
|
const text = `${source} — ${describe(error)}`;
|
|
106
100
|
|
|
107
101
|
console.error(text, error);
|
package/src/astro/element.ts
CHANGED
|
@@ -1,116 +1,143 @@
|
|
|
1
1
|
import { reportDevError } from "./dev-log.ts";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
2
|
+
import {
|
|
3
|
+
type CheckedKeys,
|
|
4
|
+
type CheckedTagName,
|
|
5
|
+
type Component,
|
|
6
|
+
type ComponentRoles,
|
|
7
|
+
component,
|
|
8
|
+
type Marked,
|
|
9
|
+
type MarkupFields,
|
|
10
|
+
type Reserved,
|
|
11
|
+
ROOT_ATTRIBUTE,
|
|
12
|
+
} from "./markup.ts";
|
|
13
|
+
|
|
14
|
+
/** What is left to undo when the element leaves; usually nothing. */
|
|
12
15
|
// biome-ignore lint/suspicious/noConfusingVoidType: that is the distinction.
|
|
13
16
|
type Undo = void | (() => void);
|
|
14
17
|
|
|
15
|
-
/** One role's lookups, already pointed at the element they belong to. */
|
|
16
|
-
type Bound<M> =
|
|
17
|
-
M extends Markup<infer F>
|
|
18
|
-
? {
|
|
19
|
-
all<T extends Element = HTMLElement>(): Marked<T, F>[];
|
|
20
|
-
one<T extends Element = HTMLElement>(): Marked<T, F> | null;
|
|
21
|
-
require<T extends Element = HTMLElement>(): Marked<T, F>;
|
|
22
|
-
}
|
|
23
|
-
: never;
|
|
24
|
-
|
|
25
18
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* The root is not a parameter here, which is the point: `faq.row.all(document)`
|
|
29
|
-
* compiles and quietly returns the roles that belong to *no* instance, and this
|
|
30
|
-
* removes the chance to write it.
|
|
19
|
+
* One role's lookups inside this instance. A role with no fields is found for
|
|
20
|
+
* the element alone; one with fields comes back with its values read.
|
|
31
21
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
type Lookups<F extends MarkupFields> = [keyof F] extends [never]
|
|
23
|
+
? {
|
|
24
|
+
all<T extends Element = HTMLElement>(): T[];
|
|
25
|
+
one<T extends Element = HTMLElement>(): T | null;
|
|
26
|
+
require<T extends Element = HTMLElement>(): T;
|
|
27
|
+
}
|
|
28
|
+
: {
|
|
29
|
+
all<T extends Element = HTMLElement>(): Marked<T, F>[];
|
|
30
|
+
one<T extends Element = HTMLElement>(): Marked<T, F> | null;
|
|
31
|
+
require<T extends Element = HTMLElement>(): Marked<T, F>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Every role of an element, bound to one instance. */
|
|
35
|
+
export type ElementRoles<R extends ComponentRoles> = {
|
|
36
|
+
readonly [K in keyof R]: Lookups<R[K]>;
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
|
-
* What an element does while it is on the page.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* anything registered with `signal` comes down on its own — and `roles` are
|
|
41
|
-
* this instance's. Whatever else has to be undone is the returned function,
|
|
42
|
-
* which runs at the same moment.
|
|
40
|
+
* What an element does while it is on the page. `signal` is aborted when it
|
|
41
|
+
* leaves, so listeners registered with it come down on their own; anything else
|
|
42
|
+
* to undo is the returned function.
|
|
43
43
|
*/
|
|
44
|
-
export type Connect<
|
|
44
|
+
export type Connect<R extends ComponentRoles> = (
|
|
45
45
|
host: HTMLElement,
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
roles: ElementRoles<R>,
|
|
47
|
+
signal: AbortSignal
|
|
48
48
|
) => Undo;
|
|
49
49
|
|
|
50
|
-
/** The shape this file needs off a role, without importing its field types. */
|
|
51
|
-
interface AnyRole {
|
|
52
|
-
all(root: ParentNode): unknown;
|
|
53
|
-
one(root: ParentNode): unknown;
|
|
54
|
-
require(root: ParentNode): unknown;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
50
|
/**
|
|
58
|
-
*
|
|
51
|
+
* A custom element: its markup contract and its behaviour, in one call.
|
|
59
52
|
*
|
|
60
53
|
* ```ts
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
54
|
+
* // faq.ts — imported by the template, and by the page's script
|
|
55
|
+
* export const faq = element("go-faq", { row: { key: { kind: "text" } }, search: marker },
|
|
56
|
+
* (host, { row, search }, signal) => { … });
|
|
57
|
+
* ```
|
|
58
|
+
* ```astro
|
|
59
|
+
* <script src="./faq.ts"></script>
|
|
67
60
|
* ```
|
|
68
61
|
*
|
|
62
|
+
* Registers itself when loaded in a browser and does nothing in Node, so the
|
|
63
|
+
* template imports the same file for the contract.
|
|
64
|
+
*
|
|
69
65
|
* - **An element can enter the page more than once.** Moving it runs the undo
|
|
70
|
-
* and
|
|
71
|
-
* `ClientRouter` connects a persisted element three times per navigation.
|
|
66
|
+
* and `connect` again; Astro's `ClientRouter` does so on every navigation.
|
|
72
67
|
* State that must survive belongs in a `WeakMap` keyed by `host`.
|
|
73
|
-
* - **The
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* - **A `connect` that throws leaves that one element inert** and reports it,
|
|
78
|
-
* rather than taking its siblings down with it.
|
|
79
|
-
*
|
|
80
|
-
* See docs/client-scripts.md.
|
|
68
|
+
* - **The page's script must stay a bundled module** (a plain `<script>`), so
|
|
69
|
+
* the element has its children when it upgrades.
|
|
70
|
+
* - **A `connect` that throws leaves that one element inert** and reports it.
|
|
71
|
+
* - **A tag defined twice keeps the first definition** and reports the second.
|
|
81
72
|
*/
|
|
82
|
-
export function
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
73
|
+
export function element<
|
|
74
|
+
const Tag extends string,
|
|
75
|
+
const R extends ComponentRoles &
|
|
76
|
+
CheckedKeys<R> & { readonly [K in Reserved]?: never },
|
|
77
|
+
>(
|
|
78
|
+
tag: Tag & CheckedTagName<Tag>,
|
|
79
|
+
roles: R,
|
|
80
|
+
connect: Connect<R>
|
|
81
|
+
): Component<Tag, R> {
|
|
82
|
+
// Already checked by this function's own signature.
|
|
83
|
+
const contract = component(tag as never, roles) as Component<Tag, R>;
|
|
84
|
+
if (typeof customElements !== "undefined") {
|
|
85
|
+
define(contract, roles, connect);
|
|
86
|
+
}
|
|
87
|
+
return contract;
|
|
88
|
+
}
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
90
|
+
/** The shape this file needs off a role, without its field types. */
|
|
91
|
+
interface AnyRole {
|
|
92
|
+
all(root: ParentNode): Marked<Element, MarkupFields>[];
|
|
93
|
+
one(root: ParentNode): Marked<Element, MarkupFields> | null;
|
|
94
|
+
require(root: ParentNode): Marked<Element, MarkupFields>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function define<R extends ComponentRoles>(
|
|
98
|
+
contract: Component<string, R>,
|
|
99
|
+
roles: R,
|
|
100
|
+
connect: Connect<R>
|
|
101
|
+
): void {
|
|
102
|
+
const { tag } = contract;
|
|
103
|
+
if (customElements.get(tag) !== undefined) {
|
|
104
|
+
reportDevError(
|
|
105
|
+
tag,
|
|
106
|
+
new Error(`<${tag}> is already defined; this definition is ignored`)
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const bind = (host: HTMLElement): ElementRoles<R> => {
|
|
112
|
+
const bound: Record<string, unknown> = {};
|
|
113
|
+
for (const [name, fields] of Object.entries(roles)) {
|
|
114
|
+
const role = (contract as unknown as Record<string, AnyRole>)[name];
|
|
115
|
+
if (role === undefined) continue;
|
|
116
|
+
bound[name] =
|
|
117
|
+
Object.keys(fields).length === 0
|
|
118
|
+
? {
|
|
119
|
+
all: () => role.all(host).map((each) => each.element),
|
|
120
|
+
one: () => role.one(host)?.element ?? null,
|
|
121
|
+
require: () => role.require(host).element,
|
|
122
|
+
}
|
|
123
|
+
: {
|
|
124
|
+
all: () => role.all(host),
|
|
125
|
+
one: () => role.one(host),
|
|
126
|
+
require: () => role.require(host),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return bound as ElementRoles<R>;
|
|
130
|
+
};
|
|
93
131
|
|
|
94
|
-
// The class is built in here rather than at module scope so `HTMLElement`
|
|
95
|
-
// is only read in a browser.
|
|
96
132
|
customElements.define(
|
|
97
133
|
tag,
|
|
98
134
|
class extends HTMLElement {
|
|
99
|
-
/**
|
|
100
|
-
* This visit's listeners, and only this visit's. Remade every time:
|
|
101
|
-
* an `AbortController` is single-use, and a reused one comes back
|
|
102
|
-
* already aborted, leaving the element inert but normal-looking.
|
|
103
|
-
*/
|
|
135
|
+
/** This visit's listeners. Remade every time: a controller is single-use. */
|
|
104
136
|
#ac?: AbortController;
|
|
105
|
-
|
|
106
|
-
/** What `connect` handed back, if anything. */
|
|
107
137
|
#undo?: () => void;
|
|
108
138
|
|
|
109
139
|
connectedCallback(): void {
|
|
110
|
-
// Insurance: a live controller still here would orphan its
|
|
111
|
-
// listeners.
|
|
112
140
|
this.#end();
|
|
113
|
-
|
|
114
141
|
const ac = new AbortController();
|
|
115
142
|
this.#ac = ac;
|
|
116
143
|
try {
|
|
@@ -123,9 +150,9 @@ export function defineElement<C extends { readonly tag: string }>(
|
|
|
123
150
|
);
|
|
124
151
|
}
|
|
125
152
|
this.#undo =
|
|
126
|
-
connect(this, ac.signal
|
|
153
|
+
connect(this, bind(this), ac.signal) ?? undefined;
|
|
127
154
|
} catch (error) {
|
|
128
|
-
this
|
|
155
|
+
reportDevError(this.localName, error);
|
|
129
156
|
}
|
|
130
157
|
}
|
|
131
158
|
|
|
@@ -133,50 +160,22 @@ export function defineElement<C extends { readonly tag: string }>(
|
|
|
133
160
|
this.#end();
|
|
134
161
|
}
|
|
135
162
|
|
|
136
|
-
/** Moving to another document ends the old document's visit. */
|
|
137
163
|
adoptedCallback(): void {
|
|
138
164
|
this.#end();
|
|
139
165
|
}
|
|
140
166
|
|
|
141
|
-
/** This instance's roles: the component's, with the root supplied. */
|
|
142
|
-
#roles(): Roles<C> {
|
|
143
|
-
const bound: Record<string, unknown> = {};
|
|
144
|
-
for (const name of roleNames) {
|
|
145
|
-
const role = (component as Record<string, unknown>)[
|
|
146
|
-
name
|
|
147
|
-
] as AnyRole;
|
|
148
|
-
bound[name] = {
|
|
149
|
-
all: () => role.all(this),
|
|
150
|
-
one: () => role.one(this),
|
|
151
|
-
require: () => role.require(this),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
return bound as Roles<C>;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
167
|
#end(): void {
|
|
158
168
|
// Aborted before the undo runs, so an async continuation that
|
|
159
169
|
// checks the signal can see the visit is over.
|
|
160
170
|
this.#ac?.abort();
|
|
161
171
|
this.#ac = undefined;
|
|
162
|
-
|
|
163
172
|
const undo = this.#undo;
|
|
164
173
|
this.#undo = undefined;
|
|
165
174
|
try {
|
|
166
175
|
undo?.();
|
|
167
176
|
} catch (error) {
|
|
168
|
-
this.#report(error);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
#report(error: unknown): void {
|
|
173
|
-
// The bare expression Vite substitutes, so this collapses to
|
|
174
|
-
// `if (false)` and the dev panel leaves the bundle.
|
|
175
|
-
if (import.meta.env.DEV) {
|
|
176
177
|
reportDevError(this.localName, error);
|
|
177
|
-
return;
|
|
178
178
|
}
|
|
179
|
-
console.error(`${this.localName}:`, error);
|
|
180
179
|
}
|
|
181
180
|
}
|
|
182
181
|
);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* See docs/client-scripts.md.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import type { FieldMap, Filters } from "./filters.ts";
|
|
23
|
+
import type { FieldMap, FilterChange, Filters } from "./filters.ts";
|
|
24
24
|
|
|
25
25
|
/** The `text` fields of `F`, so a search box cannot be pointed at a flag. */
|
|
26
26
|
export type TextFieldOf<F extends FieldMap> = {
|
|
@@ -46,77 +46,61 @@ export interface SearchBoxElements {
|
|
|
46
46
|
readonly count?: HTMLElement | null;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export interface SearchBox {
|
|
50
|
-
/**
|
|
51
|
-
* Brings the box into line with the state, from inside `onChange`. Two
|
|
52
|
-
* scalars rather than the change object, so this does not care what the
|
|
53
|
-
* field was named.
|
|
54
|
-
*/
|
|
55
|
-
render(query: string, matchCount: number): void;
|
|
56
|
-
/**
|
|
57
|
-
* Points the input and the clear button at one `text` field, and returns
|
|
58
|
-
* the undo. Separate from construction because `render` is called from the
|
|
59
|
-
* list's `onChange`: the box has to exist before the list, and this needs
|
|
60
|
-
* the list.
|
|
61
|
-
*/
|
|
62
|
-
bind<F extends FieldMap>(
|
|
63
|
-
list: Filters<F>,
|
|
64
|
-
field: TextFieldOf<F>
|
|
65
|
-
): () => void;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
49
|
/**
|
|
69
|
-
* The search input beside a `filters` list
|
|
50
|
+
* The search input beside a `filters` list, kept in step with it: typing sets
|
|
51
|
+
* `field`, the clear button resets it, and every change repaints the box.
|
|
52
|
+
* Returns the undo.
|
|
70
53
|
*
|
|
71
54
|
* ```ts
|
|
72
|
-
* const
|
|
73
|
-
* const
|
|
74
|
-
*
|
|
75
|
-
* items,
|
|
76
|
-
* onChange: ({ state, matched }) => search.render(state.q, matched.size),
|
|
77
|
-
* });
|
|
78
|
-
* const unbind = search.bind(list, "q");
|
|
55
|
+
* const list = filters({ fields, items, onChange: … });
|
|
56
|
+
* const detach = list.attach();
|
|
57
|
+
* const unbind = searchBox(list, "q", { input, clear, empty, count });
|
|
79
58
|
* ```
|
|
80
59
|
*/
|
|
81
|
-
export function searchBox
|
|
60
|
+
export function searchBox<F extends FieldMap>(
|
|
61
|
+
list: Filters<F>,
|
|
62
|
+
field: TextFieldOf<F>,
|
|
63
|
+
elements: SearchBoxElements
|
|
64
|
+
): () => void {
|
|
82
65
|
const { input, clear, empty, count } = elements;
|
|
66
|
+
// The narrowing `TextFieldOf` already did: inside a generic function
|
|
67
|
+
// TypeScript cannot see that this field holds a string.
|
|
68
|
+
const name = field as Parameters<typeof list.set>[0];
|
|
83
69
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
bind(list, field) {
|
|
96
|
-
const listeners = new AbortController();
|
|
97
|
-
const { signal } = listeners;
|
|
98
|
-
// The narrowing `TextFieldOf` already did: inside a generic
|
|
99
|
-
// function TypeScript cannot see that this field holds a string.
|
|
100
|
-
const name = field as Parameters<typeof list.set>[0];
|
|
101
|
-
|
|
102
|
-
input?.addEventListener(
|
|
103
|
-
"input",
|
|
104
|
-
() => list.set(name, input.value as never),
|
|
105
|
-
{ signal }
|
|
106
|
-
);
|
|
70
|
+
const render = ({ state, matched }: FilterChange<F>): void => {
|
|
71
|
+
const query = String(state[name]);
|
|
72
|
+
// Guarded by inequality: assigning `value` while someone types moves
|
|
73
|
+
// the caret to the end, and this exists for state that moved without
|
|
74
|
+
// the keyboard — the back button, or a `reset`.
|
|
75
|
+
if (input != null && input.value !== query) input.value = query;
|
|
76
|
+
if (clear != null) clear.hidden = query === "";
|
|
77
|
+
if (empty != null) empty.hidden = matched.size > 0;
|
|
78
|
+
if (count != null) count.textContent = String(matched.size);
|
|
79
|
+
};
|
|
107
80
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
() => {
|
|
111
|
-
list.reset(name);
|
|
112
|
-
// The button just hid itself; focus must not stay on it.
|
|
113
|
-
input?.focus();
|
|
114
|
-
},
|
|
115
|
-
{ signal }
|
|
116
|
-
);
|
|
81
|
+
render(list);
|
|
82
|
+
const unsubscribe = list.subscribe(render);
|
|
117
83
|
|
|
118
|
-
|
|
84
|
+
const events = new AbortController();
|
|
85
|
+
const { signal } = events;
|
|
86
|
+
input?.addEventListener(
|
|
87
|
+
"input",
|
|
88
|
+
() => list.set(name, input.value as never),
|
|
89
|
+
{ signal }
|
|
90
|
+
);
|
|
91
|
+
clear?.addEventListener(
|
|
92
|
+
"click",
|
|
93
|
+
() => {
|
|
94
|
+
list.reset(name);
|
|
95
|
+
// The button just hid itself; focus must not stay on it.
|
|
96
|
+
input?.focus();
|
|
119
97
|
},
|
|
98
|
+
{ signal }
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
events.abort();
|
|
103
|
+
unsubscribe();
|
|
120
104
|
};
|
|
121
105
|
}
|
|
122
106
|
|
|
@@ -131,8 +115,8 @@ export function searchBox(elements: SearchBoxElements): SearchBox {
|
|
|
131
115
|
* **So the order is load-bearing: innermost first.**
|
|
132
116
|
*
|
|
133
117
|
* ```ts
|
|
134
|
-
* hideEmpty(countries, (c) =>
|
|
135
|
-
* hideEmpty(regions, (r) =>
|
|
118
|
+
* hideEmpty(countries, (c) => citiesIn.get(c) ?? []);
|
|
119
|
+
* hideEmpty(regions, (r) => countriesIn.get(r) ?? []);
|
|
136
120
|
* ```
|
|
137
121
|
*/
|
|
138
122
|
export function hideEmpty<T extends HTMLElement>(
|