@johnmorrisdotca/kazoku-ui 0.13.1 → 0.15.0

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 (38) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +46 -8
  3. package/dist/embed-panel/choices.d.ts +42 -0
  4. package/dist/embed-panel/choices.js +48 -0
  5. package/dist/embed-panel/define.d.ts +1 -0
  6. package/dist/embed-panel/define.js +9 -0
  7. package/dist/embed-panel/element.d.ts +84 -0
  8. package/dist/embed-panel/element.js +683 -0
  9. package/dist/embed-panel/index.d.ts +5 -0
  10. package/dist/embed-panel/index.js +6 -0
  11. package/dist/embed-panel/snippets.d.ts +94 -0
  12. package/dist/embed-panel/snippets.js +73 -0
  13. package/dist/embed-panel/styles.d.ts +1 -0
  14. package/dist/embed-panel/styles.js +43 -0
  15. package/dist/embed-panel/words.d.ts +43 -0
  16. package/dist/embed-panel/words.js +69 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/speed-buttons/define.d.ts +1 -0
  20. package/dist/speed-buttons/define.js +9 -0
  21. package/dist/speed-buttons/element.d.ts +34 -0
  22. package/dist/speed-buttons/element.js +178 -0
  23. package/dist/speed-buttons/index.d.ts +4 -0
  24. package/dist/speed-buttons/index.js +5 -0
  25. package/dist/speed-buttons/steps.d.ts +18 -0
  26. package/dist/speed-buttons/steps.js +57 -0
  27. package/dist/speed-buttons/styles.d.ts +1 -0
  28. package/dist/speed-buttons/styles.js +9 -0
  29. package/dist/speed-buttons/words.d.ts +9 -0
  30. package/dist/speed-buttons/words.js +7 -0
  31. package/dist/view-controls/address.d.ts +3 -3
  32. package/dist/view-controls/address.js +12 -9
  33. package/dist/view-controls/element.d.ts +2 -1
  34. package/dist/view-controls/element.js +55 -26
  35. package/dist/view-controls/levels.d.ts +1 -1
  36. package/dist/view-controls/styles.d.ts +1 -1
  37. package/dist/view-controls/styles.js +6 -6
  38. package/package.json +43 -4
