PageSourceSearch

https://icde2015.kr/wp-content/plugins/universally-language-transl…/assets/js/language-switcher.js?ver=1.0.9

js icde2015.kr collected 2026-09-28 12:12:21 UTC 10,006 bytes, 291 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      // 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.