openclacky 1.5.11 → 1.5.13

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -108,6 +108,32 @@
108
108
  return `<pre>${text.replace(/</g, "&lt;")}</pre>`;
109
109
  }
110
110
 
111
+ // Inline (non-modal) editor: mount a CodeMirror instance into `container`
112
+ // without any modal chrome. Used by the workspace file viewer.
113
+ // Returns { view, getContent, destroy }.
114
+ function createInline(container, opts = {}) {
115
+ const getContent = () => view.state.doc.toString();
116
+ const editorOpts = {
117
+ language: opts.language,
118
+ readOnly: !!opts.readOnly,
119
+ onSave: opts.onSave ? () => opts.onSave(getContent()) : null,
120
+ onChange: opts.onChange || null,
121
+ _getContent: () => getContent(),
122
+ };
123
+ const view = new CM.EditorView({
124
+ state: CM.EditorState.create({
125
+ doc: opts.content || "",
126
+ extensions: _buildExtensions(editorOpts),
127
+ }),
128
+ parent: container,
129
+ });
130
+ return {
131
+ view,
132
+ getContent,
133
+ destroy: () => { try { view.destroy(); } catch (_e) { /* already gone */ } },
134
+ };
135
+ }
136
+
111
137
  function open(opts) {
112
138
  const {
113
139
  content = "",
@@ -231,5 +257,11 @@
231
257
  return { view, close, getContent };
232
258
  }
233
259
 
234
- window.CodeEditor = { open, fileKind: _fileKind };
260
+ window.CodeEditor = {
261
+ open,
262
+ createInline,
263
+ fileKind: _fileKind,
264
+ detectLanguage: _detectLanguage,
265
+ renderMarkdown: _renderMarkdownHtml,
266
+ };
235
267
  })(window);
