PageSourceSearch

https://www.paralympic.org.au/wp-content/plugins/universally-langu…/assets/js/language-switcher.js?ver=1.0.8

js paralympic.org.au collected 2026-09-24 09:53:13 UTC 9,366 bytes, 282 lines download raw bytes

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.