@@ -0,0 +1,5 @@
1
+ export { DEFAULT_KINDS, DEFAULT_SIZE, DEFAULT_SIZES, filterItems, groupsOf, KIND_ADDRESS_KEY, nearestSize, parseKinds, parseSizes, SEARCH_FROM, SIZE_ADDRESS_KEY, type PickItem } from "./choices.ts";
2
+ export { attrText, EMBED_KINDS, embedSnippet, type EmbedKind, type SnippetSpec } from "./snippets.ts";
3
+ export { kindLabel, languageOf, WORDS, wordsFor, type Language, type PanelWords } from "./words.ts";
4
+ export declare const EVENT = "kazoku-embedchange";
5
+ export declare const TAG = "kazoku-embed-panel";
@@ -0,0 +1,6 @@
1
+ // The embed panel without the element: the seven ways a page is made and their text, the sizes, the tab and picker choices, the words. Nothing here touches the DOM, so it imports on a server.
2
+ export { DEFAULT_KINDS, DEFAULT_SIZE, DEFAULT_SIZES, filterItems, groupsOf, KIND_ADDRESS_KEY, nearestSize, parseKinds, parseSizes, SEARCH_FROM, SIZE_ADDRESS_KEY } from "./choices.js";
3
+ export { attrText, EMBED_KINDS, embedSnippet } from "./snippets.js";
4
+ export { kindLabel, languageOf, WORDS, wordsFor } from "./words.js";
5
+ export const EVENT = "kazoku-embedchange";
6
+ export const TAG = "kazoku-embed-panel";
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The ways `embedSnippet` writes an element into a page, in the order the panel's tabs show them.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { EMBED_KINDS } from "@johnmorrisdotca/kazoku-ui/embed-panel";
7
+ *
8
+ * console.log(EMBED_KINDS.join(", "));
9
+ * ```
10
+ */
11
+ export declare const EMBED_KINDS: readonly ["html", "module", "react", "vue", "angular", "svelte", "iframe"];
12
+ /**
13
+ * One of the ways in `EMBED_KINDS`.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { EMBED_KINDS, type EmbedKind } from "@johnmorrisdotca/kazoku-ui/embed-panel";
18
+ *
19
+ * const kind: EmbedKind = EMBED_KINDS[2];
20
+ * console.log(kind);
21
+ * ```
22
+ */
23
+ export type EmbedKind = (typeof EMBED_KINDS)[number];
24
+ /**
25
+ * What `embedSnippet` needs to know about an element: where it comes from, what it is called and what it is given.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { embedSnippet, type SnippetSpec } from "@johnmorrisdotca/kazoku-ui/embed-panel";
30
+ *
31
+ * const spec: SnippetSpec = {
32
+ * package: "@scope/dial",
33
+ * cdn: "https://cdn.jsdelivr.net/npm/@scope/dial@1.0.0",
34
+ * tag: "scope-dial",
35
+ * element: "element",
36
+ * entries: [],
37
+ * attrs: [["value", "7"]],
38
+ * name: "dial",
39
+ * iframe: { src: "https://example.com/embed.html?value=7", title: "Dial", width: 320, height: 320 },
40
+ * };
41
+ * console.log(embedSnippet("html", spec));
42
+ * ```
43
+ */
44
+ export interface SnippetSpec {
45
+ /** The npm name, for the `import` lines: `@scope/name`. */
46
+ package: string;
47
+ /** The package on the CDN with its version: `https://cdn.jsdelivr.net/npm/@scope/name@1.2.3`. */
48
+ cdn: string;
49
+ /** The element's tag. */
50
+ tag: string;
51
+ /** The subpath (under the package name) that defines the tag; an empty text is the package's main entry. */
52
+ element: string;
53
+ /** More subpaths to load beside it (data, looks); none is fine. */
54
+ entries: readonly string[];
55
+ /** The attributes, in the order they are written. A value of `""` is written as a bare attribute. */
56
+ attrs: readonly (readonly [string, string])[];
57
+ /** The name the React, Angular and plain module examples use: `planet` gives `Planet`, `PlanetComponent`, `app-planet` and `#planet`. */
58
+ name: string;
59
+ /** The embed page as an iframe. */
60
+ iframe: {
61
+ src: string;
62
+ title: string;
63
+ width: number;
64
+ height: number;
65
+ };
66
+ }
67
+ /** The attributes as they are written in a tag: `body="mars" moons`. */
68
+ export declare function attrText(attrs: SnippetSpec["attrs"]): string;
69
+ /**
70
+ * The text that puts an element in a page, for one way of making the page: a script tag from the CDN, an ES module, a React component, a Vue single-file component, an Angular
71
+ * standalone component, a Svelte component, or an iframe.
72
+ *
73
+ * @param kind - The way the page is made.
74
+ * @param spec - The element and what it is given.
75
+ * @returns The text to paste into the page's source.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * import { embedSnippet } from "@johnmorrisdotca/kazoku-ui/embed-panel";
80
+ *
81
+ * const spec = {
82
+ * package: "@scope/dial",
83
+ * cdn: "https://cdn.jsdelivr.net/npm/@scope/dial@1.0.0",
84
+ * tag: "scope-dial",
85
+ * element: "element",
86
+ * entries: [],
87
+ * attrs: [["value", "7"]] as const,
88
+ * name: "dial",
89
+ * iframe: { src: "https://example.com/embed.html", title: "Dial", width: 320, height: 320 },
90
+ * };
91
+ * console.log(embedSnippet("react", spec));
92
+ * ```
93
+ */
94
+ export declare function embedSnippet(kind: EmbedKind, spec: SnippetSpec): string;
@@ -0,0 +1,73 @@
1
+ // The text that puts an element in a page, for each way a page is made: a script tag from a CDN, an ES module, React, Vue, Angular, Svelte, or an iframe of the embed page.
2
+ // Pure: no DOM. A package gives the tag, the entry points and the attributes of its element once and gets all seven; the panel shows them as the reader changes a choice.
3
+ /**
4
+ * The ways `embedSnippet` writes an element into a page, in the order the panel's tabs show them.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { EMBED_KINDS } from "@johnmorrisdotca/kazoku-ui/embed-panel";
9
+ *
10
+ * console.log(EMBED_KINDS.join(", "));
11
+ * ```
12
+ */
13
+ export const EMBED_KINDS = ["html", "module", "react", "vue", "angular", "svelte", "iframe"];
14
+ const quote = (value) => `"${value.replace(/&/g, "&").replace(/"/g, """)}"`;
15
+ const joinPath = (...parts) => parts.filter((part) => part !== "").join("/");
16
+ /** The attributes as they are written in a tag: `body="mars" moons`. */
17
+ export function attrText(attrs) {
18
+ return attrs.map(([key, value]) => (value === "" ? key : `${key}=${quote(value)}`)).join(" ");
19
+ }
20
+ /**
21
+ * The text that puts an element in a page, for one way of making the page: a script tag from the CDN, an ES module, a React component, a Vue single-file component, an Angular
22
+ * standalone component, a Svelte component, or an iframe.
23
+ *
24
+ * @param kind - The way the page is made.
25
+ * @param spec - The element and what it is given.
26
+ * @returns The text to paste into the page's source.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import { embedSnippet } from "@johnmorrisdotca/kazoku-ui/embed-panel";
31
+ *
32
+ * const spec = {
33
+ * package: "@scope/dial",
34
+ * cdn: "https://cdn.jsdelivr.net/npm/@scope/dial@1.0.0",
35
+ * tag: "scope-dial",
36
+ * element: "element",
37
+ * entries: [],
38
+ * attrs: [["value", "7"]] as const,
39
+ * name: "dial",
40
+ * iframe: { src: "https://example.com/embed.html", title: "Dial", width: 320, height: 320 },
41
+ * };
42
+ * console.log(embedSnippet("react", spec));
43
+ * ```
44
+ */
45
+ export function embedSnippet(kind, spec) {
46
+ const Name = spec.name.charAt(0).toUpperCase() + spec.name.slice(1);
47
+ const attrs = attrText(spec.attrs);
48
+ const subpaths = [spec.element, ...spec.entries];
49
+ if (kind === "iframe") {
50
+ const f = spec.iframe;
51
+ return `<iframe\n src=${quote(f.src)}\n title=${quote(f.title)}\n width="${f.width}"\n height="${f.height}"\n loading="lazy"\n style="border: 0"\n></iframe>`;
52
+ }
53
+ if (kind === "html") {
54
+ const scripts = subpaths.map((path) => `<script type="module" src="${joinPath(spec.cdn, path)}/+esm"></script>`);
55
+ return `${scripts.join("\n")}\n\n<${spec.tag} ${attrs}></${spec.tag}>`;
56
+ }
57
+ const imports = subpaths.map((path) => `import "${joinPath(spec.package, path)}";`);
58
+ if (kind === "module") {
59
+ const sets = spec.attrs.map(([key, value]) => `${spec.name}.setAttribute(${JSON.stringify(key)}, ${JSON.stringify(value)});`);
60
+ return `${imports.join("\n")}\n\nconst ${spec.name} = document.createElement("${spec.tag}");\n${sets.join("\n")}\ndocument.querySelector("#${spec.name}").append(${spec.name});`;
61
+ }
62
+ const dynamic = subpaths.map((path) => `void import("${joinPath(spec.package, path)}");`);
63
+ if (kind === "react") {
64
+ return `import { useEffect } from "react";\n\nexport function ${Name}() {\n useEffect(() => {\n // The element needs a browser, so it is imported in an effect, never at the top of the file.\n${dynamic.map((l) => ` ${l}`).join("\n")}\n }, []);\n return <${spec.tag} ${attrs} />;\n}`;
65
+ }
66
+ if (kind === "vue") {
67
+ return `<script setup lang="ts">\nimport { onMounted } from "vue";\n\nonMounted(() => {\n${dynamic.map((l) => ` ${l}`).join("\n")}\n});\n</script>\n\n<template>\n <${spec.tag} ${attrs}></${spec.tag}>\n</template>`;
68
+ }
69
+ if (kind === "angular") {
70
+ return `import { Component, CUSTOM_ELEMENTS_SCHEMA, OnInit } from "@angular/core";\n\n@Component({\n selector: "app-${spec.name}",\n standalone: true,\n schemas: [CUSTOM_ELEMENTS_SCHEMA],\n template: \`<${spec.tag} ${attrs}></${spec.tag}>\`,\n})\nexport class ${Name}Component implements OnInit {\n ngOnInit(): void {\n // The element needs a browser: ngOnInit also runs when a server renders.\n if (typeof window !== "undefined") {\n${dynamic.map((l) => ` ${l}`).join("\n")}\n }\n }\n}`;
71
+ }
72
+ return `<script lang="ts">\n import { onMount } from "svelte";\n\n onMount(() => {\n${dynamic.map((l) => ` ${l}`).join("\n")}\n });\n</script>\n\n<${spec.tag} ${attrs}></${spec.tag}>`;
73
+ }
@@ -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:host { display: block; box-sizing: border-box; width: 100%; justify-self: stretch; container-type: inline-size; --_preview-bg: var(--kazoku-preview-bg, var(--_surface)); --_preview-w: var(--kazoku-preview-width, 260px); }\n:host([hidden]) { display: none; }\n.panel { display: grid; gap: 12px; min-width: 0; }\n.title { margin: 0; font-size: 1.25rem; font-weight: 700; line-height: 1.25; }\n.row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }\n.lbl { font-weight: 600; }\n.pick { display: grid; gap: 8px; min-width: 0; }\n.builtin { display: grid; gap: 8px; min-width: 0; }\n.chips { display: flex; flex-wrap: wrap; gap: 4px; }\n.search { -webkit-appearance: none; appearance: none; box-sizing: border-box; width: 100%; max-width: 320px; min-height: 48px; margin: 0; padding: 0 16px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; }\n.search:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\n.search::placeholder { color: var(--_ink); opacity: 0.6; }\n.items { display: flex; flex-wrap: wrap; gap: 4px; max-height: 164px; overflow: auto; padding: 2px; }\n.items button { white-space: normal; text-align: center; }\n.none { margin: 0; font-weight: 400; opacity: 0.75; }\n.tabs { display: flex; gap: 4px; overflow-x: auto; padding: 2px; margin: -2px; }\n.tabs button { flex: 0 0 auto; }\n.tabs button[aria-selected=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\n.grid { display: grid; grid-template-columns: minmax(0, var(--_preview-w)) minmax(0, 1fr); gap: 16px; align-items: start; min-width: 0; }\n:host([no-preview]) .grid { grid-template-columns: minmax(0, 1fr); }\n@container (max-width: 640px) { .grid { grid-template-columns: minmax(0, 1fr); } }\n.preview { box-sizing: border-box; display: grid; place-items: center; min-width: 0; min-height: 160px; padding: 8px; overflow: hidden; border: 1px solid var(--_line); border-radius: 12px; background: var(--_preview-bg); }\n.frame { position: relative; overflow: hidden; max-width: 100%; }\n.fit { display: grid; place-items: center; transform-origin: 0 0; }\n.fit > * { max-width: none; }\n.fit iframe { border: 0; display: block; }\n.codecol { display: grid; gap: 10px; min-width: 0; }\n.note { margin: 0; font-weight: 400; font-size: 0.85rem; }\n.codebox { box-sizing: border-box; margin: 0; padding: 12px; overflow: auto; max-height: 340px; border: 1px solid var(--_line); border-radius: 12px; background: var(--_surface); color: var(--_ink); font: 400 0.8rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 2; white-space: pre; }\n.codebox:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\n.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }\na.btn { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 48px; min-width: 48px; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; text-decoration: none; white-space: nowrap; }\na.btn:hover { border-color: var(--_ink); }\na.btn:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton.primary { background: var(--_accent); border-color: var(--_accent); color: var(--_surface); }\nbutton.primary:hover:not(:disabled) { border-color: var(--_ink); }\n.status { font-weight: 400; font-size: 0.85rem; min-height: 1.2em; }\n.credits { margin: 0; font-weight: 400; font-size: 0.85rem; opacity: 0.85; }\n";
@@ -0,0 +1,43 @@
1
+ import { BASE_STYLES } from "../shared/base.js";
2
+ // The panel: a picker, the size buttons, the tabs, then the picture beside the code. It stacks when its own box is narrow (a container query on the host, so it
3
+ // follows the box it sits in, not the window). The picture sits in a box it always fits: the element inside is scaled down to the box, never cut off.
4
+ export const STYLES = `${BASE_STYLES}
5
+ :host { display: block; box-sizing: border-box; width: 100%; justify-self: stretch; container-type: inline-size; --_preview-bg: var(--kazoku-preview-bg, var(--_surface)); --_preview-w: var(--kazoku-preview-width, 260px); }
6
+ :host([hidden]) { display: none; }
7
+ .panel { display: grid; gap: 12px; min-width: 0; }
8
+ .title { margin: 0; font-size: 1.25rem; font-weight: 700; line-height: 1.25; }
9
+ .row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
10
+ .lbl { font-weight: 600; }
11
+ .pick { display: grid; gap: 8px; min-width: 0; }
12
+ .builtin { display: grid; gap: 8px; min-width: 0; }
13
+ .chips { display: flex; flex-wrap: wrap; gap: 4px; }
14
+ .search { -webkit-appearance: none; appearance: none; box-sizing: border-box; width: 100%; max-width: 320px; min-height: 48px; margin: 0; padding: 0 16px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; }
15
+ .search:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }
16
+ .search::placeholder { color: var(--_ink); opacity: 0.6; }
17
+ .items { display: flex; flex-wrap: wrap; gap: 4px; max-height: 164px; overflow: auto; padding: 2px; }
18
+ .items button { white-space: normal; text-align: center; }
19
+ .none { margin: 0; font-weight: 400; opacity: 0.75; }
20
+ .tabs { display: flex; gap: 4px; overflow-x: auto; padding: 2px; margin: -2px; }
21
+ .tabs button { flex: 0 0 auto; }
22
+ .tabs button[aria-selected="true"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }
23
+ .grid { display: grid; grid-template-columns: minmax(0, var(--_preview-w)) minmax(0, 1fr); gap: 16px; align-items: start; min-width: 0; }
24
+ :host([no-preview]) .grid { grid-template-columns: minmax(0, 1fr); }
25
+ @container (max-width: 640px) { .grid { grid-template-columns: minmax(0, 1fr); } }
26
+ .preview { box-sizing: border-box; display: grid; place-items: center; min-width: 0; min-height: 160px; padding: 8px; overflow: hidden; border: 1px solid var(--_line); border-radius: 12px; background: var(--_preview-bg); }
27
+ .frame { position: relative; overflow: hidden; max-width: 100%; }
28
+ .fit { display: grid; place-items: center; transform-origin: 0 0; }
29
+ .fit > * { max-width: none; }
30
+ .fit iframe { border: 0; display: block; }
31
+ .codecol { display: grid; gap: 10px; min-width: 0; }
32
+ .note { margin: 0; font-weight: 400; font-size: 0.85rem; }
33
+ .codebox { box-sizing: border-box; margin: 0; padding: 12px; overflow: auto; max-height: 340px; border: 1px solid var(--_line); border-radius: 12px; background: var(--_surface); color: var(--_ink); font: 400 0.8rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 2; white-space: pre; }
34
+ .codebox:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }
35
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
36
+ a.btn { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 48px; min-width: 48px; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; text-decoration: none; white-space: nowrap; }
37
+ a.btn:hover { border-color: var(--_ink); }
38
+ a.btn:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }
39
+ button.primary { background: var(--_accent); border-color: var(--_accent); color: var(--_surface); }
40
+ button.primary:hover:not(:disabled) { border-color: var(--_ink); }
41
+ .status { font-weight: 400; font-size: 0.85rem; min-height: 1.2em; }
42
+ .credits { margin: 0; font-weight: 400; font-size: 0.85rem; opacity: 0.85; }
43
+ `;
@@ -0,0 +1,43 @@
1
+ export interface PanelWords {
2
+ /** The panel's heading, when it shows one (`heading` attribute). */
3
+ title: string;
4
+ /** The name of the group of size buttons. */
5
+ size: string;
6
+ /** The name of the row of tabs. */
7
+ kinds: string;
8
+ /** The name of the picker. */
9
+ pick: string;
10
+ search: string;
11
+ all: string;
12
+ /** Said under the picker when the search finds nothing. */
13
+ none: string;
14
+ /** The name of the picture beside the code. */
15
+ preview: string;
16
+ /** The name of the code box. */
17
+ code: string;
18
+ copy: string;
19
+ copied: string;
20
+ copyFail: string;
21
+ open: string;
22
+ docs: string;
23
+ credits: string;
24
+ /** Tab names (`kind_<id>`). */
25
+ kind_html: string;
26
+ kind_module: string;
27
+ kind_react: string;
28
+ kind_vue: string;
29
+ kind_angular: string;
30
+ kind_svelte: string;
31
+ kind_iframe: string;
32
+ kind_image: string;
33
+ kind_element: string;
34
+ kind_url: string;
35
+ kind_svg: string;
36
+ }
37
+ export type Language = "en" | "ja";
38
+ export declare const WORDS: Record<Language, PanelWords>;
39
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
40
+ export declare const languageOf: (lang: string | null | undefined) => Language;
41
+ export declare const wordsFor: (language: Language, overrides?: Partial<PanelWords>) => PanelWords;
42
+ /** What a tab says: the family's word for the id, else the page's own `labels`, else the id itself. */
43
+ export declare function kindLabel(id: string, language: Language, labels?: Readonly<Record<string, string>>, overrides?: Partial<PanelWords>): string;
@@ -0,0 +1,69 @@
1
+ export const WORDS = {
2
+ en: {
3
+ title: "Put it in your page",
4
+ size: "Size",
5
+ kinds: "How your page is made",
6
+ pick: "What to put in",
7
+ search: "Search",
8
+ all: "All",
9
+ none: "Nothing matches",
10
+ preview: "Preview",
11
+ code: "Code",
12
+ copy: "Copy code",
13
+ copied: "Copied",
14
+ copyFail: "Select the code and copy it by hand.",
15
+ open: "Open the embed page",
16
+ docs: "Read the docs",
17
+ credits: "Credit",
18
+ kind_html: "HTML",
19
+ kind_module: "ES module",
20
+ kind_react: "React",
21
+ kind_vue: "Vue",
22
+ kind_angular: "Angular",
23
+ kind_svelte: "Svelte",
24
+ kind_iframe: "iframe",
25
+ kind_image: "Image",
26
+ kind_element: "Custom element",
27
+ kind_url: "iframe URL",
28
+ kind_svg: "SVG",
29
+ },
30
+ ja: {
31
+ title: "自分のページに入れる",
32
+ size: "大きさ",
33
+ kinds: "ページの作り方",
34
+ pick: "入れるもの",
35
+ search: "さがす",
36
+ all: "すべて",
37
+ none: "見つかりません",
38
+ preview: "プレビュー",
39
+ code: "コード",
40
+ copy: "コードをコピー",
41
+ copied: "コピーしました",
42
+ copyFail: "コードを選んで、手でコピーしてください。",
43
+ open: "埋め込みページを開く",
44
+ docs: "ドキュメントを読む",
45
+ credits: "クレジット",
46
+ kind_html: "HTML",
47
+ kind_module: "ESモジュール",
48
+ kind_react: "React",
49
+ kind_vue: "Vue",
50
+ kind_angular: "Angular",
51
+ kind_svelte: "Svelte",
52
+ kind_iframe: "iframe",
53
+ kind_image: "画像",
54
+ kind_element: "カスタム要素",
55
+ kind_url: "iframe URL",
56
+ kind_svg: "SVG",
57
+ },
58
+ };
59
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
60
+ export const languageOf = (lang) => (/^ja\b/i.test(lang ?? "") ? "ja" : "en");
61
+ export const wordsFor = (language, overrides = {}) => ({ ...WORDS[language], ...overrides });
62
+ /** What a tab says: the family's word for the id, else the page's own `labels`, else the id itself. */
63
+ export function kindLabel(id, language, labels = {}, overrides = {}) {
64
+ const mine = labels[id];
65
+ if (mine !== undefined && mine !== "")
66
+ return mine;
67
+ const words = wordsFor(language, overrides);
68
+ return words[`kind_${id}`] ?? id;
69
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** The subpaths of the parts, in the order they were added. */
2
- export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "renderer", "testing"];
2
+ export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "speed-buttons", "embed-panel", "renderer", "testing"];
3
3
  export type Part = (typeof PARTS)[number];
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kazoku UI 家族: the family's shared interface parts, each its own subpath so a page loads only what it uses.
2
2
  /** The subpaths of the parts, in the order they were added. */
