1/** 2 * Mobile Add Buttons â Composite Icon Transform 3 * 4 * On mobile, sub-module "New X" buttons become compact icon-only buttons. 5 * Instead of a generic "+" icon, we show the tab's module icon with a small 6 * "+" badge overlay â like how Lucide's image-plus works. 7 * 8 * The module icon is derived automatically from the parent tab button's 9 * rendered SVG class (e.g. tab "quotations" has lucide-file-text â file-text). 10 * 11 * Zero template changes required. initMobileAddButtons() is called from loadAppShell(), 12 * which re-runs once per authenticated session (teardownAuthenticatedServices resets 13 * `shellLoaded`), so the caller captures the returned disposer and calls it at the session 14 * boundary â that disposer is this module's paired teardown. 15 */ 16 17import { isMobile } from './mobile.js'; 18import { BottomSheet } from '../components/BottomSheet.js'; 19 20const TRANSFORM_MARKER = 'data-mobile-btn-transformed'; 21const GROUP_MARKER = 'data-mobile-group-transformed'; 22 23/** 24 * Initialize the observer that auto-transforms add buttons on mobile. 25 * Returns a cleanup function. 26 */ 27export function initMobileAddButtons() { 28 if (!isMobile()) return () => {}; 29 30 const appContent = document.getElementById('app-content'); 31 if (!appContent) return () => {}; 32 33 // Debounced observer â fires after DOM settles 34 let raf = null; 35 let isTransforming = false; 36 const observer = new MutationObserver(() => { 37 if (isTransforming) return; // Avoid re-entry from our own DOM changes 38 if (raf) cancelAnimationFrame(raf); 39 raf = requestAnimationFrame(() => { 40 isTransforming = true; 41 transformAllPanels(); 42 isTransforming = false; 43 }); 44 }); 45 46 observer.observe(appContent, { childList: true, subtree: true }); 47 48 // Initial transform 49 requestAnimationFrame(() => transformAllPanels()); 50 51 return () => { 52 observer.disconnect(); 53 if (raf) cancelAnimationFrame(raf); 54 }; 55} 56 57/** 58 * Scan all visible tab panels and transform their add buttons. 59 */ 60function transformAllPanels() { 61 if (!isMobile()) return; // Defensive â skip if resized to desktop 62 63 // Find all tab panels that are visible (not hidden) 64 const panels = document.querySelectorAll('.tab-panel:not(.hidden)'); 65 panels.forEach(panel => { 66 // Multi-button groups first â â trigger 67 transformMultiButtonGroups(panel); 68 // Remaining single buttons â composite icon 69 transformPanelButtons(panel); 70 }); 71 72 // Also transform buttons in dropdown nav containers and detail headers 73 // (some modules put add buttons outside tab panels) 74 const detailColumns = document.querySelectorAll('[id$="-detail-column"]:not(.hidden)'); 75 detailColumns.forEach(col => { 76 col.querySelectorAll('.tab-panel:not(.hidden)').forEach(panel => { 77 transformMultiButtonGroups(panel); 78 transformPanelButtons(panel); 79 }); 80 }); 81} 82 83/** 84 * Find button groups (2+ add buttons in the same container) and collapse 85 * them into a single â command trigger that opens a BottomSheet. 86 */ 87function transformMultiButtonGroups(panel) { 88 // Look for flex containers with multiple btn-st buttons (the button group wrappers) 89 const containers = panel.querySelectorAll('.flex.gap-2'); 90 91 containers.forEach(container => { 92 // An Options Menu owns its scope on every breakpoint (DESIGN_BIBLE §THE Menu, CA-541). 93 if (container.closest('[data-action-scope]')) return; 94 95 // Already transformed? 96 if (container.hasAttribute(GROUP_MARKER)) return; 97 98 // Skip containers inside modals, tables, comment areas 99 if (container.closest('.modal-st, .modal-footer-st, [role="dialog"], td, table')) return; 100 101 // Find add buttons (primary or outline with icons and text) 102 const buttons = [...container.querySelectorAll( 103 '.btn-st-primary:not([data-dismiss]), .btn-st-outline:not([data-dismiss])' 104 )].filter(btn => { 105 const hasIcon = btn.querySelector('svg[class*="lucide-"]'); 106 const hasText = btn.querySelector(':scope > span'); 107 // A wrapper around an Options scope must not collect the scope's buttons (CA-541). 108 return hasIcon && hasText && hasText.textContent.trim() 109 && !btn.closest('[data-action-scope]'); 110 }); 111 112 if (buttons.length < 2) return; 113 114 // Mark container 115 container.setAttribute(GROUP_MARKER, 'true'); 116 117 // Collect button info for BottomSheet items 118 const items = buttons.map(btn => { 119 const svg = btn.querySelector('svg[class*="lucide-"]'); 120 const iconClass = [...svg.classList].find(c => c.startsWith('lucide-') && c !== 'lucide'); 121 const icon = iconClass ? iconClass.replace('lucide-', '')
121: 'plus'; 122 const label = btn.querySelector(':scope > span').textContent.trim(); 123 124 const variant = btn.classList.contains('btn-st-outline-error') || btn.classList.contains('btn-st-error') 125 ? 'error' 126 : btn.classList.contains('btn-st-primary') 127 ? 'primary' 128 : undefined; 129 130 return { icon, label, variant, action: () => btn.click() }; 131 }); 132 133 // Hide original buttons 134 buttons.forEach(btn => { 135 btn.classList.add('hidden'); 136 btn.setAttribute(TRANSFORM_MARKER, 'true'); 137 }); 138 139 // Create â trigger 140 const trigger = document.createElement('button'); 141 trigger.className = 'mobile-action-trigger'; 142 trigger.innerHTML = ` 143 <i data-lucide="command" class="w-5 h-5"></i> 144 <i data-lucide="chevron-down" class="w-3 h-3 opacity-50"></i> 145 `; 146 trigger.addEventListener('click', (e) => { 147 e.stopPropagation(); 148 BottomSheet.show({ title: 'Add', items }); 149 }); 150 151 container.appendChild(trigger); 152 153 if (typeof lucide !== 'undefined') { 154 lucide.createIcons({ root: trigger }); 155 } 156 }); 157} 158 159/** 160 * Transform add buttons within a single panel. 161 */ 162function transformPanelButtons(panel) { 163 // Find buttons with a plus icon that haven't been transformed 164 const buttons = panel.querySelectorAll( 165 `.btn-st-primary:not([${TRANSFORM_MARKER}]):not([data-dismiss]),` + 166 `.btn-st-outline:not([${TRANSFORM_MARKER}]):not([data-dismiss])` 167 ); 168 169 buttons.forEach(btn => { 170 // Must have a rendered plus icon (SVG after lucide processes it) 171 const plusSvg = btn.querySelector('svg.lucide-plus'); 172 if (!plusSvg) return; 173 174 // Must have a text span (to confirm it's a "New X" button, not just an icon button) 175 const textSpan = btn.querySelector(':scope > span'); 176 if (!textSpan || !textSpan.textContent.trim()) return; 177 178 // Skip buttons inside modals 179 if (btn.closest('.modal-st, .modal-footer-st, [role="dialog"]')) return; 180 181 // Skip inline table buttons and comment buttons 182 if (btn.closest('td, .comment-input-area, table')) return; 183 184 // Skip buttons an Options Menu owns (DESIGN_BIBLE §THE Menu, CA-541) 185 if (btn.closest('[data-action-scope]')) return; 186 187 // Mark as transformed 188 btn.setAttribute(TRANSFORM_MARKER, 'true'); 189 190 // Get the tab icon for this panel 191 const tabIcon = getTabIconForPanel(panel); 192 193 if (tabIcon) { 194 // Create composite icon: module icon + plus badge 195 const composite = document.createElement('span'); 196 composite.className = 'icon-composite'; 197 composite.innerHTML = `<i data-lucide="${tabIcon}" class="w-5 h-5"></i>`; 198 199 plusSvg.replaceWith(composite); 200 201 if (typeof lucide !== 'undefined') { 202 lucide.createIcons({ root: composite }); 203 } 204 } 205 206 // Hide text label 207 textSpan.classList.add('hidden'); 208 209 // Compact the button 210 btn.classList.add('btn-mobile-icon-only'); 211 }); 212} 213 214/** 215 * Derive the Lucide icon name from the tab button that owns this panel. 216 * Panel id "tab-quotations" â tab button [data-tab="quotations"] â SVG class "lucide-file-text" â "file-text" 217 */ 218function getTabIconForPanel(panel) { 219 const tabId = panel.id?.replace('tab-', ''); 220 if (!tabId) return null; 221 222 // Find the tab button with this data-tab value 223 const tabBtn = document.querySelector(`[data-tab="${tabId}"]`); 224 if (!tabBtn) return null; 225 226 // Get the lucide icon name from the rendered SVG class 227 const svg = tabBtn.querySelector('svg[class*="lucide-"]'); 228 if (!svg) return null; 229 230 const iconClass = [...svg.classList].find(c => c.startsWith('lucide-') && c !== 'lucide'); 231 return iconClass ? iconClass.replace('lucide-', '') : null; 232}
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.