char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Somui rework403dcb9

main
3.2 KiB99 linesraw
1import type { Signal } from "@char/aftercare";
2import type { JSX } from "@char/aftercare/jsx-runtime";
3
4/**
5 * run `render` now and (coalesced per microtask) whenever any signal changes, for as long as
6 * `el` stays in the document
7 */
8// deno-lint-ignore no-explicit-any
9export function watch(el: Node, signals: readonly Signal<any>[], render: () => void): void {
10  let mounted = false;
11  let queued = false;
12  const run = () => {
13    queued = false;
14    if (mounted && !el.isConnected) return subs.forEach(s => s.unsubscribe());
15    mounted ||= el.isConnected;
16    render();
17  };
18  const subs = signals.map(s =>
19    s.subscribe(() => {
20      if (queued) return;
21      queued = true;
22      queueMicrotask(run);
23    }),
24  );
25  render();
26}
27
28/** follow a pointer drag at the window level, so it survives the target being re-rendered */
29export function drag(
30  start: PointerEvent,
31  move: (e: PointerEvent) => void,
32  end?: (e: PointerEvent) => void,
33): void {
34  const onMove = (e: PointerEvent) => {
35    if (e.pointerId === start.pointerId) move(e);
36  };
37  const onUp = (e: PointerEvent) => {
38    if (e.pointerId !== start.pointerId) return;
39    removeEventListener("pointermove", onMove);
40    removeEventListener("pointerup", onUp);
41    removeEventListener("pointercancel", onUp);
42    end?.(e);
43  };
44  addEventListener("pointermove", onMove);
45  addEventListener("pointerup", onUp);
46  addEventListener("pointercancel", onUp);
47}
48
49/**
50 * make `parent`'s children exactly `nodes`, leaving nodes that stay in place untouched: moving a
51 * node out and back in would blur it if focused, or drop a click that's halfway through
52 */
53export function setChildren(parent: Node, nodes: readonly Node[]): void {
54  const keep = new Set(nodes);
55  for (const child of [...parent.childNodes]) if (!keep.has(child)) child.remove();
56  let at = parent.firstChild;
57  for (const node of nodes) {
58    if (node === at) at = at.nextSibling;
59    else parent.insertBefore(node, at);
60  }
61}
62
63export const px = (n: number): string => `${n}px`;
64
65/** a toggle for a collapsible panel, with a chevron for which way it is */
66export const disclosure = (open: Signal<boolean>, label: string): JSX.Element => (
67  <button
68    type="button"
69    class="disclosure"
70    aria-expanded={open.derive(String)}
71    _onclick={() => open.mut(o => !o)}
72  >
73    {label}
74  </button>
75);
76
77/** a (?) that explains something when hovered */
78export const help = (text: string): JSX.Element => (
79  <span class="help" title={text} aria-label={text} role="note">
80    ?
81  </span>
82);
83
84/** ask for a file to open; nothing comes of it if none is chosen */
85export function chooseFile(accept: string, then: (file: File) => void): void {
86  const input = (<input type="file" accept={accept} />) as HTMLInputElement;
87  input.addEventListener("change", () => input.files?.[0] && then(input.files[0]));
88  input.click();
89}
90
91/** download something as a file, named for `name` (which may be anything a project's called) */
92export function saveFile(name: string, extension: string, data: BlobPart, type: string): void {
93  const url = URL.createObjectURL(new Blob([data], { type }));
94  const link = document.createElement("a");
95  link.href = url;
96  link.download = `${name.replace(/[/\\:*?"<>|]/g, "-") || "untitled"}${extension}`;
97  link.click();
98  setTimeout(() => URL.revokeObjectURL(url), 1000);
99}