PageSourceSearch

https://pupwife.com/scripts/effects/settingsBubbleSelect.js

js pupwife.com collected 2026-09-30 15:06:22 UTC 10,334 bytes, 257 lines download raw bytes

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.