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, '&') 212 .replace(/</g, '<') 213 .replace(/>/g, '>') 214 .replace(/"/g, '"') 215 .replace(/'/g, '''); 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.