@akonwi/mica 0.1.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.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@akonwi/mica",
3
+ "version": "0.1.0",
4
+ "description": "Custom elements. Native behavior. Nearly no JavaScript.",
5
+ "keywords": ["css", "custom-elements", "web-components", "layout", "no-build", "html-first"],
6
+ "homepage": "https://github.com/akonwi/mica",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/akonwi/mica.git"
10
+ },
11
+ "license": "MIT",
12
+ "author": "Akonwi Ngoh",
13
+ "type": "module",
14
+ "exports": {
15
+ ".": "./mica.css",
16
+ "./mica.css": "./mica.css",
17
+ "./field.js": "./field.js",
18
+ "./select.js": "./select.js",
19
+ "./tabs.js": "./tabs.js",
20
+ "./toast.js": "./toast.js",
21
+ "./combobox.js": "./combobox.js"
22
+ },
23
+ "files": [
24
+ "mica.css",
25
+ "field.js",
26
+ "select.js",
27
+ "tabs.js",
28
+ "toast.js",
29
+ "combobox.js"
30
+ ],
31
+ "sideEffects": true
32
+ }
package/select.js ADDED
@@ -0,0 +1,30 @@
1
+ /* mica/select.js — align the open select picker so the selected option
2
+ * overlays the trigger (macOS-native / Base UI `alignItemWithTrigger`
3
+ * behavior). Tier 2, and about as small as a module can be:
4
+ *
5
+ * JS supplies exactly one datum — the selected index, as a custom
6
+ * property. All geometry lives in mica.css (anchor positioning).
7
+ * Without this module, selects keep the anchored-below picker. Only
8
+ * meaningful where `appearance: base-select` is supported.
9
+ */
10
+
11
+ const ALIGNED = "picker-aligned";
12
+ const SELECTOR = "select:not([multiple]):not([size])";
13
+
14
+ function sync(select) {
15
+ select.style.setProperty("--m-sel-index", select.selectedIndex);
16
+ select.classList.add(ALIGNED);
17
+ }
18
+
19
+ if (CSS.supports("appearance", "base-select")) {
20
+ for (const s of document.querySelectorAll(SELECTOR)) sync(s);
21
+ document.addEventListener("change", (e) => {
22
+ if (!e.target.matches?.(SELECTOR)) return;
23
+ const select = e.target;
24
+ // Defer re-alignment until the picker's exit transition (120ms in
25
+ // mica.css) has finished — updating the index while the closing
26
+ // picker is still rendered makes it visibly jump to the new value's
27
+ // position before fading. Fade out in place; re-align invisibly.
28
+ setTimeout(() => sync(select), 200);
29
+ });
30
+ }
package/tabs.js ADDED
@@ -0,0 +1,82 @@
1
+ /* mica/tabs.js — <m-tabs>: accessible tabs. Tier 2.
2
+ *
3
+ * Enhances working light-DOM markup; never renders it. Without this
4
+ * module, the nav buttons are inert and every panel is visible in
5
+ * order — complete, readable content. With it: real tablist semantics,
6
+ * roving tabindex, arrow-key navigation, automatic activation.
7
+ *
8
+ * <m-tabs>
9
+ * <nav>
10
+ * <button>Account</button>
11
+ * <button selected>Password</button> <!-- optional initial -->
12
+ * </nav>
13
+ * <section>…account panel…</section>
14
+ * <section>…password panel…</section>
15
+ * </m-tabs>
16
+ *
17
+ * Buttons pair with panels positionally, or explicitly via
18
+ * aria-controls="panel-id".
19
+ */
20
+
21
+ let uid = 0;
22
+
23
+ class MTabs extends HTMLElement {
24
+ #tabs = [];
25
+ #panels = [];
26
+
27
+ connectedCallback() {
28
+ const nav = this.querySelector(":scope > nav");
29
+ if (!nav) return;
30
+ this.#tabs = [...nav.querySelectorAll("button")];
31
+ const sections = [...this.querySelectorAll(":scope > section")];
32
+ this.#panels = this.#tabs.map((b, i) => {
33
+ const id = b.getAttribute("aria-controls");
34
+ return id ? this.querySelector(`#${CSS.escape(id)}`) : sections[i];
35
+ });
36
+
37
+ nav.setAttribute("role", "tablist");
38
+ this.#tabs.forEach((tab, i) => {
39
+ tab.setAttribute("role", "tab");
40
+ tab.id ||= `m-tab-${++uid}`;
41
+ const panel = this.#panels[i];
42
+ if (panel) {
43
+ panel.setAttribute("role", "tabpanel");
44
+ panel.setAttribute("aria-labelledby", tab.id);
45
+ panel.tabIndex = 0;
46
+ }
47
+ tab.addEventListener("click", () => this.select(i));
48
+ });
49
+
50
+ nav.addEventListener("keydown", (e) => {
51
+ const n = this.#tabs.length;
52
+ const current = this.#tabs.findIndex(
53
+ (t) => t.getAttribute("aria-selected") === "true",
54
+ );
55
+ let next = null;
56
+ if (e.key === "ArrowRight") next = (current + 1) % n;
57
+ else if (e.key === "ArrowLeft") next = (current - 1 + n) % n;
58
+ else if (e.key === "Home") next = 0;
59
+ else if (e.key === "End") next = n - 1;
60
+ if (next === null) return;
61
+ e.preventDefault();
62
+ this.select(next); // automatic activation, radix-style
63
+ this.#tabs[next].focus();
64
+ });
65
+
66
+ const initial = this.#tabs.findIndex(
67
+ (t) => t.hasAttribute("selected") || t.getAttribute("aria-selected") === "true",
68
+ );
69
+ this.select(initial === -1 ? 0 : initial);
70
+ }
71
+
72
+ select(index) {
73
+ this.#tabs.forEach((tab, i) => {
74
+ const on = i === index;
75
+ tab.setAttribute("aria-selected", String(on));
76
+ tab.tabIndex = on ? 0 : -1;
77
+ this.#panels[i]?.toggleAttribute("hidden", !on);
78
+ });
79
+ }
80
+ }
81
+
82
+ if (!customElements.get("m-tabs")) customElements.define("m-tabs", MTabs);
package/toast.js ADDED
@@ -0,0 +1,100 @@
1
+ /* mica/toast.js — toast stacking, auto-dismiss, and a spawn helper.
2
+ * Tier 2.
3
+ *
4
+ * Enhances the Tier-1 toast recipe (corner-pinned manual popovers).
5
+ * Declared toasts keep working without this module — it adds:
6
+ *
7
+ * - stacking: open toasts stack upward; JS ships one number per toast
8
+ * (--m-toast-offset), CSS owns the geometry and the reflow motion
9
+ * - auto-dismiss: `duration` attribute in ms (default 5000, "0" = sticky),
10
+ * paused while hovered
11
+ * - toast(title, { description, variant, duration }): spawns the same
12
+ * recipe markup, shows it, and removes it after dismissal
13
+ */
14
+
15
+ const GAP = 8;
16
+ const DEFAULT_DURATION = 5000;
17
+
18
+ const open = [];
19
+ const timers = new WeakMap();
20
+
21
+ const isToast = (el) =>
22
+ el instanceof HTMLElement && el.matches("[popover].toast");
23
+
24
+ function restack() {
25
+ let offset = 0;
26
+ for (let i = open.length - 1; i >= 0; i--) {
27
+ open[i].style.setProperty("--m-toast-offset", `${offset}px`);
28
+ offset += open[i].getBoundingClientRect().height + GAP;
29
+ }
30
+ }
31
+
32
+ function startTimer(el) {
33
+ const ms = el.hasAttribute("duration")
34
+ ? Number(el.getAttribute("duration"))
35
+ : DEFAULT_DURATION;
36
+ if (!ms) return;
37
+ timers.set(el, setTimeout(() => el.hidePopover(), ms));
38
+ }
39
+
40
+ function stopTimer(el) {
41
+ clearTimeout(timers.get(el));
42
+ }
43
+
44
+ function attachHoverPause(el) {
45
+ if (el.__mToastHover) return;
46
+ el.__mToastHover = true;
47
+ el.addEventListener("pointerenter", () => stopTimer(el));
48
+ el.addEventListener("pointerleave", () => {
49
+ if (el.matches(":popover-open")) startTimer(el);
50
+ });
51
+ }
52
+
53
+ // toggle doesn't bubble; capture reaches the target anyway
54
+ document.addEventListener(
55
+ "toggle",
56
+ (e) => {
57
+ const el = e.target;
58
+ if (!isToast(el)) return;
59
+ if (e.newState === "open") {
60
+ open.push(el);
61
+ attachHoverPause(el);
62
+ startTimer(el);
63
+ } else {
64
+ const i = open.indexOf(el);
65
+ if (i > -1) open.splice(i, 1);
66
+ stopTimer(el);
67
+ if (el.dataset.ephemeral !== undefined) el.remove();
68
+ }
69
+ restack();
70
+ },
71
+ true,
72
+ );
73
+
74
+ export function toast(title, { description = "", variant = "", duration } = {}) {
75
+ const el = document.createElement("div");
76
+ el.popover = "manual";
77
+ el.className = `toast${variant ? ` ${variant}` : ""}`;
78
+ el.setAttribute("role", "status");
79
+ el.dataset.ephemeral = "";
80
+ if (duration !== undefined) el.setAttribute("duration", String(duration));
81
+
82
+ const x = document.createElement("button");
83
+ x.className = "close";
84
+ x.setAttribute("aria-label", "Dismiss");
85
+ x.textContent = "\u2715";
86
+ x.addEventListener("click", () => el.hidePopover());
87
+
88
+ const b = document.createElement("b");
89
+ b.textContent = title;
90
+
91
+ el.append(x, b);
92
+ if (description) {
93
+ const s = document.createElement("span");
94
+ s.textContent = description;
95
+ el.append(s);
96
+ }
97
+ document.body.append(el);
98
+ el.showPopover();
99
+ return el;
100
+ }