PageSourceSearch

https://smrtbld.ai/js/utils/mobile-add-buttons.js

js smrtbld.ai collected 2026-10-05 15:08:07 UTC 8,803 bytes, 232 lines download raw bytes

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.