@phillipsharring/graspr-framework 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/README.md +119 -0
- package/package.json +19 -0
- package/src/auth.js +47 -0
- package/src/core/auth-state.js +227 -0
- package/src/core/boosted-nav.js +94 -0
- package/src/core/csrf.js +63 -0
- package/src/core/forms.js +290 -0
- package/src/core/navigation.js +115 -0
- package/src/core/pagination.js +94 -0
- package/src/core/search.js +77 -0
- package/src/core/sortable.js +116 -0
- package/src/core/table-sort.js +111 -0
- package/src/fetch-client.js +46 -0
- package/src/helpers/debounce.js +48 -0
- package/src/helpers/escape-html.js +10 -0
- package/src/helpers/handlebars-helpers.js +171 -0
- package/src/helpers/index.js +6 -0
- package/src/helpers/populate-select.js +21 -0
- package/src/helpers/route-params.js +50 -0
- package/src/helpers/utils.js +27 -0
- package/src/index.js +73 -0
- package/src/init.js +13 -0
- package/src/lib/client-side-templates.js +75 -0
- package/src/lib/htmx.js +7 -0
- package/src/lib/json-enc.js +20 -0
- package/src/ui/click-burst.js +116 -0
- package/src/ui/confirm-dialog.js +457 -0
- package/src/ui/image-preview.js +21 -0
- package/src/ui/index.js +37 -0
- package/src/ui/modal-form.js +162 -0
- package/src/ui/modal.js +127 -0
- package/src/ui/toast.js +126 -0
- package/src/ui/typeahead.js +186 -0
- package/styles/base.css +165 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Modal Form Utility
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Provides a unified way to open forms in the global modal.
|
|
5
|
+
|
|
6
|
+
import Handlebars from 'handlebars';
|
|
7
|
+
import htmx from '../lib/htmx.js';
|
|
8
|
+
import { openGlobalModal } from './modal.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Opens a form in the global modal with the given configuration.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} config - Configuration object
|
|
14
|
+
* @param {string} config.templateId - ID of the template element (without #)
|
|
15
|
+
* @param {string} config.title - Modal title text
|
|
16
|
+
* @param {string} [config.formUrl] - URL to set on form's hx-patch/hx-post and action attributes
|
|
17
|
+
* @param {'post'|'patch'} [config.formMethod] - HTTP method for the form
|
|
18
|
+
* @param {Object} [config.fields] - Object mapping field names to values to populate
|
|
19
|
+
* @param {string[]} [config.removeFields] - Array of field names to remove from the form
|
|
20
|
+
* @param {string} [config.submitButtonText] - Text to set on the submit button
|
|
21
|
+
* @param {boolean} [config.clearErrors=true] - Whether to clear existing form errors
|
|
22
|
+
* @param {string} [config.focusSelector] - Selector for element to focus (defaults to first input)
|
|
23
|
+
* @param {function} [config.beforeOpen] - Callback(content, form) for custom setup before modal opens
|
|
24
|
+
* @param {'sm'|'takeover'|'default'} [config.size] - Modal size variant
|
|
25
|
+
*/
|
|
26
|
+
export function openFormModal({
|
|
27
|
+
templateId,
|
|
28
|
+
title,
|
|
29
|
+
formUrl,
|
|
30
|
+
formMethod,
|
|
31
|
+
fields,
|
|
32
|
+
removeFields,
|
|
33
|
+
submitButtonText,
|
|
34
|
+
clearErrors = true,
|
|
35
|
+
focusSelector,
|
|
36
|
+
beforeOpen,
|
|
37
|
+
size,
|
|
38
|
+
}) {
|
|
39
|
+
const template = document.getElementById(templateId);
|
|
40
|
+
const content = document.getElementById('global-modal-content');
|
|
41
|
+
const dialog = document.querySelector('#global-modal [role="dialog"]');
|
|
42
|
+
const titleEl = document.getElementById('global-modal-title');
|
|
43
|
+
|
|
44
|
+
if (!template || !content) {
|
|
45
|
+
console.error('openFormModal: template or modal content not found', { templateId });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Set modal title
|
|
50
|
+
if (titleEl) {
|
|
51
|
+
titleEl.textContent = title;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Set modal size
|
|
55
|
+
const modal = document.getElementById('global-modal');
|
|
56
|
+
if (dialog) {
|
|
57
|
+
dialog.classList.remove('modal-sm', 'modal-lg');
|
|
58
|
+
if (size === 'sm') {
|
|
59
|
+
dialog.classList.add('modal-sm');
|
|
60
|
+
} else if (size === 'lg') {
|
|
61
|
+
dialog.classList.add('modal-lg');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (modal) {
|
|
65
|
+
modal.classList.remove('modal-takeover');
|
|
66
|
+
if (size === 'takeover') {
|
|
67
|
+
modal.classList.add('modal-takeover');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Inject template HTML — compile through Handlebars to resolve partials
|
|
72
|
+
// (e.g. {{> formButtons}}). innerHTML escapes ">" to ">" per the HTML
|
|
73
|
+
// serialization spec, so unescape partial calls first.
|
|
74
|
+
const source = template.innerHTML.replaceAll('{{>', '{{>');
|
|
75
|
+
content.innerHTML = Handlebars.compile(source)({});
|
|
76
|
+
|
|
77
|
+
// Find the form
|
|
78
|
+
const form = content.querySelector('form');
|
|
79
|
+
|
|
80
|
+
if (form) {
|
|
81
|
+
// Set form URL and method if provided
|
|
82
|
+
if (formUrl) {
|
|
83
|
+
if (formMethod === 'patch') {
|
|
84
|
+
form.removeAttribute('hx-post');
|
|
85
|
+
form.setAttribute('hx-patch', formUrl);
|
|
86
|
+
form.setAttribute('method', 'patch');
|
|
87
|
+
} else if (formMethod === 'post') {
|
|
88
|
+
form.removeAttribute('hx-patch');
|
|
89
|
+
form.setAttribute('hx-post', formUrl);
|
|
90
|
+
form.setAttribute('method', 'post');
|
|
91
|
+
}
|
|
92
|
+
form.setAttribute('action', formUrl);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Remove specified fields
|
|
96
|
+
if (removeFields && Array.isArray(removeFields)) {
|
|
97
|
+
for (const fieldName of removeFields) {
|
|
98
|
+
const field = form.querySelector(`[name="${fieldName}"]`);
|
|
99
|
+
if (field) {
|
|
100
|
+
field.remove();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Populate fields
|
|
106
|
+
if (fields && typeof fields === 'object') {
|
|
107
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
108
|
+
const input = form.querySelector(`[name="${name}"]`);
|
|
109
|
+
if (input) {
|
|
110
|
+
if (input.tagName === 'TEXTAREA') {
|
|
111
|
+
input.value = value || '';
|
|
112
|
+
} else if (input.tagName === 'INPUT') {
|
|
113
|
+
input.value = value || '';
|
|
114
|
+
} else if (input.tagName === 'SELECT') {
|
|
115
|
+
input.value = value || '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Update submit button text if provided
|
|
122
|
+
if (submitButtonText) {
|
|
123
|
+
const submitBtn = form.querySelector('button[type="submit"]');
|
|
124
|
+
if (submitBtn) {
|
|
125
|
+
submitBtn.textContent = submitButtonText;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Clear form errors if requested
|
|
130
|
+
if (clearErrors) {
|
|
131
|
+
content.querySelectorAll('.field-error-text').forEach((el) => {
|
|
132
|
+
el.textContent = '';
|
|
133
|
+
el.classList.add('hidden');
|
|
134
|
+
});
|
|
135
|
+
content.querySelectorAll('.form-field').forEach((el) => {
|
|
136
|
+
el.classList.remove('has-error');
|
|
137
|
+
});
|
|
138
|
+
content.querySelectorAll('.field-error').forEach((el) => {
|
|
139
|
+
el.classList.remove('field-error');
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Call custom setup callback if provided
|
|
144
|
+
if (typeof beforeOpen === 'function') {
|
|
145
|
+
beforeOpen(content, form);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Process HTMX attributes
|
|
150
|
+
htmx.process(content);
|
|
151
|
+
|
|
152
|
+
// Open the modal
|
|
153
|
+
openGlobalModal();
|
|
154
|
+
|
|
155
|
+
// Focus the appropriate element
|
|
156
|
+
const focusTarget = focusSelector
|
|
157
|
+
? content.querySelector(focusSelector)
|
|
158
|
+
: content.querySelector('input, select, textarea, button');
|
|
159
|
+
if (focusTarget) {
|
|
160
|
+
focusTarget.focus();
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/ui/modal.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Global Modal
|
|
3
|
+
// ---------------------------
|
|
4
|
+
|
|
5
|
+
let lastFocusBeforeModal = null;
|
|
6
|
+
|
|
7
|
+
// Callback for when modal is closed while confirm is active
|
|
8
|
+
let onModalCloseWithConfirm = null;
|
|
9
|
+
|
|
10
|
+
export function setOnModalCloseWithConfirm(callback) {
|
|
11
|
+
onModalCloseWithConfirm = callback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getGlobalModal() {
|
|
15
|
+
return document.getElementById('global-modal');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getGlobalModalDialog() {
|
|
19
|
+
return document.querySelector('#global-modal [role="dialog"]');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getGlobalModalHeader() {
|
|
23
|
+
return document.getElementById('global-modal-header');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getGlobalModalCloseButton() {
|
|
27
|
+
return document.getElementById('global-modal-close-btn');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isGlobalModalOpen() {
|
|
31
|
+
const modal = getGlobalModal();
|
|
32
|
+
return !!modal && modal.classList.contains('modal-open');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function openGlobalModal(options = {}) {
|
|
36
|
+
const modal = getGlobalModal();
|
|
37
|
+
if (!modal) return;
|
|
38
|
+
lastFocusBeforeModal = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
39
|
+
// Clear inline styles used to prevent flash of content on page load
|
|
40
|
+
modal.removeAttribute('style');
|
|
41
|
+
|
|
42
|
+
// Apply takeover mode if requested
|
|
43
|
+
if (options.takeover) {
|
|
44
|
+
modal.classList.add('modal-takeover');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
modal.classList.add('modal-open');
|
|
48
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
49
|
+
document.body.classList.add('overflow-hidden');
|
|
50
|
+
|
|
51
|
+
const dialog = getGlobalModalDialog();
|
|
52
|
+
if (dialog instanceof HTMLElement) {
|
|
53
|
+
// Defer to ensure it's visible before focusing
|
|
54
|
+
queueMicrotask(() => dialog.focus());
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function closeGlobalModal() {
|
|
59
|
+
const modal = getGlobalModal();
|
|
60
|
+
if (!modal) return;
|
|
61
|
+
|
|
62
|
+
// If a confirm dialog is active, closing the modal counts as "cancel".
|
|
63
|
+
if (onModalCloseWithConfirm) {
|
|
64
|
+
onModalCloseWithConfirm();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Avoid hiding a focused element from assistive tech (prevents aria-hidden warning).
|
|
68
|
+
const active = document.activeElement;
|
|
69
|
+
if (active instanceof HTMLElement && modal.contains(active)) {
|
|
70
|
+
active.blur();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check if takeover mode before removing modal-open
|
|
74
|
+
const wasTakeover = modal.classList.contains('modal-takeover');
|
|
75
|
+
|
|
76
|
+
modal.classList.remove('modal-open');
|
|
77
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
78
|
+
document.body.classList.remove('overflow-hidden');
|
|
79
|
+
|
|
80
|
+
// Dispatch event so listeners can react (e.g. refresh widgets after pack opening)
|
|
81
|
+
document.dispatchEvent(new CustomEvent('modal:closed', { detail: { takeover: wasTakeover } }));
|
|
82
|
+
|
|
83
|
+
// Delay cleanup of size/mode classes until after transition completes
|
|
84
|
+
// to prevent flash of default modal style
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
modal.classList.remove('modal-takeover');
|
|
87
|
+
const header = getGlobalModalHeader();
|
|
88
|
+
if (header) header.style.display = '';
|
|
89
|
+
const dialog = getGlobalModalDialog();
|
|
90
|
+
if (dialog) {
|
|
91
|
+
dialog.classList.remove('modal-sm', 'modal-lg', 'confirm-mode');
|
|
92
|
+
}
|
|
93
|
+
}, wasTakeover ? 200 : 150);
|
|
94
|
+
|
|
95
|
+
if (lastFocusBeforeModal && document.contains(lastFocusBeforeModal)) {
|
|
96
|
+
queueMicrotask(() => lastFocusBeforeModal?.focus());
|
|
97
|
+
}
|
|
98
|
+
lastFocusBeforeModal = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------
|
|
102
|
+
// Event Listeners
|
|
103
|
+
// ---------------------------
|
|
104
|
+
|
|
105
|
+
// Close modal on overlay click / close button click (anything with data-modal-close)
|
|
106
|
+
document.addEventListener('click', (e) => {
|
|
107
|
+
const closeBtn = e.target.closest('[data-modal-close]');
|
|
108
|
+
if (!closeBtn) return;
|
|
109
|
+
|
|
110
|
+
// In takeover mode, only explicit close buttons work (not backdrop clicks)
|
|
111
|
+
const modal = getGlobalModal();
|
|
112
|
+
if (modal?.classList.contains('modal-takeover')) {
|
|
113
|
+
// Check if this is the backdrop overlay (not an explicit close button)
|
|
114
|
+
if (closeBtn.classList.contains('absolute') && closeBtn.classList.contains('inset-0')) {
|
|
115
|
+
return; // Ignore backdrop clicks in takeover mode
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
closeGlobalModal();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Close modal on Escape
|
|
123
|
+
document.addEventListener('keydown', (e) => {
|
|
124
|
+
if (e.key !== 'Escape') return;
|
|
125
|
+
if (!isGlobalModalOpen()) return;
|
|
126
|
+
closeGlobalModal();
|
|
127
|
+
});
|
package/src/ui/toast.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toast notification system
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import Handlebars from 'handlebars';
|
|
6
|
+
|
|
7
|
+
let toastHideTimer = null;
|
|
8
|
+
const DEFAULT_TOAST_MS = 5_000;
|
|
9
|
+
let lastFocusBeforeToast = null;
|
|
10
|
+
let toastTransitionTimer = null;
|
|
11
|
+
const TOAST_TRANSITION_MS = 200;
|
|
12
|
+
|
|
13
|
+
function getToastWrap() {
|
|
14
|
+
return document.getElementById('global-toast-wrap');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function openToast({ timeoutMs = DEFAULT_TOAST_MS } = {}) {
|
|
18
|
+
const wrap = getToastWrap();
|
|
19
|
+
if (!wrap) return;
|
|
20
|
+
|
|
21
|
+
lastFocusBeforeToast = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
22
|
+
if (toastTransitionTimer) window.clearTimeout(toastTransitionTimer);
|
|
23
|
+
wrap.classList.remove('hidden');
|
|
24
|
+
wrap.setAttribute('aria-hidden', 'false');
|
|
25
|
+
|
|
26
|
+
// Fade in: ensure the "from" styles are applied before switching to "to" styles.
|
|
27
|
+
wrap.classList.add('opacity-0', 'translate-y-2');
|
|
28
|
+
wrap.classList.remove('opacity-100', 'translate-y-0');
|
|
29
|
+
// Force layout so the browser commits the initial state (prevents "just appears").
|
|
30
|
+
void wrap.getBoundingClientRect();
|
|
31
|
+
wrap.classList.remove('opacity-0', 'translate-y-2');
|
|
32
|
+
wrap.classList.add('opacity-100', 'translate-y-0');
|
|
33
|
+
|
|
34
|
+
if (toastHideTimer) window.clearTimeout(toastHideTimer);
|
|
35
|
+
toastHideTimer = window.setTimeout(() => closeToast(), timeoutMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function closeToast() {
|
|
39
|
+
const wrap = getToastWrap();
|
|
40
|
+
if (!wrap) return;
|
|
41
|
+
|
|
42
|
+
// Avoid hiding a focused element from assistive tech (prevents aria-hidden warning).
|
|
43
|
+
const active = document.activeElement;
|
|
44
|
+
if (active instanceof HTMLElement && wrap.contains(active)) {
|
|
45
|
+
active.blur();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (toastHideTimer) window.clearTimeout(toastHideTimer);
|
|
49
|
+
toastHideTimer = null;
|
|
50
|
+
|
|
51
|
+
if (lastFocusBeforeToast && document.contains(lastFocusBeforeToast)) {
|
|
52
|
+
queueMicrotask(() => lastFocusBeforeToast?.focus());
|
|
53
|
+
}
|
|
54
|
+
lastFocusBeforeToast = null;
|
|
55
|
+
|
|
56
|
+
// Fade out, then fully hide
|
|
57
|
+
if (toastTransitionTimer) window.clearTimeout(toastTransitionTimer);
|
|
58
|
+
wrap.classList.remove('opacity-100', 'translate-y-0');
|
|
59
|
+
wrap.classList.add('opacity-0', 'translate-y-2');
|
|
60
|
+
toastTransitionTimer = window.setTimeout(() => {
|
|
61
|
+
wrap.classList.add('hidden');
|
|
62
|
+
wrap.setAttribute('aria-hidden', 'true');
|
|
63
|
+
toastTransitionTimer = null;
|
|
64
|
+
}, TOAST_TRANSITION_MS);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderToastHtml(data) {
|
|
68
|
+
const tpl = document.getElementById('global-toast-template');
|
|
69
|
+
if (!(tpl instanceof HTMLTemplateElement)) return null;
|
|
70
|
+
const html = tpl.innerHTML;
|
|
71
|
+
const render = Handlebars.compile(html);
|
|
72
|
+
return render(data || {});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Public API
|
|
76
|
+
export const GrasprToast = {
|
|
77
|
+
show({ message, status = 'success', timeoutMs = DEFAULT_TOAST_MS } = {}) {
|
|
78
|
+
const wrap = getToastWrap();
|
|
79
|
+
const content = document.getElementById('global-toast-content');
|
|
80
|
+
if (!wrap || !content) return;
|
|
81
|
+
const html = renderToastHtml({ message, status });
|
|
82
|
+
if (html) content.innerHTML = html;
|
|
83
|
+
openToast({ timeoutMs });
|
|
84
|
+
},
|
|
85
|
+
close: closeToast,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Handlebars helper for toast styling
|
|
89
|
+
export function registerToastHelpers(Handlebars) {
|
|
90
|
+
Handlebars.registerHelper('toastClass', (status) => {
|
|
91
|
+
const s = String(status || 'success').toLowerCase();
|
|
92
|
+
// Note: accept the user's typo "eror" as error.
|
|
93
|
+
const normalized = s === 'eror' ? 'error' : s;
|
|
94
|
+
|
|
95
|
+
switch (normalized) {
|
|
96
|
+
case 'warning':
|
|
97
|
+
return 'bg-amber-100 text-amber-900 border-amber-200';
|
|
98
|
+
case 'error':
|
|
99
|
+
return 'bg-red-600 text-white border-red-700';
|
|
100
|
+
case 'success':
|
|
101
|
+
default:
|
|
102
|
+
return 'bg-green-600 text-white border-green-700';
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Toast event handlers
|
|
108
|
+
export function initToastEventHandlers() {
|
|
109
|
+
// Close toast on dismiss button
|
|
110
|
+
document.addEventListener('click', (e) => {
|
|
111
|
+
const closeBtn = e.target.closest('[data-toast-close]');
|
|
112
|
+
if (!closeBtn) return;
|
|
113
|
+
closeToast();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Auto-show toast when HTMX swaps content into the toast container
|
|
117
|
+
document.body.addEventListener('htmx:afterSwap', (e) => {
|
|
118
|
+
const target = e.detail?.target;
|
|
119
|
+
if (target && target.id === 'global-toast-content') {
|
|
120
|
+
openToast();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Internal exports for use by other modules
|
|
126
|
+
export { openToast, closeToast };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { escapeHtml } from '../helpers/escape-html.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reusable typeahead/autocomplete widget factory.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} options
|
|
7
|
+
* @param {Element} options.input - text input element
|
|
8
|
+
* @param {Element} options.dropdown - dropdown container element
|
|
9
|
+
* @param {Function} options.onSelect - called with the selected item
|
|
10
|
+
* @param {Array} [options.items] - searchable items (each should have .name)
|
|
11
|
+
* @param {Function} [options.renderItem] - (item) → innerHTML string
|
|
12
|
+
* @param {Function} [options.filterItem] - (item, query) → boolean
|
|
13
|
+
* @param {Array} [options.prependItems] - items always prepended to results
|
|
14
|
+
* @param {string|null} [options.noMatchText] - text when no results; null hides dropdown
|
|
15
|
+
* @param {number} [options.minQueryLength] - 0 = show all on empty, 1+ = require chars
|
|
16
|
+
* @param {number} [options.debounceMs] - debounce input events
|
|
17
|
+
* @param {boolean} [options.clearOnSelect] - clear input after selection
|
|
18
|
+
* @param {Element} [options.closeParent] - for outside-click detection
|
|
19
|
+
* @returns {{ setItems: Function, destroy: Function }}
|
|
20
|
+
*/
|
|
21
|
+
export function createTypeahead({
|
|
22
|
+
input,
|
|
23
|
+
dropdown,
|
|
24
|
+
onSelect,
|
|
25
|
+
items = [],
|
|
26
|
+
renderItem,
|
|
27
|
+
filterItem,
|
|
28
|
+
prependItems = [],
|
|
29
|
+
noMatchText = 'No matches',
|
|
30
|
+
minQueryLength = 1,
|
|
31
|
+
debounceMs = 0,
|
|
32
|
+
clearOnSelect = false,
|
|
33
|
+
closeParent,
|
|
34
|
+
}) {
|
|
35
|
+
var highlightIndex = -1;
|
|
36
|
+
var debounceTimer = null;
|
|
37
|
+
|
|
38
|
+
if (!renderItem) {
|
|
39
|
+
renderItem = function(item) { return escapeHtml(item.name); };
|
|
40
|
+
}
|
|
41
|
+
if (!filterItem) {
|
|
42
|
+
filterItem = function(item, query) {
|
|
43
|
+
return item.name.toLowerCase().indexOf(query) !== -1;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getFiltered() {
|
|
48
|
+
var query = input.value.trim().toLowerCase();
|
|
49
|
+
var matches = query
|
|
50
|
+
? items.filter(function(item) { return filterItem(item, query); })
|
|
51
|
+
: items;
|
|
52
|
+
return prependItems.concat(matches);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function render() {
|
|
56
|
+
var query = input.value.trim().toLowerCase();
|
|
57
|
+
if (query.length < minQueryLength) {
|
|
58
|
+
hide();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var filtered = getFiltered();
|
|
63
|
+
|
|
64
|
+
if (filtered.length === 0) {
|
|
65
|
+
if (noMatchText) {
|
|
66
|
+
dropdown.innerHTML = '<div class="px-3 py-2 text-sm text-slate-400">' + escapeHtml(noMatchText) + '</div>';
|
|
67
|
+
dropdown.classList.remove('hidden');
|
|
68
|
+
} else {
|
|
69
|
+
hide();
|
|
70
|
+
}
|
|
71
|
+
highlightIndex = -1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
var html = '';
|
|
76
|
+
filtered.forEach(function(item, i) {
|
|
77
|
+
html += '<div class="px-3 py-2 text-sm cursor-pointer hover:bg-slate-100" data-ta-index="' + i + '">';
|
|
78
|
+
html += renderItem(item);
|
|
79
|
+
html += '</div>';
|
|
80
|
+
});
|
|
81
|
+
dropdown.innerHTML = html;
|
|
82
|
+
dropdown.classList.remove('hidden');
|
|
83
|
+
highlightIndex = -1;
|
|
84
|
+
|
|
85
|
+
// Mousedown (not click) so input keeps focus
|
|
86
|
+
dropdown.querySelectorAll('[data-ta-index]').forEach(function(el) {
|
|
87
|
+
el.addEventListener('mousedown', function(e) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
select(filtered[parseInt(el.dataset.taIndex, 10)]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function highlight() {
|
|
95
|
+
var els = dropdown.querySelectorAll('[data-ta-index]');
|
|
96
|
+
els.forEach(function(el, i) {
|
|
97
|
+
if (i === highlightIndex) {
|
|
98
|
+
el.classList.add('bg-slate-100');
|
|
99
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
100
|
+
} else {
|
|
101
|
+
el.classList.remove('bg-slate-100');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hide() {
|
|
107
|
+
dropdown.classList.add('hidden');
|
|
108
|
+
highlightIndex = -1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function select(item) {
|
|
112
|
+
hide();
|
|
113
|
+
if (clearOnSelect) input.value = '';
|
|
114
|
+
onSelect(item);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onInput() {
|
|
118
|
+
if (debounceMs > 0) {
|
|
119
|
+
clearTimeout(debounceTimer);
|
|
120
|
+
debounceTimer = setTimeout(render, debounceMs);
|
|
121
|
+
} else {
|
|
122
|
+
render();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function onFocus() {
|
|
127
|
+
if (input.value.trim().length >= minQueryLength) {
|
|
128
|
+
render();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onKeydown(e) {
|
|
133
|
+
if (dropdown.classList.contains('hidden')) {
|
|
134
|
+
if (e.key === 'ArrowDown' && minQueryLength === 0) {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
render();
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
var els = dropdown.querySelectorAll('[data-ta-index]');
|
|
142
|
+
if (els.length === 0) return;
|
|
143
|
+
|
|
144
|
+
if (e.key === 'ArrowDown') {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
highlightIndex = Math.min(highlightIndex + 1, els.length - 1);
|
|
147
|
+
highlight();
|
|
148
|
+
} else if (e.key === 'ArrowUp') {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
highlightIndex = Math.max(highlightIndex - 1, 0);
|
|
151
|
+
highlight();
|
|
152
|
+
} else if (e.key === 'Enter') {
|
|
153
|
+
if (highlightIndex >= 0 && highlightIndex < els.length) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
var filtered = getFiltered();
|
|
156
|
+
select(filtered[highlightIndex]);
|
|
157
|
+
}
|
|
158
|
+
} else if (e.key === 'Escape') {
|
|
159
|
+
hide();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function onOutsideClick(e) {
|
|
164
|
+
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
|
|
165
|
+
hide();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
input.addEventListener('input', onInput);
|
|
170
|
+
input.addEventListener('focus', onFocus);
|
|
171
|
+
input.addEventListener('keydown', onKeydown);
|
|
172
|
+
|
|
173
|
+
var clickTarget = closeParent || document;
|
|
174
|
+
clickTarget.addEventListener('click', onOutsideClick);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
setItems: function(newItems) { items = newItems; },
|
|
178
|
+
destroy: function() {
|
|
179
|
+
input.removeEventListener('input', onInput);
|
|
180
|
+
input.removeEventListener('focus', onFocus);
|
|
181
|
+
input.removeEventListener('keydown', onKeydown);
|
|
182
|
+
clickTarget.removeEventListener('click', onOutsideClick);
|
|
183
|
+
clearTimeout(debounceTimer);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|