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 // Resolved server-side so it honors the "Hreflang Format" setting; the 75 // region/variant chain is a fallback for configs cached before that existed. 76 const hreflang = lang.hreflang || lang.region || lang.variant || ''; 77 // data-lang carries the urlPrefix for target languages; an empty value 78 // marks the source language so the click handler can clear the cookie. 79 const dataLang = lang.isSource ? '' : (lang.urlPrefix || ''); 80 // The source link carries an explicit opt-out marker so the server clears 81 // the stored preference even when the click handler doesn't run (new tab, 82 // prefetch, cookie path/domain mismatch). PHP strips the marker and 83 // redirects to the clean URL. 84 const href = lang.isSource 85 ? `${lang.url}${lang.url.includes('?') ? '&' : '?'}universally_switch=source` 86 : lang.url; 87 return `<li><a href="${href}" hreflang="${hreflang}" lang="${hreflang}" data-lang="${dataLang}">${labelHtml(lang)}</a></li>`; 88 }).join(''); 89 90 this.shadowRoot.innerHTML = ` 91 <style> 92 :host { 93 display: inline-block; 94 font-family: inherit; 95 font-size: var(--universally-font-size, 14px); 96 line-height: 1.4; 97 ${hostStyles} 98 } 99 * { box-sizing: border-box; margin: 0; padding: 0; } 100 101 .trigger { 102 display: inline-flex; 103 align-items: center; 104 gap: 8px; 105 padding: var(--universally-trigger-padding, 8px 12px); 106 background: var(--universally-trigger-bg, #ffffff); 107 border: 1px solid var(--universally-trigger-border, #d1d5db); 108 border-radius: var(--universally-trigger-radius, 6px); 109 cursor: pointer; 110 font: inherit; 111 color: var(--universally-trigger-text, #111827); 112 transition: border-color 0.15s; 113 user-select: none; 114 } 115 .trigger:hover { border-color: var(--universally-trigger-border-hover, #9ca3af); } 116 .trigger:focus-visible { 117 outline: 2px solid var(--universally-focus, #0073aa); 118 outline-offset: 2px; 119 } 120 121 .arrow { 122 display: inline-block; 123 width: 0; 124 height: 0; 125 border-left: 4px solid transparent; 126 border-right: 4px solid transparent; 127 border-top: 5px solid currentColor; 128 transition: transform 0.2s; 129 } 130 :host([open]) .arrow { transform: rotate(180deg); } 131 132 .dropdown { 133 display: none; 134 position: absolute; 135 left: 0; 136 margin: 4px 0; 137 padding: 4px; 138 list-style: none; 139 background: var(--universally-dropdown-bg, #ffffff); 140 border: 1px solid var(--universally-dropdown-border, #d1d5db); 141 border-radius: var(--universally-dropdown-radius, 6px); 142 box-shadow: var(--universally-dropdown-shadow, 0 4px 12px rgba(0,0,0,0.1)); 143 min-width: 100%; 144 max-height: 200px; 145 overflow-y: auto; 146 z-index: 10; 147 } 148 :host([open]) .dropdown { display: block; } 149 .dropdown.above { bottom: 100%; top: auto; } 150 .dropdown.below { top: 100%; bottom: auto; } 151 152 .dropdown li { white-space: nowrap; } 153 .dropdown a { 154 display: flex; 155 align-items: center; 156 gap: 8px; 157 padding: 8px 10px; 158 color: var(--universally-dropdown-text, #111827); 159 text-decoration: none; 160 border-radius: 4px; 161 transition: background 0.15s; 162 font: inherit; 163 } 164 .dropdown a:hover { background: var(--universally-dropdown-hover-bg, #f3f4f6); } 165 .dropdown a:focus-visible { 166 outline: 2px solid var(--universally-focus, #0073aa); 167 outline-offset: -2px; 168 } 169 170 .flag {
171 width: 20px;${flagStyle === 'square' ? '' : '\n height: 20px;'} 172 object-fit: cover; 173 border-radius: ${flagRadius}; 174 flex-shrink: 0; 175 } 176 .name { color: inherit; } 177 .code { color: inherit; font-weight: 500; letter-spacing: 0.5px; } 178 179 .wrap { position: relative; display: inline-block; } 180 </style> 181 <div class="wrap"> 182 <button class="trigger" type="button" aria-expanded="false" aria-haspopup="listbox"> 183 ${labelHtml(current)}${showFlags && !showNames ? '' : '<span class="arrow" aria-hidden="true"></span>'} 184 </button> 185 <ul class="dropdown below" role="listbox">${itemsHtml}</ul> 186 </div> 187 `; 188 } 189 190 _bind() { 191 this._trigger = this.shadowRoot.querySelector('.trigger'); 192 this._dropdown = this.shadowRoot.querySelector('.dropdown'); 193 if (!this._trigger || !this._dropdown) return; 194 195 this._trigger.addEventListener('click', (e) => { 196 e.stopPropagation(); 197 this._toggle(); 198 }); 199 200 this._dropdown.querySelectorAll('a[data-lang]').forEach((a) => { 201 a.addEventListener('click', () => { 202 this._persistLanguageChoice(a.getAttribute('data-lang') || ''); 203 }); 204 }); 205 206 document.addEventListener('click', this._onDocClick, true); 207 document.addEventListener('keydown', this._onKeyDown); 208 } 209 210 _persistLanguageChoice(urlPrefix) { 211 // The server only honors the cookie when "Remember visitor's language" is 212 // on; writing it anyway would leave a stray cookie the server ignores. 213 if (this._config.rememberLanguage === false) return; 214 215 // Empty urlPrefix means the source language: store the literal value 216 // "source" for instant local effect. The hosted runtime script shares this 217 // cookie and reads "source" as "visitor opted out, do not redirect"; 218 // deleting the cookie instead would make the script redirect again on the 219 // next source page load. The authoritative write still happens server-side 220 // via the ?universally_switch=source marker on the source link, which uses 221 // the exact cookie path/domain attributes the cookie was set with. 222 const maxAge = 60 * 60 * 24 * 30; 223 const value = urlPrefix ? encodeURIComponent(urlPrefix) : 'source'; 224 const secure = location.protocol === 'https:' ? ';secure' : ''; 225 document.cookie = `universally_lang=${value};path=/;max-age=${maxAge};samesite=Lax${secure}`; 226 } 227 228 _toggle() { 229 this._open = !this._open; 230 if (this._open) { 231 this.setAttribute('open', ''); 232 this._trigger.setAttribute('aria-expanded', 'true'); 233 this._positionDropdown(); 234 } else { 235 this._close(); 236 } 237 } 238 239 _close() { 240 this._open = false; 241 this.removeAttribute('open'); 242 this._trigger.setAttribute('aria-expanded', 'false'); 243 } 244 245 _positionDropdown() { 246 const dd = this._dropdown; 247 const pad = 20; 248 249 // Reset to default position for measurement 250 dd.classList.remove('above'); 251 dd.classList.add('below'); 252 dd.style.left = '0'; 253 254 const triggerRect = this._trigger.getBoundingClientRect(); 255 const ddRect = dd.getBoundingClientRect(); 256 const vh = window.innerHeight; 257 const vw = window.innerWidth; 258 259 // Vertical: prefer below, flip above if not enough space 260 const spaceBelow = vh - triggerRect.bottom; 261 const spaceAbove = triggerRect.top; 262 if (ddRect.height + pad > spaceBelow && spaceAbove > spaceBelow) { 263 dd.classList.remove('below'); 264 dd.classList.add('above'); 265 } 266 267 // Horizontal: shift if overflowing viewport edges 268 if (ddRect.right > vw - pad) { 269 dd.style.left = `${-(ddRect.right - vw + pad)}px`; 270 } else if (ddRect.left < pad) { 271 dd.style.left = `${pad - ddRect.left}px`; 272 } 273 } 274 275 _onDocClick(e) { 276 if (this._open && !e.composedPath().includes(this)) { 277 this._close(); 278 } 279 } 280 281 _onKeyDown(e) { 282 if (e.key === 'Escape' && this._open) { 283 this._close(); 284 this._trigger.focus(); 285 } 286 } 287} 288 289if (!customElements.get('universally-switcher')) { 290 customElements.define('universally-switcher', UniversallySwitcher); 291}
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.