@ktfth/stickjs 3.0.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/CHANGELOG.md +169 -0
- package/README.md +449 -0
- package/bin/registry.json +20 -0
- package/bin/stickjs.js +158 -0
- package/llms.txt +244 -0
- package/package.json +38 -0
- package/stick-ui/components/accordion.html +25 -0
- package/stick-ui/components/autocomplete.html +82 -0
- package/stick-ui/components/command-palette.html +28 -0
- package/stick-ui/components/copy-button.html +12 -0
- package/stick-ui/components/data-table.html +191 -0
- package/stick-ui/components/dialog.html +23 -0
- package/stick-ui/components/dropdown.html +16 -0
- package/stick-ui/components/notification.html +11 -0
- package/stick-ui/components/skeleton.html +11 -0
- package/stick-ui/components/stepper.html +102 -0
- package/stick-ui/components/tabs.html +26 -0
- package/stick-ui/components/toast.html +10 -0
- package/stick-ui/components/toggle-group.html +16 -0
- package/stick-ui/components/toggle.html +9 -0
- package/stick-ui/components/tooltip.html +12 -0
- package/stick-ui/plugins/autocomplete.js +422 -0
- package/stick-ui/plugins/command-palette.js +289 -0
- package/stick-ui/plugins/data-table.js +426 -0
- package/stick-ui/plugins/dropdown.js +70 -0
- package/stick-ui/plugins/stepper.js +155 -0
- package/stick-ui/plugins/toast.js +51 -0
- package/stick-ui/plugins/tooltip.js +67 -0
- package/stick-ui/stick-ui.css +825 -0
- package/stick.d.ts +105 -0
- package/stick.js +655 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Stick UI — Autocomplete plugin
|
|
3
|
+
* Usage: data-stick="input:autocomplete:#list-id"
|
|
4
|
+
* data-stick="input:autocomplete" data-stk-autocomplete-url="/api/search?q="
|
|
5
|
+
* API: stkAutocomplete.setOptions(inputEl, [{label, value}])
|
|
6
|
+
*/
|
|
7
|
+
(function (root) {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
var uid = 0;
|
|
11
|
+
var instances = []; // track all active autocompletes for click-outside
|
|
12
|
+
|
|
13
|
+
/* ── Helpers ─────────────────────────────────────────── */
|
|
14
|
+
|
|
15
|
+
function escapeHTML(str) {
|
|
16
|
+
var div = document.createElement('div');
|
|
17
|
+
div.appendChild(document.createTextNode(str));
|
|
18
|
+
return div.innerHTML;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function highlightMatch(text, query) {
|
|
22
|
+
if (!query) return escapeHTML(text);
|
|
23
|
+
var idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
24
|
+
if (idx === -1) return escapeHTML(text);
|
|
25
|
+
return escapeHTML(text.substring(0, idx)) +
|
|
26
|
+
'<mark>' + escapeHTML(text.substring(idx, idx + query.length)) + '</mark>' +
|
|
27
|
+
escapeHTML(text.substring(idx + query.length));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeOption(item) {
|
|
31
|
+
if (typeof item === 'string') return { label: item, value: item };
|
|
32
|
+
return { label: item.label || item.value || '', value: item.value || item.label || '' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function debounce(fn, ms) {
|
|
36
|
+
var timer;
|
|
37
|
+
return function () {
|
|
38
|
+
var ctx = this, args = arguments;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
timer = setTimeout(function () { fn.apply(ctx, args); }, ms);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ── Instance state ─────────────────────────────────── */
|
|
45
|
+
|
|
46
|
+
function getInstance(input) {
|
|
47
|
+
for (var i = 0; i < instances.length; i++) {
|
|
48
|
+
if (instances[i].input === input) return instances[i];
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createInstance(input) {
|
|
54
|
+
var existing = getInstance(input);
|
|
55
|
+
if (existing) return existing;
|
|
56
|
+
|
|
57
|
+
var id = 'stk-ac-' + (++uid);
|
|
58
|
+
|
|
59
|
+
// Create dropdown
|
|
60
|
+
var dropdown = document.createElement('div');
|
|
61
|
+
dropdown.className = 'stk-autocomplete-dropdown';
|
|
62
|
+
dropdown.id = id + '-listbox';
|
|
63
|
+
dropdown.setAttribute('role', 'listbox');
|
|
64
|
+
dropdown.hidden = true;
|
|
65
|
+
|
|
66
|
+
// Insert dropdown after input (inside the .stk-autocomplete wrapper if present)
|
|
67
|
+
var wrapper = input.closest('.stk-autocomplete');
|
|
68
|
+
if (wrapper) {
|
|
69
|
+
wrapper.appendChild(dropdown);
|
|
70
|
+
} else {
|
|
71
|
+
input.parentNode.insertBefore(dropdown, input.nextSibling);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ARIA on input
|
|
75
|
+
input.setAttribute('role', 'combobox');
|
|
76
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
77
|
+
input.setAttribute('aria-expanded', 'false');
|
|
78
|
+
input.setAttribute('aria-controls', dropdown.id);
|
|
79
|
+
input.setAttribute('autocomplete', 'off');
|
|
80
|
+
|
|
81
|
+
var inst = {
|
|
82
|
+
input: input,
|
|
83
|
+
dropdown: dropdown,
|
|
84
|
+
options: [], // [{label, value}]
|
|
85
|
+
filtered: [],
|
|
86
|
+
selectedIndex: -1,
|
|
87
|
+
remoteUrl: null,
|
|
88
|
+
minChars: 1,
|
|
89
|
+
fetching: false
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
instances.push(inst);
|
|
93
|
+
return inst;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ── Options loading ────────────────────────────────── */
|
|
97
|
+
|
|
98
|
+
function loadStaticOptions(inst, selector) {
|
|
99
|
+
var src = selector || inst.input.getAttribute('data-stk-autocomplete-src');
|
|
100
|
+
if (!src) return;
|
|
101
|
+
|
|
102
|
+
var el = document.querySelector(src);
|
|
103
|
+
if (!el) return;
|
|
104
|
+
|
|
105
|
+
var items = [];
|
|
106
|
+
if (el.tagName === 'DATALIST') {
|
|
107
|
+
var opts = el.querySelectorAll('option');
|
|
108
|
+
for (var i = 0; i < opts.length; i++) {
|
|
109
|
+
items.push({ label: opts[i].textContent || opts[i].value, value: opts[i].value || opts[i].textContent });
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
// <ul> or similar — grab <li> children
|
|
113
|
+
var lis = el.querySelectorAll('li');
|
|
114
|
+
for (var j = 0; j < lis.length; j++) {
|
|
115
|
+
var val = lis[j].getAttribute('data-value') || lis[j].textContent.trim();
|
|
116
|
+
items.push({ label: lis[j].textContent.trim(), value: val });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
inst.options = items;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fetchRemote(inst, query) {
|
|
124
|
+
if (inst.fetching) return;
|
|
125
|
+
inst.fetching = true;
|
|
126
|
+
showLoading(inst);
|
|
127
|
+
|
|
128
|
+
var url = inst.remoteUrl + encodeURIComponent(query);
|
|
129
|
+
var xhr = new XMLHttpRequest();
|
|
130
|
+
xhr.open('GET', url, true);
|
|
131
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
132
|
+
|
|
133
|
+
xhr.onload = function () {
|
|
134
|
+
inst.fetching = false;
|
|
135
|
+
hideLoading(inst);
|
|
136
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
137
|
+
try {
|
|
138
|
+
var data = JSON.parse(xhr.responseText);
|
|
139
|
+
if (Array.isArray(data)) {
|
|
140
|
+
inst.options = data.map(normalizeOption);
|
|
141
|
+
filterAndRender(inst, query);
|
|
142
|
+
}
|
|
143
|
+
} catch (e) { /* ignore parse errors */ }
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
xhr.onerror = function () {
|
|
148
|
+
inst.fetching = false;
|
|
149
|
+
hideLoading(inst);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
xhr.send();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ── Rendering ──────────────────────────────────────── */
|
|
156
|
+
|
|
157
|
+
function filterAndRender(inst, query) {
|
|
158
|
+
if (!query || query.length < inst.minChars) {
|
|
159
|
+
closeDropdown(inst);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
var q = query.toLowerCase();
|
|
164
|
+
inst.filtered = inst.options.filter(function (opt) {
|
|
165
|
+
return opt.label.toLowerCase().indexOf(q) !== -1;
|
|
166
|
+
});
|
|
167
|
+
inst.selectedIndex = -1;
|
|
168
|
+
|
|
169
|
+
renderItems(inst, query);
|
|
170
|
+
|
|
171
|
+
if (inst.filtered.length > 0 || query.length >= inst.minChars) {
|
|
172
|
+
openDropdown(inst);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderItems(inst, query) {
|
|
177
|
+
var dd = inst.dropdown;
|
|
178
|
+
dd.innerHTML = '';
|
|
179
|
+
|
|
180
|
+
if (inst.filtered.length === 0) {
|
|
181
|
+
var empty = document.createElement('div');
|
|
182
|
+
empty.className = 'stk-autocomplete-empty';
|
|
183
|
+
empty.textContent = 'No results found.';
|
|
184
|
+
dd.appendChild(empty);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (var i = 0; i < inst.filtered.length; i++) {
|
|
189
|
+
var opt = inst.filtered[i];
|
|
190
|
+
var item = document.createElement('div');
|
|
191
|
+
item.className = 'stk-autocomplete-item';
|
|
192
|
+
item.setAttribute('role', 'option');
|
|
193
|
+
item.setAttribute('aria-selected', 'false');
|
|
194
|
+
item.id = dd.id + '-opt-' + i;
|
|
195
|
+
item.dataset.index = String(i);
|
|
196
|
+
item.innerHTML = highlightMatch(opt.label, query);
|
|
197
|
+
|
|
198
|
+
// Click handler (closure)
|
|
199
|
+
item.addEventListener('mousedown', (function (idx) {
|
|
200
|
+
return function (e) {
|
|
201
|
+
e.preventDefault(); // prevent blur on input
|
|
202
|
+
selectOption(inst, idx);
|
|
203
|
+
};
|
|
204
|
+
})(i));
|
|
205
|
+
|
|
206
|
+
item.addEventListener('mouseenter', (function (idx) {
|
|
207
|
+
return function () {
|
|
208
|
+
setHighlight(inst, idx);
|
|
209
|
+
};
|
|
210
|
+
})(i));
|
|
211
|
+
|
|
212
|
+
dd.appendChild(item);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function showLoading(inst) {
|
|
217
|
+
var dd = inst.dropdown;
|
|
218
|
+
dd.innerHTML = '';
|
|
219
|
+
var loader = document.createElement('div');
|
|
220
|
+
loader.className = 'stk-autocomplete-loading';
|
|
221
|
+
loader.textContent = 'Loading\u2026';
|
|
222
|
+
dd.appendChild(loader);
|
|
223
|
+
openDropdown(inst);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function hideLoading(inst) {
|
|
227
|
+
var loader = inst.dropdown.querySelector('.stk-autocomplete-loading');
|
|
228
|
+
if (loader) loader.remove();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ── Dropdown open/close ────────────────────────────── */
|
|
232
|
+
|
|
233
|
+
function openDropdown(inst) {
|
|
234
|
+
inst.dropdown.hidden = false;
|
|
235
|
+
inst.input.setAttribute('aria-expanded', 'true');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function closeDropdown(inst) {
|
|
239
|
+
inst.dropdown.hidden = true;
|
|
240
|
+
inst.input.setAttribute('aria-expanded', 'false');
|
|
241
|
+
inst.selectedIndex = -1;
|
|
242
|
+
inst.input.removeAttribute('aria-activedescendant');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ── Highlight / select ─────────────────────────────── */
|
|
246
|
+
|
|
247
|
+
function setHighlight(inst, idx) {
|
|
248
|
+
inst.selectedIndex = idx;
|
|
249
|
+
var items = inst.dropdown.querySelectorAll('.stk-autocomplete-item');
|
|
250
|
+
for (var i = 0; i < items.length; i++) {
|
|
251
|
+
items[i].setAttribute('aria-selected', String(i === idx));
|
|
252
|
+
}
|
|
253
|
+
if (idx >= 0 && items[idx]) {
|
|
254
|
+
items[idx].scrollIntoView({ block: 'nearest' });
|
|
255
|
+
inst.input.setAttribute('aria-activedescendant', items[idx].id);
|
|
256
|
+
} else {
|
|
257
|
+
inst.input.removeAttribute('aria-activedescendant');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function selectOption(inst, idx) {
|
|
262
|
+
var opt = inst.filtered[idx];
|
|
263
|
+
if (!opt) return;
|
|
264
|
+
inst.input.value = opt.label;
|
|
265
|
+
closeDropdown(inst);
|
|
266
|
+
|
|
267
|
+
// Dispatch custom event
|
|
268
|
+
var ev;
|
|
269
|
+
try {
|
|
270
|
+
ev = new CustomEvent('autocomplete-selected', { bubbles: true, detail: { value: opt.value, label: opt.label } });
|
|
271
|
+
} catch (e) {
|
|
272
|
+
ev = document.createEvent('CustomEvent');
|
|
273
|
+
ev.initCustomEvent('autocomplete-selected', true, true, { value: opt.value, label: opt.label });
|
|
274
|
+
}
|
|
275
|
+
inst.input.dispatchEvent(ev);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ── Keyboard handler ───────────────────────────────── */
|
|
279
|
+
|
|
280
|
+
function onKeydown(inst, e) {
|
|
281
|
+
var count = inst.filtered.length;
|
|
282
|
+
|
|
283
|
+
if (e.key === 'ArrowDown') {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
if (!inst.dropdown.hidden && count) {
|
|
286
|
+
setHighlight(inst, (inst.selectedIndex + 1) % count);
|
|
287
|
+
} else if (inst.dropdown.hidden && inst.input.value.length >= inst.minChars) {
|
|
288
|
+
filterAndRender(inst, inst.input.value);
|
|
289
|
+
}
|
|
290
|
+
} else if (e.key === 'ArrowUp') {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
if (count) {
|
|
293
|
+
setHighlight(inst, (inst.selectedIndex - 1 + count) % count);
|
|
294
|
+
}
|
|
295
|
+
} else if (e.key === 'Enter') {
|
|
296
|
+
if (inst.selectedIndex >= 0 && !inst.dropdown.hidden) {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
selectOption(inst, inst.selectedIndex);
|
|
299
|
+
}
|
|
300
|
+
} else if (e.key === 'Escape') {
|
|
301
|
+
if (!inst.dropdown.hidden) {
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
closeDropdown(inst);
|
|
304
|
+
}
|
|
305
|
+
} else if (e.key === 'Tab') {
|
|
306
|
+
if (inst.selectedIndex >= 0 && !inst.dropdown.hidden) {
|
|
307
|
+
selectOption(inst, inst.selectedIndex);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ── Click outside ──────────────────────────────────── */
|
|
313
|
+
|
|
314
|
+
document.addEventListener('click', function (e) {
|
|
315
|
+
for (var i = 0; i < instances.length; i++) {
|
|
316
|
+
var inst = instances[i];
|
|
317
|
+
if (!inst.dropdown.hidden &&
|
|
318
|
+
!inst.input.contains(e.target) &&
|
|
319
|
+
!inst.dropdown.contains(e.target)) {
|
|
320
|
+
closeDropdown(inst);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}, true);
|
|
324
|
+
|
|
325
|
+
/* ── Public API ──────────────────────────────────────── */
|
|
326
|
+
|
|
327
|
+
var stkAutocomplete = {
|
|
328
|
+
install: function (stick) {
|
|
329
|
+
|
|
330
|
+
/* autocomplete handler — called on input events */
|
|
331
|
+
stick.add('autocomplete', function (el, param, e) {
|
|
332
|
+
var inst = getInstance(el);
|
|
333
|
+
|
|
334
|
+
// First-time init
|
|
335
|
+
if (!inst) {
|
|
336
|
+
inst = createInstance(el);
|
|
337
|
+
|
|
338
|
+
// Determine data source
|
|
339
|
+
var remoteUrl = el.getAttribute('data-stk-autocomplete-url');
|
|
340
|
+
var minAttr = el.getAttribute('data-stk-autocomplete-min');
|
|
341
|
+
inst.minChars = minAttr ? parseInt(minAttr, 10) : 1;
|
|
342
|
+
|
|
343
|
+
if (remoteUrl) {
|
|
344
|
+
inst.remoteUrl = remoteUrl;
|
|
345
|
+
} else {
|
|
346
|
+
loadStaticOptions(inst, param || null);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Attach keydown once
|
|
350
|
+
el.addEventListener('keydown', function (ke) {
|
|
351
|
+
onKeydown(inst, ke);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Close on blur (with small delay so click on item registers)
|
|
355
|
+
el.addEventListener('blur', function () {
|
|
356
|
+
setTimeout(function () { closeDropdown(inst); }, 150);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// On each input event, filter
|
|
361
|
+
var query = el.value;
|
|
362
|
+
if (inst.remoteUrl) {
|
|
363
|
+
if (query.length >= inst.minChars) {
|
|
364
|
+
// Use debounced fetch
|
|
365
|
+
if (!inst._debouncedFetch) {
|
|
366
|
+
inst._debouncedFetch = debounce(function (q) {
|
|
367
|
+
fetchRemote(inst, q);
|
|
368
|
+
}, 300);
|
|
369
|
+
}
|
|
370
|
+
inst._debouncedFetch(query);
|
|
371
|
+
} else {
|
|
372
|
+
closeDropdown(inst);
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
filterAndRender(inst, query);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
/* autocomplete-select handler — manually trigger selection */
|
|
380
|
+
stick.add('autocomplete-select', function (el, param, e, target) {
|
|
381
|
+
// Find the autocomplete input this belongs to
|
|
382
|
+
var input = target || el.closest('.stk-autocomplete');
|
|
383
|
+
if (input) input = input.querySelector('[role="combobox"]');
|
|
384
|
+
if (!input) return;
|
|
385
|
+
|
|
386
|
+
var inst = getInstance(input);
|
|
387
|
+
if (!inst) return;
|
|
388
|
+
|
|
389
|
+
var value = param || el.textContent.trim();
|
|
390
|
+
// Find matching option
|
|
391
|
+
for (var i = 0; i < inst.filtered.length; i++) {
|
|
392
|
+
if (inst.filtered[i].value === value || inst.filtered[i].label === value) {
|
|
393
|
+
selectOption(inst, i);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Fallback: set raw value
|
|
398
|
+
input.value = value;
|
|
399
|
+
closeDropdown(inst);
|
|
400
|
+
});
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Set options programmatically for an input element.
|
|
405
|
+
* @param {HTMLInputElement} inputEl
|
|
406
|
+
* @param {Array<string|{label:string, value:string}>} options
|
|
407
|
+
*/
|
|
408
|
+
setOptions: function (inputEl, options) {
|
|
409
|
+
var inst = getInstance(inputEl);
|
|
410
|
+
if (!inst) inst = createInstance(inputEl);
|
|
411
|
+
inst.options = (options || []).map(normalizeOption);
|
|
412
|
+
// If input has a value, re-filter
|
|
413
|
+
if (inputEl.value) {
|
|
414
|
+
filterAndRender(inst, inputEl.value);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
if (root.Stick) root.Stick.use(stkAutocomplete);
|
|
420
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = stkAutocomplete;
|
|
421
|
+
root.stkAutocomplete = stkAutocomplete;
|
|
422
|
+
}(typeof window !== 'undefined' ? window : this));
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Stick UI — Command Palette plugin
|
|
3
|
+
* Usage: data-stick="click:command-palette"
|
|
4
|
+
* Global shortcut: Ctrl+K (Cmd+K on Mac)
|
|
5
|
+
* API: stkCommandPalette.register([{ id, label, group?, shortcut?, action }])
|
|
6
|
+
*/
|
|
7
|
+
(function (root) {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
var commands = [];
|
|
11
|
+
var overlay = null;
|
|
12
|
+
var input = null;
|
|
13
|
+
var list = null;
|
|
14
|
+
var selectedIndex = -1;
|
|
15
|
+
var filtered = [];
|
|
16
|
+
|
|
17
|
+
function buildDOM() {
|
|
18
|
+
if (overlay) return;
|
|
19
|
+
|
|
20
|
+
overlay = document.createElement('div');
|
|
21
|
+
overlay.className = 'stk-command-palette-overlay';
|
|
22
|
+
overlay.addEventListener('click', function (e) {
|
|
23
|
+
if (e.target === overlay) close();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
var dialog = document.createElement('div');
|
|
27
|
+
dialog.className = 'stk-command-palette';
|
|
28
|
+
dialog.setAttribute('role', 'dialog');
|
|
29
|
+
dialog.setAttribute('aria-label', 'Command palette');
|
|
30
|
+
|
|
31
|
+
input = document.createElement('input');
|
|
32
|
+
input.className = 'stk-command-palette-input';
|
|
33
|
+
input.type = 'text';
|
|
34
|
+
input.placeholder = 'Type a command\u2026';
|
|
35
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
36
|
+
input.setAttribute('aria-controls', 'stk-command-palette-listbox');
|
|
37
|
+
input.addEventListener('input', onInput);
|
|
38
|
+
input.addEventListener('keydown', onKeydown);
|
|
39
|
+
|
|
40
|
+
list = document.createElement('div');
|
|
41
|
+
list.className = 'stk-command-palette-list';
|
|
42
|
+
list.id = 'stk-command-palette-listbox';
|
|
43
|
+
list.setAttribute('role', 'listbox');
|
|
44
|
+
|
|
45
|
+
dialog.appendChild(input);
|
|
46
|
+
dialog.appendChild(list);
|
|
47
|
+
overlay.appendChild(dialog);
|
|
48
|
+
document.body.appendChild(overlay);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function destroyDOM() {
|
|
52
|
+
if (overlay && overlay.parentElement) {
|
|
53
|
+
overlay.parentElement.removeChild(overlay);
|
|
54
|
+
}
|
|
55
|
+
overlay = null;
|
|
56
|
+
input = null;
|
|
57
|
+
list = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isOpen() {
|
|
61
|
+
return overlay && !overlay.hidden;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function open() {
|
|
65
|
+
buildDOM();
|
|
66
|
+
overlay.hidden = false;
|
|
67
|
+
input.value = '';
|
|
68
|
+
selectedIndex = -1;
|
|
69
|
+
render('');
|
|
70
|
+
// Focus after paint so animation + focus work together
|
|
71
|
+
setTimeout(function () { input.focus(); }, 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function close() {
|
|
75
|
+
if (overlay) overlay.hidden = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toggle() {
|
|
79
|
+
if (isOpen()) close(); else open();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ── Filtering ─────────────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
function match(text, query) {
|
|
85
|
+
return text.toLowerCase().indexOf(query.toLowerCase()) !== -1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getFiltered(query) {
|
|
89
|
+
if (!query) return commands.slice();
|
|
90
|
+
return commands.filter(function (cmd) {
|
|
91
|
+
return match(cmd.label, query) || (cmd.group && match(cmd.group, query));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ── Rendering ─────────────────────────────────────── */
|
|
96
|
+
|
|
97
|
+
function render(query) {
|
|
98
|
+
filtered = getFiltered(query);
|
|
99
|
+
list.innerHTML = '';
|
|
100
|
+
|
|
101
|
+
if (filtered.length === 0) {
|
|
102
|
+
var empty = document.createElement('div');
|
|
103
|
+
empty.className = 'stk-command-palette-empty';
|
|
104
|
+
empty.textContent = 'No results found.';
|
|
105
|
+
list.appendChild(empty);
|
|
106
|
+
selectedIndex = -1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
var currentGroup = null;
|
|
111
|
+
for (var i = 0; i < filtered.length; i++) {
|
|
112
|
+
var cmd = filtered[i];
|
|
113
|
+
|
|
114
|
+
// Group header
|
|
115
|
+
if (cmd.group && cmd.group !== currentGroup) {
|
|
116
|
+
currentGroup = cmd.group;
|
|
117
|
+
var groupEl = document.createElement('div');
|
|
118
|
+
groupEl.className = 'stk-command-palette-group';
|
|
119
|
+
groupEl.textContent = currentGroup;
|
|
120
|
+
list.appendChild(groupEl);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
var item = document.createElement('div');
|
|
124
|
+
item.className = 'stk-command-palette-item';
|
|
125
|
+
item.setAttribute('role', 'option');
|
|
126
|
+
item.setAttribute('aria-selected', 'false');
|
|
127
|
+
item.dataset.index = String(i);
|
|
128
|
+
|
|
129
|
+
var label = document.createElement('span');
|
|
130
|
+
label.className = 'stk-command-palette-label';
|
|
131
|
+
label.textContent = cmd.label;
|
|
132
|
+
item.appendChild(label);
|
|
133
|
+
|
|
134
|
+
if (cmd.shortcut) {
|
|
135
|
+
var badge = document.createElement('span');
|
|
136
|
+
badge.className = 'stk-command-palette-shortcut';
|
|
137
|
+
badge.textContent = cmd.shortcut;
|
|
138
|
+
item.appendChild(badge);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
item.addEventListener('click', (function (idx) {
|
|
142
|
+
return function () { execute(idx); };
|
|
143
|
+
})(i));
|
|
144
|
+
|
|
145
|
+
item.addEventListener('mouseenter', (function (idx) {
|
|
146
|
+
return function () { select(idx); };
|
|
147
|
+
})(i));
|
|
148
|
+
|
|
149
|
+
list.appendChild(item);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Default select first item
|
|
153
|
+
if (filtered.length > 0) {
|
|
154
|
+
selectedIndex = 0;
|
|
155
|
+
updateSelection();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function updateSelection() {
|
|
160
|
+
var items = list.querySelectorAll('.stk-command-palette-item');
|
|
161
|
+
for (var i = 0; i < items.length; i++) {
|
|
162
|
+
var sel = (i === selectedIndex);
|
|
163
|
+
items[i].setAttribute('aria-selected', String(sel));
|
|
164
|
+
}
|
|
165
|
+
// Scroll selected into view
|
|
166
|
+
if (selectedIndex >= 0 && items[selectedIndex]) {
|
|
167
|
+
items[selectedIndex].scrollIntoView({ block: 'nearest' });
|
|
168
|
+
}
|
|
169
|
+
// Update aria-activedescendant
|
|
170
|
+
if (input && selectedIndex >= 0 && items[selectedIndex]) {
|
|
171
|
+
items[selectedIndex].id = 'stk-cp-opt-' + selectedIndex;
|
|
172
|
+
input.setAttribute('aria-activedescendant', items[selectedIndex].id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function select(idx) {
|
|
177
|
+
selectedIndex = idx;
|
|
178
|
+
updateSelection();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function execute(idx) {
|
|
182
|
+
var cmd = filtered[idx];
|
|
183
|
+
if (cmd && typeof cmd.action === 'function') {
|
|
184
|
+
close();
|
|
185
|
+
cmd.action();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ── Input handlers ────────────────────────────────── */
|
|
190
|
+
|
|
191
|
+
function onInput() {
|
|
192
|
+
selectedIndex = -1;
|
|
193
|
+
render(input.value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function onKeydown(e) {
|
|
197
|
+
var count = filtered.length;
|
|
198
|
+
if (e.key === 'ArrowDown') {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
if (count) {
|
|
201
|
+
selectedIndex = (selectedIndex + 1) % count;
|
|
202
|
+
updateSelection();
|
|
203
|
+
}
|
|
204
|
+
} else if (e.key === 'ArrowUp') {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
if (count) {
|
|
207
|
+
selectedIndex = (selectedIndex - 1 + count) % count;
|
|
208
|
+
updateSelection();
|
|
209
|
+
}
|
|
210
|
+
} else if (e.key === 'Enter') {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
if (selectedIndex >= 0) execute(selectedIndex);
|
|
213
|
+
} else if (e.key === 'Escape') {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ── Global shortcut Ctrl+K / Cmd+K ────────────────── */
|
|
220
|
+
|
|
221
|
+
document.addEventListener('keydown', function (e) {
|
|
222
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
toggle();
|
|
225
|
+
}
|
|
226
|
+
// Also close on Escape if palette is open (handles focus outside input)
|
|
227
|
+
if (e.key === 'Escape' && isOpen()) {
|
|
228
|
+
close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/* ── Public API ─────────────────────────────────────── */
|
|
233
|
+
|
|
234
|
+
var stkCommandPalette = {
|
|
235
|
+
install: function (stick) {
|
|
236
|
+
stick.add('command-palette', function () {
|
|
237
|
+
toggle();
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Register commands.
|
|
243
|
+
* @param {Array<{id:string, label:string, group?:string, shortcut?:string, action:Function}>} cmds
|
|
244
|
+
*/
|
|
245
|
+
register: function (cmds) {
|
|
246
|
+
if (!Array.isArray(cmds)) return;
|
|
247
|
+
for (var i = 0; i < cmds.length; i++) {
|
|
248
|
+
var cmd = cmds[i];
|
|
249
|
+
if (cmd && cmd.id && cmd.label) {
|
|
250
|
+
// Replace existing command with same id
|
|
251
|
+
var found = false;
|
|
252
|
+
for (var j = 0; j < commands.length; j++) {
|
|
253
|
+
if (commands[j].id === cmd.id) {
|
|
254
|
+
commands[j] = cmd;
|
|
255
|
+
found = true;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (!found) commands.push(cmd);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Sort by group then label for consistent ordering
|
|
263
|
+
commands.sort(function (a, b) {
|
|
264
|
+
var ga = a.group || '';
|
|
265
|
+
var gb = b.group || '';
|
|
266
|
+
if (ga !== gb) return ga < gb ? -1 : 1;
|
|
267
|
+
return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
/** Open the palette programmatically. */
|
|
272
|
+
open: open,
|
|
273
|
+
|
|
274
|
+
/** Close the palette programmatically. */
|
|
275
|
+
close: close,
|
|
276
|
+
|
|
277
|
+
/** Toggle the palette. */
|
|
278
|
+
toggle: toggle,
|
|
279
|
+
|
|
280
|
+
/** Clear all registered commands. */
|
|
281
|
+
clear: function () {
|
|
282
|
+
commands = [];
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
if (root.Stick) root.Stick.use(stkCommandPalette);
|
|
287
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = stkCommandPalette;
|
|
288
|
+
root.stkCommandPalette = stkCommandPalette;
|
|
289
|
+
}(typeof window !== 'undefined' ? window : this));
|