PageSourceSearch

https://smrtbld.ai/js/utils/dom.js

js smrtbld.ai collected 2026-10-05 15:07:04 UTC 18,460 bytes, 477 lines download raw bytes

1/**
2 * DOM Utilities - "The jQuery Replacements"
3 * Standard helpers for common DOM operations.
4 */
5
6import { SearchableDropdown } from '../components/SearchableDropdown.js';
7import { DatePicker } from '../components/DatePicker.js';
8import { enhanceProjectEmailAlias } from './project-email-alias.js';
9import { initTablists } from './tablist-keyboard.js';
10import { warnMissingLucide } from './lucide-guard.js';
11
12/**
13 * Run all post-render enhancements on a container.
14 * Call after injecting JsRender HTML into the DOM (when not using `renderTo`,
15 * which handles this itself — or once `renderTo` is upgraded to call this).
16 *
17 * Handles: Lucide icons, SearchableDropdown, DatePicker, the tabs-pattern roving
18 * tab stop (CA-110), and optional help-popover wiring.
19 *
20 * @param {HTMLElement} container - The container that was just rendered into
21 * @param {object} [options]
22 * @param {object} [options.lucide] - Extra options merged into lucide.createIcons (e.g.,
23 *        `{ attrs: { 'stroke-width': 1.75 } }` for thinner system-admin icons).
24 */
25export function afterRender(container, options = {}) {
26    if (!container) return;
27    // Lucide — `root` is its only scope option; it draws the container attached or detached (CA-620).
28    if (window.lucide) {
29        window.lucide.createIcons({ root: container, ...(options.lucide || {}) });
30    } else {
31        warnMissingLucide();
32    }
33    // Component enhancers — idempotent + safe to call on containers without matches.
34    SearchableDropdown.enhanceAll(container);
35    DatePicker.enhanceAll(container);
36    enhanceProjectEmailAlias(container);
37    autoGrowTextareas(container);
38    // help-popover is loaded lazily to avoid a top-level circular import (modal.js → form-validation.js → ...).
39    // If [data-help] buttons exist, dispatch the wiring asynchronously.
40    if (container.querySelector?.('[data-help]')) {
41        import('./help-popover.js')
42            .then(m => m.wireHelpButtons?.(container))
43            .catch(() => { /* help wiring is optional */ });
44    }
45    // Tabs pattern keyboard half (CA-110): give every authored [role="tablist"] in
46    // this container — including the container itself, which ten sub-tab renderers
47    // pass directly — its single roving tab stop, and install the document-level
48    // Arrow/Home/End delegate once. No-op on containers with no tablist.
49    initTablists(container);
50}
51
52/**
53 * Auto-grow textareas — the textarea's height tracks its content.
54 *
55 * UNIVERSAL behavior: every <textarea> rendered through the template pipeline
56 * gets this treatment via afterRender (Design Bible §Form Elements → "Textarea
57 * Auto-Grow"). The `rows` attribute (or any CSS height like `h-32`) sets the
58 * INITIAL visible height; the textarea grows from there as the user types.
59 *
60 * Caps: use CSS `max-height` to clamp growth (e.g. chat inputs that shouldn't
61 * take over the page). When content exceeds max-height, the textarea scrolls
62 * normally — no JS cap needed.
63 *
64 * Opt out: `<textarea data-no-autogrow>` — for the rare case where a page
65 * already owns its own resize logic (currently: TeamsPanel chat, messages
66 * reply, project-meetings input). New code should not opt out — prefer CSS
67 * max-height for chat-style caps.
68 *
69 * Idempotent: `data-autogrow-bound` flag prevents double-binding when
70 * afterRender runs over an already-enhanced subtree (which is common — modal
71 * bodies pass through afterRender on open, then again on inner re-renders).
72 *
73 * @param {Element} container - root container to scan for textareas
74 */
75export function autoGrowTextareas(container) {
76    if (!container?.querySelectorAll) return;
77    const textareas = container.querySelectorAll(
78        'textarea:not([data-no-autogrow]):not([data-autogrow-bound])'
79    );
80    textareas.forEach(ta => {
81        ta.setAttribute('data-autogrow-bound', '');
82
83        // Preserve the designed initial height (from `rows` attr or CSS like `h-32`)
84        // as the minimum — empty textareas don't collapse to 1 line.
85        const initialHeight = ta.clientHeight;
86        if (initialHeight > 0 && !ta.style.minHeight) {
87            ta.style.minHeight = initialHeight + 'px';
88        }
89
90        const fit = () => {
91            ta.style.height = 'auto';
92            // scrollHeight = 0 when the textarea is in a hidden ancestor (e.g. a tab
93            // panel that hasn't been activated). Keep the previous height in that
94            // case — focus/input will re-measure correctly once the panel shows.
95            const h = ta.scrollHeight;
96            if (h > 0) ta.style.height = h + 'px';
97        };
98
99        ta.addEventListener('input', fit);
100        ta.addEventListener('change', fit);   // programmatic value changes
101        ta.addEventListener('focus', fit);    // lazy re-measure when shown after hidden
102
103        // Initial pass — measures whatever content is already in the textarea
104        // (prefilled forms, edit modals). Cheap and idempotent.
105        fit();
106    });
107}
108
109/**
110 * Resolve a selector string, function, or element to a DOM element.
111 * Returns null for falsy input. Use as a single resolver to avoid scattered
112 * `el ?? document.querySelector(...)` patterns.
113 *
114 * @param {string|Function|Element|null} target - Selector, ()=>Element, or Element
115 * @param {ParentNode} [scope=document] - Scope for string-selector queries
116 * @returns {Element|null}
117 */
118export function resolveEl(target, scope = document) {
119    if (!target) return null;
120    if (typeof target === 'string') return scope.querySelector(target);
121    if (typeof target === 'function') return target() || null;
122    return target;
123}
124
125/**
126 * Query Selector (single)
127 * @param {string} selector
128 * @param {Element} [scope=document]
129 * @returns {Element|null}
130 */
131export function qs(selector, scope = document) {
132    return scope.querySelector(selector);
133}
134
135/**
136 * Query Selector All (array)
137 * @param {string} selector 
138 * @param {Element} [scope=document] 
139 * @returns {Element[]}
140 */
141export function qsa(selector, scope = document) {
142    return Array.from(scope.querySelectorAll(selector));
143}
144
145/**
146 * Add Event Listener
147 * @param {Element|string} target - Element or selector
148 * @param {string} type - Event type (e.g., 'click')
149 * @param {Function} callback 
150 * @param {object} [options] 
151 */
152export function on(target, type, callback, options) {
153    const elements = typeof target === 'string' ? qsa(target) : [target];
154    elements.forEach(el => {
155        if (el) el.addEventListener(type, callback, options);
156    });
157}
158
159/**
160 * Remove Event Listener
161 */
162export function off(target, type, callback, options) {
163    const elements = typeof target === 'string' ? qsa(target) : [target];
164    elements.forEach(el => {
165        if (el) el.removeEventListener(type, callback, options);
166    });
167}
168
169/**
170 * Event Delegation
171 * @param {Element} target - Parent element to attach listener to
172 * @param {string} selector - Child selector to match
173 * @param {string} type - Event type
174 * @param {Function} callback 
175 */
176export function delegate(target, selector, type, callback) {
177    const element = typeof target === 'string' ? qs(target) : target;
178    if (!element) return;
179
180    element.addEventListener(type, (event) => {
181        const closest = event.target.closest(selector);
182        if (closest && element.contains(closest)) {
183            callback.call(closest, event, closest);
184        }
185    });
186}
187
188/**
189 * Escape HTML to prevent XSS in element content.
190 * Note: doesn't escape apostrophes — fine for body text, NOT safe for single-quoted attributes.
191 * Use escapeAttr() for attribute values.
192 * @param {string} str
193 * @returns {string}
194 */
195export function escapeHtml(str) {
196    if (!str) return '';
197    const div = document.createElement('div');
198    div.textContent = str;
199    return div.innerHTML;
200}
201
202/**
203 * Escape a string for safe inclusion as an HTML attribute value (single OR double quoted).
204 * Escapes &, <, >, ", and ' — broader than escapeHtml.
205 * @param {string} str
206 * @returns {string}
207 */
208export function escapeAttr(str) {
209    if (str == null) return '';
210    return String(str)
211        .replace(/&/g, '&amp;')
212        .replace(/</g, '&lt;')
213        .replace(/>/g, '&gt;')
214        .replace(/"/g, '&quot;')
215        .replace(/'/g, '&#39;');
216}
217
218/**
219 * Serialize Form to Object
220 * @param {HTMLFormElement} form 
221 * @returns {object}
222 */
223export function serializeForm(form) {
224    const formData = new FormData(form);
225    const obj = {};
226    formData.forEach((value, key) => {
227        // Handle multiple values (checkboxes)
228        if (obj.hasOwnProperty(key)) {
229            if (!Array.isArray(obj[key])) {
230                obj[key] = [obj[key]];
231            }
232            obj[key].push(value);
233        } else {
234            obj[key] = value;
235        }
236    });
237
238    // Post-process specific fields
239    // 1. Ensure divisionIds is always an array
240    if (obj.divisionIds && !Array.isArray(obj.divisionIds)) {
241        obj.divisionIds = [obj.divisionIds];
242    }
243    // 2. Convert checkbox booleans
244    if (form.querySelector('input[type="checkbox"]#sendInvite')) {
245        obj.sendInvite = !!obj.sendInvite;
246    }
247
248    return obj;
249}
250
251/**
252 * Set a badge element's content using DOM APIs (no innerHTML).
253 * @param {Element} el - Container element to place the badge in
254 * @param {string|null} variant - Badge variant: 'success','error','warning','primary','secondary',
255 *                                'info' — or null/'text' for plain text
256 * @param {string} text - Badge/text content
257 * @param {string} [textClass] - Class override when variant is null (default: 'text-sm text-theme-secondary')
258 */
259export function setBadge(el, variant, text, textClass = 'text-sm text-theme-secondary') {
260    if (!el) return;
261    const span = document.createElement('span');
262    if (variant) {
263        span.className = `badge-st-${variant} text-2xs`;
264    } else {
265        span.className = textClass;
266    }
267    span.textContent = text;
268    el.replaceChildren(span);
269}
270
271/**
272 * Toggle loading state on a button.
273 * Saves original innerHTML on first call so it can be restored.
274 * Uses a CSS-only spinner — no Lucide dependency.
275 *
276 * Icon-only buttons (square w-N h-N with a single lucide icon inside) need
277 * a spinner that matches the icon scale rather than the text-button span.
278 * Pass `{ iconOnly: true }` to get a 20px spinning loader instead.
279 *
280 * @param {HTMLButtonElement} btn
281 * @param {boolean} isLoading
282 * @param {string} [loadingText='Loading...']
283 * @param {{ iconOnly?: boolean }} [options]
284 */
285export function setLoading(btn, isLoading, loadingText = 'Loading...', options = {}) {
286    if (!btn) return;
287    if (isLoading) {
288        if (btn.dataset.originalHtml === undefined) {
289            btn.dataset.originalHtml = btn.innerHTML;
290        }
291        btn.disabled = true;
292
293        if (options.iconOnly) {
294            btn.innerHTML = '<i data-lucide="loader-2" class="w-5 h-5 animate-spin pointer-events-none"></i>';
295            if (window.lucide) window.lucide.createIcons({ root: btn });
296            return;
297        }
298
299        const spinner = document.createElement('span');
300        spinner.className = 'btn-loading-spinner';
301        const isOutline = btn.classList.contains('btn-st-outline') ||
302            btn.classList.contains('btn-st-ghost') ||
303            [...btn.classList].some(c => c.startsWith('btn-st-outline-'));
304        if (isOutline) spinner.classList.add('btn-loading-spinner-outline');
305
306        const text = document.createTextNode(loadingText);
307        btn.textContent = '';
308        btn.appendChild(spinner);
309        btn.appendChild(text);
310    } else {
311        btn.innerHTML = btn.dataset.originalHtml || btn.innerHTML;
312        delete btn.dataset.originalHtml;
313        btn.disabled = false;
314        if (window.lucide) window.lucide.createIcons({ root: btn });
315    }
316}
317
318/**
319 * Wrap an async button action with busy-guard, error handling, and reset-on-error.
320 *
321 * Consolidates the duplicated pattern across ~13 sites where each handler manually:
322 *   1. Checks `btn.dataset.busy === '1'` for double-click prevention
323 *   2. Sets `btn.dataset.busy = '1'` + `btn.disabled = true`
324 *   3. Manually swaps button label / shows spinner
325 *   4. Wraps action in try/catch
326 *   5. Swallows AbortError
327 *   6. Shows error toast with description from err.message
328 *   7. Restores button state in finally
329 *
330 * After: caller writes the action, the helper owns the choreography.
331 *
332 * Two usage modes via `resetOnSuccess`:
333 *   - false (default) — modal action / DOM is replaced after success; setLoading
334 *                       cleanup only runs on error.
335 *   - true            — persistent button (export, import) — caller stays on
336 *                       page after success; setLoading is reset on both paths.
337 *
338 * @param {HTMLElement} btn - The button being clicked. Required; no-op if null.
339 * @param {string} busyText - Label shown while busy ("Saving…", "Exporting…").
340 * @param {() => Promise<any>} fn - The async action to run.
341 * @param {Object} [options]
342 * @param {string} [options.errorTitle='Action failed'] - Toast title on error.
343 * @param {string} [options.errorLogPrefix] - Prefix for console.error.
344 *                                              Defaults to `[errorTitle]`.
345 * @param {boolean} [options.resetOnSuccess=false] - true for persistent buttons.
346 * @returns {Promise<any|undefined>} fn's return value, or undefined if blocked.
347 *
348 * @example
349 *   // Modal action button (success closes the modal, so no reset needed)
350 *   await runWithBusyButton(btn, 'Saving…', async () => {
351 *       await api.update(...);
352 *       modal.close();
353 *   }, { errorTitle: 'Save failed', errorLogPrefix: '[AccInvoices] Save:' });
354 *
355 * @example
356 *   // Persistent export button (button stays after download)
357 *   await runWithBusyButton(btn, 'Exporting…', async () => {
358 *       const { blob, fileName } = await api.exportPdf(projectId);
359 *       downloadBlob(blob, fileName, { successMessage: 'PDF downloaded' });
360 *   }, { errorTitle: 'Export failed', resetOnSuccess: true });
361 */
362export async function runWithBusyButton(btn, busyText, fn, options = {}) {
363    if (!btn) return;
364    if (btn.dataset.busy === '1') return;
365    btn.dataset.busy = '1';
366    setLoading(btn, true, busyText);
367    try {
368        const result = await fn();
369        if (options.resetOnSuccess) {
370            setLoading(btn, false);
371        }
372        return result;
373    } catch (err) {
374        if (err?.name === 'AbortError') return;
375        const title = options.errorTitle || 'Action failed';
376        console.error(options.errorLogPrefix || `[${title}]`, err);
377        // MCF-0602: OfflineCacheMissError's message is "Not available offline: <endpoint>", and a raw
378        // endpoint path is not user copy. This is the TWIN of utils/async.js's branch (safeAsync, the
379        // documented pair of this helper) — same predicate, same sentence; keep them aligned.
380        const description = err?.name === 'OfflineCacheMissError'
381            ? "Couldn't reach the server, and this data isn't cached for offline use."
382            : err?.message;
383        window.showToast?.(title, 'error', { description });
384        setLoading(btn, false);
385    } finally {
386        btn.dataset.busy = '';
387    }
388}
389
390/**
391 * Cross-method action runner with encapsulated busy state.
392 *
393 * Consolidates the per-page `let actionBusy = false` + manual try/finally pattern
394 * that was duplicated across accounting-invoices / change-orders / purchase-orders.
395 * The module-scoped flag becomes a closure variable, and the three "is something
396 * in flight?" usage shapes get first-class methods.
397 *
398 * Three shapes:
399 *   - `runner.isBusy()` — short-circuit guard at the top of a sibling handler.
400 *   - `runner.runWithButton(btn, busyText, fn, options?)` — guarded `runWithBusyButton`.
401 *                                            Defaults from the factory carry through.
402 *   - `runner.lock(fn)` — guarded raw execution. Use when the body wraps
403 *                          `safeAsync` itself, or there's no button to attach to.
404 *
405 * Use `runner.reset()` from `destroyXxxPage()` so a stale flag doesn't leak across navigations.
406 *
407 * @param {Object} [defaults]
408 * @param {string} [defaults.errorTitle='Action failed'] - Default toast title for runWithButton.
409 * @param {string} [defaults.errorLogPrefix] - Default console.error prefix for runWithButton.
410 * @returns {{
411 *   isBusy: () => boolean,
412 *   runWithButton: (btn: HTMLElement, busyText: string, fn: () => Promise<any>, options?: object) => Promise<any|undefined>,
413 *   lock: (fn: () => Promise<any>) => Promise<any|undefined>,
414 *   reset: () => void
415 * }}
416 *
417 * @example
418 *   const action = createActionRunner({ errorLogPrefix: '[AccInvoices] Action:' });
419 *
420 *   // Cross-method guard at the top of a handler
421 *   if (action.isBusy() || !detailBundle?.header) return;
422 *
423 *   // Modal confirm button (replaces the per-page runActionWithButton wrapper)
424 *   await action.runWithButton(modal.element.querySelector('#btn-approve'), 'Approving…', async () => {
425 *       await api.approve(...);
426 *       modal.close();
427 *       await reloadDetail();
428 *   });
429 *
430 *   // Lock without a button (delete confirm — body wraps safeAsync)
431 *   await action.lock(async () => {
432 *       await safeAsync(async () => {
433 *           await api.delete_(...);
434 *           await closeDetail();
435 *       }, { label: 'AccInvoices', toast: 'Delete failed' });
436 *   });
437 *
438 *   // In destroy
439 *   action.reset();
440 */
441export function createActionRunner(defaults = {}) {
442    let busy = false;
443    const defaultErrorTitle = defaults.errorTitle || 'Action failed';
444    const defaultErrorLogPrefix = defaults.errorLogPrefix;
445
446    async function runWithButton(btn, busyText, fn, options = {}) {
447        if (busy) return;
448        if (!btn) return;
449        busy = true;
450        try {
451            return await runWithBusyButton(btn, busyText, fn, {
452                errorTitle: options.errorTitle || defaultErrorTitle,
453                errorLogPrefix: options.errorLogPrefix || defaultErrorLogPrefix,
454                resetOnSuccess: options.resetOnSuccess,
455            });
456        } finally {
457            busy = false;
458        }
459    }
460
461    async function lock(fn) {
462        if (busy) return;
463        busy = true;
464        try {
465            return await fn();
466        } finally {
467            busy = false;
468        }
469    }
470
471    return {
472        isBusy: () => busy,
473        runWithButton,
474        lock,
475        reset: () => { busy = false; },
476    };
477}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.