@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.
@@ -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
+ }
@@ -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
+ });
@@ -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
+ }