1class UniversallySwitcher extends HTMLElement { 2 constructor() { 3 super(); 4 this.attachShadow({ mode: 'open' }); 5 this._open = false; 6 this._onDocClick = this._onDocClick.bind(this); 7 this._onKeyDown = this._onKeyDown.bind(this); 8 } 9 10 connectedCallback() { 11 const raw = this.getAttribute('data-config'); 12 if (!raw) return; 13 14 try { 15 this._config = JSON.parse(raw); 16 } catch { 17 return; 18 } 19 20 this.setAttribute('translate', 'no'); 21 this._render(); 22 this._bind(); 23 } 24 25 disconnectedCallback() { 26 document.removeEventListener('click', this._onDocClick, true); 27 document.removeEventListener('keydown', this._onKeyDown); 28 } 29 30 _render() { 31 const { languages, showNames, showFlags, flagStyle, fixed, position } = this._config; 32 if (!languages || !languages.length) return; 33 34 const current = languages.find(l => l.isCurrent); 35 if (!current) return; 36 37 const others = languages.filter(l => !l.isCurrent && !l.isDisabled); 38 39 const flagRadius = flagStyle === 'square' ? '0' : '50%'; 40 41 let hostStyles = ''; 42 if (fixed) { 43 const posMap = { 44 bottom_right: 'bottom:20px;right:20px', 45 bottom_left: 'bottom:20px;left:20px', 46 top_right: 'top:20px;right:20px', 47 top_left: 'top:20px;left:20px', 48 }; 49 const pos = posMap[position] || posMap.bottom_right; 50 hostStyles = `position:fixed;${pos};z-index:999999;`; 51 } 52 53 const flagHtml = (lang) => { 54 if (!showFlags || !lang.flagUrl) return ''; 55 return `<img class="flag" src="${lang.flagUrl}" alt="" />`; 56 }; 57 58 const nameHtml = (lang) => { 59 if (!showNames) return ''; 60 return `<span class="name">${lang.originalName || lang.name || ''}</span>`; 61 }; 62 63 const codeHtml = (lang) => { 64 const code = (lang.region || lang.variant || '').split('-')[0]; 65 return `<span class="code">${code.toUpperCase()}</span>`; 66 }; 67 68 const labelHtml = (lang) => { 69 if (showFlags || showNames) return `${flagHtml(lang)}${nameHtml(lang)}`; 70 return codeHtml(lang); 71 }; 72 73 const itemsHtml = others.map(lang => { 74 const hreflang = lang.region || lang.variant || ''; 75 // data-lang carries the urlPrefix for target languages; an empty value 76 // marks the source language so the click handler can clear the cookie. 77 const dataLang = lang.isSource ? '' : (lang.urlPrefix || ''); 78 // The source link carries an explicit opt-out marker so the server clears 79 // the stored preference even when the click handler doesn't run (new tab, 80 // prefetch, cookie path/domain mismatch). PHP strips the marker and 81 // redirects to the clean URL. 82 const href = lang.isSource 83 ? `${lang.url}${lang.url.includes('?') ? '&' : '?'}universally_switch=source` 84 : lang.url; 85 return `<li><a href="${href}" hreflang="${hreflang}" lang="${hreflang}" data-lang="${dataLang}">${labelHtml(lang)}</a></li>`; 86 }).join(''); 87 88 this.shadowRoot.innerHTML = ` 89 <style> 90 :host { 91 display: inline-block; 92 font-family: inherit; 93 font-size: var(--universally-font-size, 14px); 94 line-height: 1.4; 95 ${hostStyles} 96 } 97 * { box-sizing: border-box; margin: 0; padding: 0; } 98 99 .trigger { 100 display: inline-flex; 101 align-items: center; 102 gap: 8px; 103 padding: var(--universally-trigger-padding, 8px 12px); 104 background: var(--universally-trigger-bg, #fff); 105 border: 1px solid var(--universally-trigger-border, #ddd); 106 border-radius: var(--universally-trigger-radius, 6px); 107 cursor: pointer; 108 font: inherit; 109 color: var(--universally-trigger-text, #1a1a1a); 110 transition: border-color 0.15s; 111 user-select: none; 112 } 113 .trigger:hover { border-color: var(--universally-trigger-border-hover, #999); } 114 .trigger:focus-visible { 115 outline: 2px solid var(--universally-focus, #0073aa); 116 outline-offset: 2px; 117 } 118 119 .arrow { 120 display: inline-block; 121 width: 0; 122 height: 0; 123 border-left: 4px solid transparent; 124 border-right: 4px solid transparent; 125 border-top: 5px solid currentColor; 126 transition: transform 0.2s; 127 } 128 :host([open]) .arrow { transform: rotate(180deg); } 129 130 .dropdown { 131 display: none; 132 position: absolute; 133 left: 0; 134 margin: 4px 0; 135 padding: 4px; 136 list-style: none; 137 background: var(--universally-dropdown-bg, #fff); 138 border: 1px solid var(--universally-dropdown-border, #ddd); 139 border-radius: var(--universally-dropdown-radius, 6px); 140 box-shadow: var(--universally-dropdown-shadow, 0 4px 12px rgba(0,0,0,0.1)); 141 min-width: 100%; 142 max-height: 200px; 143 overflow-y: auto; 144 z-index: 10; 145 } 146 :host([open]) .dropdown { display: block; } 147 .dropdown.above { bottom: 100%; top: auto; } 148 .dropdown.below { top: 100%; bottom: auto; } 149 150 .dropdown li { white-space: nowrap; } 151 .dropdown a { 152 display: flex; 153 align-items: center; 154 gap: 8px; 155 padding: 8px 10px; 156 color: var(--universally-dropdown-text, #1a1a1a); 157 text-decoration: none; 158 border-radius: 4px; 159 transition: background 0.15s; 160 font: inherit; 161 } 162 .dropdown a:hover { background: var(--universally-dropdown-hover-bg, #f5f5f5); } 163 .dropdown a:focus-visible { 164 outline: 2px solid var(--universally-focus, #0073aa); 165 outline-offset: -2px; 166 } 167 168 .flag {
169 width: 20px;${flagStyle === 'square' ? '' : '\n height: 20px;'} 170 object-fit: cover; 171 border-radius: ${flagRadius}; 172 flex-shrink: 0; 173 } 174 .name { color: inherit; } 175 .code { color: inherit; font-weight: 500; letter-spacing: 0.5px; } 176 177 .wrap { position: relative; display: inline-block; } 178 </style> 179 <div class="wrap"> 180 <button class="trigger" type="button" aria-expanded="false" aria-haspopup="listbox"> 181 ${labelHtml(current)}${showFlags && !showNames ? '' : '<span class="arrow" aria-hidden="true"></span>'} 182 </button> 183 <ul class="dropdown below" role="listbox">${itemsHtml}</ul> 184 </div> 185 `; 186 } 187 188 _bind() { 189 this._trigger = this.shadowRoot.querySelector('.trigger'); 190 this._dropdown = this.shadowRoot.querySelector('.dropdown'); 191 if (!this._trigger || !this._dropdown) return; 192 193 this._trigger.addEventListener('click', (e) => { 194 e.stopPropagation(); 195 this._toggle(); 196 }); 197 198 this._dropdown.querySelectorAll('a[data-lang]').forEach((a) => { 199 a.addEventListener('click', () => { 200 this._persistLanguageChoice(a.getAttribute('data-lang') || ''); 201 }); 202 }); 203 204 document.addEventListener('click', this._onDocClick, true); 205 document.addEventListener('keydown', this._onKeyDown); 206 } 207 208 _persistLanguageChoice(urlPrefix) { 209 // Empty urlPrefix means the source language: clear the cookie for instant 210 // local effect. The authoritative clear happens server-side via the 211 // ?universally_switch=source marker on the source link, which uses the 212 // exact cookie path/domain attributes the cookie was set with. 213 const maxAge = urlPrefix ? 60 * 60 * 24 * 30 : 0; 214 const value = urlPrefix ? encodeURIComponent(urlPrefix) : ''; 215 const secure = location.protocol === 'https:' ? ';secure' : ''; 216 document.cookie = `universally_lang=${value};path=/;max-age=${maxAge};samesite=Lax${secure}`; 217 } 218 219 _toggle() { 220 this._open = !this._open; 221 if (this._open) { 222 this.setAttribute('open', ''); 223 this._trigger.setAttribute('aria-expanded', 'true'); 224 this._positionDropdown(); 225 } else { 226 this._close(); 227 } 228 } 229 230 _close() { 231 this._open = false; 232 this.removeAttribute('open'); 233 this._trigger.setAttribute('aria-expanded', 'false'); 234 } 235 236 _positionDropdown() { 237 const dd = this._dropdown; 238 const pad = 20; 239 240 // Reset to default position for measurement 241 dd.classList.remove('above'); 242 dd.classList.add('below'); 243 dd.style.left = '0'; 244 245 const triggerRect = this._trigger.getBoundingClientRect(); 246 const ddRect = dd.getBoundingClientRect(); 247 const vh = window.innerHeight; 248 const vw = window.innerWidth; 249 250 // Vertical: prefer below, flip above if not enough space 251 const spaceBelow = vh - triggerRect.bottom; 252 const spaceAbove = triggerRect.top; 253 if (ddRect.height + pad > spaceBelow && spaceAbove > spaceBelow) { 254 dd.classList.remove('below'); 255 dd.classList.add('above'); 256 } 257 258 // Horizontal: shift if overflowing viewport edges 259 if (ddRect.right > vw - pad) { 260 dd.style.left = `${-(ddRect.right - vw + pad)}px`; 261 } else if (ddRect.left < pad) { 262 dd.style.left = `${pad - ddRect.left}px`; 263 } 264 } 265 266 _onDocClick(e) { 267 if (this._open && !e.composedPath().includes(this)) { 268 this._close(); 269 } 270 } 271 272 _onKeyDown(e) { 273 if (e.key === 'Escape' && this._open) { 274 this._close(); 275 this._trigger.focus(); 276 } 277 } 278} 279 280if (!customElements.get('universally-switcher')) { 281 customElements.define('universally-switcher', UniversallySwitcher); 282}
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.