@johnmorrisdotca/kazoku-ui 0.3.0 → 0.7.2

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +81 -5
  3. package/dist/address-state/address.d.ts +76 -0
  4. package/dist/address-state/address.js +171 -0
  5. package/dist/address-state/index.d.ts +1 -0
  6. package/dist/address-state/index.js +2 -0
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/language-picker/define.d.ts +1 -0
  10. package/dist/language-picker/define.js +9 -0
  11. package/dist/language-picker/element.d.ts +39 -0
  12. package/dist/language-picker/element.js +194 -0
  13. package/dist/language-picker/index.d.ts +4 -0
  14. package/dist/language-picker/index.js +5 -0
  15. package/dist/language-picker/languages.d.ts +38 -0
  16. package/dist/language-picker/languages.js +100 -0
  17. package/dist/language-picker/styles.d.ts +1 -0
  18. package/dist/language-picker/styles.js +3 -0
  19. package/dist/language-picker/words.d.ts +8 -0
  20. package/dist/language-picker/words.js +7 -0
  21. package/dist/save-image/define.d.ts +1 -0
  22. package/dist/save-image/define.js +9 -0
  23. package/dist/save-image/element.d.ts +39 -0
  24. package/dist/save-image/element.js +186 -0
  25. package/dist/save-image/format.d.ts +20 -0
  26. package/dist/save-image/format.js +37 -0
  27. package/dist/save-image/index.d.ts +6 -0
  28. package/dist/save-image/index.js +8 -0
  29. package/dist/save-image/render.d.ts +55 -0
  30. package/dist/save-image/render.js +188 -0
  31. package/dist/save-image/styles.d.ts +1 -0
  32. package/dist/save-image/styles.js +9 -0
  33. package/dist/save-image/words.d.ts +12 -0
  34. package/dist/save-image/words.js +7 -0
  35. package/dist/shared/adopt.d.ts +4 -0
  36. package/dist/shared/adopt.js +34 -0
  37. package/dist/shared/base.d.ts +1 -0
  38. package/dist/shared/base.js +40 -0
  39. package/dist/shared/scheme.d.ts +1 -0
  40. package/dist/shared/scheme.js +19 -0
  41. package/dist/shared/store.d.ts +2 -0
  42. package/dist/shared/store.js +17 -0
  43. package/dist/shared/target.d.ts +2 -0
  44. package/dist/shared/target.js +11 -0
  45. package/dist/theme-switch/define.d.ts +1 -0
  46. package/dist/theme-switch/define.js +9 -0
  47. package/dist/theme-switch/element.d.ts +39 -0
  48. package/dist/theme-switch/element.js +203 -0
  49. package/dist/theme-switch/index.d.ts +4 -0
  50. package/dist/theme-switch/index.js +5 -0
  51. package/dist/theme-switch/styles.d.ts +1 -0
  52. package/dist/theme-switch/styles.js +8 -0
  53. package/dist/theme-switch/theme.d.ts +29 -0
  54. package/dist/theme-switch/theme.js +43 -0
  55. package/dist/theme-switch/words.d.ts +11 -0
  56. package/dist/theme-switch/words.js +7 -0
  57. package/dist/view-controls/controller.d.ts +6 -1
  58. package/dist/view-controls/controller.js +26 -3
  59. package/dist/view-controls/element.d.ts +1 -1
  60. package/dist/view-controls/element.js +54 -24
  61. package/package.json +67 -4