@@ -0,0 +1,315 @@
1
+ // ── Composer — contenteditable composer abstraction ─────────────────────
2
+ // Wraps the message composer (now a <div contenteditable> instead of a
3
+ // <textarea>) so the rest of the app reads/writes plain text and manages
4
+ // mention chips through one API, hiding the DOM / caret / paste differences.
5
+ //
6
+ // Chips are <span class="mention-chip" contenteditable="false"> elements
7
+ // carrying a data-mention-type (file | directory | session) plus payload
8
+ // attributes. They are atomic: the caret skips them, Backspace/Delete removes
9
+ // them whole, and text() excludes them from the plain-text value.
10
+ //
11
+ // Depends on: nothing (pure DOM + Selection API). Loaded after app.js ($ helper).
12
+ // ─────────────────────────────────────────────────────────────────────────
13
+ "use strict";
14
+
15
+ const Composer = (() => {
16
+ const CHIP_CLASS = "mention-chip";
17
+ const ELEMENT_NODE = 1;
18
+ const TEXT_NODE = 3;
19
+
20
+ // Lucide-style 24×24 stroke paths for the chip type icons (matches the
21
+ // inline SVGs used across the UI).
22
+ const CHIP_ICONS = {
23
+ file: ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z", "M14 2v4a2 2 0 0 0 2 2h4"],
24
+ directory: "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z",
25
+ session: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",
26
+ };
27
+
28
+ function _chipIcon(type) {
29
+ const paths = CHIP_ICONS[type];
30
+ if (!paths) return null;
31
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
32
+ svg.setAttribute("width", "13");
33
+ svg.setAttribute("height", "13");
34
+ svg.setAttribute("viewBox", "0 0 24 24");
35
+ svg.setAttribute("fill", "none");
36
+ svg.setAttribute("stroke", "currentColor");
37
+ svg.setAttribute("stroke-width", "2");
38
+ svg.setAttribute("stroke-linecap", "round");
39
+ svg.setAttribute("stroke-linejoin", "round");
40
+ svg.setAttribute("aria-hidden", "true");
41
+ (Array.isArray(paths) ? paths : [paths]).forEach((d) => {
42
+ const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
43
+ p.setAttribute("d", d);
44
+ svg.appendChild(p);
45
+ });
46
+ const wrap = document.createElement("span");
47
+ wrap.className = "mention-chip-icon";
48
+ wrap.appendChild(svg);
49
+ return wrap;
50
+ }
51
+
52
+ function _isChip(node) {
53
+ return !!node && node.nodeType === ELEMENT_NODE && node.classList && node.classList.contains(CHIP_CLASS);
54
+ }
55
+
56
+ // Plain text of the composer, excluding chips. Newlines are reconstructed
57
+ // from <br> and block elements; zero-width / non-breaking spaces normalized.
58
+ function text(el) {
59
+ let out = "";
60
+ const walk = (node) => {
61
+ for (const n of Array.from(node.childNodes)) {
62
+ if (n.nodeType === TEXT_NODE) {
63
+ out += n.nodeValue;
64
+ continue;
65
+ }
66
+ if (n.nodeType !== ELEMENT_NODE) continue;
67
+ if (_isChip(n)) continue;
68
+ switch (n.tagName) {
69
+ case "BR":
70
+ out += "\n";
71
+ break;
72
+ case "DIV":
73
+ case "P":
74
+ case "LI":
75
+ if (out && !out.endsWith("\n")) out += "\n";
76
+ walk(n);
77
+ if (!out.endsWith("\n")) out += "\n";
78
+ break;
79
+ default:
80
+ walk(n);
81
+ }
82
+ }
83
+ };
84
+ walk(el);
85
+ return out.replace(/\u200B/g, "").replace(/\u00A0/g, " ");
86
+ }
87
+
88
+ // Replace composer contents with plain text (newlines → <br>), caret at end.
89
+ function setText(el, value) {
90
+ el.innerHTML = "";
91
+ const v = String(value == null ? "" : value);
92
+ if (v !== "") {
93
+ v.split("\n").forEach((line, i) => {
94
+ if (i > 0) el.appendChild(document.createElement("br"));
95
+ el.appendChild(document.createTextNode(line));
96
+ });
97
+ }
98
+ focusEnd(el);
99
+ }
100
+
101
+ function clear(el) {
102
+ el.innerHTML = "";
103
+ }
104
+
105
+ function focus(el) {
106
+ el.focus();
107
+ }
108
+
109
+ // Move the caret to the very end of the composer and focus it.
110
+ function focusEnd(el) {
111
+ el.focus();
112
+ const range = document.createRange();
113
+ range.selectNodeContents(el);
114
+ range.collapse(false);
115
+ const sel = window.getSelection();
116
+ sel.removeAllRanges();
117
+ sel.addRange(range);
118
+ }
119
+
120
+ // True when the composer holds any text or chip (for send-button state).
121
+ function hasContent(el) {
122
+ return text(el).trim().length > 0 || el.querySelector("." + CHIP_CLASS) != null;
123
+ }
124
+
125
+ // Extract mention chips as plain data objects, in document order.
126
+ // file/directory → { type, name, path }; session → { type, name, session_id }.
127
+ function chips(el) {
128
+ return Array.from(el.querySelectorAll("." + CHIP_CLASS)).map((c) => {
129
+ const type = c.dataset.mentionType || "";
130
+ const out = { type: type, name: c.dataset.name || "" };
131
+ if (type === "session") {
132
+ out.session_id = c.dataset.sessionId || "";
133
+ } else {
134
+ out.path = c.dataset.path || "";
135
+ }
136
+ return out;
137
+ });
138
+ }
139
+
140
+ function _emitInput(el) {
141
+ el.dispatchEvent(new Event("input", { bubbles: true }));
142
+ }
143
+
144
+ function _buildChip(chip) {
145
+ const span = document.createElement("span");
146
+ span.className = CHIP_CLASS;
147
+ span.contentEditable = "false";
148
+ span.dataset.mentionType = chip.type || "";
149
+ span.dataset.name = chip.name || "";
150
+ if (chip.type === "session") {
151
+ span.dataset.sessionId = chip.sessionId || "";
152
+ } else {
153
+ span.dataset.path = chip.path || "";
154
+ }
155
+
156
+ const icon = _chipIcon(chip.type);
157
+ if (icon) span.appendChild(icon);
158
+
159
+ const label = document.createElement("span");
160
+ label.className = "mention-chip-label";
161
+ label.textContent = chip.name || "";
162
+
163
+ span.appendChild(label);
164
+ return span;
165
+ }
166
+
167
+ // Insert a chip at the current caret, followed by a space; caret after it.
168
+ function insertChip(el, chip) {
169
+ const sel = window.getSelection();
170
+ const range = (sel && sel.rangeCount && el.contains(sel.anchorNode))
171
+ ? sel.getRangeAt(0)
172
+ : (() => { const r = document.createRange(); r.selectNodeContents(el); r.collapse(false); return r; })();
173
+ range.collapse(false);
174
+
175
+ const chipEl = _buildChip(chip);
176
+ const space = document.createTextNode(" ");
177
+ range.insertNode(chipEl);
178
+ chipEl.parentNode.insertBefore(space, chipEl.nextSibling);
179
+
180
+ // A leading zero-width guard keeps the caret from sitting on the bare
181
+ // block boundary before an atomic chip, where browsers insert a <br>/<div>
182
+ // on input/delete and push the chip onto its own line.
183
+ const prev = chipEl.previousSibling;
184
+ if (!prev || prev.nodeType !== TEXT_NODE) {
185
+ chipEl.parentNode.insertBefore(document.createTextNode("\u200B"), chipEl);
186
+ }
187
+
188
+ const after = document.createRange();
189
+ after.setStartAfter(space);
190
+ after.collapse(true);
191
+ sel.removeAllRanges();
192
+ sel.addRange(after);
193
+ el.focus();
194
+ _emitInput(el);
195
+ }
196
+
197
+ // Remove a chip DOM node and its surrounding zero-width guards + separator whitespace.
198
+ function removeChip(chipEl) {
199
+ const prev = chipEl.previousSibling;
200
+ if (prev && prev.nodeType === TEXT_NODE && /^\u200B+$/.test(prev.nodeValue)) {
201
+ prev.remove();
202
+ }
203
+ const next = chipEl.nextSibling;
204
+ if (next && next.nodeType === TEXT_NODE && /^[\s\u00A0\u200B]*$/.test(next.nodeValue)) {
205
+ next.remove();
206
+ }
207
+ chipEl.remove();
208
+ }
209
+
210
+ // Re-establish a text anchor before each chip. Editing can delete the
211
+ // leading zero-width guard (when the caret sits inside it), after which the
212
+ // browser drops a <br>/<div> line anchor before the chip and wraps it onto
213
+ // its own line. Runs on every input event.
214
+ function _ensureChipGuards(el) {
215
+ for (const chip of Array.from(el.querySelectorAll("." + CHIP_CLASS))) {
216
+ let prev = chip.previousSibling;
217
+ // A stray <br> that has no visible text before it is the browser's line
218
+ // anchor, not a user Shift+Enter break — drop it so the chip rejoins the
219
+ // current line.
220
+ if (prev && prev.nodeType === ELEMENT_NODE && prev.tagName === "BR") {
221
+ const beforeBr = prev.previousSibling;
222
+ const noTextBefore = !beforeBr ||
223
+ (beforeBr.nodeType === TEXT_NODE && /^[\u200B]*$/.test(beforeBr.nodeValue));
224
+ if (noTextBefore) prev.remove();
225
+ }
226
+ const before = chip.previousSibling;
227
+ if (!before || before.nodeType !== TEXT_NODE || before.nodeValue === "") {
228
+ chip.parentNode.insertBefore(document.createTextNode("\u200B"), chip);
229
+ }
230
+ }
231
+ }
232
+
233
+ // Backspace/Delete at a chip boundary removes the chip atomically.
234
+ function _handleKeydown(el, e) {
235
+ if (e.key !== "Backspace" && e.key !== "Delete") return false;
236
+ const sel = window.getSelection();
237
+ if (!sel || !sel.rangeCount || !sel.isCollapsed) return false;
238
+ const range = sel.getRangeAt(0);
239
+ const container = range.startContainer;
240
+ const offset = range.startOffset;
241
+
242
+ let target = null;
243
+ if (e.key === "Backspace") {
244
+ if (container.nodeType === TEXT_NODE) {
245
+ if (offset === 0) target = container.previousSibling;
246
+ } else if (container === el) {
247
+ target = container.childNodes[offset - 1];
248
+ }
249
+ } else {
250
+ if (container.nodeType === TEXT_NODE) {
251
+ if (offset === container.nodeValue.length) target = container.nextSibling;
252
+ } else if (container === el) {
253
+ target = container.childNodes[offset];
254
+ }
255
+ }
256
+
257
+ if (_isChip(target)) {
258
+ e.preventDefault();
259
+ removeChip(target);
260
+ _emitInput(el);
261
+ return true;
262
+ }
263
+ return false;
264
+ }
265
+
266
+ // Plain-text paste. File pastes are left untouched so the caller's own
267
+ // paste handler (which stages attachments) owns those events.
268
+ function _handlePaste(el, e) {
269
+ const items = Array.from(e.clipboardData && e.clipboardData.items ? e.clipboardData.items : []);
270
+ if (items.some((it) => it.kind === "file")) return;
271
+ const plain = e.clipboardData.getData("text/plain");
272
+ if (plain) {
273
+ e.preventDefault();
274
+ document.execCommand("insertText", false, plain);
275
+ }
276
+ }
277
+
278
+ // Shift+Enter inserts a line break (Enter-to-send is owned by SkillAC).
279
+ function _handleEnter(el, e) {
280
+ if (e.key === "Enter" && e.shiftKey && !e.isComposing) {
281
+ e.preventDefault();
282
+ document.execCommand("insertLineBreak");
283
+ }
284
+ }
285
+
286
+ function setPlaceholder(el, value) {
287
+ el.setAttribute("data-placeholder", value || "");
288
+ }
289
+
290
+ // Wire up chip deletion, plain-text paste, and Shift+Enter for one composer.
291
+ function init(el) {
292
+ if (!el || el.dataset.composerInit) return;
293
+ el.dataset.composerInit = "1";
294
+ el.addEventListener("keydown", (e) => {
295
+ if (_handleKeydown(el, e)) return;
296
+ _handleEnter(el, e);
297
+ });
298
+ el.addEventListener("paste", (e) => _handlePaste(el, e));
299
+ // Browsers leave a stray <br> after deleting all text, which breaks the
300
+ // :empty::before placeholder. Clear it so the placeholder shows again.
301
+ el.addEventListener("input", () => {
302
+ _ensureChipGuards(el);
303
+ if (text(el).trim() === "" && !el.querySelector("." + CHIP_CLASS) && el.innerHTML !== "") {
304
+ el.innerHTML = "";
305
+ }
306
+ });
307
+ }
308
+
309
+ return {
310
+ text, setText, clear, focus, focusEnd, hasContent,
311
+ chips, insertChip, removeChip, setPlaceholder, init,
312
+ };
313
+ })();
314
+
315
+ Clacky.Composer = Composer;
@@ -0,0 +1,136 @@
1
+ // Generic single-choice dropdown shared by settings/onboard.
2
+ // DOM contract:
3
+ // wrapper > .custom-select-trigger > .custom-select-value (+ .custom-select-arrow)
4
+ // + .custom-select-dropdown > .custom-select-option[data-value][data-label]
5
+ // Business code owns the option markup (populate); this module owns the
6
+ // open/close/selection mechanics so every dropdown behaves identically.
7
+ window.CustomSelect = (function () {
8
+ "use strict";
9
+
10
+ const instances = [];
11
+ let globalBound = false;
12
+
13
+ function bindGlobalClose() {
14
+ if (globalBound) return;
15
+ globalBound = true;
16
+ document.addEventListener("click", () => {
17
+ for (let i = instances.length - 1; i >= 0; i--) {
18
+ const inst = instances[i];
19
+ if (!inst._trigger.isConnected) { instances.splice(i, 1); continue; }
20
+ inst.close();
21
+ }
22
+ });
23
+ }
24
+
25
+ function positionFixed(dropdown, anchor) {
26
+ const rect = anchor.getBoundingClientRect();
27
+ dropdown.style.position = "fixed";
28
+ dropdown.style.top = (rect.bottom + 4) + "px";
29
+ dropdown.style.left = rect.left + "px";
30
+ // min-width:100% resolves against the viewport once position:fixed,
31
+ // so pin it to the anchor width; leave width to the CSS max-content
32
+ // rule so narrow triggers (search type filter) still fit their options.
33
+ dropdown.style.minWidth = rect.width + "px";
34
+ dropdown.style.right = "auto";
35
+ dropdown.style.zIndex = "9999";
36
+ }
37
+
38
+ function resetPosition(dropdown) {
39
+ dropdown.style.position = "";
40
+ dropdown.style.top = "";
41
+ dropdown.style.left = "";
42
+ dropdown.style.width = "";
43
+ dropdown.style.minWidth = "";
44
+ dropdown.style.right = "";
45
+ dropdown.style.zIndex = "";
46
+ }
47
+
48
+ function init(opts) {
49
+ const trigger = opts.trigger;
50
+ const dropdown = opts.dropdown;
51
+ const valueSpan = trigger.querySelector(".custom-select-value");
52
+ const home = dropdown.parentElement;
53
+ const anchor = opts.anchor || home;
54
+ const placeholderText = valueSpan ? valueSpan.textContent.trim() : "";
55
+ const selectedOption = dropdown.querySelector(".custom-select-option.selected");
56
+
57
+ const instance = {
58
+ _trigger: trigger,
59
+ value: selectedOption ? (selectedOption.dataset.value || "") : "",
60
+ open: function () {
61
+ instances.forEach(function (other) {
62
+ if (other !== instance) other.close();
63
+ });
64
+ if (opts.portal) {
65
+ positionFixed(dropdown, anchor);
66
+ document.body.appendChild(dropdown);
67
+ }
68
+ dropdown.classList.add("open");
69
+ trigger.classList.add("open");
70
+ },
71
+ close: function () {
72
+ if (!dropdown.classList.contains("open")) return;
73
+ dropdown.classList.remove("open");
74
+ trigger.classList.remove("open");
75
+ if (opts.portal && home) {
76
+ resetPosition(dropdown);
77
+ home.appendChild(dropdown);
78
+ }
79
+ },
80
+ setValue: function (value, text) {
81
+ if (valueSpan) {
82
+ let label = text;
83
+ if (typeof label !== "string") {
84
+ const opt = dropdown.querySelector('.custom-select-option[data-value="' + value + '"]');
85
+ label = opt ? (opt.dataset.label || opt.textContent.trim()) : "";
86
+ }
87
+ if (!value && !label) label = placeholderText;
88
+ valueSpan.textContent = label;
89
+ valueSpan.classList.toggle("placeholder", !value);
90
+ }
91
+ dropdown.querySelectorAll(".custom-select-option").forEach(function (opt) {
92
+ opt.classList.toggle("selected", opt.dataset.value === value);
93
+ });
94
+ instance.value = value;
95
+ }
96
+ };
97
+
98
+ trigger.addEventListener("click", function (e) {
99
+ e.stopPropagation();
100
+ if (dropdown.classList.contains("open")) {
101
+ instance.close();
102
+ } else {
103
+ instance.open();
104
+ }
105
+ });
106
+
107
+ trigger.addEventListener("keydown", function (e) {
108
+ if (e.key !== "Enter" && e.key !== " ") return;
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ if (dropdown.classList.contains("open")) {
112
+ instance.close();
113
+ } else {
114
+ instance.open();
115
+ }
116
+ });
117
+
118
+ dropdown.addEventListener("click", function (e) {
119
+ const option = e.target.closest(".custom-select-option");
120
+ if (!option) return;
121
+ e.stopPropagation();
122
+ const value = option.dataset.value || "";
123
+ const text = option.dataset.label || option.textContent.trim();
124
+ instance.setValue(value, text);
125
+ instance.close();
126
+ if (typeof opts.onSelect === "function") opts.onSelect(value, text, option);
127
+ });
128
+
129
+ instances.push(instance);
130
+ dropdown.__customSelect = instance;
131
+ bindGlobalClose();
132
+ return instance;
133
+ }
134
+
135
+ return { init: init };
136
+ })();