// Gaia's value-selector markup and stylesheet, without a browser-chrome popup. (() => { let active; function close(commit) { if (!active) return; const {select, wrapper, chosen} = active; active = null; if (commit && select.isConnected && !select.disabled) { const before = [...select.options].map(o => o.selected).join(','); [...select.options].forEach((o, i) => { o.selected = chosen.has(i); }); if (before !== [...select.options].map(o => o.selected).join(',')) { select.dispatchEvent(new Event('input', {bubbles:true})); select.dispatchEvent(new Event('change', {bubbles:true})); } } wrapper.remove(); // Several original panels commit modem settings on blur. select.blur(); } function open(select) { if (select.disabled || select.closest('[aria-disabled="true"]')) return; close(false); if (!document.querySelector('[data-vulpes-select-style]')) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.dataset.vulpesSelectStyle = ''; link.href = `${location.protocol}//system.localhost:${location.port}/shared/style/value_selector.css`; document.head.append(link); } const wrapper = document.createElement('div'); wrapper.className = 'value-selector'; wrapper.id = 'vulpes-value-selector-layer'; const form = document.createElement('form'); form.id = 'vulpes-value-selector'; form.role = 'dialog'; form.dataset.type = 'value-selector'; form.setAttribute('aria-modal', 'true'); form.innerHTML = '

    '; form.setAttribute('aria-labelledby', 'vulpes-select-title'); const fr = (document.documentElement.lang || navigator.language).startsWith('fr'); form.querySelector('[data-action="cancel"]').textContent = fr ? 'Annuler' : 'Cancel'; form.querySelector('h1').textContent = document.getElementById(select.getAttribute('aria-labelledby'))?.textContent || select.closest('li')?.querySelector('span[data-l10n-id],label,p')?.textContent || (fr ? 'Choisir une option' : 'Choose an option'); const chosen = new Set([...select.options].flatMap((o,i) => o.selected ? [i] : [])); const list = form.querySelector('ol'); if (select.multiple) list.setAttribute('aria-multiselectable', 'true'); [...select.children].forEach(child => { if (child.tagName === 'OPTGROUP') { const heading = document.createElement('li'); heading.role = 'subheader'; heading.textContent = child.label; list.append(heading); } for (const option of child.tagName === 'OPTGROUP' ? child.children : [child]) { const i = [...select.options].indexOf(option); if (i < 0 || option.hidden) continue; const li = document.createElement('li'); li.role = 'option'; li.tabIndex = 0; li.dataset.index = i; li.dataset.value = option.value; li.setAttribute('aria-selected', String(chosen.has(i))); const disabled = option.disabled || option.parentElement.disabled === true; li.setAttribute('aria-disabled', String(disabled)); const label = document.createElement('label'), span = document.createElement('span'); label.role = 'presentation'; span.textContent = option.textContent; label.append(span); li.append(label); list.append(li); const pick = () => { if (disabled) return; if (!select.multiple) chosen.clear(); if (select.multiple && chosen.has(i)) chosen.delete(i); else chosen.add(i); list.querySelectorAll('[role="option"]').forEach(n => n.setAttribute('aria-selected', String(chosen.has(Number(n.dataset.index))))); }; li.onclick = pick; li.onkeydown = event => { if (['Enter',' '].includes(event.key)) {event.preventDefault();pick();} }; } }); active = {select, form, wrapper, chosen}; select.focus({preventScroll:true}); form.onsubmit = event => {event.preventDefault();close(true);}; form.querySelector('[data-action="cancel"]').onclick = () => close(false); wrapper.append(form); document.body.append(wrapper); } // Gecko's embedded native select may consume the compatibility click. // Open on a completed tap too, without treating a scroll or a hold as a tap. let gesture, recentTap; addEventListener('touchstart', event => { const select=event.target; if (select.tagName !== 'SELECT' || select.size > 1 || event.touches.length !== 1) {gesture=null;return;} const point=event.touches[0]; gesture={select,id:point.identifier,x:point.clientX,y:point.clientY,moved:false,at:performance.now()}; },{capture:true,passive:true}); addEventListener('touchmove', event => { if (!gesture) return; const point=[...event.touches].find(p=>p.identifier===gesture.id); if (!point || event.touches.length!==1 || Math.hypot(point.clientX-gesture.x,point.clientY-gesture.y)>10) gesture.moved=true; },{capture:true,passive:true}); addEventListener('touchcancel',()=>{gesture=null;},{capture:true,passive:true}); addEventListener('touchend', event => { const tap=gesture;gesture=null; if (!tap || tap.moved || performance.now()-tap.at>600 || event.touches.length) return; const point=[...event.changedTouches].find(p=>p.identifier===tap.id); if (!point || Math.hypot(point.clientX-tap.x,point.clientY-tap.y)>10) return; event.preventDefault();event.stopImmediatePropagation(); recentTap={select:tap.select,at:performance.now()};open(tap.select); },{capture:true,passive:false}); // Cancel the browser-chrome popup. Mouse and keyboard activation use Gaia too. for (const type of ['mousedown','pointerdown']) addEventListener(type, event => { if (event.target.tagName === 'SELECT' && event.target.size <= 1) event.preventDefault(); }, true); addEventListener('click', event => { if (event.target.tagName !== 'SELECT' || event.target.size > 1) return; event.preventDefault(); event.stopImmediatePropagation(); if (recentTap?.select===event.target && performance.now()-recentTap.at<700) return; open(event.target); }, true); addEventListener('keydown', event => { if (active && event.key === 'Escape') {event.preventDefault();close(false);} else if (event.target.tagName === 'SELECT' && [' ','Enter','ArrowDown'].includes(event.key)) { event.preventDefault();open(event.target); } }, true); addEventListener('pagehide', () => close(false)); document.addEventListener('visibilitychange', () => {if (document.hidden) close(false);}); addEventListener('panelready', event => { // A late transition event for the same panel must not dismiss a menu // opened immediately after navigation. Leaving the panel still cancels it. if (active && event.detail?.current !== '#' + active.select.closest('section')?.id) close(false); }); })(); // Date/time pickers still belong to Gaia System. Embedded Gecko does not // provide the retired mozInputMethod value-selector events for these inputs. (() => { const systemOrigin = 'http://system.localhost:8765'; if (location.origin === systemOrigin) { addEventListener('message', event => { if (event.data?.type === 'vulpes-keyboard-close' && event.origin === 'http://keyboard.localhost:8765') { const frame=[...document.querySelectorAll('iframe')].find(f=>f.contentWindow===event.source && f.src.startsWith(event.origin+'/settings.html')); if(frame) { const keyboard=Service.query('getApp',event.origin); const close=()=>frame.isConnected && frame.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true})); // Closing a still-transitioning background window can send Gaia // Home over the application which has just replaced it. if(keyboard?.isActive())keyboard.element.addEventListener('_closed',close,{once:true}); else close(); } return; } const app = window.Service?.query('getActiveApp'); if (!app || event.source !== app.iframe?.contentWindow || event.origin !== app.origin) return; if (event.data?.type !== 'vulpes-datetime-open' || !['date','time'].includes(event.data.inputType)) return; const picker = app.valueSelector; if (!picker) return; const previous = picker._im; const reply = value => event.source.postMessage({type:'vulpes-datetime-result', id:event.data.id, value}, event.origin); picker._im = { setValue: value => reply(value), removeFocus() { reply(null); picker._im = previous; } }; picker.handleEvent({type:'_inputfocus', detail:{inputType:event.data.inputType, value:String(event.data.value || '').slice(0,32), min:event.data.min, max:event.data.max}}); }); return; } if (location.hostname === 'keyboard.localhost' && /settings/.test(location.pathname)) { window.close = () => parent.postMessage({type:'vulpes-keyboard-close'}, systemOrigin); } let pending, sequence=0; addEventListener('click', event => { const input=event.target; if (input.tagName !== 'INPUT' || !['date','time'].includes(input.type) || input.disabled || input.readOnly) return; event.preventDefault();event.stopImmediatePropagation(); pending={input,id:++sequence}; parent.postMessage({type:'vulpes-datetime-open',id:sequence,inputType:input.type, value:input.value,min:input.min,max:input.max},systemOrigin); },true); addEventListener('message', event => { if(event.source!==parent || event.origin!==systemOrigin || event.data?.type!=='vulpes-datetime-result' || event.data.id!==pending?.id)return; const {input}=pending;pending=null; if(event.data.value!==null && input.isConnected && !input.disabled) { input.value=event.data.value; if(input.value) { input.dispatchEvent(new Event('input',{bubbles:true})); input.dispatchEvent(new Event('change',{bubbles:true})); } } input.blur(); }); })(); // Modern native select text metrics differ from Gaia's 4rem button box. // Keep the real select for interaction and accessibility, and paint its label // in a regular line box so Fira's descenders are not clipped by the control. (() => { const labels=new WeakMap(); function refresh() { for(const select of document.querySelectorAll('.button select,.bb-button select')) { if(select.multiple || select.size>1)continue; let label=labels.get(select); if(!label) { label=document.createElement('span');label.className='vulpes-select-label'; label.setAttribute('aria-hidden','true');select.after(label);labels.set(select,label); select.classList.add('vulpes-select-label-owner'); select.addEventListener('change',refresh); } const text=select.selectedOptions[0]?.textContent || ''; if(label.textContent!==text)label.textContent=text; } } let queued=false; new MutationObserver(()=>{if(!queued){queued=true;queueMicrotask(()=>{queued=false;refresh();});}}) .observe(document.documentElement,{childList:true,subtree:true,characterData:true,attributes:true,attributeFilter:['selected','value']}); addEventListener('panelready',refresh);addEventListener('localized',refresh); refresh(); })();