fabrica

hanna/fabrica

7938 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Progressive enhancement only. Every interactive element works without this
6// file; it adds the mobile drawer, the theme picker, clipboard buttons, and a
7// couple of keyboard shortcuts. Kept small and dependency-free (< 200 lines).
8
9(function () {
10 "use strict";
11
12 // ---- Mobile nav drawer ----
13 function toggleDrawer(open) {
14 var body = document.body;
15 var isOpen = open === undefined ? !body.classList.contains("drawer-open") : open;
16 body.classList.toggle("drawer-open", isOpen);
17 var toggle = document.querySelector("[data-drawer-toggle]");
18 if (toggle) toggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
19 }
20
21 document.addEventListener("click", function (e) {
22 var toggle = e.target.closest("[data-drawer-toggle]");
23 if (toggle) {
24 e.preventDefault();
25 toggleDrawer();
26 return;
27 }
28 if (e.target.closest("[data-drawer-close]") || e.target.classList.contains("drawer-backdrop")) {
29 toggleDrawer(false);
30 }
31 });
32
33 // ---- Theme picker ----
34 // Apply the chosen theme instantly for feedback, then submit the picker's form
35 // so the server persists the cookie and serves the matching stylesheet. Without
36 // JS the form's submit button does the same thing.
37 document.addEventListener("change", function (e) {
38 var picker = e.target.closest("[data-theme-picker]");
39 if (!picker) return;
40 var opt = picker.options[picker.selectedIndex];
41 var root = document.documentElement;
42 root.setAttribute("data-theme", picker.value);
43 if (opt && opt.getAttribute("data-scheme")) {
44 root.style.colorScheme = opt.getAttribute("data-scheme");
45 }
46 if (picker.form) picker.form.submit();
47 });
48
49 // ---- Dropdowns (details-based popovers) ----
50 // Native <details> stays open until its summary is clicked again; close any
51 // open menu when a click lands outside it. Without JS the menus still work,
52 // they just close on a second summary click.
53 document.addEventListener("click", function (e) {
54 document.querySelectorAll("details.clone-menu[open], details.branch-menu[open], details[data-menu][open]").forEach(function (d) {
55 if (!d.contains(e.target)) d.removeAttribute("open");
56 });
57 });
58
59 // ---- Auto-submitting controls ----
60 // Any [data-autosubmit] control (select or checkbox) submits its form on
61 // change; without JS a noscript button does the same.
62 document.querySelectorAll("[data-autosubmit]").forEach(function (el) {
63 el.addEventListener("change", function () {
64 if (el.form) el.form.submit();
65 });
66 });
67
68 // ---- Label dropdown: show checked labels as chips inside the toggle ----
69 document.querySelectorAll("[data-label-dropdown]").forEach(function (dd) {
70 var summaryLabel = dd.querySelector("[data-label-summary]");
71 if (!summaryLabel) return;
72 function sync() {
73 var checked = dd.querySelectorAll('input[type="checkbox"]:checked');
74 summaryLabel.textContent = "";
75 if (checked.length === 0) {
76 summaryLabel.textContent = "Select labels";
77 return;
78 }
79 checked.forEach(function (cb) {
80 var name = cb.getAttribute("data-label-name") || cb.value;
81 var chip = document.createElement("span");
82 chip.style.setProperty("--label-color", cb.getAttribute("data-label-color") || "#888");
83 var sep = name.indexOf(": ");
84 if (sep === -1) {
85 chip.className = "label-chip";
86 chip.textContent = name;
87 } else {
88 // Scoped label: static markup, text set via textContent (escaped).
89 chip.className = "label-chip label-chip-scoped";
90 chip.innerHTML = '<span class="label-scope"></span><span class="label-value"></span>';
91 chip.firstChild.textContent = name.slice(0, sep).trim();
92 chip.lastChild.textContent = name.slice(sep + 2).trim();
93 }
94 summaryLabel.appendChild(chip);
95 });
96 }
97 dd.addEventListener("change", sync);
98 sync();
99 });
100
101 // ---- Progressive link fields ----
102 // Show the filled links plus one empty slot; reveal more (up to the max) via
103 // the Add link button. Without JS every slot is visible.
104 document.querySelectorAll("[data-link-fields]").forEach(function (wrap) {
105 var slots = Array.prototype.slice.call(wrap.querySelectorAll(".link-slot"));
106 var addBtn = wrap.querySelector("[data-link-add]");
107 // Initial layout: show every filled slot plus one empty one, hide the rest.
108 var firstEmptyShown = false;
109 slots.forEach(function (s) {
110 if (s.value.trim()) {
111 s.hidden = false;
112 } else if (!firstEmptyShown) {
113 s.hidden = false;
114 firstEmptyShown = true;
115 } else {
116 s.hidden = true;
117 }
118 });
119 // Keep the Add button beside the last visible slot; hide it once all show.
120 function mark() {
121 var lastVisible = null;
122 slots.forEach(function (s) {
123 s.classList.remove("last-visible");
124 if (!s.hidden) lastVisible = s;
125 });
126 if (lastVisible) lastVisible.classList.add("last-visible");
127 if (addBtn) addBtn.hidden = !slots.some(function (s) { return s.hidden; });
128 }
129 if (addBtn) {
130 addBtn.addEventListener("click", function () {
131 var next = slots.filter(function (s) { return s.hidden; })[0];
132 if (next) {
133 next.hidden = false;
134 next.focus();
135 }
136 mark();
137 });
138 }
139 mark();
140 });
141
142 // ---- Clipboard buttons ----
143 document.addEventListener("click", function (e) {
144 var btn = e.target.closest("[data-clipboard]");
145 if (!btn) return;
146 e.preventDefault();
147 var text = btn.getAttribute("data-clipboard");
148 if (navigator.clipboard) {
149 navigator.clipboard.writeText(text).then(function () {
150 var old = btn.getAttribute("aria-label") || btn.textContent;
151 btn.setAttribute("aria-label", "Copied");
152 btn.classList.add("copied");
153 setTimeout(function () {
154 btn.setAttribute("aria-label", old);
155 btn.classList.remove("copied");
156 }, 1200);
157 });
158 }
159 });
160
161 // ---- Keyboard shortcuts ----
162 document.addEventListener("keydown", function (e) {
163 // Ignore when typing in a field.
164 var tag = (e.target.tagName || "").toLowerCase();
165 if (tag === "input" || tag === "textarea" || tag === "select" || e.target.isContentEditable) {
166 if (e.key === "Escape") e.target.blur();
167 return;
168 }
169 if (e.key === "/") {
170 var search = document.querySelector('input[type="search"], [data-search-input]');
171 if (search) {
172 e.preventDefault();
173 search.focus();
174 }
175 } else if (e.key === "Escape") {
176 toggleDrawer(false);
177 document.querySelectorAll("details.clone-menu[open], details.branch-menu[open], details[data-menu][open]").forEach(function (d) {
178 d.removeAttribute("open");
179 });
180 }
181 });
182
183 // ---- Release asset dropzone: pick or drop a file to upload it immediately ----
184 document.querySelectorAll(".asset-upload").forEach(function (form) {
185 var input = form.querySelector("input[type=file]");
186 var zone = form.querySelector(".dropzone");
187 if (!input || !zone) return;
188 var submit = form.querySelector("button[type=submit]");
189 if (submit) submit.hidden = true; // JS uploads on selection; button is no-JS only
190 input.addEventListener("change", function () { if (input.files.length) form.submit(); });
191 ["dragover", "dragenter", "dragleave", "drop"].forEach(function (ev) {
192 zone.addEventListener(ev, function (e) {
193 e.preventDefault();
194 zone.classList.toggle("drag", ev === "dragover" || ev === "dragenter");
195 if (ev === "drop" && e.dataTransfer.files.length) { input.files = e.dataTransfer.files; form.submit(); }
196 });
197 });
198 });
199})();