PageSourceSearch

https://seogu.go.kr/js/kor/2026/layout.js

js seogu.go.kr collected 2026-10-02 20:17:16 UTC 19,147 bytes, 656 lines download raw bytes

1function debounce(func, delay) {
2	let timer;
3	return function(...args) {
4		clearTimeout(timer);
5		timer = setTimeout(() => {
6			func.apply(this, args);
7		}, delay);
8	}
9}
10function throttle(func, delay, trailing = true) {
11  let isThrottled = false;
12  let trailingTimer;
13  return function(...args) {
14    if (trailing) {
15      clearTimeout(trailingTimer);
16      trailingTimer = setTimeout(() => {
17        func.apply(this, args);
18      }, delay);
19    }
20    if (isThrottled) return;
21    func.apply(this, args);
22    isThrottled = true;
23    setTimeout(() => {
24      isThrottled = false;
25    }, delay);
26  }
27}
28function focusTrap(trap) {
29  const selectors = 'a, button, [tabindex="0"], input, textarea, select';
30
31  trap.addEventListener("keydown", (event) => {
32    if (event.key !== "Tab") return;
33
34    const focusables = [...trap.querySelectorAll(selectors)].filter(el => el.tabIndex !== -1 && !el.disabled);
35
36    if (!focusables.length) return;
37
38    const first = focusables[0];
39    const last = focusables[focusables.length - 1];
40
41    if (event.shiftKey && (document.activeElement === first || document.activeElement === trap)) {
42      event.preventDefault();
43      last.focus();
44    } else if (!event.shiftKey && document.activeElement === last) {
45      event.preventDefault();
46      first.focus();
47    }
48  });
49
50}
51
52
53
54////////////////////////////////////////
55////// 드롭다운
56;(function() { // 드롭다운
57	var $dropBtn = $(".drop-btn");
58	if (!$dropBtn.length) return;
59
60	//// 드롭다운 닫기
61	function closeDrop() {
62		$dropBtn.removeClass("active");
63		$dropBtn.attr("aria-expanded", "false");
64		$dropBtn.next(".drop-menu").removeClass("active");
65	}
66
67	//// 드롭다운 버튼 설정
68	$dropBtn.each(function() {
69		var $this = $(this);
70		var menu = this.nextElementSibling;
71		if (!menu) return;
72
73		//// Aria 설정
74		$this.attr("aria-expanded", "false");
75
76		//// 드롭다운 열기
77		$this.on("click", function() {
78			if ($(this).attr("aria-expanded") == "false") {
79				closeDrop();
80				$(this).addClass("active");
81				$(this).attr("aria-expanded", "true");
82				$(this).next(".drop-menu").addClass("active");
83			} else {
84				closeDrop();
85			}
86		});
87
88		//// 드롭다운 포커스 아웃 처리
89		$this.on("focusout", function(e) {
90			if (!e.relatedTarget) return;
91			var isFocusInside = menu.contains(e.relatedTarget) || $this.get(0).contains(e.relatedTarget);
92			if (!isFocusInside) closeDrop();
93		});
94		menu.addEventListener("focusout", function(e) {
95			if (!e.relatedTarget) return;
96			var isFocusInside = menu.contains(e.relatedTarget) || $this.get(0).contains(e.relatedTarget);
97			if (!isFocusInside) closeDrop();
98		});
99		menu.addEventListener("click", function() {
100			closeDrop();
101			$this[0].focus();
102		});
103
104	});
105
106	///// 드롭다운 외부 클릭 시 닫기
107	$(document).on("click", function(e) {
108		if (!$(e.target).closest(".drop-btn").length) {
109			closeDrop();
110		}
111	});
112	$(document).on("keydown", function(e) {
113		if (e.key === "Escape" || e.key === "Esc") {
114			var $dropBtn = $(e.target).closest(".drop-menu").prev(".drop-btn");
115			if ($dropBtn.length) $dropBtn[0].focus();
116			closeDrop();
117		}
118	});
119})();
120
121
122////////////////////////////////////////
123////// Swiper 커스텀
124class SwiperCustom {
125
126  // 기능 옵션 기본값
127  static defaults = {
128    autoBtn:        null,
129    timer:          false,
130    edgeClass:      false,
131    slideFocus:     true,
132    bulletsAria:    true,
133  };
134  static config = {
135    timerVar:       'auto-progress',
136  };
137
138  /**
139   * @version 2.4.2
140   * @param {Swiper} swiper - Swiper 인스턴스
141   * @param {SwiperCustomOption} [option] - 옵션
142   *
143   * @example
144   * new SwiperCustom(mySwiper, {
145   *   autoBtn: null,
146   *   timer: false,
147   *   timerVar: 'auto-progress',
148   *   edgeClass: false,
149   *   slideFocus: true,
150   *   bulletsAria: true,
151   * });
152   */
153  constructor(swiper, option = {}) {
154    if (Array.isArray(swiper)) {
155      swiper.forEach(sw => new SwiperCustom(sw, option));
156      return;
157    }
158
159    if (!swiper || !swiper.el) {
160      const target = swiper?.originalParams?.el || swiper?.params?.el || swiper;
161      console.warn(`SwiperCustom: 유효한 Swiper가 아닙니다. (${target})`);
162      return;
163    }
164
165    this.swiper = swiper;
166    this.autoBtnEls = null;
167    this.timerEls = null;
168    this._listeners = [];
169    this._wasRunning = null;
170
171    this.option = { ...SwiperCustom.defaults, ...SwiperCustom.config, ...option };
172
173    this.setup();
174
175    this.swiper.on('beforeDestroy', () => {
176      this._listeners.forEach(fn => fn());
177      this._listeners = [];
178      this.autoBtnEls = null;
179      this.timerEls = null;
180    });
181  }
182
183
184  // ─── 내부 유틸리티 ──────────────────────────────────────
185
186  setup() {
187    for (const key in SwiperCustom.defaults) {
188      if (!this.option[key]) continue;
189      const method = `init${key[0].toUpperCase()}${key.slice(1)}`;
190      if (typeof this[method] === 'function') this[method]();
191    }
192
193    this.on('autoplayStart', () => {
194      if (this.swiper.isLocked) {
195        this.swiper.autoplay?.stop();
196        return;
197      }
198      this.updateAutoState();
199    });
200    this.on('autoplayStop', () => {
201      this.resetTimerVar();
202      this.updateAutoState();
203    });
204    this.on('lock', () => {
205      this.stopForLock();
206    });
207    this.on('unlock', () => {
208      this.updateLockClass();
209      if (this._wasRunning) {
210        this.swiper.autoplay?.start();
211      }
212    });
213    this.on('breakpoint', () => {
214      if (!this.swiper.isLocked && this.swiper.params.autoplay?.enabled) {
215        this.swiper.autoplay?.start();
216      } else {
217        this.swiper.autoplay?.stop();
218      }
219    });
220    
221    if (this.swiper.isLocked) {
222      this.stopForLock();
223    } else {
224      this.updateAutoState();
225    }
226  }
227
228  /**
229   * CSS 선택자 또는 DOM 요소를 받아 배열로 반환
230   * swiper.el → 상위 3단계 → document 순서로 탐색
231   */
232  getEl(target) {
233    const list = Array.isArray(target) ? target : [target];
234    return list.map(t => {
235      if (t instanceof Element) return t;
236      if (typeof t !== 'string') return null;
237
238      let el = this.swiper.el;
239      for (let i = 0; i < 3; i++) {
240        const found = el?.querySelector(t);
241        if (found) return found;
242        el = el?.parentElement;
243      }
244      return document.querySelector(t);
245    }).filter(Boolean);
246  }
247
248  /** swiper 이벤트 등록 + destroy 시 자동 해제 */
249  on(event, handler) {
250    this.swiper.on(event, handler);
251    this._listeners.push(() => this.swiper.off(event, handler));
252  }
253
254  /** 외부 DOM 이벤트 등록 + destroy 시 자동 해제 */
255  listen(target, event, handler, options) {
256    target.addEventListener(event, handler, options);
257    this._listeners.push(() => target.removeEventListener(event, handler, options));
258  }
259
260  /** 자동재생 버튼·타이머 상태 UI 동기화, wrapperEl에 aria-live 세팅 보완 */
261  updateAutoState() {
262    const running = !!this.swiper.autoplay?.running;
263    this.autoBtnEls?.forEach(btn => {
264      btn.setAttribute('aria-pressed', running);
265      btn.classList.toggle('paused', !running);
266    });
267    this.swiper.wrapperEl.setAttribute('aria-live', running ? 'off' : 'polite');
268  }
269
270  /** timer 초기화 */
271  resetTimerVar() {
272    this.timerEls?.forEach(el => el.style.setProperty('--' + this.option.timerVar, '0'));
273  }
274
275  /** lock 시 autoplay 정지 + 상태 저장 */
276  stopForLock() {
277    this._wasRunning = !!this.swiper.autoplay?.running;
278    this.swiper.autoplay?.stop();
279    this.resetTimerVar();
280    this.updateAutoState();
281    this.updateLockClass();
282  }
283
284  /** lock 상태에 따라 autoBtnEls에 lockClass 토글 */
285  updateLockClass() {
286    const lockClass = this.swiper.params?.navigation?.lockClass || 'swiper-button-lock';
287    this.autoBtnEls?.forEach(btn => {
288      btn.classList.toggle(lockClass, this.swiper.isLocked);
289    });
290  }
291
292
293  // ─── 기능 초기화 (옵션별) ───────────────────────────────
294  /**
295   * @typedef {Object} SwiperCustomOption
296   *
297   * @property {string|Element|Array} [autoBtn=null]
298   * 자동재생 버튼 지정 (aria-pressed 상태 전달)
299   * - `null`: 자동재생 버튼 미적용 **(기본값)**
300   * - `'선택자'`: CSS 선택자 문자열
301   * - `DOM`: document.querySelector() 등으로 가져온 요소
302   * - `['선택자', DOM, ...]`: 여러 개일 때 배열로 전달
303   * 
304   * **※`aria-label` 미설정 시 `aria-label="Autoplay"` 자동 부여**
305   * 
306   * @property {boolean|string|Element|Array} [timer=false]
307   * 자동재생 진행도 CSS변수(`--timerVar`) 적용 대상 지정
308   * - `false`: 미적용 **(기본값)**
309   * - `true`: swiper.el + autoBtn에 적용
310   * - `'선택자'`: CSS 선택자 문자열
311   * - `DOM`: document.querySelector() 등으로 가져온 요소
312   * - `['선택자', DOM, ...]`: 여러 개일 때 배열로 전달
313   * 
314   * @property {string} [timerVar='--auto-progress']
315   * 자동재생 타이머 CSS변수 이름 지정
316   * - **기본값**: `'auto-progress'`
317   * - 예시: `'swiper-autoplay-timer'`
318   *
319   * @property {boolean|string|Element} [edgeClass=false]
320   * Swiper 진행도에 따라 클래스 업데이트
321   * - `false`: 미적용 **(기본값)**
322   * - `true`: swiper.el에 적용
323   * - `'선택자'`: CSS 선택자 문자열
324   * - `DOM`: document.querySelector() 등으로 가져온 요소
325   * 
326   * **※`loop` 설정 시 is-begin/is-end는 미적용**
327   * 
328   * @property {boolean} [slideFocus=true]
329   * - `true`: 보이는 슬라이드만 포커싱 **(기본값)**
330   * - `false`: 모든 슬라이드 포커싱
331   */
332
333  initAutoBtn() {
334    this.autoBtnEls = this.getEl(this.option.autoBtn);
335    if (!this.autoBtnEls.length) {
336      console.warn('SwiperCustom Warning: autoBtn 요소를 찾을 수 없습니다.', this.swiper.el);
337      return;
338    }
339
340    this.autoBtnEls.forEach(btn => {
341      if (!btn.hasAttribute('aria-label')) btn.setAttribute('aria-label', 'Autoplay');
342      const wrapperId = this.swiper.wrapperEl.id;
343      if (wrapperId) btn.setAttribute('aria-controls', wrapperId);
344      this.listen(btn, 'click', () => {
345        if (this.swiper.isLocked) return;
346        this.swiper.autoplay?.running
347          ? this.swiper.autoplay?.stop()
348          : this.swiper.autoplay?.start();
349      });
350    });
351  }
352
353  initTimer() {
354    this.timerEls = [];
355    if (this.option.timer === true) {
356      this.timerEls.push(this.swiper.el);
357      if (this.autoBtnEls?.length) this.timerEls.push(...this.autoBtnEls);
358    } else {
359      this.timerEls.push(...this.getEl(this.option.timer));
360      if (!this.timerEls.length) {
361        console.warn('SwiperCustom Warning: timer 요소를 찾을 수 없습니다.', this.swiper.el);
362      }
363    }
364
365    if (this.timerEls.length) {
366      this.on('autoplayTimeLeft', (s, t, p) => {
367        const value = (1 - p).toFixed(4);
368        this.timerEls.forEach(el => el.style.setProperty('--' + this.option.timerVar, value));
369      });
370    }
371  }
372
373  initEdgeClass() {
374    const sw = this.swiper;
375    const target = this.option.edgeClass === true
376      ? sw.el
377      : this.getEl(this.option.edgeClass)[0];
378
379    if (!target) return;
380
381    const update = () => {
382      if (!sw.params.loop) {
383        target.classList.toggle('is-begin', sw.progress === 0);
384        target.classList.toggle('is-end', sw.progress === 1);
385      }
386      target.classList.toggle('is-overflow', !sw.isLocked);
387    };
388
389    update();
390    this.on('progress', update);
391  }
392
393  initSlideFocus() {
394    const sw = this.swiper;
395    const visibleClass = sw.params.slideVisibleClass || 'swiper-slide-visible';
396    sw.params.watchSlidesProgress = true;
397    sw.update();
398
399    const focusableSelector = 'a, button, input, select, textarea, [tabindex], iframe, video, audio';
400
401    const update = () => {
402      if (!sw.slides?.length) return;
403      sw.slides.forEach(slide => {
404        const hidden = !slide.classList.contains(visibleClass);
405        slide.setAttribute('aria-hidden', String(hidden));
406        if (hidden) {
407          slide.setAttribute('tabindex', '-1');
408          slide.querySelectorAll(focusableSelector).forEach(el => el.setAttribute('tabindex', '-1'));
409        } else {
410          slide.removeAttribute('tabindex');
411          slide.querySelectorAll(focusableSelector).forEach(el => el.removeAttribute('tabindex'));
412        }
413      });
414    };
415
416    update();
417    this.on('slideChangeTransitionStart', update);
418    this.on('observerUpdate', update);
419    this.on('resize', update);
420    this.listen(window, 'load', update);
421  }
422
423  initBulletsAria() {
424    if (!this.swiper.pagination?.bullets?.length) return;
425    const activeClass = this.swiper.pagination.bulletActiveClass || 'swiper-pagination-bullet-active';
426    const update = () => {
427      const bullets = this.swiper.pagination.bullets;
428      if (!bullets?.length) return;
429      bullets.forEach(bullet => bullet.removeAttribute('title'));
430      const active = [...bullets].find(b => b.classList.contains(activeClass));
431      active?.setAttribute('title', '선택됨');
432    };
433    update();
434    this.on('slideChange', update);
435  }
436}
437
438
439////////////////////////////////////////
440////// 탭
441const ExTab = (function () {
442  'use strict';
443
444  const DEFAULTS = {
445    tabBtn: '.tab-btn',
446    activeClass: 'active',
447    defaultIndex: 0,
448    onChange: null,
449  };
450
451  class ExTab {
452    constructor(el, options = {}) {
453      if (typeof el === 'string') el = document.querySelector(el);
454      if (!el || el._tabInstance) return;
455
456      this.el = el;
457      this.options = Object.assign({}, DEFAULTS, options);
458      this.tabs = [];
459      this.currentIndex = -1;
460
461      el._tabInstance = this;
462      this._init();
463    }
464
465    _init() {
466      const o = this.options;
467      const btns = this.el.querySelectorAll(o.tabBtn);
468
469      if (!btns.length) return;
470
471      const firstParent = btns[0].parentElement;
472      this.list = firstParent.tagName === 'LI' ? firstParent.parentElement : firstParent;
473
474      btns.forEach((btn, i) => {
475        const li = btn.closest('li');
476        const controlsId = btn.getAttribute('aria-controls');
477        const panel = controlsId ? document.getElementById(controlsId) : null;
478        const isTabItem = !!controlsId;
479
480        this.tabs.push({ btn, li, panel, isTabItem });
481
482        if (isTabItem) {
483          btn.addEventListener('click', (e) => {
484            e.preventDefault();
485            this.goTo(i, true);
486          });
487        }
488      });
489
490      this._setupAria();
491      this._activateDefault();
492    }
493
494    // ── ARIA ──
495
496    _setupAria() {
497
498      this.tabs.forEach((item, i) => {
499        if (!item.isTabItem) return;
500
501        item.btn.setAttribute('aria-current', 'false');
502      });
503    }
504
505    // ── 초기 활성화 ──
506
507    _activateDefault() {
508      const o = this.options;
509      const tabItems = this.tabs.filter(t => t.isTabItem);
510
511      if (!tabItems.length) return;
512
513      let activeIdx = -1;
514
515      this.tabs.forEach((item, i) => {
516        if (item.isTabItem && item.btn.classList.contains(o.activeClass)) {
517          activeIdx = i;
518        }
519      });
520
521      if (activeIdx === -1 && o.defaultIndex >= 0) {
522        const target = tabItems[o.defaultIndex];
523        if (target) activeIdx = this.tabs.indexOf(target);
524      }
525
526      if (activeIdx !== -1) {
527        this.goTo(activeIdx, false);
528      }
529    }
530
531    // ── 공개 메서드 ──
532
533    goTo(index, isFocus) {
534      const o = this.options;
535      const target = this.tabs[index];
536      if (!target || !target.isTabItem) return;
537
538      const prevIndex = this.currentIndex;
539
540      this.tabs.forEach((item) => {
541        if (!item.isTabItem) return;
542
543        item.li?.classList.remove(o.activeClass);
544        item.btn.classList.remove(o.activeClass);
545        item.btn.setAttribute('aria-current', 'false');
546
547        if (item.panel) {
548          item.panel.classList.remove(o.activeClass);
549        }
550      });
551
552      target.li?.classList.add(o.activeClass);
553      target.btn.classList.add(o.activeClass);
554      target.panel.classList.add(o.activeClass);
555
556      target.btn.setAttribute('aria-current', 'true');
557
558      this.currentIndex = index;
559
560      if (isFocus) target.btn.focus();
561
562      if (prevIndex !== index && typeof o.onChange === 'function') {
563        o.onChange(index, target.btn, target.panel);
564      }
565    }
566
567    static init(selector, options = {}) {
568      return Array.from(document.querySelectorAll(selector)).map(
569        el => new ExTab(el, options)
570      );
571    }
572  }
573
574  return ExTab;
575})();
576
577
578;(() => { // 배너 모음
579	const banner = document.querySelector('.bottom-banner');
580	if (!banner) return;
581
582  let swiper = null;
583  let swiperMode = null;
584	const swiperEl = banner.querySelector('.swiper');
585  const wrapperEl = banner.querySelector('.swiper-wrapper');
586	if (!swiperEl) return;
587
588  const bannerInit = () => {
589
590    if (swiper) {
591      swiper.destroy();
592      swiper = null;
593    }
594
595    // 슬라이드 수가 많으면 loop, 적으면 rewind
596    if (wrapperEl.scrollWidth > swiperEl.offsetWidth * 1.5) {
597      swiperMode = {
598        loop: true,
599        loopAdditionalSlides: 1,
600      };
601      swiperEl.dataset.mode = 'loop';
602    } else {
603      swiperMode = {
604        rewind: true,
605      };
606      swiperEl.dataset.mode = 'rewind';
607    }
608
609    swiper = new Swiper(swiperEl, {
610      ...swiperMode,
611      slidesPerView: 'auto',
612      speed: 600,
613      autoplay: {
614        delay: 5000,
615        disableOnInteraction: false,
616      },
617      navigation: {
618        nextEl: banner.querySelector('.swiper-btn.next'),
619        prevEl: banner.querySelector('.swiper-btn.prev'),
620      },
621    });
622    new SwiperCustom(swiper, {
623      autoBtn: banner.querySelector('.swiper-btn.toggle'),
624      edgeClass: true,
625    });
626  }
627  bannerInit();
628
629  let prevWidth = window.innerWidth;
630  window.addEventListener('resize', debounce(() => {
631    const currentWidth = window.innerWidth;
632    if (currentWidth !== prevWidth) {
633      prevWidth = currentWidth;
634      bannerInit();
635    }
636  }, 200));
637
638})();
639
640
641;(() => { // 페이지 스크롤 감지
642  const html = document.documentElement;
643
644	const TOL = 100;
645	function setPageClass() {
646		const atEnd = window.scrollY + window.innerHeight >= html.scrollHeight - TOL;
647		html.classList.toggle("is-scroll-end", atEnd);
648    html.classList.toggle("is-scrolling", window.scrollY > 100);
649	}
650
651	window.addEventListener("scroll", throttle(setPageClass, 100));
652	if (window.visualViewport) {
653		window.visualViewport.addEventListener("scroll", setPageClass);
654	}
655
656})();

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.