3
- export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "renderer", "testing"];
3
+ export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "speed-buttons", "embed-panel", "renderer", "testing"];
@@ -0,0 +1 @@
1
+ export declare function defineSpeedButtons(registry?: CustomElementRegistry): boolean;
@@ -0,0 +1,9 @@
1
+ // Importing this registers <kazoku-speed-buttons>, unless the tag is already taken (the first definition wins; see view-controls/define.ts for why a taken tag is left alone).
2
+ import { SpeedButtonsElement, TAG } from "./element.js";
3
+ export function defineSpeedButtons(registry = customElements) {
4
+ if (registry.get(TAG) !== undefined)
5
+ return false;
6
+ registry.define(TAG, SpeedButtonsElement);
7
+ return true;
8
+ }
9
+ defineSpeedButtons();
@@ -0,0 +1,34 @@
1
+ import { EVENT, TAG } from "./index.ts";
2
+ import { type SpeedWords } from "./words.ts";
3
+ export { EVENT, TAG };
4
+ /** What the `kazoku-speedchange` event carries in `detail`. */
5
+ export interface SpeedChangeDetail {
6
+ /** The speed now: one of `steps`. */
7
+ value: number;
8
+ steps: readonly number[];
9
+ /** `init` (once, when it comes up), `user` (a button or `step()`) or `set` (the `value` property or attribute). */
10
+ reason: "init" | "user" | "set";
11
+ }
12
+ /**
13
+ * Attributes: `steps` (the speeds, `0.5 1 2` by default; any numbers, `1 10 100`, `0 1 3600 86400 -3600`), `value` (the one that is on; the start, and it follows the reader's choice),
14
+ * `unit` (the word after each number, `×` by default), `label` (the group's name, shown before the buttons and read by a screen reader; "Speed" or 速さ by default), `no-label`
15
+ * (hide the name from the eye), `address` (keep the choice in the address under `address-key`, `speed` by default; the start is left off it), `lang` (`en` or `ja`), `theme` (`light` or `dark`).
16
+ */
17
+ export declare class SpeedButtonsElement extends HTMLElement {
18
+ #private;
19
+ static observedAttributes: string[];
20
+ constructor();
21
+ /** The speed now. Setting it to a speed that is not a step takes the nearest step. */
22
+ get value(): number;
23
+ set value(next: number | string);
24
+ /** The speeds the buttons stand for. Setting a list (or a text such as `1 10 100`) draws them again, keeping the nearest to the speed now. */
25
+ get steps(): readonly number[];
26
+ set steps(list: readonly number[] | string);
27
+ /** Your own label over the language's, for example `{ group: "Time" }`. */
28
+ set words(words: Partial<SpeedWords>);
29
+ /** One press of a single button that cycles: the next speed (`direction` 1) or the one before (-1), going round at the ends. */
30
+ step(direction?: 1 | -1): void;
31
+ connectedCallback(): void;
32
+ disconnectedCallback(): void;
33
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
34
+ }
@@ -0,0 +1,178 @@
1
+ // <kazoku-speed-buttons>: one button for each speed (0.5×, 1×, 2× by default), no menu. It is the one speed control of the family: the view controls use it for Auto-turn,
2
+ // and a solar system, a replay bar or a race takes it with the steps it needs. The choice is the `value` (an attribute, a property and, if asked, the address) and the
3
+ // page hears every change as a `kazoku-speedchange` event. It moves nothing itself: the page applies the number.
4
+ import { applyParams, createAddressWriter, readParam } from "../address-state/address.js";
5
+ import { adoptStyles } from "../shared/adopt.js";
6
+ import { watchScheme } from "../shared/scheme.js";
7
+ import { EVENT, TAG } from "./index.js";
8
+ import { DEFAULT_STEPS, DEFAULT_UNIT, nearestStep, parseSpeed, parseSteps, SPEED_ADDRESS_KEY, speedText, startStep, stepFrom } from "./steps.js";
9
+ import { STYLES } from "./styles.js";
10
+ import { languageOf, wordsFor } from "./words.js";
11
+ export { EVENT, TAG };
12
+ /**
13
+ * Attributes: `steps` (the speeds, `0.5 1 2` by default; any numbers, `1 10 100`, `0 1 3600 86400 -3600`), `value` (the one that is on; the start, and it follows the reader's choice),
14
+ * `unit` (the word after each number, `×` by default), `label` (the group's name, shown before the buttons and read by a screen reader; "Speed" or 速さ by default), `no-label`
15
+ * (hide the name from the eye), `address` (keep the choice in the address under `address-key`, `speed` by default; the start is left off it), `lang` (`en` or `ja`), `theme` (`light` or `dark`).
16
+ */
17
+ export class SpeedButtonsElement extends HTMLElement {
18
+ static observedAttributes = ["steps", "value", "unit", "label", "lang"];
19
+ #root;
20
+ #steps = [...DEFAULT_STEPS];
21
+ #value = 1;
22
+ #start = 1;
23
+ #ready = false;
24
+ #styled = false;
25
+ #words = {};
26
+ #writer = createAddressWriter({ delay: 0 });
27
+ #stop;
28
+ #reflecting = false;
29
+ constructor() {
30
+ super();
31
+ this.#root = this.attachShadow({ mode: "open" });
32
+ }
33
+ /** The speed now. Setting it to a speed that is not a step takes the nearest step. */
34
+ get value() {
35
+ return this.#value;
36
+ }
37
+ set value(next) {
38
+ const value = typeof next === "number" ? next : Number(next);
39
+ if (Number.isFinite(value))
40
+ this.#choose(nearestStep(value, this.#steps) ?? this.#value, "set");
41
+ }
42
+ /** The speeds the buttons stand for. Setting a list (or a text such as `1 10 100`) draws them again, keeping the nearest to the speed now. */
43
+ get steps() {
44
+ return this.#steps;
45
+ }
46
+ set steps(list) {
47
+ this.#aim(typeof list === "string" ? parseSteps(list) : parseSteps(list.join(" ")));
48
+ }
49
+ /** Your own label over the language's, for example `{ group: "Time" }`. */
50
+ set words(words) {
51
+ this.#words = words;
52
+ this.#render();
53
+ }
54
+ /** One press of a single button that cycles: the next speed (`direction` 1) or the one before (-1), going round at the ends. */
55
+ step(direction = 1) {
56
+ this.#choose(stepFrom(this.#value, this.#steps, direction), "user");
57
+ }
58
+ connectedCallback() {
59
+ if (!this.#styled) {
60
+ // Inside another part's shadow root (the view controls) it carries no nonce of its own: the part it sits in has the page's.
61
+ const outer = this.getRootNode();
62
+ adoptStyles(this.#root, STYLES, this.nonce || (outer instanceof ShadowRoot ? outer.host.nonce : "") || "");
63
+ this.#styled = true;
64
+ }
65
+ this.#stop = watchScheme(this);
66
+ this.#steps = parseSteps(this.getAttribute("steps"));
67
+ this.#start = parseSpeed(this.getAttribute("value"), this.#steps) ?? startStep(this.#steps);
68
+ const kept = this.hasAttribute("address") ? parseSpeed(readParam(this.#key(), globalThis.location?.search ?? ""), this.#steps) : undefined;
69
+ this.#value = kept ?? this.#start;
70
+ this.#render();
71
+ this.#ready = true;
72
+ // Said once the page has had its turn to listen; `value` can be read at any time.
73
+ queueMicrotask(() => {
74
+ if (this.isConnected)
75
+ this.#dispatch("init");
76
+ });
77
+ }
78
+ disconnectedCallback() {
79
+ this.#stop?.();
80
+ this.#stop = undefined;
81
+ this.#writer.cancel();
82
+ this.#ready = false;
83
+ }
84
+ attributeChangedCallback(name, _old, value) {
85
+ if (!this.#ready || this.#reflecting)
86
+ return;
87
+ if (name === "steps")
88
+ this.#aim(parseSteps(value));
89
+ else if (name === "value") {
90
+ const next = parseSpeed(value, this.#steps);
91
+ if (next !== undefined)
92
+ this.#choose(next, "set");
93
+ }
94
+ else
95
+ this.#render();
96
+ }
97
+ #key() {
98
+ return this.getAttribute("address-key") || SPEED_ADDRESS_KEY;
99
+ }
100
+ #aim(steps) {
101
+ this.#steps = steps;
102
+ this.#start = nearestStep(this.#start, steps) ?? startStep(steps);
103
+ const kept = nearestStep(this.#value, steps) ?? this.#start;
104
+ if (kept !== this.#value)
105
+ this.#value = kept;
106
+ this.#render();
107
+ if (kept !== this.#value)
108
+ this.#dispatch("set");
109
+ }
110
+ #choose(next, reason) {
111
+ if (next === this.#value)
112
+ return;
113
+ this.#value = next;
114
+ this.#render();
115
+ this.#reflect();
116
+ if (this.hasAttribute("address")) {
117
+ const key = this.#key();
118
+ this.#writer.write((search) => applyParams(search, { [key]: next }, { [key]: String(this.#start) }));
119
+ }
120
+ this.#dispatch(reason);
121
+ }
122
+ /** The `value` attribute follows the reader's choice, so the page and a framework that watches attributes see it. */
123
+ #reflect() {
124
+ if (this.getAttribute("value") === String(this.#value))
125
+ return;
126
+ this.#reflecting = true;
127
+ this.setAttribute("value", String(this.#value));
128
+ this.#reflecting = false;
129
+ }
130
+ #render() {
131
+ const language = languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
132
+ const group = this.getAttribute("label") || wordsFor(language, this.#words).group;
133
+ const unit = this.getAttribute("unit") ?? DEFAULT_UNIT;
134
+ const doc = this.ownerDocument;
135
+ let box = this.#root.querySelector(".group");
136
+ if (box === null) {
137
+ const label = doc.createElement("span");
138
+ label.className = "lbl";
139
+ label.setAttribute("part", "label");
140
+ label.setAttribute("aria-hidden", "true");
141
+ box = doc.createElement("div");
142
+ box.className = "group";
143
+ box.setAttribute("part", "group");
144
+ box.setAttribute("role", "group");
145
+ this.#root.append(label, box);
146
+ box.addEventListener("click", (event) => {
147
+ const button = event.target.closest("button[data-v]");
148
+ if (button !== null)
149
+ this.#choose(Number(button.dataset["v"]), "user");
150
+ });
151
+ }
152
+ const label = this.#root.querySelector(".lbl");
153
+ if (label !== null && label.textContent !== group)
154
+ label.textContent = group;
155
+ box.setAttribute("aria-label", group);
156
+ const stale = [...box.querySelectorAll("button")];
157
+ const same = stale.length === this.#steps.length && stale.every((button, i) => button.dataset["v"] === String(this.#steps[i]));
158
+ if (!same) {
159
+ box.replaceChildren(...this.#steps.map((step) => {
160
+ const button = doc.createElement("button");
161
+ button.type = "button";
162
+ button.dataset["v"] = String(step);
163
+ button.setAttribute("part", "button");
164
+ return button;
165
+ }));
166
+ }
167
+ for (const button of box.querySelectorAll("button")) {
168
+ const step = Number(button.dataset["v"]);
169
+ const text = speedText(step, unit);
170
+ if (button.textContent !== text)
171
+ button.textContent = text;
172
+ button.setAttribute("aria-pressed", String(step === this.#value));
173
+ }
174
+ }
175
+ #dispatch(reason) {
176
+ this.dispatchEvent(new CustomEvent(EVENT, { detail: { value: this.#value, steps: this.#steps, reason }, bubbles: true, composed: true }));
177
+ }
178
+ }
@@ -0,0 +1,4 @@
1
+ export { DEFAULT_STEPS, DEFAULT_UNIT, nearestStep, parseSpeed, parseSteps, SPEED_ADDRESS_KEY, speedText, startStep, stepFrom } from "./steps.ts";
2
+ export { languageOf, WORDS, wordsFor, type Language, type SpeedWords } from "./words.ts";
3
+ export declare const EVENT = "kazoku-speedchange";
4
+ export declare const TAG = "kazoku-speed-buttons";
@@ -0,0 +1,5 @@
1
+ // The speed buttons without the element: the steps, how a speed reads and how one is chosen. Nothing here touches the DOM, so it imports on a server.
2
+ export { DEFAULT_STEPS, DEFAULT_UNIT, nearestStep, parseSpeed, parseSteps, SPEED_ADDRESS_KEY, speedText, startStep, stepFrom } from "./steps.js";
3
+ export { languageOf, WORDS, wordsFor } from "./words.js";
4
+ export const EVENT = "kazoku-speedchange";
5
+ export const TAG = "kazoku-speed-buttons";
@@ -0,0 +1,18 @@
1
+ /** The steps when a page names none: half speed, normal and double. */
2
+ export declare const DEFAULT_STEPS: readonly number[];
3
+ /** The word on each step's button after its number, when the page names none. */
4
+ export declare const DEFAULT_UNIT = "\u00D7";
5
+ /** The address key the buttons keep their choice under, unless `address-key` names another. */
6
+ export declare const SPEED_ADDRESS_KEY = "speed";
7
+ /** The steps in a list such as `0.5 1 2` or `1, 10, 100`: finite numbers in the order given, each once. `fallback` when the list holds none. A step may be 0 (Paused) or negative (Back). */
8
+ export declare function parseSteps(text: string | null | undefined, fallback?: readonly number[]): number[];
9
+ /** The number as a button shows it: `0.5×`, `1×`, `100×`, `−1×` (a real minus sign). At most four significant figures, so 0.333333 reads `0.333×`. */
10
+ export declare function speedText(value: number, unit?: string): string;
11
+ /** The step nearest `value`: by ratio where both are above zero (so 2.5 is nearer 2 than 4), else by plain distance. `undefined` when there are no steps. */
12
+ export declare function nearestStep(value: number, steps: readonly number[]): number | undefined;
13
+ /** The step the buttons start on: 1 where the steps have it (normal speed), else the first. */
14
+ export declare function startStep(steps: readonly number[]): number;
15
+ /** The step after (`direction` 1) or before (-1) `value`, going round at the ends: one press of a single button that cycles through the speeds. */
16
+ export declare function stepFrom(value: number, steps: readonly number[], direction?: 1 | -1): number;
17
+ /** What an address or attribute says: a number that is (or is nearest to) one of the steps. `undefined` when it is not a number at all. The words `slow`, `medium` and `fast` of the first view controls mean 0.5, 1 and 2. */
18
+ export declare function parseSpeed(text: string | null | undefined, steps: readonly number[]): number | undefined;