slick-address-kr 1.0.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,639 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/react/index.tsx
21
+ var react_exports = {};
22
+ __export(react_exports, {
23
+ KoreanAddressFinderView: () => KoreanAddressFinderView,
24
+ useKoreanAddress: () => useKoreanAddress
25
+ });
26
+ module.exports = __toCommonJS(react_exports);
27
+ var import_react = require("react");
28
+
29
+ // src/constants.ts
30
+ var DEFAULT_ENDPOINT = "https://business.juso.go.kr/addrlink/addrLinkApi.do";
31
+ var API_KEY_ISSUE_URL = "https://business.juso.go.kr/jst/jstAddressApiApplicationWrite";
32
+
33
+ // src/errors.ts
34
+ var JusoApiError = class _JusoApiError extends Error {
35
+ constructor(code, apiMessage) {
36
+ const userMessage = describeJusoError(code, apiMessage);
37
+ super(`[${code}] ${apiMessage}`);
38
+ this.name = "JusoApiError";
39
+ this.code = code;
40
+ this.userMessage = userMessage;
41
+ this.isInputError = isInputErrorCode(code);
42
+ Object.setPrototypeOf(this, _JusoApiError.prototype);
43
+ }
44
+ };
45
+ var INPUT_ERROR_MESSAGES = {
46
+ // 검색어가 입력되지 않았습니다.
47
+ E0005: "\uAC80\uC0C9\uC5B4\uB97C \uC785\uB825\uD574 \uC8FC\uC138\uC694.",
48
+ // 주소를 상세히 입력해 주시기 바랍니다. ('서울', '서울시'처럼 시도명만 넣은 경우)
49
+ E0006: "\uC8FC\uC18C\uB97C \uC870\uAE08 \uB354 \uC790\uC138\uD788 \uC785\uB825\uD574 \uC8FC\uC138\uC694. (\uC608: \uD310\uAD50\uC5ED\uB85C 235)",
50
+ // 검색어는 두글자 이상 입력되어야 합니다.
51
+ E0008: "\uB450 \uAE00\uC790 \uC774\uC0C1 \uC785\uB825\uD574 \uC8FC\uC138\uC694.",
52
+ // 검색어는 문자와 숫자 같이 입력되어야 합니다. (숫자만 입력한 경우)
53
+ E0009: "\uB3C4\uB85C\uBA85\uC774\uB098 \uAC74\uBB3C\uBA85\uC744 \uD568\uAED8 \uC785\uB825\uD574 \uC8FC\uC138\uC694.",
54
+ // 특수문자+숫자만으로는 검색이 불가능 합니다.
55
+ E0012: "\uB3C4\uB85C\uBA85\uC774\uB098 \uAC74\uBB3C\uBA85\uC744 \uD568\uAED8 \uC785\uB825\uD574 \uC8FC\uC138\uC694.",
56
+ // SQL 예약어 또는 특수문자( %,=,>,<,[,] )는 검색이 불가능 합니다.
57
+ E0013: "\uD2B9\uC218\uBB38\uC790(%, =, <, > \uB4F1)\uB97C \uBE7C\uACE0 \uB2E4\uC2DC \uC785\uB825\uD574 \uC8FC\uC138\uC694."
58
+ };
59
+ function isInputErrorCode(code) {
60
+ return Object.prototype.hasOwnProperty.call(INPUT_ERROR_MESSAGES, code);
61
+ }
62
+ function describeJusoError(code, apiMessage) {
63
+ if (isInputErrorCode(code)) return INPUT_ERROR_MESSAGES[code];
64
+ switch (code) {
65
+ // 승인되지 않은 KEY 입니다. (키가 틀렸거나 비어 있는 경우)
66
+ case "E0001":
67
+ return `\uC2B9\uC778\uD0A4\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uAC70\uB098 \uC774 \uB3C4\uBA54\uC778\uC5D0\uC11C \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uBC1C\uAE09\uBC1B\uC740 \uD0A4\uB97C \uADF8\uB300\uB85C \uB123\uC5C8\uB294\uC9C0, \uBC1C\uAE09 \uC2DC \uB4F1\uB85D\uD55C \uB3C4\uBA54\uC778\uACFC \uD604\uC7AC \uC2E4\uD589 \uC911\uC778 \uB3C4\uBA54\uC778\uC774 \uAC19\uC740\uC9C0 \uD655\uC778\uD558\uC138\uC694. \uBC1C\uAE09/\uC218\uC815: ${API_KEY_ISSUE_URL}`;
68
+ default:
69
+ return `\uC8FC\uC18C \uAC80\uC0C9\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4. (${code}) ${apiMessage}`.trim();
70
+ }
71
+ }
72
+
73
+ // src/api-client.ts
74
+ var AddressApiClient = class {
75
+ constructor(options = {}) {
76
+ this.apiKey = options.apiKey ?? "";
77
+ this.endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
78
+ this.usesDefaultEndpoint = this.endpoint === DEFAULT_ENDPOINT;
79
+ this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
80
+ }
81
+ async search(keyword, options = {}) {
82
+ const trimmed = keyword?.trim() ?? "";
83
+ if (trimmed.length === 0) {
84
+ return { results: [], totalCount: 0 };
85
+ }
86
+ if (this.usesDefaultEndpoint && !this.apiKey) {
87
+ throw new Error(
88
+ `API \uD0A4\uAC00 \uC124\uC815\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. KoreanAddressFinder \uC0DD\uC131 \uC2DC apiKey \uC635\uC158\uC744 \uC804\uB2EC\uD558\uAC70\uB098, endpoint \uC635\uC158\uC73C\uB85C \uC790\uCCB4 \uD504\uB85D\uC2DC \uC11C\uBC84\uB97C \uC9C0\uC815\uD558\uC138\uC694. \uD0A4 \uBC1C\uAE09: ${API_KEY_ISSUE_URL}`
89
+ );
90
+ }
91
+ const params = new URLSearchParams({
92
+ currentPage: String(options.currentPage ?? 1),
93
+ countPerPage: String(options.countPerPage ?? 10),
94
+ keyword: trimmed,
95
+ resultType: "json"
96
+ });
97
+ if (this.usesDefaultEndpoint) {
98
+ params.set("confmKey", this.apiKey);
99
+ }
100
+ const response = await this.fetchFn(`${this.endpoint}?${params.toString()}`, {
101
+ method: "GET",
102
+ signal: options.signal
103
+ });
104
+ if (!response.ok) {
105
+ throw new Error(`\uC8FC\uC18C \uAC80\uC0C9 \uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4 (HTTP ${response.status})`);
106
+ }
107
+ const data = await response.json();
108
+ return this.toSearchResult(data);
109
+ }
110
+ toSearchResult(data) {
111
+ const { errorCode, errorMessage } = data.results.common;
112
+ if (errorCode !== "0") {
113
+ throw new JusoApiError(errorCode, errorMessage);
114
+ }
115
+ const juso = data.results.juso ?? [];
116
+ const results = juso.map((item) => ({
117
+ roadAddress: item.roadAddr,
118
+ jibunAddress: item.jibunAddr,
119
+ zipCode: item.zipNo,
120
+ buildingName: item.bdNm || "",
121
+ sido: item.siNm,
122
+ sigungu: item.sggNm,
123
+ bname: item.emdNm,
124
+ extraAddress: buildExtraAddress(item),
125
+ codes: toCodes(item)
126
+ }));
127
+ return {
128
+ results,
129
+ totalCount: Number.parseInt(data.results.common.totalCount, 10) || 0
130
+ };
131
+ }
132
+ };
133
+ function toCodes(item) {
134
+ return {
135
+ admCd: item.admCd,
136
+ rnMgtSn: item.rnMgtSn,
137
+ udrtYn: item.udrtYn,
138
+ buldMnnm: item.buldMnnm,
139
+ buldSlno: item.buldSlno
140
+ };
141
+ }
142
+ function buildExtraAddress(item) {
143
+ const extras = [];
144
+ if (item.bdNm) extras.push(item.bdNm);
145
+ if (item.detBdNmList) extras.push(item.detBdNmList);
146
+ return extras.join(", ");
147
+ }
148
+
149
+ // src/address-finder.ts
150
+ var MIN_AUTOCOMPLETE_LENGTH = 2;
151
+ var instanceCounter = 0;
152
+ var KoreanAddressFinder = class {
153
+ constructor(options = {}) {
154
+ // instanceCounter는 모듈 단위(=번들 단위)로 격리된다. 한 페이지에 ESM과
155
+ // IIFE 번들이 동시에 로드되면 카운터가 각각 0부터 시작해 id가 겹칠 수
156
+ // 있으므로, 짧은 무작위 접미사를 더해 번들 경계를 넘는 충돌을 막는다.
157
+ this.instanceId = `kaf-${++instanceCounter}-${Math.random().toString(36).slice(2, 8)}`;
158
+ this.activeIndex = -1;
159
+ this.container = null;
160
+ this.searchInput = null;
161
+ this.resultsContainer = null;
162
+ this.detailInput = null;
163
+ this.isOpen = false;
164
+ this.debounceTimer = null;
165
+ this.abortController = null;
166
+ this.selected = null;
167
+ this.results = [];
168
+ this.listeners = [];
169
+ this.destroyed = false;
170
+ this.apiClient = new AddressApiClient({
171
+ apiKey: options.apiKey,
172
+ endpoint: options.endpoint,
173
+ fetchFn: options.fetchFn
174
+ });
175
+ this.options = {
176
+ apiKey: options.apiKey || "",
177
+ countPerPage: options.countPerPage || 10,
178
+ currentPage: options.currentPage || 1,
179
+ containerId: options.containerId || "address-finder-container",
180
+ onSelect: options.onSelect || (() => {
181
+ }),
182
+ showDetailInput: options.showDetailInput !== false,
183
+ customClass: options.customClass || "",
184
+ autocompleteMode: options.autocompleteMode || false,
185
+ endpoint: options.endpoint ?? "",
186
+ fetchFn: options.fetchFn
187
+ };
188
+ }
189
+ /**
190
+ * 주소 검색 UI 초기화
191
+ */
192
+ init(targetElement) {
193
+ if (this.container) {
194
+ this.teardown();
195
+ }
196
+ this.destroyed = false;
197
+ if (targetElement) {
198
+ this.container = targetElement;
199
+ } else {
200
+ this.container = document.getElementById(this.options.containerId);
201
+ }
202
+ if (!this.container) {
203
+ throw new Error(`Container element not found: ${this.options.containerId}`);
204
+ }
205
+ this.render();
206
+ this.attachEventListeners();
207
+ }
208
+ /**
209
+ * UI 렌더링
210
+ */
211
+ render() {
212
+ if (!this.container) return;
213
+ const className = this.options.customClass ? ` ${this.escapeHtml(this.options.customClass)}` : "";
214
+ const listId = `${this.instanceId}-listbox`;
215
+ this.container.innerHTML = `
216
+ <div class="kaf-wrapper${className}">
217
+ <div class="kaf-search-box">
218
+ <input
219
+ type="text"
220
+ class="kaf-search-input"
221
+ placeholder="\uC8FC\uC18C\uB97C \uC785\uB825\uD558\uC138\uC694 (\uC608: \uD310\uAD50\uC5ED\uB85C 235)"
222
+ autocomplete="off"
223
+ role="combobox"
224
+ aria-autocomplete="list"
225
+ aria-expanded="false"
226
+ aria-controls="${listId}"
227
+ />
228
+ <button type="button" class="kaf-search-btn">\uAC80\uC0C9</button>
229
+ </div>
230
+ <div class="kaf-results" style="display: none;">
231
+ <div class="kaf-results-header">
232
+ <span class="kaf-results-count" aria-live="polite">\uAC80\uC0C9 \uACB0\uACFC</span>
233
+ <button type="button" class="kaf-close-btn" aria-label="\uAC80\uC0C9 \uACB0\uACFC \uB2EB\uAE30">\xD7</button>
234
+ </div>
235
+ <div class="kaf-results-list" id="${listId}" role="listbox" aria-label="\uC8FC\uC18C \uAC80\uC0C9 \uACB0\uACFC"></div>
236
+ <div class="kaf-results-message"></div>
237
+ </div>
238
+ ${this.options.showDetailInput ? `
239
+ <div class="kaf-detail-box" style="display: none;">
240
+ <input
241
+ type="text"
242
+ class="kaf-detail-input"
243
+ placeholder="\uC0C1\uC138\uC8FC\uC18C\uB97C \uC785\uB825\uD558\uC138\uC694"
244
+ aria-label="\uC0C1\uC138\uC8FC\uC18C"
245
+ />
246
+ </div>
247
+ ` : ""}
248
+ <div class="kaf-selected-address" style="display: none;"></div>
249
+ </div>
250
+ `;
251
+ this.searchInput = this.container.querySelector(".kaf-search-input");
252
+ this.resultsContainer = this.container.querySelector(".kaf-results");
253
+ this.detailInput = this.container.querySelector(".kaf-detail-input");
254
+ }
255
+ /**
256
+ * 이벤트를 등록하면서 해제 함수를 모아둔다.
257
+ * destroy()가 이 목록만 비우면 리스너가 남지 않는다.
258
+ */
259
+ on(target, type, handler) {
260
+ target.addEventListener(type, handler);
261
+ this.listeners.push(() => target.removeEventListener(type, handler));
262
+ }
263
+ /**
264
+ * 이벤트 리스너 등록
265
+ */
266
+ attachEventListeners() {
267
+ if (!this.container) return;
268
+ const searchBtn = this.container.querySelector(".kaf-search-btn");
269
+ if (searchBtn) {
270
+ this.on(searchBtn, "click", () => void this.search());
271
+ }
272
+ if (this.searchInput) {
273
+ this.on(
274
+ this.searchInput,
275
+ "keydown",
276
+ (e) => this.handleKeydown(e)
277
+ );
278
+ if (this.options.autocompleteMode) {
279
+ this.on(this.searchInput, "input", () => this.handleAutocomplete());
280
+ }
281
+ }
282
+ const closeBtn = this.container.querySelector(".kaf-close-btn");
283
+ if (closeBtn) {
284
+ this.on(closeBtn, "click", () => this.closeResults());
285
+ }
286
+ if (this.detailInput) {
287
+ this.on(this.detailInput, "input", () => this.emitSelect("detail-change"));
288
+ }
289
+ this.on(document, "click", (e) => {
290
+ if (this.isOpen && !this.container?.contains(e.target)) {
291
+ this.closeResults();
292
+ }
293
+ });
294
+ }
295
+ handleKeydown(event) {
296
+ const count = this.results.length;
297
+ switch (event.key) {
298
+ case "ArrowDown":
299
+ if (!this.isOpen || count === 0) return;
300
+ event.preventDefault();
301
+ this.setActiveIndex(this.activeIndex >= count - 1 ? 0 : this.activeIndex + 1);
302
+ return;
303
+ case "ArrowUp":
304
+ if (!this.isOpen || count === 0) return;
305
+ event.preventDefault();
306
+ this.setActiveIndex(this.activeIndex <= 0 ? count - 1 : this.activeIndex - 1);
307
+ return;
308
+ case "Enter":
309
+ event.preventDefault();
310
+ if (this.isOpen && this.activeIndex >= 0 && this.results[this.activeIndex]) {
311
+ this.selectAddress(this.results[this.activeIndex]);
312
+ } else {
313
+ void this.search();
314
+ }
315
+ return;
316
+ case "Escape":
317
+ if (!this.isOpen) return;
318
+ event.preventDefault();
319
+ this.closeResults();
320
+ return;
321
+ default:
322
+ }
323
+ }
324
+ /**
325
+ * 활성 항목을 옮기고 ARIA 속성과 하이라이트를 함께 갱신한다.
326
+ * -1은 활성 항목 없음을 뜻한다.
327
+ */
328
+ setActiveIndex(index) {
329
+ this.activeIndex = index;
330
+ const options = this.container?.querySelectorAll(".kaf-result-item");
331
+ options?.forEach((option, i) => {
332
+ const active = i === index;
333
+ if (active) {
334
+ option.setAttribute("aria-selected", "true");
335
+ } else {
336
+ option.removeAttribute("aria-selected");
337
+ }
338
+ option.classList.toggle("kaf-result-item--active", active);
339
+ if (active) {
340
+ option.scrollIntoView?.({ block: "nearest" });
341
+ }
342
+ });
343
+ if (this.searchInput) {
344
+ const active = index >= 0 ? options?.[index] : void 0;
345
+ if (active) {
346
+ this.searchInput.setAttribute("aria-activedescendant", active.id);
347
+ } else {
348
+ this.searchInput.removeAttribute("aria-activedescendant");
349
+ }
350
+ }
351
+ }
352
+ /**
353
+ * 자동완성 처리 (디바운스 적용)
354
+ */
355
+ handleAutocomplete() {
356
+ if (this.debounceTimer) {
357
+ clearTimeout(this.debounceTimer);
358
+ }
359
+ const keyword = this.searchInput?.value.trim() ?? "";
360
+ if (keyword.length < MIN_AUTOCOMPLETE_LENGTH) {
361
+ this.debounceTimer = null;
362
+ this.abortController?.abort();
363
+ this.abortController = null;
364
+ this.closeResults();
365
+ return;
366
+ }
367
+ this.debounceTimer = window.setTimeout(() => {
368
+ this.search();
369
+ }, 300);
370
+ }
371
+ /**
372
+ * 현재 입력값으로 주소를 검색한다.
373
+ * 진행 중이던 이전 요청은 취소되며, 취소된 응답은 무시된다.
374
+ */
375
+ async search() {
376
+ const keyword = this.searchInput?.value.trim();
377
+ if (!keyword) return;
378
+ this.abortController?.abort();
379
+ const controller = new AbortController();
380
+ this.abortController = controller;
381
+ try {
382
+ const { results, totalCount } = await this.apiClient.search(keyword, {
383
+ currentPage: this.options.currentPage,
384
+ countPerPage: this.options.countPerPage,
385
+ signal: controller.signal
386
+ });
387
+ if (this.abortController !== controller) return;
388
+ this.displayResults(results, totalCount);
389
+ } catch (error) {
390
+ if (this.abortController !== controller) return;
391
+ if (error instanceof DOMException && error.name === "AbortError") return;
392
+ if (error instanceof JusoApiError && error.isInputError) {
393
+ this.showHint(error.userMessage);
394
+ return;
395
+ }
396
+ const message = error instanceof JusoApiError ? error.userMessage : error instanceof Error ? error.message : "\uC8FC\uC18C \uAC80\uC0C9 \uC911 \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4.";
397
+ this.showError(message);
398
+ }
399
+ }
400
+ /**
401
+ * 검색 결과 표시
402
+ */
403
+ displayResults(results, totalCount) {
404
+ const resultsList = this.container?.querySelector(".kaf-results-list");
405
+ const resultsMessage = this.container?.querySelector(".kaf-results-message");
406
+ const resultsCount = this.container?.querySelector(".kaf-results-count");
407
+ if (!resultsList || !resultsMessage || !resultsCount) return;
408
+ this.results = results;
409
+ if (results.length === 0) {
410
+ resultsList.innerHTML = "";
411
+ resultsMessage.innerHTML = '<div class="kaf-no-results">\uAC80\uC0C9 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</div>';
412
+ } else {
413
+ resultsMessage.innerHTML = "";
414
+ resultsList.innerHTML = results.map(
415
+ (result, index) => `
416
+ <div
417
+ class="kaf-result-item"
418
+ id="${this.instanceId}-option-${index}"
419
+ role="option"
420
+ data-index="${index}"
421
+ >
422
+ <div class="kaf-result-main">
423
+ <span class="kaf-result-type">\uB3C4\uB85C\uBA85</span>
424
+ <span class="kaf-result-address">${this.escapeHtml(result.roadAddress)}</span>
425
+ </div>
426
+ <div class="kaf-result-sub">
427
+ <span class="kaf-result-type">\uC9C0\uBC88</span>
428
+ <span class="kaf-result-address">${this.escapeHtml(result.jibunAddress)}</span>
429
+ </div>
430
+ <div class="kaf-result-zipcode">\uC6B0\uD3B8\uBC88\uD638: ${this.escapeHtml(result.zipCode)}</div>
431
+ ${result.buildingName ? `<div class="kaf-result-building">${this.escapeHtml(result.buildingName)}</div>` : ""}
432
+ </div>
433
+ `
434
+ ).join("");
435
+ resultsList.querySelectorAll(".kaf-result-item").forEach((item, index) => {
436
+ item.addEventListener("click", () => this.selectAddress(results[index]));
437
+ });
438
+ }
439
+ this.openResults();
440
+ resultsCount.textContent = "";
441
+ resultsCount.textContent = results.length === 0 ? "\uAC80\uC0C9 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." : `\uAC80\uC0C9 \uACB0\uACFC (${totalCount}\uAC74)`;
442
+ this.setActiveIndex(-1);
443
+ }
444
+ /**
445
+ * 주소 선택 처리
446
+ */
447
+ selectAddress(address) {
448
+ if (this.destroyed) return;
449
+ this.selected = address;
450
+ const selectedContainer = this.container?.querySelector(".kaf-selected-address");
451
+ if (selectedContainer) {
452
+ selectedContainer.innerHTML = `
453
+ <div class="kaf-selected-content">
454
+ <div class="kaf-selected-main">
455
+ <strong>\uB3C4\uB85C\uBA85:</strong> ${this.escapeHtml(address.roadAddress)}
456
+ </div>
457
+ <div class="kaf-selected-sub">
458
+ <strong>\uC6B0\uD3B8\uBC88\uD638:</strong> ${this.escapeHtml(address.zipCode)}
459
+ </div>
460
+ </div>
461
+ `;
462
+ selectedContainer.style.display = "block";
463
+ }
464
+ if (this.options.showDetailInput && this.detailInput) {
465
+ const detailBox = this.container?.querySelector(".kaf-detail-box");
466
+ if (detailBox) {
467
+ detailBox.style.display = "block";
468
+ this.detailInput.focus();
469
+ }
470
+ }
471
+ this.closeResults();
472
+ this.emitSelect("select");
473
+ }
474
+ /**
475
+ * getSelectedAddress()로 최신 상세주소를 채운 뒤 onSelect를 호출한다.
476
+ * 선택된 주소가 없으면 아무 일도 하지 않는다.
477
+ */
478
+ emitSelect(reason) {
479
+ const address = this.getSelectedAddress();
480
+ if (!address) return;
481
+ this.options.onSelect(address, { reason });
482
+ }
483
+ /**
484
+ * 검색 결과 열기
485
+ */
486
+ openResults() {
487
+ if (this.resultsContainer) {
488
+ this.resultsContainer.style.display = "block";
489
+ this.isOpen = true;
490
+ this.searchInput?.setAttribute("aria-expanded", "true");
491
+ }
492
+ }
493
+ /**
494
+ * 검색 결과 닫기
495
+ */
496
+ closeResults() {
497
+ if (this.resultsContainer) {
498
+ this.resultsContainer.style.display = "none";
499
+ this.isOpen = false;
500
+ this.searchInput?.setAttribute("aria-expanded", "false");
501
+ this.setActiveIndex(-1);
502
+ }
503
+ }
504
+ /**
505
+ * 검색어 안내 표시 (두 글자 이상 등)
506
+ *
507
+ * 오류가 아니라 고쳐 쓰면 되는 안내라서, 빨간 본문 없이 머리줄 한 줄로만
508
+ * 보여준다. 머리줄은 aria-live라 스크린리더에도 그대로 읽힌다.
509
+ */
510
+ showHint(message) {
511
+ const resultsList = this.container?.querySelector(".kaf-results-list");
512
+ const resultsMessage = this.container?.querySelector(".kaf-results-message");
513
+ const resultsCount = this.container?.querySelector(".kaf-results-count");
514
+ if (!resultsList || !resultsMessage || !resultsCount) return;
515
+ this.results = [];
516
+ resultsList.innerHTML = "";
517
+ resultsMessage.innerHTML = "";
518
+ this.openResults();
519
+ resultsCount.textContent = "";
520
+ resultsCount.textContent = message;
521
+ this.setActiveIndex(-1);
522
+ }
523
+ /**
524
+ * 에러 표시
525
+ */
526
+ showError(message) {
527
+ const resultsList = this.container?.querySelector(".kaf-results-list");
528
+ const resultsMessage = this.container?.querySelector(".kaf-results-message");
529
+ const resultsCount = this.container?.querySelector(".kaf-results-count");
530
+ if (!resultsList || !resultsMessage || !resultsCount) return;
531
+ this.results = [];
532
+ resultsList.innerHTML = "";
533
+ resultsMessage.innerHTML = `<div class="kaf-error">${this.escapeHtml(message)}</div>`;
534
+ this.openResults();
535
+ resultsCount.textContent = "";
536
+ resultsCount.textContent = message;
537
+ this.setActiveIndex(-1);
538
+ }
539
+ /**
540
+ * HTML 특수문자 이스케이프 (XSS 방지)
541
+ */
542
+ escapeHtml(value) {
543
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
544
+ }
545
+ /**
546
+ * 리스너와 진행 중 요청을 정리하고 DOM을 비운다.
547
+ * destroy()와 init()의 재마운트 양쪽이 공유하는 정리 로직이다.
548
+ */
549
+ teardown() {
550
+ for (const off of this.listeners) off();
551
+ this.listeners = [];
552
+ if (this.debounceTimer !== null) {
553
+ clearTimeout(this.debounceTimer);
554
+ this.debounceTimer = null;
555
+ }
556
+ this.abortController?.abort();
557
+ this.abortController = null;
558
+ if (this.container) {
559
+ this.container.innerHTML = "";
560
+ }
561
+ this.selected = null;
562
+ this.results = [];
563
+ this.isOpen = false;
564
+ this.activeIndex = -1;
565
+ this.searchInput = null;
566
+ this.resultsContainer = null;
567
+ this.detailInput = null;
568
+ }
569
+ /**
570
+ * 리스너와 진행 중 요청을 정리하고 DOM을 비운다.
571
+ * 여러 번 호출해도 안전하다. init()으로 다시 초기화하면 완전히
572
+ * 재사용할 수 있다 — destroyed 가드는 초기화되지 않은 인스턴스가 아니라
573
+ * "이 destroy() 호출 이후, 다음 init() 전까지"만 selectAddress()를 막는다.
574
+ */
575
+ destroy() {
576
+ if (this.destroyed) return;
577
+ this.destroyed = true;
578
+ this.teardown();
579
+ }
580
+ /**
581
+ * 선택된 주소를 돌려준다. 선택 전이면 null.
582
+ * detailAddress는 상세주소 입력란의 현재 값이 반영된다.
583
+ */
584
+ getSelectedAddress() {
585
+ if (!this.selected) return null;
586
+ return {
587
+ ...this.selected,
588
+ detailAddress: this.detailInput?.value ?? ""
589
+ };
590
+ }
591
+ };
592
+
593
+ // src/react/index.tsx
594
+ var import_jsx_runtime = require("react/jsx-runtime");
595
+ function useKoreanAddress(options = {}) {
596
+ const [address, setAddress] = (0, import_react.useState)(null);
597
+ const [finder, setFinder] = (0, import_react.useState)(null);
598
+ const optionsRef = (0, import_react.useRef)(options);
599
+ optionsRef.current = options;
600
+ const instanceRef = (0, import_react.useRef)(null);
601
+ const containerRef = (0, import_react.useCallback)((node) => {
602
+ if (instanceRef.current) {
603
+ instanceRef.current.destroy();
604
+ instanceRef.current = null;
605
+ setFinder(null);
606
+ }
607
+ if (!node) return;
608
+ const instance = new KoreanAddressFinder({
609
+ ...optionsRef.current,
610
+ onSelect: (selected, meta) => {
611
+ setAddress(selected);
612
+ optionsRef.current.onSelect?.(selected, meta);
613
+ }
614
+ });
615
+ instance.init(node);
616
+ instanceRef.current = instance;
617
+ setFinder(instance);
618
+ }, []);
619
+ (0, import_react.useEffect)(() => {
620
+ return () => {
621
+ instanceRef.current?.destroy();
622
+ instanceRef.current = null;
623
+ };
624
+ }, []);
625
+ return { containerRef, address, finder };
626
+ }
627
+ function KoreanAddressFinderView({
628
+ className,
629
+ ...options
630
+ }) {
631
+ const { containerRef } = useKoreanAddress(options);
632
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className, ref: containerRef });
633
+ }
634
+ // Annotate the CommonJS export names for ESM import in node:
635
+ 0 && (module.exports = {
636
+ KoreanAddressFinderView,
637
+ useKoreanAddress
638
+ });
639
+ //# sourceMappingURL=index.cjs.map