1/** 2 * Reusable bubbly dropdown for settings panels (per-instance). 3 */ 4(function (global) { 5 'use strict'; 6 7 var openSelect = null; 8 var globalBound = false; 9 var FLOAT_Z = 10050; 10 var FLOAT_GAP = 7; 11 var FLOAT_MAX_HEIGHT = 280; 12 13 function getSettingsScrollEl() { 14 return document.getElementById('settings-panel'); 15 } 16 17 function positionFloatPanel(trigger, panel, scrollEl, opts) { 18 opts = opts || {}; 19 var rect = trigger.getBoundingClientRect(); 20 var maxH = Math.min(window.innerHeight * 0.42, FLOAT_MAX_HEIGHT); 21 22 panel.style.position = 'fixed'; 23 panel.style.left = rect.left + 'px'; 24 panel.style.width = rect.width + 'px'; 25 panel.style.right = 'auto'; 26 panel.style.zIndex = String(FLOAT_Z); 27 28 if (scrollEl) scrollEl.style.maxHeight = maxH + 'px'; 29 30 var naturalH = panel.offsetHeight; 31 var spaceBelow = window.innerHeight - rect.bottom - FLOAT_GAP; 32 var spaceAbove = rect.top - FLOAT_GAP; 33 var useBelow; 34 if (opts.preferAbove) { 35 useBelow = spaceBelow > spaceAbove && spaceBelow >= Math.min(naturalH, maxH); 36 } else { 37 useBelow = spaceBelow >= naturalH || spaceBelow >= spaceAbove; 38 } 39 var avail = Math.max(96, useBelow ? spaceBelow : spaceAbove); 40 41 if (scrollEl && avail < naturalH) { 42 scrollEl.style.maxHeight = Math.min(maxH, avail) + 'px'; 43 } 44 45 if (useBelow) { 46 panel.style.top = (rect.bottom + FLOAT_GAP) + 'px'; 47 panel.style.bottom = 'auto'; 48 } else { 49 panel.style.top = 'auto'; 50 panel.style.bottom = (window.innerHeight - rect.top + FLOAT_GAP) + 'px'; 51 } 52 } 53 54 function createFloatingDropdown(opts) { 55 var root = opts.root; 56 var trigger = opts.trigger; 57 var panel = opts.panel; 58 var scrollEl = opts.scrollEl || null; 59 var floatingClass = opts.floatingClass || 'settings-dropdown__panel--floating'; 60 var placeholder = document.createComment('settings-floating-panel'); 61 var floated = false; 62 var repositionFn = function () { 63 if (!floated || panel.hidden) return; 64 positionFloatPanel(trigger, panel, scrollEl, opts); 65 }; 66 67 return { 68 open: function () { 69 if (!floated) { 70 root.insertBefore(placeholder, panel); 71 document.body.appendChild(panel); 72 floated = true; 73 } 74 panel.classList.add(floatingClass); 75 positionFloatPanel(trigger, panel, scrollEl, opts); 76 window.addEventListener('resize', repositionFn); 77 var settingsPanel = getSettingsScrollEl(); 78 if (settingsPanel) settingsPanel.addEventListener('scroll', repositionFn, true); 79 }, 80 close: function () { 81 panel.classList.remove(floatingClass); 82 panel.style.cssText = ''; 83 if (scrollEl) scrollEl.style.maxHeight = ''; 84 window.removeEventListener('resize', repositionFn); 85 var settingsPanel = getSettingsScrollEl(); 86 if (settingsPanel) settingsPanel.removeEventListener('scroll', repositionFn, true); 87 if (floated && placeholder.parentNode) { 88 placeholder.parentNode.insertBefore(panel, placeholder); 89 placeholder.remove(); 90 floated = false; 91 } 92 }, 93 contains: function (target) { 94 return panel.contains(target); 95 } 96 }; 97 } 98 99 function findOption(options, value) { 100 for (var i = 0; i < options.length; i++) { 101 if (options[i].value === value) return options[i]; 102 } 103 return { value: value, label: String(value) }; 104 } 105 106 function bindGlobalListeners() { 107 if (globalBound) return; 108 globalBound = true; 109 110 document.addEventListener('click', function (e) { 111 if (!openSelect || !openSelect.root) return; 112 if (openSelect.root.contains(e.target)) return; 113 if (openSelect.floatAnchor && openSelect.floatAnchor.contains(e.target)) return; 114 openSelect.close(); 115 }); 116 117 document.addEventListener('keydown', function (e) { 118 if (e.key === 'Escape' && openSelect) { 119 openSelect.close(); 120 } 121 }); 122 } 123 124 function create(root, cfg) { 125 cfg = cfg || {}; 126 var id = cfg.idPrefix || ('bubble-' + Math.random().toString(36).slice(2, 8)); 127 var options = cfg.options || []; 128 var ariaLabel = cfg.ariaLabel || 'Choose option'; 129 var onChange = typeof cfg.onChange === 'function' ? cfg.onChange : null; 130 var disabled = !!cfg.disabled; 131 var value = cfg.value !== undefined ? cfg.value : (options[0] ? options[0].value : ''); 132 133 root.classList.add('settings-bubble-select'); 134 135 root.innerHTML = 136 '<input type="hidden" id="' + id + '-value" value="">' + 137 '<button type="button" class="settings-bubble-select__trigger" id="' + id + '-trigger"' + 138 ' aria-haspopup="listbox" aria-expanded="false" aria-controls="' + id + '-listbox">' + 139 '<span class="settings-bubble-select__trigger-text" id="' + id + '-label"></span>' + 140 '<span class="settings-bubble-select__chevron" aria-hidden="true">' + 141 '<i class="fa-solid fa-chevron-down fa-width-auto"></i></span>' + 142 '</button>' + 143 '<div class="settings-bubble-select__panel" id="' + id + '-panel" hidden>' + 144 '<div class="settings-bubble-select__scroll" id="' + id + '-listbox" role="listbox" aria-label="' + ariaLabel + '"></div>' + 145 '</div>'; 146 147 var hidden = root.querySelector('#' + id + '-value'); 148 var trigger = root.querySelector('#' + id + '-trigger'); 149 var triggerLabel = root.querySelector('#' + id + '-label'); 150 var panel = root.querySelector('#' + id + '-panel'); 151 var listbox = root.querySelector('#' + id + '-listbox'); 152 153 options.forEach(function (opt) { 154 var btn = document.createElement('button'); 155 btn.type = 'button'; 156 btn.className = 'settings-bubble-select__option'; 157 btn.dataset.value = opt.value; 158 btn.setAttribute('role', 'option'); 159 btn.setAttribute('aria-selected', 'false'); 160 161 if (opt.stripes && opt.stripes.length) { 162 var swatch = document.createElement('span'); 163 swatch.className = 'settings-bubble-select__option-swatch'; 164 swatch.style.background = 'linear-gradient(90deg, ' + opt.stripes.join(', ') + ')'; 165 swatch.setAttribute('aria-hidden', 'true'); 166 btn.appendChild(swatch); 167 } 168 169 var labelSpan = document.createElement('span'); 170 labelSpan.className = 'settings-bubble-select__option-label'; 171 labelSpan.textContent = opt.label; 172 btn.appendChild(labelSpan); 173 174 btn.addEventListener('click', function () { 175 if (disabled) return; 176 api.setValue(opt.value); 177 api.close(); 178 if (onChange) onChange(opt.value); 179 }); 180 181 listbox.appendChild(btn); 182 }); 183 184 var floatAnchor = createFloatingDropdown({ 185 root: root, 186 trigger: trigger, 187 panel: panel, 188 scrollEl: listbox, 189 floatingClass: 'settings-bubble-select__panel--floating' 190 }); 191 192 var api = { 193 root: root, 194 panel: panel, 195 floatAnchor: floatAnchor, 196 setValue: function (next) { 197 var meta = findOption(options, next); 198 value = meta.value; 199 hidden.value = value; 200 triggerLabel.textContent = meta.label; 201 listbox.querySelectorAll('.settings-bubble-select__option').forEach(function (btn) { 202 var selected = btn.dataset.value === value; 203 btn.classList.toggle('is-selected', selected); 204 btn.setAttribute('aria-selected', selected ? 'true' : 'false'); 205 }); 206 }, 207 getValue: function () { return value; }, 208 close: function () { 209 floatAnchor.close(); 210 panel.hidden = true; 211 trigger.setAttribute('aria-expanded', 'false'); 212 root.classList.remove('is-open'); 213 if (openSelect === api) openSelect = null; 214 }, 215 open: function () { 216 if (disabled) return; 217 if (openSelect && openSelect !== api) openSelect.close(); 218 if (global.PupwifeSettingsColorPicker && typeof global.PupwifeSettingsColorPicker.closeOpen === 'function') { 219 global.PupwifeSettingsColorPicker.closeOpen(); 220 } 221 panel.hidden = false; 222 trigger.setAttribute('aria-expanded', 'true'); 223 root.classList.add('is-open'); 224 floatAnchor.open(); 225 openSelect = api; 226 }, 227 setDisabled: function (next) { 228 disabled = !!next; 229 trigger.disabled = disabled; 230 root.classList.toggle('is-disabled', disabled); 231 if (disabled) api.close(); 232 } 233 }; 234 235 trigger.addEventListener('click', function () { 236 if (disabled) return; 237 if (panel.hidden) api.open(); 238 else api.close(); 239 }); 240 241 bindGlobalListeners(); 242 api.setValue(value); 243 api.setDisabled(disabled); 244 245 return api; 246 } 247 248 function closeOpen() { 249 if (openSelect) openSelect.close(); 250 } 251 252 global.PupwifeSettingsBubbleSelect = { 253 create: create, 254 closeOpen: closeOpen, 255 createFloatingDropdown: createFloatingDropdown 256 }; 257})(window);
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.