cautpartener / client / src / app / ui-kit.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
/**
 * Minimal DOM kit every view builds on: h() element helper, toast(), sheet().
 * No framework, no virtual DOM — views own their subtree and re-render it.
 */

type Attrs = Record<string, string | number | boolean | EventListener | undefined>;
type Child = Node | string | null | undefined | false;

/** Create an element: h("button", { class: "btn", onclick: fn }, "Save"). */
export function h<K extends keyof HTMLElementTagNameMap>(
  tag: K,
  attrs: Attrs = {},
  ...children: Child[]
): HTMLElementTagNameMap[K] {
  const el = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs)) {
    if (v === undefined || v === false) continue;
    if (k.startsWith("on") && typeof v === "function") {
      el.addEventListener(k.slice(2).toLowerCase(), v as EventListener);
    } else if (v === true) {
      el.setAttribute(k, "");
    } else {
      el.setAttribute(k, String(v));
    }
  }
  for (const c of children) {
    if (c === null || c === undefined || c === false) continue;
    el.append(typeof c === "string" ? document.createTextNode(c) : c);
  }
  return el;
}

/** Inline SVG from a path spec (24×24 viewBox); used for tab-bar icons. */
export function svgIcon(d: string, cls = "icon"): SVGSVGElement {
  const NS = "http://www.w3.org/2000/svg";
  const svg = document.createElementNS(NS, "svg");
  svg.setAttribute("viewBox", "0 0 24 24");
  svg.setAttribute("class", cls);
  svg.setAttribute("aria-hidden", "true");
  const path = document.createElementNS(NS, "path");
  path.setAttribute("d", d);
  path.setAttribute("fill", "none");
  path.setAttribute("stroke", "currentColor");
  path.setAttribute("stroke-width", "1.8");
  path.setAttribute("stroke-linecap", "round");
  path.setAttribute("stroke-linejoin", "round");
  svg.append(path);
  return svg;
}

/** Transient notification above the tab bar; auto-dismisses. */
export function toast(msg: string, ms = 3000): void {
  document.querySelector(".toast")?.remove();
  const el = h("div", { class: "toast", role: "status" }, msg);
  document.body.append(el);
  requestAnimationFrame(() => el.classList.add("show"));
  setTimeout(() => {
    el.classList.remove("show");
    setTimeout(() => el.remove(), 250);
  }, ms);
}

export interface SheetHandle {
  close: () => void;
  /**
   * Resolves when the sheet is gone — whether via close() or a backdrop tap.
   * Promise-based flows MUST settle on this (treat an un-settled close as
   * reject/dismiss) so a backdrop dismiss can never deadlock them.
   */
  closed: Promise<void>;
}

/** Bottom sheet with backdrop; close() is idempotent, backdrop tap closes. */
export function sheet(content: HTMLElement): SheetHandle {
  const panel = h("div", { class: "sheet", role: "dialog", "aria-modal": "true" }, content);
  const backdrop = h("div", { class: "sheet-backdrop" }, panel);
  let resolveClosed!: () => void;
  const closed = new Promise<void>((r) => (resolveClosed = r));
  let done = false;
  const close = (): void => {
    if (done) return;
    done = true;
    panel.classList.remove("show");
    backdrop.classList.remove("show");
    setTimeout(() => backdrop.remove(), 250);
    resolveClosed();
  };
  backdrop.addEventListener("click", (e) => {
    if (e.target === backdrop) close();
  });
  document.body.append(backdrop);
  requestAnimationFrame(() => {
    backdrop.classList.add("show");
    panel.classList.add("show");
  });
  return { close, closed };
}

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git