@@ -0,0 +1,203 @@
1
+ // <kazoku-theme-switch>: Light, Dark and Auto as a row of buttons. It writes the choice as `data-theme` on a target (the page's <html> by default; `auto` removes it),
2
+ // keeps it in the address and, if asked, in localStorage, and tells the page with a `kazoku-themechange` event. It draws nothing of the page: the page's CSS reads `data-theme`.
3
+ import { createAddressState } from "../address-state/address.js";
4
+ import { adoptStyles } from "../shared/adopt.js";
5
+ import { watchScheme } from "../shared/scheme.js";
6
+ import { localStore, remember } from "../shared/store.js";
7
+ import { resolveTarget } from "../shared/target.js";
8
+ import { EVENT, TAG } from "./index.js";
9
+ import { STYLES } from "./styles.js";
10
+ import { applyTheme, parseModes, parseTheme, readTheme, resolveTheme, THEME_ADDRESS_KEY, THEME_STORAGE_KEY, themeOf } from "./theme.js";
11
+ import { languageOf, wordsFor } from "./words.js";
12
+ export { EVENT, TAG };
13
+ const icon = (body) => `<svg viewBox="0 0 24 24" aria-hidden="true">${body}</svg>`;
14
+ const ICON = {
15
+ light: icon('<path d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zM11 1h2v3h-2zM11 20h2v3h-2zM1 11h3v2H1zM20 11h3v2h-3zM4.2 5.6l1.4-1.4 2.1 2.1-1.4 1.4zM16.3 17.7l1.4-1.4 2.1 2.1-1.4 1.4zM5.6 19.8l-1.4-1.4 2.1-2.1 1.4 1.4zM17.7 7.7l-1.4-1.4 2.1-2.1 1.4 1.4z"/>'),
16
+ dark: icon('<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5z"/>'),
17
+ auto: icon('<circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17z"/>'),
18
+ };
19
+ /**
20
+ * Attributes: `value` (`light`, `dark` or `auto`; the choice), `target` (a CSS selector for the element that gets `data-theme`; the page's <html> by default),
21
+ * `modes` (which buttons: `light dark` leaves Auto out), `address` (keep the choice in `?theme=`, or the key `address-key` names), `storage` (also remember it in
22
+ * localStorage under that key; a bare `storage` uses `kazoku-theme`), `lang` (`en` or `ja`), `theme` (`light` or `dark`: its own look, otherwise the page's).
23
+ * The choice is read, in this order, from the address, the store, `value`, then what the target already holds.
24
+ */
25
+ export class ThemeSwitchElement extends HTMLElement {
26
+ static observedAttributes = ["value", "target", "modes", "lang"];
27
+ #root;
28
+ #choice = "auto";
29
+ #target;
30
+ #words = {};
31
+ #ready = false;
32
+ #styled = false;
33
+ #address = createAddressState({ delay: 0 });
34
+ #stop = [];
35
+ #watching;
36
+ constructor() {
37
+ super();
38
+ this.#root = this.attachShadow({ mode: "open" });
39
+ }
40
+ /** The choice now: `light`, `dark` or `auto`. Setting it applies it (event reason `set`). */
41
+ get value() {
42
+ return this.#choice;
43
+ }
44
+ set value(next) {
45
+ const theme = parseTheme(next);
46
+ if (theme !== undefined)
47
+ this.#choose(theme, "set");
48
+ }
49
+ /** The colours showing now: `light` or `dark`. */
50
+ get scheme() {
51
+ return resolveTheme(this.#choice, this.#prefersDark());
52
+ }
53
+ /** The element the choice is written on. */
54
+ get target() {
55
+ return this.#target;
56
+ }
57
+ set target(element) {
58
+ this.#aim(element);
59
+ }
60
+ /** Your own labels over the language's, for example `{ auto: "System" }`. */
61
+ set words(words) {
62
+ this.#words = words;
63
+ this.#render();
64
+ }
65
+ connectedCallback() {
66
+ if (!this.#styled) {
67
+ adoptStyles(this.#root, STYLES, this.nonce);
68
+ this.#styled = true;
69
+ }
70
+ this.#stop.push(watchScheme(this));
71
+ this.#resolveTarget();
72
+ const target = this.#target;
73
+ const remembered = readTheme({
74
+ ...(this.hasAttribute("address") ? { search: globalThis.location?.search ?? "", addressKey: this.#addressKey() } : {}),
75
+ ...(this.hasAttribute("storage") ? { storage: localStore(), storageKey: this.#storageKey() } : {}),
76
+ });
77
+ this.#choice = remembered ?? parseTheme(this.getAttribute("value")) ?? (target !== undefined && target.hasAttribute("data-theme") ? themeOf(target) : "auto");
78
+ if (target !== undefined)
79
+ applyTheme(target, this.#choice);
80
+ this.#render();
81
+ const media = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
82
+ const onSystem = () => {
83
+ if (this.#choice === "auto")
84
+ this.#dispatch("system");
85
+ };
86
+ media?.addEventListener("change", onSystem);
87
+ this.#stop.push(() => media?.removeEventListener("change", onSystem));
88
+ this.#ready = true;
89
+ // Said once the page has had its turn to listen; `value` can be read at any time.
90
+ queueMicrotask(() => {
91
+ if (this.isConnected)
92
+ this.#dispatch("init");
93
+ });
94
+ }
95
+ disconnectedCallback() {
96
+ for (const stop of this.#stop.splice(0))
97
+ stop();
98
+ this.#watching?.disconnect();
99
+ this.#watching = undefined;
100
+ this.#ready = false;
101
+ }
102
+ attributeChangedCallback(name, _old, value) {
103
+ if (!this.#ready)
104
+ return;
105
+ if (name === "value") {
106
+ const theme = parseTheme(value);
107
+ if (theme !== undefined)
108
+ this.#choose(theme, "set");
109
+ }
110
+ else if (name === "target") {
111
+ this.#resolveTarget();
112
+ if (this.#target !== undefined)
113
+ applyTheme(this.#target, this.#choice);
114
+ }
115
+ else
116
+ this.#render();
117
+ }
118
+ #addressKey() {
119
+ return this.getAttribute("address-key") || THEME_ADDRESS_KEY;
120
+ }
121
+ #storageKey() {
122
+ return this.getAttribute("storage") || THEME_STORAGE_KEY;
123
+ }
124
+ #prefersDark() {
125
+ return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches === true;
126
+ }
127
+ #resolveTarget() {
128
+ this.#aim(resolveTarget(this, this.getAttribute("target")));
129
+ }
130
+ #aim(element) {
131
+ if (element === this.#target)
132
+ return;
133
+ this.#watching?.disconnect();
134
+ this.#target = element;
135
+ if (element === undefined)
136
+ return;
137
+ // Something else writing the target's data-theme (a page's own button, another switch) moves this one with it.
138
+ this.#watching = new MutationObserver(() => {
139
+ const held = themeOf(element);
140
+ if (held !== this.#choice) {
141
+ this.#choice = held;
142
+ this.#render();
143
+ this.#dispatch("external");
144
+ }
145
+ });
146
+ this.#watching.observe(element, { attributes: true, attributeFilter: ["data-theme"] });
147
+ }
148
+ #choose(theme, reason) {
149
+ if (this.#target !== undefined)
150
+ applyTheme(this.#target, theme);
151
+ const changed = theme !== this.#choice;
152
+ this.#choice = theme;
153
+ this.#render();
154
+ if (this.hasAttribute("address"))
155
+ this.#address.set(this.#addressKey(), theme === "auto" ? null : theme);
156
+ if (this.hasAttribute("storage")) {
157
+ remember(this.#storageKey(), theme);
158
+ }
159
+ if (changed || reason === "user")
160
+ this.#dispatch(reason);
161
+ }
162
+ #language() {
163
+ return languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
164
+ }
165
+ #render() {
166
+ const language = this.#language();
167
+ const words = wordsFor(language, this.#words);
168
+ const modes = parseModes(this.getAttribute("modes"));
169
+ let group = this.#root.querySelector(".group");
170
+ if (group === null) {
171
+ group = this.ownerDocument.createElement("div");
172
+ group.className = "group";
173
+ group.setAttribute("part", "group");
174
+ group.setAttribute("role", "group");
175
+ this.#root.append(group);
176
+ }
177
+ group.setAttribute("aria-label", words.group);
178
+ group.setAttribute("lang", language);
179
+ const have = [...group.querySelectorAll("button")];
180
+ if (have.length !== modes.length || have.some((button, index) => button.dataset.v !== modes[index])) {
181
+ group.replaceChildren(...modes.map((mode) => {
182
+ const button = this.ownerDocument.createElement("button");
183
+ button.type = "button";
184
+ button.dataset.v = mode;
185
+ button.setAttribute("part", "button");
186
+ button.innerHTML = `${ICON[mode]}<span class="t"></span>`;
187
+ button.addEventListener("click", () => this.#choose(mode, "user"));
188
+ return button;
189
+ }));
190
+ }
191
+ for (const button of group.querySelectorAll("button")) {
192
+ const mode = button.dataset.v;
193
+ button.querySelector(".t").textContent = words[mode];
194
+ button.setAttribute("aria-pressed", String(mode === this.#choice));
195
+ }
196
+ }
197
+ #dispatch(reason) {
198
+ if (this.#target === undefined)
199
+ return;
200
+ const detail = { choice: this.#choice, scheme: this.scheme, reason, target: this.#target };
201
+ this.dispatchEvent(new CustomEvent(EVENT, { detail, bubbles: true, composed: true }));
202
+ }
203
+ }
@@ -0,0 +1,4 @@
1
+ export { applyTheme, parseModes, parseTheme, readTheme, resolveTheme, THEME_ADDRESS_KEY, THEME_ATTRIBUTE, THEME_STORAGE_KEY, themeOf, THEMES, type RememberedTheme, type Scheme, type Theme } from "./theme.ts";
2
+ export { languageOf, WORDS, wordsFor, type Language, type ThemeWords } from "./words.ts";
3
+ export declare const EVENT = "kazoku-themechange";
4
+ export declare const TAG = "kazoku-theme-switch";
@@ -0,0 +1,5 @@
1
+ // The theme switch without the element: the choices, how one is applied and where one is remembered. Nothing here touches the DOM until `applyTheme` is called with an element.
2
+ export { applyTheme, parseModes, parseTheme, readTheme, resolveTheme, THEME_ADDRESS_KEY, THEME_ATTRIBUTE, THEME_STORAGE_KEY, themeOf, THEMES } from "./theme.js";
3
+ export { languageOf, WORDS, wordsFor } from "./words.js";
4
+ export const EVENT = "kazoku-themechange";
5
+ export const TAG = "kazoku-theme-switch";
@@ -0,0 +1 @@
1
+ export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: inline-block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"]):not([data-scheme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]), :host([data-scheme=\"dark\"]:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n[hidden] { display: none !important; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\n\n@media (max-width: 640px) {\n .t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n button { padding: 0; }\n}\n";
@@ -0,0 +1,8 @@
1
+ import { BASE_STYLES } from "../shared/base.js";
2
+ // Light, Dark and Auto as one row of buttons. On a phone the words are left to a screen reader and the icons stay.
3
+ export const STYLES = `${BASE_STYLES}
4
+ @media (max-width: 640px) {
5
+ .t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
6
+ button { padding: 0; }
7
+ }
8
+ `;
@@ -0,0 +1,29 @@
1
+ export declare const THEMES: readonly ["light", "dark", "auto"];
2
+ export type Theme = (typeof THEMES)[number];
3
+ export type Scheme = "light" | "dark";
4
+ /** The attribute the choice is written to on the target. `auto` removes it, so the page's own `prefers-color-scheme` rules decide. */
5
+ export declare const THEME_ATTRIBUTE = "data-theme";
6
+ /** The address parameter, with `address`. */
7
+ export declare const THEME_ADDRESS_KEY = "theme";
8
+ /** The localStorage key, with a bare `storage`. */
9
+ export declare const THEME_STORAGE_KEY = "kazoku-theme";
10
+ export declare const parseTheme: (value: unknown) => Theme | undefined;
11
+ /** The colours a choice gives: light and dark are themselves, auto is the device's. */
12
+ export declare const resolveTheme: (choice: Theme, prefersDark: boolean) => Scheme;
13
+ /** The choices a switch offers, from `modes="light dark"`: the known ones, once each, in the order light, dark, auto. Nothing usable gives all three. */
14
+ export declare function parseModes(value: string | null | undefined): Theme[];
15
+ /** What a target holds now: `light` or `dark` when forced, `auto` when it says nothing or something else. */
16
+ export declare const themeOf: (target: Element) => Theme;
17
+ /** Writes the choice on the target (`data-theme="light"`, `"dark"`, or no attribute for auto). */
18
+ export declare function applyTheme(target: Element, choice: Theme): void;
19
+ /** Where a remembered choice may be read from. A page that keeps nothing passes neither. */
20
+ export interface RememberedTheme {
21
+ /** An address' search string such as `?theme=dark`, read with `addressKey`. */
22
+ search?: string;
23
+ addressKey?: string;
24
+ /** A localStorage-like object and the key in it. A store that throws is the same as an empty one. */
25
+ storage?: Pick<Storage, "getItem">;
26
+ storageKey?: string;
27
+ }
28
+ /** The remembered choice: the address first, then the store. `undefined` when neither holds a valid one. */
29
+ export declare function readTheme(from: RememberedTheme): Theme | undefined;
@@ -0,0 +1,43 @@
1
+ // The theme choice without the element: the three choices, what each means, and how a choice is applied to a page. Nothing here touches the DOM until a function is called with an element.
2
+ export const THEMES = ["light", "dark", "auto"];
3
+ /** The attribute the choice is written to on the target. `auto` removes it, so the page's own `prefers-color-scheme` rules decide. */
4
+ export const THEME_ATTRIBUTE = "data-theme";
5
+ /** The address parameter, with `address`. */
6
+ export const THEME_ADDRESS_KEY = "theme";
7
+ /** The localStorage key, with a bare `storage`. */
8
+ export const THEME_STORAGE_KEY = "kazoku-theme";
9
+ export const parseTheme = (value) => THEMES.includes(value) ? value : undefined;
10
+ /** The colours a choice gives: light and dark are themselves, auto is the device's. */
11
+ export const resolveTheme = (choice, prefersDark) => (choice === "auto" ? (prefersDark ? "dark" : "light") : choice);
12
+ /** The choices a switch offers, from `modes="light dark"`: the known ones, once each, in the order light, dark, auto. Nothing usable gives all three. */
13
+ export function parseModes(value) {
14
+ const named = new Set((value ?? "").split(/[\s,]+/).map((word) => parseTheme(word.toLowerCase())));
15
+ const kept = THEMES.filter((theme) => named.has(theme));
16
+ return kept.length > 0 ? kept : [...THEMES];
17
+ }
18
+ /** What a target holds now: `light` or `dark` when forced, `auto` when it says nothing or something else. */
19
+ export const themeOf = (target) => parseTheme(target.getAttribute(THEME_ATTRIBUTE)) ?? "auto";
20
+ /** Writes the choice on the target (`data-theme="light"`, `"dark"`, or no attribute for auto). */
21
+ export function applyTheme(target, choice) {
22
+ if (choice === "auto")
23
+ target.removeAttribute(THEME_ATTRIBUTE);
24
+ else
25
+ target.setAttribute(THEME_ATTRIBUTE, choice);
26
+ }
27
+ /** The remembered choice: the address first, then the store. `undefined` when neither holds a valid one. */
28
+ export function readTheme(from) {
29
+ if (from.search !== undefined) {
30
+ const asked = parseTheme(new URLSearchParams(from.search).get(from.addressKey ?? THEME_ADDRESS_KEY));
31
+ if (asked !== undefined)
32
+ return asked;
33
+ }
34
+ if (from.storage !== undefined) {
35
+ try {
36
+ return parseTheme(from.storage.getItem(from.storageKey ?? THEME_STORAGE_KEY));
37
+ }
38
+ catch {
39
+ /* a browser that keeps nothing */
40
+ }
41
+ }
42
+ return undefined;
43
+ }
@@ -0,0 +1,11 @@
1
+ export interface ThemeWords {
2
+ group: string;
3
+ light: string;
4
+ dark: string;
5
+ auto: string;
6
+ }
7
+ export type Language = "en" | "ja";
8
+ export declare const WORDS: Record<Language, ThemeWords>;
9
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
10
+ export declare const languageOf: (lang: string | null | undefined) => Language;
11
+ export declare const wordsFor: (language: Language, overrides?: Partial<ThemeWords>) => ThemeWords;
@@ -0,0 +1,7 @@
1
+ export const WORDS = {
2
+ en: { group: "Colour theme", light: "Light", dark: "Dark", auto: "Auto" },
3
+ ja: { group: "テーマ", light: "ライト", dark: "ダーク", auto: "自動" },
4
+ };
5
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
6
+ export const languageOf = (lang) => (/^ja\b/i.test(lang ?? "") ? "ja" : "en");
7
+ export const wordsFor = (language, overrides = {}) => ({ ...WORDS[language], ...overrides });
@@ -68,9 +68,14 @@ export interface ViewController {
68
68
  /** Start at "fit" or at a scale relative to natural size, and go there now. */
69
69
  setStartZoom(zoom: "fit" | number): void;
70
70
  set(next: Partial<ViewState>, final?: boolean, reason?: string): void;
71
- play(): void;
71
+ /** Turn by itself. With `seconds`, stop by itself (as `pause`) after that many seconds of turning; time spent suspended does not count. `toggle` and a bare `play()` turn without a limit. */
72
+ play(seconds?: number): void;
72
73
  pause(): void;
73
74
  toggle(): void;
75
+ /** Stop the frame loop while nothing can see the view (it is off screen, the tab is hidden): no frames are asked for and no "auto" change is sent. `state.turning` stays true. Counts, so suspends nest. */
76
+ suspendTurn(): void;
77
+ /** Undo one `suspendTurn`; the turn goes on from where it was when the last one is undone. */
78
+ resumeTurn(): void;
74
79
  /** Hold the automatic turn still while a hand (a slider being dragged) has the angle; `releaseTurn` lets it carry on from where the hand left it. Counts, so holds nest. */
75
80
  holdTurn(): void;
76
81
  releaseTurn(): void;
@@ -12,6 +12,9 @@ export function createViewController(options = {}) {
12
12
  let frame = 0;
13
13
  let last = 0;
14
14
  let holds = 0;
15
+ let suspends = 0;
16
+ let limit = 0;
17
+ let turned = 0;
15
18
  let fitScale = positive(options.fitScale) ?? 1;
16
19
  let start = positive(options.zoom) ?? "fit";
17
20
  let atStart = true;
@@ -62,7 +65,7 @@ export function createViewController(options = {}) {
62
65
  return { x: at.x - c.x, y: at.y - c.y };
63
66
  };
64
67
  const tick = (now) => {
65
- if (!state.turning)
68
+ if (!state.turning || suspends > 0)
66
69
  return;
67
70
  frame = clock.request(tick);
68
71
  if (holds > 0) {
@@ -74,7 +77,10 @@ export function createViewController(options = {}) {
74
77
  if (dt === 0)
75
78
  return;
76
79
  state.yaw = wrapYaw(state.yaw + controller.speed * dt);
80
+ turned += dt;
77
81
  emit(false, "auto");
82
+ if (limit > 0 && turned >= limit)
83
+ controller.pause();
78
84
  };
79
85
  const controller = {
80
86
  state,
@@ -125,12 +131,15 @@ export function createViewController(options = {}) {
125
131
  }
126
132
  emit(final, reason);
127
133
  },
128
- play() {
134
+ play(seconds) {
129
135
  if (state.turning || !controller.turnable)
130
136
  return;
131
137
  state.turning = true;
132
138
  last = 0;
133
- frame = clock.request(tick);
139
+ turned = 0;
140
+ limit = positive(seconds) ?? 0;
141
+ if (suspends === 0)
142
+ frame = clock.request(tick);
134
143
  emit(true, "play");
135
144
  },
136
145
  pause() {
@@ -146,6 +155,20 @@ export function createViewController(options = {}) {
146
155
  else
147
156
  controller.play();
148
157
  },
158
+ suspendTurn() {
159
+ suspends += 1;
160
+ if (suspends === 1)
161
+ clock.cancel(frame);
162
+ },
163
+ resumeTurn() {
164
+ if (suspends === 0)
165
+ return;
166
+ suspends -= 1;
167
+ if (suspends > 0 || !state.turning)
168
+ return;
169
+ last = 0;
170
+ frame = clock.request(tick);
171
+ },
149
172
  holdTurn() {
150
173
  holds += 1;
151
174
  },
@@ -11,7 +11,7 @@ export interface ViewChangeDetail extends ViewState {
11
11
  /**
12
12
  * Attributes: `for` (the id of the view to bind), `lang` (en or ja; else the page's), `yaw`, `tilt` (the starting view), `zoom` (`fit`, the default, or a scale where 1
13
13
  * is the scene's natural size), `fit-scale` (the page's scale at Fit: what zoom 1 means), `ignore` (a selector for what inside the view a pointer press leaves alone;
14
- * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `speed` (degrees a second),
14
+ * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
15
15
  * `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
16
16
  * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
17
17
  * or dark), `overlay` (sit over the bottom of a positioned parent).
@@ -1,5 +1,7 @@
1
1
  // <kazoku-view-controls>: the view controls as a framework-free custom element. It never draws the scene. It binds the pointer, pinch, wheel and keys
2
2
  // to the element named by `for`, and tells the page what the view is now with a `kazoku-viewchange` event; the page applies it to its canvas, WebGL, SVG or CSS 3D.
3
+ import { createAddressWriter } from "../address-state/address.js";
4
+ import { adoptStyles } from "../shared/adopt.js";
3
5
  import { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
4
6
  import { createViewController, DEFAULT_IGNORE } from "./controller.js";
5
7
  import { EVENT, TAG } from "./index.js";
@@ -44,7 +46,7 @@ const HTML = `
44
46
  /**
45
47
  * Attributes: `for` (the id of the view to bind), `lang` (en or ja; else the page's), `yaw`, `tilt` (the starting view), `zoom` (`fit`, the default, or a scale where 1
46
48
  * is the scene's natural size), `fit-scale` (the page's scale at Fit: what zoom 1 means), `ignore` (a selector for what inside the view a pointer press leaves alone;
47
- * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `speed` (degrees a second),
49
+ * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
48
50
  * `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
49
51
  * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
50
52
  * or dark), `overlay` (sit over the bottom of a positioned parent).
@@ -58,8 +60,11 @@ export class ViewControlsElement extends HTMLElement {
58
60
  #words = {};
59
61
  #ready = false;
60
62
  #sliderHeld = 0;
61
- #saveTimer = 0;
63
+ #writer = createAddressWriter({ delay: 250 });
62
64
  #keys = DEFAULT_KEYS;
65
+ #seen;
66
+ #onScreen = true;
67
+ #suspended = false;
63
68
  constructor() {
64
69
  super();
65
70
  this.#root = this.attachShadow({ mode: "open" });
@@ -145,25 +150,30 @@ export class ViewControlsElement extends HTMLElement {
145
150
  this.ownerDocument.addEventListener("DOMContentLoaded", () => this.#resolveView(), { once: true });
146
151
  this.#ready = true;
147
152
  this.#show();
153
+ this.ownerDocument.addEventListener("visibilitychange", this.#sight);
154
+ this.#watch();
148
155
  // Said once the page has had its turn to listen; a page can also read `state` at any time.
149
156
  queueMicrotask(() => {
150
157
  if (!this.isConnected)
151
158
  return;
152
159
  this.#dispatch(c.state, { final: true, reason: "init" });
153
160
  if (this.hasAttribute("autoplay") && !globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
154
- c.play();
161
+ c.play(Number(this.getAttribute("autoplay-for")));
155
162
  });
156
163
  }
157
164
  disconnectedCallback() {
165
+ this.ownerDocument.removeEventListener("visibilitychange", this.#sight);
166
+ this.#seen?.disconnect();
167
+ this.#seen = undefined;
168
+ this.#suspended = false;
169
+ this.#onScreen = true;
158
170
  this.#unbind?.();
159
171
  this.#unbind = undefined;
160
172
  this.#controller?.destroy();
161
173
  this.#controller = undefined;
162
174
  this.#view = undefined;
163
175
  this.#ready = false;
164
- if (this.#saveTimer !== 0)
165
- globalThis.clearTimeout(this.#saveTimer);
166
- this.#saveTimer = 0;
176
+ this.#writer.cancel();
167
177
  }
168
178
  attributeChangedCallback(name, _old, value) {
169
179
  if (!this.#ready)
@@ -221,7 +231,8 @@ export class ViewControlsElement extends HTMLElement {
221
231
  return this.#controller;
222
232
  }
223
233
  #build() {
224
- this.#root.innerHTML = `<style>${STYLES}</style>${HTML}`;
234
+ this.#root.innerHTML = HTML;
235
+ adoptStyles(this.#root, STYLES, this.nonce);
225
236
  const q = (selector) => this.#root.querySelector(selector);
226
237
  const c = this.#ensure();
227
238
  const slider = (id) => {
@@ -291,11 +302,40 @@ export class ViewControlsElement extends HTMLElement {
291
302
  this.#unbind?.();
292
303
  this.#unbind = undefined;
293
304
  this.#view = element;
294
- if (element === undefined)
295
- return;
296
305
  // A view takes the keys only if it can be focused: a page that wants arrows and plus and minus on it gives it a tabindex.
297
- this.#unbind = this.#ensure().bind(element);
306
+ if (element !== undefined)
307
+ this.#unbind = this.#ensure().bind(element);
308
+ this.#watch();
298
309
  }
310
+ /** The automatic turn runs only while the view (else this element) is on screen in a visible tab; otherwise its frame loop is stopped. */
311
+ #watch() {
312
+ this.#seen?.disconnect();
313
+ this.#seen = undefined;
314
+ this.#onScreen = true;
315
+ if (typeof IntersectionObserver === "function" && this.isConnected) {
316
+ this.#seen = new IntersectionObserver((entries) => {
317
+ const last = entries[entries.length - 1];
318
+ if (last === undefined)
319
+ return;
320
+ this.#onScreen = last.isIntersecting;
321
+ this.#sight();
322
+ });
323
+ this.#seen.observe(this.#view ?? this);
324
+ }
325
+ this.#sight();
326
+ }
327
+ #sight = () => {
328
+ if (this.#controller === undefined)
329
+ return;
330
+ const idle = !this.#onScreen || this.ownerDocument.visibilityState === "hidden";
331
+ if (idle === this.#suspended)
332
+ return;
333
+ this.#suspended = idle;
334
+ if (idle)
335
+ this.#controller.suspendTurn();
336
+ else
337
+ this.#controller.resumeTurn();
338
+ };
299
339
  #label() {
300
340
  const language = languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
301
341
  const words = wordsFor(language, this.#words);
@@ -351,19 +391,9 @@ export class ViewControlsElement extends HTMLElement {
351
391
  }
352
392
  /** The address is written at most four times a second: Safari stops a page that changes its address more than a hundred times in thirty seconds. */
353
393
  #save() {
354
- if (this.#saveTimer !== 0)
355
- return;
356
- this.#saveTimer = globalThis.setTimeout(() => {
357
- this.#saveTimer = 0;
358
- try {
359
- const s = this.#ensure().state;
360
- const next = formatAddress(globalThis.location.search, s, this.#keys, s.turning, this.#ensure().startZoom);
361
- if (next !== globalThis.location.search)
362
- globalThis.history.replaceState(globalThis.history.state, "", `${globalThis.location.pathname}${next}${globalThis.location.hash}`);
363
- }
364
- catch {
365
- /* an address that cannot be changed (a sandboxed frame): the view still works */
366
- }
367
- }, 250);
394
+ this.#writer.write((search) => {
395
+ const s = this.#ensure().state;
396
+ return formatAddress(search, s, this.#keys, s.turning, this.#ensure().startZoom);
397
+ });
368
398
  }
369
399
  }