slick-address-kr 1.0.3 → 2.0.0
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.
- package/CHANGELOG.md +133 -0
- package/README.ko.md +36 -7
- package/README.md +229 -97
- package/dist/address-finder-DIU0lay6.d.cts +236 -0
- package/dist/address-finder-DIU0lay6.d.ts +236 -0
- package/dist/browser.global.js +65 -0
- package/dist/browser.global.js.map +1 -0
- package/dist/chunk-2OLEGOFJ.js +522 -0
- package/dist/chunk-2OLEGOFJ.js.map +1 -0
- package/dist/index.cjs +554 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +46 -0
- package/dist/index.d.ts +44 -6
- package/dist/index.js +18 -8
- package/dist/index.js.map +1 -0
- package/dist/react/index.cjs +588 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +26 -0
- package/dist/react/index.d.ts +26 -0
- package/dist/react/index.js +51 -0
- package/dist/react/index.js.map +1 -0
- package/dist/styles.css +79 -34
- package/dist/vue/index.cjs +576 -0
- package/dist/vue/index.cjs.map +1 -0
- package/dist/vue/index.d.cts +23 -0
- package/dist/vue/index.d.ts +23 -0
- package/dist/vue/index.js +40 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +89 -19
- package/API-KEY-GUIDE.md +0 -428
- package/AUTOCOMPLETE-GUIDE.md +0 -399
- package/HOW-TO-USE.md +0 -416
- package/config.example.js +0 -16
- package/dist/address-finder.d.ts +0 -68
- package/dist/address-finder.js +0 -262
- package/dist/api-client.d.ts +0 -25
- package/dist/api-client.js +0 -79
- package/dist/types.d.ts +0 -83
- package/dist/types.js +0 -1
- package/index.html +0 -85
- package/korean-address-finder.js +0 -360
|
@@ -0,0 +1,588 @@
|
|
|
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
|
+
Object.setPrototypeOf(this, _JusoApiError.prototype);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
function describeJusoError(code, apiMessage) {
|
|
45
|
+
switch (code) {
|
|
46
|
+
case "E0005":
|
|
47
|
+
case "E0006":
|
|
48
|
+
return `\uC2B9\uC778\uD0A4\uAC00 \uC774 \uB3C4\uBA54\uC778\uC5D0\uC11C \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \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. localhost\uC5D0\uC11C \uAC1C\uBC1C \uC911\uC774\uB77C\uBA74 \uB3C4\uBA54\uC778\uC5D0 localhost\uB97C \uB123\uC740 \uAC1C\uBC1C\uC6A9 \uD0A4\uAC00 \uB530\uB85C \uD544\uC694\uD569\uB2C8\uB2E4. \uBC1C\uAE09/\uC218\uC815: ${API_KEY_ISSUE_URL}`;
|
|
49
|
+
default:
|
|
50
|
+
return `\uC8FC\uC18C \uAC80\uC0C9\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4. (${code}) ${apiMessage}`.trim();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/api-client.ts
|
|
55
|
+
var AddressApiClient = class {
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.apiKey = options.apiKey ?? "";
|
|
58
|
+
this.endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
|
59
|
+
this.usesDefaultEndpoint = this.endpoint === DEFAULT_ENDPOINT;
|
|
60
|
+
this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
61
|
+
}
|
|
62
|
+
async search(keyword, options = {}) {
|
|
63
|
+
const trimmed = keyword?.trim() ?? "";
|
|
64
|
+
if (trimmed.length === 0) {
|
|
65
|
+
return { results: [], totalCount: 0 };
|
|
66
|
+
}
|
|
67
|
+
if (this.usesDefaultEndpoint && !this.apiKey) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`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}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const params = new URLSearchParams({
|
|
73
|
+
currentPage: String(options.currentPage ?? 1),
|
|
74
|
+
countPerPage: String(options.countPerPage ?? 10),
|
|
75
|
+
keyword: trimmed,
|
|
76
|
+
resultType: "json"
|
|
77
|
+
});
|
|
78
|
+
if (this.usesDefaultEndpoint) {
|
|
79
|
+
params.set("confmKey", this.apiKey);
|
|
80
|
+
}
|
|
81
|
+
const response = await this.fetchFn(`${this.endpoint}?${params.toString()}`, {
|
|
82
|
+
method: "GET",
|
|
83
|
+
signal: options.signal
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`\uC8FC\uC18C \uAC80\uC0C9 \uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4 (HTTP ${response.status})`);
|
|
87
|
+
}
|
|
88
|
+
const data = await response.json();
|
|
89
|
+
return this.toSearchResult(data);
|
|
90
|
+
}
|
|
91
|
+
toSearchResult(data) {
|
|
92
|
+
const { errorCode, errorMessage } = data.results.common;
|
|
93
|
+
if (errorCode !== "0") {
|
|
94
|
+
throw new JusoApiError(errorCode, errorMessage);
|
|
95
|
+
}
|
|
96
|
+
const juso = data.results.juso ?? [];
|
|
97
|
+
const results = juso.map((item) => ({
|
|
98
|
+
roadAddress: item.roadAddr,
|
|
99
|
+
jibunAddress: item.jibunAddr,
|
|
100
|
+
zipCode: item.zipNo,
|
|
101
|
+
buildingName: item.bdNm || "",
|
|
102
|
+
sido: item.siNm,
|
|
103
|
+
sigungu: item.sggNm,
|
|
104
|
+
bname: item.emdNm,
|
|
105
|
+
extraAddress: buildExtraAddress(item),
|
|
106
|
+
codes: toCodes(item)
|
|
107
|
+
}));
|
|
108
|
+
return {
|
|
109
|
+
results,
|
|
110
|
+
totalCount: Number.parseInt(data.results.common.totalCount, 10) || 0
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
function toCodes(item) {
|
|
115
|
+
return {
|
|
116
|
+
admCd: item.admCd,
|
|
117
|
+
rnMgtSn: item.rnMgtSn,
|
|
118
|
+
udrtYn: item.udrtYn,
|
|
119
|
+
buldMnnm: item.buldMnnm,
|
|
120
|
+
buldSlno: item.buldSlno
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function buildExtraAddress(item) {
|
|
124
|
+
const extras = [];
|
|
125
|
+
if (item.bdNm) extras.push(item.bdNm);
|
|
126
|
+
if (item.detBdNmList) extras.push(item.detBdNmList);
|
|
127
|
+
return extras.join(", ");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/address-finder.ts
|
|
131
|
+
var instanceCounter = 0;
|
|
132
|
+
var KoreanAddressFinder = class {
|
|
133
|
+
constructor(options = {}) {
|
|
134
|
+
// instanceCounter는 모듈 단위(=번들 단위)로 격리된다. 한 페이지에 ESM과
|
|
135
|
+
// IIFE 번들이 동시에 로드되면 카운터가 각각 0부터 시작해 id가 겹칠 수
|
|
136
|
+
// 있으므로, 짧은 무작위 접미사를 더해 번들 경계를 넘는 충돌을 막는다.
|
|
137
|
+
this.instanceId = `kaf-${++instanceCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
138
|
+
this.activeIndex = -1;
|
|
139
|
+
this.container = null;
|
|
140
|
+
this.searchInput = null;
|
|
141
|
+
this.resultsContainer = null;
|
|
142
|
+
this.detailInput = null;
|
|
143
|
+
this.isOpen = false;
|
|
144
|
+
this.debounceTimer = null;
|
|
145
|
+
this.abortController = null;
|
|
146
|
+
this.selected = null;
|
|
147
|
+
this.results = [];
|
|
148
|
+
this.listeners = [];
|
|
149
|
+
this.destroyed = false;
|
|
150
|
+
this.apiClient = new AddressApiClient({
|
|
151
|
+
apiKey: options.apiKey,
|
|
152
|
+
endpoint: options.endpoint,
|
|
153
|
+
fetchFn: options.fetchFn
|
|
154
|
+
});
|
|
155
|
+
this.options = {
|
|
156
|
+
apiKey: options.apiKey || "",
|
|
157
|
+
countPerPage: options.countPerPage || 10,
|
|
158
|
+
currentPage: options.currentPage || 1,
|
|
159
|
+
containerId: options.containerId || "address-finder-container",
|
|
160
|
+
onSelect: options.onSelect || (() => {
|
|
161
|
+
}),
|
|
162
|
+
showDetailInput: options.showDetailInput !== false,
|
|
163
|
+
customClass: options.customClass || "",
|
|
164
|
+
autocompleteMode: options.autocompleteMode || false,
|
|
165
|
+
endpoint: options.endpoint ?? "",
|
|
166
|
+
fetchFn: options.fetchFn
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 주소 검색 UI 초기화
|
|
171
|
+
*/
|
|
172
|
+
init(targetElement) {
|
|
173
|
+
if (this.container) {
|
|
174
|
+
this.teardown();
|
|
175
|
+
}
|
|
176
|
+
this.destroyed = false;
|
|
177
|
+
if (targetElement) {
|
|
178
|
+
this.container = targetElement;
|
|
179
|
+
} else {
|
|
180
|
+
this.container = document.getElementById(this.options.containerId);
|
|
181
|
+
}
|
|
182
|
+
if (!this.container) {
|
|
183
|
+
throw new Error(`Container element not found: ${this.options.containerId}`);
|
|
184
|
+
}
|
|
185
|
+
this.render();
|
|
186
|
+
this.attachEventListeners();
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* UI 렌더링
|
|
190
|
+
*/
|
|
191
|
+
render() {
|
|
192
|
+
if (!this.container) return;
|
|
193
|
+
const className = this.options.customClass ? ` ${this.escapeHtml(this.options.customClass)}` : "";
|
|
194
|
+
const listId = `${this.instanceId}-listbox`;
|
|
195
|
+
this.container.innerHTML = `
|
|
196
|
+
<div class="kaf-wrapper${className}">
|
|
197
|
+
<div class="kaf-search-box">
|
|
198
|
+
<input
|
|
199
|
+
type="text"
|
|
200
|
+
class="kaf-search-input"
|
|
201
|
+
placeholder="\uC8FC\uC18C\uB97C \uC785\uB825\uD558\uC138\uC694 (\uC608: \uD310\uAD50\uC5ED\uB85C 235)"
|
|
202
|
+
autocomplete="off"
|
|
203
|
+
role="combobox"
|
|
204
|
+
aria-autocomplete="list"
|
|
205
|
+
aria-expanded="false"
|
|
206
|
+
aria-controls="${listId}"
|
|
207
|
+
/>
|
|
208
|
+
<button type="button" class="kaf-search-btn">\uAC80\uC0C9</button>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="kaf-results" style="display: none;">
|
|
211
|
+
<div class="kaf-results-header">
|
|
212
|
+
<span class="kaf-results-count" aria-live="polite">\uAC80\uC0C9 \uACB0\uACFC</span>
|
|
213
|
+
<button type="button" class="kaf-close-btn" aria-label="\uAC80\uC0C9 \uACB0\uACFC \uB2EB\uAE30">\xD7</button>
|
|
214
|
+
</div>
|
|
215
|
+
<div class="kaf-results-list" id="${listId}" role="listbox" aria-label="\uC8FC\uC18C \uAC80\uC0C9 \uACB0\uACFC"></div>
|
|
216
|
+
<div class="kaf-results-message"></div>
|
|
217
|
+
</div>
|
|
218
|
+
${this.options.showDetailInput ? `
|
|
219
|
+
<div class="kaf-detail-box" style="display: none;">
|
|
220
|
+
<input
|
|
221
|
+
type="text"
|
|
222
|
+
class="kaf-detail-input"
|
|
223
|
+
placeholder="\uC0C1\uC138\uC8FC\uC18C\uB97C \uC785\uB825\uD558\uC138\uC694"
|
|
224
|
+
aria-label="\uC0C1\uC138\uC8FC\uC18C"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
` : ""}
|
|
228
|
+
<div class="kaf-selected-address" style="display: none;"></div>
|
|
229
|
+
</div>
|
|
230
|
+
`;
|
|
231
|
+
this.searchInput = this.container.querySelector(".kaf-search-input");
|
|
232
|
+
this.resultsContainer = this.container.querySelector(".kaf-results");
|
|
233
|
+
this.detailInput = this.container.querySelector(".kaf-detail-input");
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* 이벤트를 등록하면서 해제 함수를 모아둔다.
|
|
237
|
+
* destroy()가 이 목록만 비우면 리스너가 남지 않는다.
|
|
238
|
+
*/
|
|
239
|
+
on(target, type, handler) {
|
|
240
|
+
target.addEventListener(type, handler);
|
|
241
|
+
this.listeners.push(() => target.removeEventListener(type, handler));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* 이벤트 리스너 등록
|
|
245
|
+
*/
|
|
246
|
+
attachEventListeners() {
|
|
247
|
+
if (!this.container) return;
|
|
248
|
+
const searchBtn = this.container.querySelector(".kaf-search-btn");
|
|
249
|
+
if (searchBtn) {
|
|
250
|
+
this.on(searchBtn, "click", () => void this.search());
|
|
251
|
+
}
|
|
252
|
+
if (this.searchInput) {
|
|
253
|
+
this.on(
|
|
254
|
+
this.searchInput,
|
|
255
|
+
"keydown",
|
|
256
|
+
(e) => this.handleKeydown(e)
|
|
257
|
+
);
|
|
258
|
+
if (this.options.autocompleteMode) {
|
|
259
|
+
this.on(this.searchInput, "input", () => this.handleAutocomplete());
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const closeBtn = this.container.querySelector(".kaf-close-btn");
|
|
263
|
+
if (closeBtn) {
|
|
264
|
+
this.on(closeBtn, "click", () => this.closeResults());
|
|
265
|
+
}
|
|
266
|
+
if (this.detailInput) {
|
|
267
|
+
this.on(this.detailInput, "input", () => this.emitSelect("detail-change"));
|
|
268
|
+
}
|
|
269
|
+
this.on(document, "click", (e) => {
|
|
270
|
+
if (this.isOpen && !this.container?.contains(e.target)) {
|
|
271
|
+
this.closeResults();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
handleKeydown(event) {
|
|
276
|
+
const count = this.results.length;
|
|
277
|
+
switch (event.key) {
|
|
278
|
+
case "ArrowDown":
|
|
279
|
+
if (!this.isOpen || count === 0) return;
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
this.setActiveIndex(this.activeIndex >= count - 1 ? 0 : this.activeIndex + 1);
|
|
282
|
+
return;
|
|
283
|
+
case "ArrowUp":
|
|
284
|
+
if (!this.isOpen || count === 0) return;
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
this.setActiveIndex(this.activeIndex <= 0 ? count - 1 : this.activeIndex - 1);
|
|
287
|
+
return;
|
|
288
|
+
case "Enter":
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
if (this.isOpen && this.activeIndex >= 0 && this.results[this.activeIndex]) {
|
|
291
|
+
this.selectAddress(this.results[this.activeIndex]);
|
|
292
|
+
} else {
|
|
293
|
+
void this.search();
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
case "Escape":
|
|
297
|
+
if (!this.isOpen) return;
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
this.closeResults();
|
|
300
|
+
return;
|
|
301
|
+
default:
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* 활성 항목을 옮기고 ARIA 속성과 하이라이트를 함께 갱신한다.
|
|
306
|
+
* -1은 활성 항목 없음을 뜻한다.
|
|
307
|
+
*/
|
|
308
|
+
setActiveIndex(index) {
|
|
309
|
+
this.activeIndex = index;
|
|
310
|
+
const options = this.container?.querySelectorAll(".kaf-result-item");
|
|
311
|
+
options?.forEach((option, i) => {
|
|
312
|
+
const active = i === index;
|
|
313
|
+
if (active) {
|
|
314
|
+
option.setAttribute("aria-selected", "true");
|
|
315
|
+
} else {
|
|
316
|
+
option.removeAttribute("aria-selected");
|
|
317
|
+
}
|
|
318
|
+
option.classList.toggle("kaf-result-item--active", active);
|
|
319
|
+
if (active) {
|
|
320
|
+
option.scrollIntoView?.({ block: "nearest" });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
if (this.searchInput) {
|
|
324
|
+
const active = index >= 0 ? options?.[index] : void 0;
|
|
325
|
+
if (active) {
|
|
326
|
+
this.searchInput.setAttribute("aria-activedescendant", active.id);
|
|
327
|
+
} else {
|
|
328
|
+
this.searchInput.removeAttribute("aria-activedescendant");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* 자동완성 처리 (디바운스 적용)
|
|
334
|
+
*/
|
|
335
|
+
handleAutocomplete() {
|
|
336
|
+
if (this.debounceTimer) {
|
|
337
|
+
clearTimeout(this.debounceTimer);
|
|
338
|
+
}
|
|
339
|
+
this.debounceTimer = window.setTimeout(() => {
|
|
340
|
+
this.search();
|
|
341
|
+
}, 300);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* 현재 입력값으로 주소를 검색한다.
|
|
345
|
+
* 진행 중이던 이전 요청은 취소되며, 취소된 응답은 무시된다.
|
|
346
|
+
*/
|
|
347
|
+
async search() {
|
|
348
|
+
const keyword = this.searchInput?.value.trim();
|
|
349
|
+
if (!keyword) return;
|
|
350
|
+
this.abortController?.abort();
|
|
351
|
+
const controller = new AbortController();
|
|
352
|
+
this.abortController = controller;
|
|
353
|
+
try {
|
|
354
|
+
const { results, totalCount } = await this.apiClient.search(keyword, {
|
|
355
|
+
currentPage: this.options.currentPage,
|
|
356
|
+
countPerPage: this.options.countPerPage,
|
|
357
|
+
signal: controller.signal
|
|
358
|
+
});
|
|
359
|
+
if (this.abortController !== controller) return;
|
|
360
|
+
this.displayResults(results, totalCount);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (this.abortController !== controller) return;
|
|
363
|
+
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
364
|
+
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.";
|
|
365
|
+
this.showError(message);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 검색 결과 표시
|
|
370
|
+
*/
|
|
371
|
+
displayResults(results, totalCount) {
|
|
372
|
+
const resultsList = this.container?.querySelector(".kaf-results-list");
|
|
373
|
+
const resultsMessage = this.container?.querySelector(".kaf-results-message");
|
|
374
|
+
const resultsCount = this.container?.querySelector(".kaf-results-count");
|
|
375
|
+
if (!resultsList || !resultsMessage || !resultsCount) return;
|
|
376
|
+
this.results = results;
|
|
377
|
+
if (results.length === 0) {
|
|
378
|
+
resultsList.innerHTML = "";
|
|
379
|
+
resultsMessage.innerHTML = '<div class="kaf-no-results">\uAC80\uC0C9 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</div>';
|
|
380
|
+
} else {
|
|
381
|
+
resultsMessage.innerHTML = "";
|
|
382
|
+
resultsList.innerHTML = results.map(
|
|
383
|
+
(result, index) => `
|
|
384
|
+
<div
|
|
385
|
+
class="kaf-result-item"
|
|
386
|
+
id="${this.instanceId}-option-${index}"
|
|
387
|
+
role="option"
|
|
388
|
+
data-index="${index}"
|
|
389
|
+
>
|
|
390
|
+
<div class="kaf-result-main">
|
|
391
|
+
<span class="kaf-result-type">\uB3C4\uB85C\uBA85</span>
|
|
392
|
+
<span class="kaf-result-address">${this.escapeHtml(result.roadAddress)}</span>
|
|
393
|
+
</div>
|
|
394
|
+
<div class="kaf-result-sub">
|
|
395
|
+
<span class="kaf-result-type">\uC9C0\uBC88</span>
|
|
396
|
+
<span class="kaf-result-address">${this.escapeHtml(result.jibunAddress)}</span>
|
|
397
|
+
</div>
|
|
398
|
+
<div class="kaf-result-zipcode">\uC6B0\uD3B8\uBC88\uD638: ${this.escapeHtml(result.zipCode)}</div>
|
|
399
|
+
${result.buildingName ? `<div class="kaf-result-building">${this.escapeHtml(result.buildingName)}</div>` : ""}
|
|
400
|
+
</div>
|
|
401
|
+
`
|
|
402
|
+
).join("");
|
|
403
|
+
resultsList.querySelectorAll(".kaf-result-item").forEach((item, index) => {
|
|
404
|
+
item.addEventListener("click", () => this.selectAddress(results[index]));
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
this.openResults();
|
|
408
|
+
resultsCount.textContent = "";
|
|
409
|
+
resultsCount.textContent = results.length === 0 ? "\uAC80\uC0C9 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." : `\uAC80\uC0C9 \uACB0\uACFC (${totalCount}\uAC74)`;
|
|
410
|
+
this.setActiveIndex(-1);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* 주소 선택 처리
|
|
414
|
+
*/
|
|
415
|
+
selectAddress(address) {
|
|
416
|
+
if (this.destroyed) return;
|
|
417
|
+
this.selected = address;
|
|
418
|
+
const selectedContainer = this.container?.querySelector(".kaf-selected-address");
|
|
419
|
+
if (selectedContainer) {
|
|
420
|
+
selectedContainer.innerHTML = `
|
|
421
|
+
<div class="kaf-selected-content">
|
|
422
|
+
<div class="kaf-selected-main">
|
|
423
|
+
<strong>\uB3C4\uB85C\uBA85:</strong> ${this.escapeHtml(address.roadAddress)}
|
|
424
|
+
</div>
|
|
425
|
+
<div class="kaf-selected-sub">
|
|
426
|
+
<strong>\uC6B0\uD3B8\uBC88\uD638:</strong> ${this.escapeHtml(address.zipCode)}
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
`;
|
|
430
|
+
selectedContainer.style.display = "block";
|
|
431
|
+
}
|
|
432
|
+
if (this.options.showDetailInput && this.detailInput) {
|
|
433
|
+
const detailBox = this.container?.querySelector(".kaf-detail-box");
|
|
434
|
+
if (detailBox) {
|
|
435
|
+
detailBox.style.display = "block";
|
|
436
|
+
this.detailInput.focus();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
this.closeResults();
|
|
440
|
+
this.emitSelect("select");
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* getSelectedAddress()로 최신 상세주소를 채운 뒤 onSelect를 호출한다.
|
|
444
|
+
* 선택된 주소가 없으면 아무 일도 하지 않는다.
|
|
445
|
+
*/
|
|
446
|
+
emitSelect(reason) {
|
|
447
|
+
const address = this.getSelectedAddress();
|
|
448
|
+
if (!address) return;
|
|
449
|
+
this.options.onSelect(address, { reason });
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 검색 결과 열기
|
|
453
|
+
*/
|
|
454
|
+
openResults() {
|
|
455
|
+
if (this.resultsContainer) {
|
|
456
|
+
this.resultsContainer.style.display = "block";
|
|
457
|
+
this.isOpen = true;
|
|
458
|
+
this.searchInput?.setAttribute("aria-expanded", "true");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* 검색 결과 닫기
|
|
463
|
+
*/
|
|
464
|
+
closeResults() {
|
|
465
|
+
if (this.resultsContainer) {
|
|
466
|
+
this.resultsContainer.style.display = "none";
|
|
467
|
+
this.isOpen = false;
|
|
468
|
+
this.searchInput?.setAttribute("aria-expanded", "false");
|
|
469
|
+
this.setActiveIndex(-1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* 에러 표시
|
|
474
|
+
*/
|
|
475
|
+
showError(message) {
|
|
476
|
+
const resultsList = this.container?.querySelector(".kaf-results-list");
|
|
477
|
+
const resultsMessage = this.container?.querySelector(".kaf-results-message");
|
|
478
|
+
const resultsCount = this.container?.querySelector(".kaf-results-count");
|
|
479
|
+
if (!resultsList || !resultsMessage || !resultsCount) return;
|
|
480
|
+
this.results = [];
|
|
481
|
+
resultsList.innerHTML = "";
|
|
482
|
+
resultsMessage.innerHTML = `<div class="kaf-error">${this.escapeHtml(message)}</div>`;
|
|
483
|
+
this.openResults();
|
|
484
|
+
resultsCount.textContent = "";
|
|
485
|
+
resultsCount.textContent = message;
|
|
486
|
+
this.setActiveIndex(-1);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* HTML 특수문자 이스케이프 (XSS 방지)
|
|
490
|
+
*/
|
|
491
|
+
escapeHtml(value) {
|
|
492
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* 리스너와 진행 중 요청을 정리하고 DOM을 비운다.
|
|
496
|
+
* destroy()와 init()의 재마운트 양쪽이 공유하는 정리 로직이다.
|
|
497
|
+
*/
|
|
498
|
+
teardown() {
|
|
499
|
+
for (const off of this.listeners) off();
|
|
500
|
+
this.listeners = [];
|
|
501
|
+
if (this.debounceTimer !== null) {
|
|
502
|
+
clearTimeout(this.debounceTimer);
|
|
503
|
+
this.debounceTimer = null;
|
|
504
|
+
}
|
|
505
|
+
this.abortController?.abort();
|
|
506
|
+
this.abortController = null;
|
|
507
|
+
if (this.container) {
|
|
508
|
+
this.container.innerHTML = "";
|
|
509
|
+
}
|
|
510
|
+
this.selected = null;
|
|
511
|
+
this.results = [];
|
|
512
|
+
this.isOpen = false;
|
|
513
|
+
this.activeIndex = -1;
|
|
514
|
+
this.searchInput = null;
|
|
515
|
+
this.resultsContainer = null;
|
|
516
|
+
this.detailInput = null;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* 리스너와 진행 중 요청을 정리하고 DOM을 비운다.
|
|
520
|
+
* 여러 번 호출해도 안전하다. init()으로 다시 초기화하면 완전히
|
|
521
|
+
* 재사용할 수 있다 — destroyed 가드는 초기화되지 않은 인스턴스가 아니라
|
|
522
|
+
* "이 destroy() 호출 이후, 다음 init() 전까지"만 selectAddress()를 막는다.
|
|
523
|
+
*/
|
|
524
|
+
destroy() {
|
|
525
|
+
if (this.destroyed) return;
|
|
526
|
+
this.destroyed = true;
|
|
527
|
+
this.teardown();
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* 선택된 주소를 돌려준다. 선택 전이면 null.
|
|
531
|
+
* detailAddress는 상세주소 입력란의 현재 값이 반영된다.
|
|
532
|
+
*/
|
|
533
|
+
getSelectedAddress() {
|
|
534
|
+
if (!this.selected) return null;
|
|
535
|
+
return {
|
|
536
|
+
...this.selected,
|
|
537
|
+
detailAddress: this.detailInput?.value ?? ""
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// src/react/index.tsx
|
|
543
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
544
|
+
function useKoreanAddress(options = {}) {
|
|
545
|
+
const [address, setAddress] = (0, import_react.useState)(null);
|
|
546
|
+
const [finder, setFinder] = (0, import_react.useState)(null);
|
|
547
|
+
const optionsRef = (0, import_react.useRef)(options);
|
|
548
|
+
optionsRef.current = options;
|
|
549
|
+
const instanceRef = (0, import_react.useRef)(null);
|
|
550
|
+
const containerRef = (0, import_react.useCallback)((node) => {
|
|
551
|
+
if (instanceRef.current) {
|
|
552
|
+
instanceRef.current.destroy();
|
|
553
|
+
instanceRef.current = null;
|
|
554
|
+
setFinder(null);
|
|
555
|
+
}
|
|
556
|
+
if (!node) return;
|
|
557
|
+
const instance = new KoreanAddressFinder({
|
|
558
|
+
...optionsRef.current,
|
|
559
|
+
onSelect: (selected, meta) => {
|
|
560
|
+
setAddress(selected);
|
|
561
|
+
optionsRef.current.onSelect?.(selected, meta);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
instance.init(node);
|
|
565
|
+
instanceRef.current = instance;
|
|
566
|
+
setFinder(instance);
|
|
567
|
+
}, []);
|
|
568
|
+
(0, import_react.useEffect)(() => {
|
|
569
|
+
return () => {
|
|
570
|
+
instanceRef.current?.destroy();
|
|
571
|
+
instanceRef.current = null;
|
|
572
|
+
};
|
|
573
|
+
}, []);
|
|
574
|
+
return { containerRef, address, finder };
|
|
575
|
+
}
|
|
576
|
+
function KoreanAddressFinderView({
|
|
577
|
+
className,
|
|
578
|
+
...options
|
|
579
|
+
}) {
|
|
580
|
+
const { containerRef } = useKoreanAddress(options);
|
|
581
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className, ref: containerRef });
|
|
582
|
+
}
|
|
583
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
584
|
+
0 && (module.exports = {
|
|
585
|
+
KoreanAddressFinderView,
|
|
586
|
+
useKoreanAddress
|
|
587
|
+
});
|
|
588
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/index.tsx","../../src/constants.ts","../../src/errors.ts","../../src/api-client.ts","../../src/address-finder.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { KoreanAddressFinder } from '../address-finder';\nimport type {\n AddressFinderOptions,\n AddressResult,\n AddressSelectMeta,\n} from '../types';\n\nexport interface UseKoreanAddressResult {\n /** 검색 UI가 그려질 요소에 연결한다 */\n containerRef: (node: HTMLElement | null) => void;\n /** 현재 선택된 주소. 상세주소 입력도 반영된다 */\n address: AddressResult | null;\n /** 필요할 때 직접 조작하기 위한 인스턴스 */\n finder: KoreanAddressFinder | null;\n}\n\n/**\n * KoreanAddressFinder를 React 생명주기에 맞춰 붙인다.\n * 로직은 코어에 그대로 두고 마운트·언마운트와 상태 노출만 담당한다.\n */\nexport function useKoreanAddress(\n options: AddressFinderOptions = {}\n): UseKoreanAddressResult {\n const [address, setAddress] = useState<AddressResult | null>(null);\n const [finder, setFinder] = useState<KoreanAddressFinder | null>(null);\n\n // 옵션이 매 렌더 새 객체로 와도 재마운트하지 않도록 최신 값만 참조한다\n const optionsRef = useRef(options);\n optionsRef.current = options;\n\n // finder 상태에만 의존하면 ref가 null로 분리될 때(요소는 사라지고\n // 컴포넌트는 유지되는 흔한 조건부 렌더 패턴) state 갱신 타이밍에\n // 기대지 않고는 정리할 방법이 없다. 그래서 살아있는 인스턴스를\n // ref로도 따로 들고, 콜백 ref 안에서 직접 파괴한다.\n const instanceRef = useRef<KoreanAddressFinder | null>(null);\n\n const containerRef = useCallback((node: HTMLElement | null) => {\n // 이전 인스턴스를 먼저 정리한다. ref가 null로 분리될 때도 여기로 들어온다.\n if (instanceRef.current) {\n instanceRef.current.destroy();\n instanceRef.current = null;\n setFinder(null);\n }\n\n if (!node) return;\n\n const instance = new KoreanAddressFinder({\n ...optionsRef.current,\n onSelect: (selected: AddressResult, meta: AddressSelectMeta) => {\n setAddress(selected);\n optionsRef.current.onSelect?.(selected, meta);\n },\n });\n instance.init(node);\n instanceRef.current = instance;\n setFinder(instance);\n }, []);\n\n // 컴포넌트 언마운트 시의 마지막 방어선.\n useEffect(() => {\n return () => {\n instanceRef.current?.destroy();\n instanceRef.current = null;\n };\n }, []);\n\n return { containerRef, address, finder };\n}\n\nexport interface KoreanAddressFinderViewProps extends AddressFinderOptions {\n className?: string;\n}\n\n/**\n * 훅을 직접 다루지 않아도 되는 기본 컴포넌트.\n */\nexport function KoreanAddressFinderView({\n className,\n ...options\n}: KoreanAddressFinderViewProps) {\n const { containerRef } = useKoreanAddress(options);\n return <div className={className} ref={containerRef} />;\n}\n\nexport type { AddressResult, AddressSelectMeta, AddressFinderOptions };\n","/** 행정안전부 도로명주소 검색 API 기본 엔드포인트 */\nexport const DEFAULT_ENDPOINT =\n 'https://business.juso.go.kr/addrlink/addrLinkApi.do';\n\n/**\n * 승인키 발급 페이지 (2026-09 확인).\n * 구 경로 /addrlink/openApi/searchApi.do 는 사이트 개편으로 404다.\n */\nexport const API_KEY_ISSUE_URL =\n 'https://business.juso.go.kr/jst/jstAddressApiApplicationWrite';\n","import { API_KEY_ISSUE_URL } from './constants';\n\n/**\n * 행안부 주소 API가 errorCode로 돌려주는 실패.\n * code는 원본을 보존하고, userMessage는 화면에 그대로 띄울 수 있는 안내다.\n */\nexport class JusoApiError extends Error {\n readonly code: string;\n readonly userMessage: string;\n\n constructor(code: string, apiMessage: string) {\n const userMessage = describeJusoError(code, apiMessage);\n super(`[${code}] ${apiMessage}`);\n this.name = 'JusoApiError';\n this.code = code;\n this.userMessage = userMessage;\n\n // ES5 타깃으로 다운레벨되는 소비자에서도 instanceof가 동작하도록\n Object.setPrototypeOf(this, JusoApiError.prototype);\n }\n}\n\n/**\n * errorCode를 행동 가능한 한국어 안내로 바꾼다.\n *\n * 코드 목록은 행안부 검색 API 문서를 따른다. 여기 담긴 것은 실제로\n * 자주 마주치는 코드이며, 모르는 코드는 API가 준 메시지를 살려서 보여준다.\n * 문서를 다시 확인할 일이 생기면:\n * https://business.juso.go.kr/jst/jstRoadNmAddrApiSearch\n */\nexport function describeJusoError(code: string, apiMessage: string): string {\n // 행안부 문서로 실제 확인된 코드만 여기 둔다. 확인되지 않은 코드를\n // 추측으로 채워 넣지 말 것 — 자신 있게 틀린 안내보다는 API가 준\n // 원본 메시지를 그대로 보여주는 편이 낫다. 확인되지 않은 코드는\n // default 분기로 흘려보내 apiMessage를 그대로 살린다.\n switch (code) {\n case 'E0005':\n case 'E0006':\n return (\n '승인키가 이 도메인에서 허용되지 않았습니다. ' +\n '발급 시 등록한 도메인과 현재 실행 중인 도메인이 같은지 확인하세요. ' +\n 'localhost에서 개발 중이라면 도메인에 localhost를 넣은 개발용 키가 따로 필요합니다. ' +\n `발급/수정: ${API_KEY_ISSUE_URL}`\n );\n default:\n return `주소 검색에 실패했습니다. (${code}) ${apiMessage}`.trim();\n }\n}\n","import type {\n AddressApiClientOptions,\n AddressCodes,\n AddressResult,\n AddressSearchOptions,\n AddressSearchResult,\n JusoApiResponse,\n} from './types';\nimport { DEFAULT_ENDPOINT, API_KEY_ISSUE_URL } from './constants';\nimport { JusoApiError } from './errors';\n\nexport { DEFAULT_ENDPOINT, API_KEY_ISSUE_URL } from './constants';\n\n/**\n * 주소 검색 API 클라이언트.\n * endpoint를 바꾸면 자체 프록시 서버로 호출을 돌릴 수 있고,\n * fetchFn을 주입하면 네트워크 없이 동작을 검증할 수 있다.\n */\nexport class AddressApiClient {\n private readonly apiKey: string;\n private readonly endpoint: string;\n private readonly usesDefaultEndpoint: boolean;\n private readonly fetchFn: typeof fetch;\n\n constructor(options: AddressApiClientOptions = {}) {\n this.apiKey = options.apiKey ?? '';\n this.endpoint = options.endpoint ?? DEFAULT_ENDPOINT;\n this.usesDefaultEndpoint = this.endpoint === DEFAULT_ENDPOINT;\n this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);\n }\n\n async search(\n keyword: string,\n options: AddressSearchOptions = {}\n ): Promise<AddressSearchResult> {\n const trimmed = keyword?.trim() ?? '';\n if (trimmed.length === 0) {\n return { results: [], totalCount: 0 };\n }\n\n if (this.usesDefaultEndpoint && !this.apiKey) {\n throw new Error(\n 'API 키가 설정되지 않았습니다. KoreanAddressFinder 생성 시 apiKey 옵션을 전달하거나, ' +\n 'endpoint 옵션으로 자체 프록시 서버를 지정하세요. ' +\n `키 발급: ${API_KEY_ISSUE_URL}`\n );\n }\n\n const params = new URLSearchParams({\n currentPage: String(options.currentPage ?? 1),\n countPerPage: String(options.countPerPage ?? 10),\n keyword: trimmed,\n resultType: 'json',\n });\n\n // 프록시를 쓰는 경우 승인키는 서버가 붙인다.\n if (this.usesDefaultEndpoint) {\n params.set('confmKey', this.apiKey);\n }\n\n const response = await this.fetchFn(`${this.endpoint}?${params.toString()}`, {\n method: 'GET',\n signal: options.signal,\n });\n\n if (!response.ok) {\n throw new Error(`주소 검색 요청이 실패했습니다 (HTTP ${response.status})`);\n }\n\n const data = (await response.json()) as JusoApiResponse;\n return this.toSearchResult(data);\n }\n\n private toSearchResult(data: JusoApiResponse): AddressSearchResult {\n const { errorCode, errorMessage } = data.results.common;\n if (errorCode !== '0') {\n throw new JusoApiError(errorCode, errorMessage);\n }\n\n const juso = data.results.juso ?? [];\n const results: AddressResult[] = juso.map((item) => ({\n roadAddress: item.roadAddr,\n jibunAddress: item.jibunAddr,\n zipCode: item.zipNo,\n buildingName: item.bdNm || '',\n sido: item.siNm,\n sigungu: item.sggNm,\n bname: item.emdNm,\n extraAddress: buildExtraAddress(item),\n codes: toCodes(item),\n }));\n\n return {\n results,\n totalCount: Number.parseInt(data.results.common.totalCount, 10) || 0,\n };\n }\n}\n\ntype JusoItem = JusoApiResponse['results']['juso'][number];\n\nfunction toCodes(item: JusoItem): AddressCodes {\n return {\n admCd: item.admCd,\n rnMgtSn: item.rnMgtSn,\n udrtYn: item.udrtYn,\n buldMnnm: item.buldMnnm,\n buldSlno: item.buldSlno,\n };\n}\n\nfunction buildExtraAddress(item: JusoItem): string {\n const extras: string[] = [];\n if (item.bdNm) extras.push(item.bdNm);\n if (item.detBdNmList) extras.push(item.detBdNmList);\n return extras.join(', ');\n}\n","import { AddressApiClient } from './api-client';\nimport type { AddressFinderOptions, AddressResult, AddressSelectMeta } from './types';\nimport { JusoApiError } from './errors';\n\nlet instanceCounter = 0;\n\n/**\n * 주소 검색 컴포넌트\n * 팝업 없이 인라인으로 주소를 검색하고 선택할 수 있는 UI를 제공합니다.\n */\nexport class KoreanAddressFinder {\n // instanceCounter는 모듈 단위(=번들 단위)로 격리된다. 한 페이지에 ESM과\n // IIFE 번들이 동시에 로드되면 카운터가 각각 0부터 시작해 id가 겹칠 수\n // 있으므로, 짧은 무작위 접미사를 더해 번들 경계를 넘는 충돌을 막는다.\n private readonly instanceId = `kaf-${++instanceCounter}-${Math.random().toString(36).slice(2, 8)}`;\n private activeIndex = -1;\n private apiClient: AddressApiClient;\n // fetchFn은 값을 넣지 않은 상태(undefined)를 진짜 옵션값으로 다루고 싶어서\n // Required로 뭉뚱그리지 않는다. AddressApiClient는 undefined를 받아도\n // 스스로 전역 fetch로 대체하므로, 여기서 캐스팅으로 거짓 보장을 만들 필요가 없다.\n private options: Required<Omit<AddressFinderOptions, 'fetchFn'>> &\n Pick<AddressFinderOptions, 'fetchFn'>;\n private container: HTMLElement | null = null;\n private searchInput: HTMLInputElement | null = null;\n private resultsContainer: HTMLElement | null = null;\n private detailInput: HTMLInputElement | null = null;\n private isOpen = false;\n private debounceTimer: number | null = null;\n private abortController: AbortController | null = null;\n private selected: AddressResult | null = null;\n private results: AddressResult[] = [];\n private listeners: Array<() => void> = [];\n private destroyed = false;\n\n constructor(options: AddressFinderOptions = {}) {\n this.apiClient = new AddressApiClient({\n apiKey: options.apiKey,\n endpoint: options.endpoint,\n fetchFn: options.fetchFn,\n });\n\n // 기본 옵션 설정\n this.options = {\n apiKey: options.apiKey || '',\n countPerPage: options.countPerPage || 10,\n currentPage: options.currentPage || 1,\n containerId: options.containerId || 'address-finder-container',\n onSelect: options.onSelect || (() => {}),\n showDetailInput: options.showDetailInput !== false,\n customClass: options.customClass || '',\n autocompleteMode: options.autocompleteMode || false,\n endpoint: options.endpoint ?? '',\n fetchFn: options.fetchFn,\n };\n }\n\n /**\n * 주소 검색 UI 초기화\n */\n public init(targetElement?: HTMLElement): void {\n // 이전에 init()된 상태(destroy() 없이 다시 불렸거나, destroy() 뒤 재사용되는\n // 경우)라면 남아있는 리스너와 상태를 먼저 정리한다. 그러지 않으면\n // destroy() 없이 두 번 init()할 때 document 리스너가 중복 등록된다.\n if (this.container) {\n this.teardown();\n }\n // destroy()가 남긴 가드를 푼다 — 그러지 않으면 재사용된 인스턴스가\n // 렌더링과 검색은 정상 동작하면서 selectAddress()만 조용히 무시해\n // onSelect가 영원히 불리지 않는다.\n this.destroyed = false;\n\n if (targetElement) {\n this.container = targetElement;\n } else {\n this.container = document.getElementById(this.options.containerId);\n }\n\n if (!this.container) {\n throw new Error(`Container element not found: ${this.options.containerId}`);\n }\n\n this.render();\n this.attachEventListeners();\n }\n\n /**\n * UI 렌더링\n */\n private render(): void {\n if (!this.container) return;\n\n const className = this.options.customClass\n ? ` ${this.escapeHtml(this.options.customClass)}`\n : '';\n const listId = `${this.instanceId}-listbox`;\n\n this.container.innerHTML = `\n <div class=\"kaf-wrapper${className}\">\n <div class=\"kaf-search-box\">\n <input\n type=\"text\"\n class=\"kaf-search-input\"\n placeholder=\"주소를 입력하세요 (예: 판교역로 235)\"\n autocomplete=\"off\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-expanded=\"false\"\n aria-controls=\"${listId}\"\n />\n <button type=\"button\" class=\"kaf-search-btn\">검색</button>\n </div>\n <div class=\"kaf-results\" style=\"display: none;\">\n <div class=\"kaf-results-header\">\n <span class=\"kaf-results-count\" aria-live=\"polite\">검색 결과</span>\n <button type=\"button\" class=\"kaf-close-btn\" aria-label=\"검색 결과 닫기\">×</button>\n </div>\n <div class=\"kaf-results-list\" id=\"${listId}\" role=\"listbox\" aria-label=\"주소 검색 결과\"></div>\n <div class=\"kaf-results-message\"></div>\n </div>\n ${this.options.showDetailInput ? `\n <div class=\"kaf-detail-box\" style=\"display: none;\">\n <input\n type=\"text\"\n class=\"kaf-detail-input\"\n placeholder=\"상세주소를 입력하세요\"\n aria-label=\"상세주소\"\n />\n </div>\n ` : ''}\n <div class=\"kaf-selected-address\" style=\"display: none;\"></div>\n </div>\n `;\n\n this.searchInput = this.container.querySelector('.kaf-search-input');\n this.resultsContainer = this.container.querySelector('.kaf-results');\n this.detailInput = this.container.querySelector('.kaf-detail-input');\n }\n\n /**\n * 이벤트를 등록하면서 해제 함수를 모아둔다.\n * destroy()가 이 목록만 비우면 리스너가 남지 않는다.\n */\n private on<T extends EventTarget>(\n target: T,\n type: string,\n handler: EventListenerOrEventListenerObject\n ): void {\n target.addEventListener(type, handler);\n this.listeners.push(() => target.removeEventListener(type, handler));\n }\n\n /**\n * 이벤트 리스너 등록\n */\n private attachEventListeners(): void {\n if (!this.container) return;\n\n const searchBtn = this.container.querySelector('.kaf-search-btn');\n if (searchBtn) {\n this.on(searchBtn, 'click', () => void this.search());\n }\n\n if (this.searchInput) {\n this.on(this.searchInput, 'keydown', (e) =>\n this.handleKeydown(e as KeyboardEvent)\n );\n\n if (this.options.autocompleteMode) {\n this.on(this.searchInput, 'input', () => this.handleAutocomplete());\n }\n }\n\n const closeBtn = this.container.querySelector('.kaf-close-btn');\n if (closeBtn) {\n this.on(closeBtn, 'click', () => this.closeResults());\n }\n\n if (this.detailInput) {\n this.on(this.detailInput, 'input', () => this.emitSelect('detail-change'));\n }\n\n this.on(document, 'click', (e) => {\n if (this.isOpen && !this.container?.contains(e.target as Node)) {\n this.closeResults();\n }\n });\n }\n\n private handleKeydown(event: KeyboardEvent): void {\n const count = this.results.length;\n\n switch (event.key) {\n case 'ArrowDown':\n if (!this.isOpen || count === 0) return;\n event.preventDefault();\n this.setActiveIndex(this.activeIndex >= count - 1 ? 0 : this.activeIndex + 1);\n return;\n\n case 'ArrowUp':\n if (!this.isOpen || count === 0) return;\n event.preventDefault();\n this.setActiveIndex(this.activeIndex <= 0 ? count - 1 : this.activeIndex - 1);\n return;\n\n case 'Enter':\n event.preventDefault();\n // 활성 항목이 있으면 선택, 없으면 검색\n if (this.isOpen && this.activeIndex >= 0 && this.results[this.activeIndex]) {\n this.selectAddress(this.results[this.activeIndex]);\n } else {\n void this.search();\n }\n return;\n\n case 'Escape':\n if (!this.isOpen) return;\n event.preventDefault();\n this.closeResults();\n return;\n\n default:\n }\n }\n\n /**\n * 활성 항목을 옮기고 ARIA 속성과 하이라이트를 함께 갱신한다.\n * -1은 활성 항목 없음을 뜻한다.\n */\n private setActiveIndex(index: number): void {\n this.activeIndex = index;\n\n const options = this.container?.querySelectorAll<HTMLElement>('.kaf-result-item');\n options?.forEach((option, i) => {\n const active = i === index;\n // APG 권고대로 활성 항목에만 aria-selected를 붙인다. 모든 항목에\n // \"false\"를 넣으면 일부 스크린리더가 화살표로 넘길 때마다\n // \"선택 안 됨\"을 매번 읽어준다.\n if (active) {\n option.setAttribute('aria-selected', 'true');\n } else {\n option.removeAttribute('aria-selected');\n }\n option.classList.toggle('kaf-result-item--active', active);\n if (active) {\n // jsdom에는 scrollIntoView가 없다. 선택적 호출이라야 테스트가 산다.\n option.scrollIntoView?.({ block: 'nearest' });\n }\n });\n\n if (this.searchInput) {\n const active = index >= 0 ? options?.[index] : undefined;\n if (active) {\n this.searchInput.setAttribute('aria-activedescendant', active.id);\n } else {\n this.searchInput.removeAttribute('aria-activedescendant');\n }\n }\n }\n\n /**\n * 자동완성 처리 (디바운스 적용)\n */\n private handleAutocomplete(): void {\n if (this.debounceTimer) {\n clearTimeout(this.debounceTimer);\n }\n\n this.debounceTimer = window.setTimeout(() => {\n this.search();\n }, 300);\n }\n\n /**\n * 현재 입력값으로 주소를 검색한다.\n * 진행 중이던 이전 요청은 취소되며, 취소된 응답은 무시된다.\n */\n public async search(): Promise<void> {\n const keyword = this.searchInput?.value.trim();\n if (!keyword) return;\n\n this.abortController?.abort();\n const controller = new AbortController();\n this.abortController = controller;\n\n try {\n const { results, totalCount } = await this.apiClient.search(keyword, {\n currentPage: this.options.currentPage,\n countPerPage: this.options.countPerPage,\n signal: controller.signal,\n });\n\n // 기다리는 사이 더 새로운 검색이 시작됐다면 결과를 버린다\n if (this.abortController !== controller) return;\n\n this.displayResults(results, totalCount);\n } catch (error) {\n if (this.abortController !== controller) return;\n if (error instanceof DOMException && error.name === 'AbortError') return;\n\n const message =\n error instanceof JusoApiError\n ? error.userMessage\n : error instanceof Error\n ? error.message\n : '주소 검색 중 오류가 발생했습니다.';\n this.showError(message);\n }\n }\n\n /**\n * 검색 결과 표시\n */\n private displayResults(results: AddressResult[], totalCount: number): void {\n const resultsList = this.container?.querySelector('.kaf-results-list');\n const resultsMessage = this.container?.querySelector('.kaf-results-message');\n const resultsCount = this.container?.querySelector('.kaf-results-count');\n\n if (!resultsList || !resultsMessage || !resultsCount) return;\n\n this.results = results;\n\n if (results.length === 0) {\n // listbox는 option/group만 자식으로 허용한다. 안내 문구는 listbox\n // 밖의 별도 영역에 둔다 — 그래야 스크린리더가 무시하지 않는다.\n resultsList.innerHTML = '';\n resultsMessage.innerHTML = '<div class=\"kaf-no-results\">검색 결과가 없습니다.</div>';\n } else {\n resultsMessage.innerHTML = '';\n\n resultsList.innerHTML = results\n .map(\n (result, index) => `\n <div\n class=\"kaf-result-item\"\n id=\"${this.instanceId}-option-${index}\"\n role=\"option\"\n data-index=\"${index}\"\n >\n <div class=\"kaf-result-main\">\n <span class=\"kaf-result-type\">도로명</span>\n <span class=\"kaf-result-address\">${this.escapeHtml(result.roadAddress)}</span>\n </div>\n <div class=\"kaf-result-sub\">\n <span class=\"kaf-result-type\">지번</span>\n <span class=\"kaf-result-address\">${this.escapeHtml(result.jibunAddress)}</span>\n </div>\n <div class=\"kaf-result-zipcode\">우편번호: ${this.escapeHtml(result.zipCode)}</div>\n ${result.buildingName ? `<div class=\"kaf-result-building\">${this.escapeHtml(result.buildingName)}</div>` : ''}\n </div>\n `\n )\n .join('');\n\n // 결과 항목 클릭 이벤트\n resultsList.querySelectorAll('.kaf-result-item').forEach((item, index) => {\n item.addEventListener('click', () => this.selectAddress(results[index]));\n });\n }\n\n // aria-live 영역은 .kaf-results가 화면에 보인 뒤에 채워야 감시된다.\n // display:none인 상태에서 넣은 텍스트는 나중에 보여줘도 다시 읽히지 않는다.\n this.openResults();\n\n // 같은 건수가 연속으로 나오면 텍스트가 그대로라 재알림이 발생하지\n // 않는다. 비웠다가 다시 채워 값이 바뀌도록 만든다.\n resultsCount.textContent = '';\n resultsCount.textContent =\n results.length === 0 ? '검색 결과가 없습니다.' : `검색 결과 (${totalCount}건)`;\n\n this.setActiveIndex(-1);\n }\n\n /**\n * 주소 선택 처리\n */\n private selectAddress(address: AddressResult): void {\n // 파괴된 인스턴스가 들고 있던 DOM 노드(detached result-item)가 클릭\n // 이벤트를 낼 수 있다. destroy() 이후에는 onSelect가 절대 불려서는\n // 안 된다.\n if (this.destroyed) return;\n\n this.selected = address;\n const selectedContainer = this.container?.querySelector('.kaf-selected-address');\n\n if (selectedContainer) {\n selectedContainer.innerHTML = `\n <div class=\"kaf-selected-content\">\n <div class=\"kaf-selected-main\">\n <strong>도로명:</strong> ${this.escapeHtml(address.roadAddress)}\n </div>\n <div class=\"kaf-selected-sub\">\n <strong>우편번호:</strong> ${this.escapeHtml(address.zipCode)}\n </div>\n </div>\n `;\n (selectedContainer as HTMLElement).style.display = 'block';\n }\n\n // 상세주소 입력 표시\n if (this.options.showDetailInput && this.detailInput) {\n const detailBox = this.container?.querySelector('.kaf-detail-box');\n if (detailBox) {\n (detailBox as HTMLElement).style.display = 'block';\n this.detailInput.focus();\n }\n }\n\n this.closeResults();\n\n this.emitSelect('select');\n }\n\n /**\n * getSelectedAddress()로 최신 상세주소를 채운 뒤 onSelect를 호출한다.\n * 선택된 주소가 없으면 아무 일도 하지 않는다.\n */\n private emitSelect(reason: AddressSelectMeta['reason']): void {\n const address = this.getSelectedAddress();\n if (!address) return;\n this.options.onSelect(address, { reason });\n }\n\n /**\n * 검색 결과 열기\n */\n private openResults(): void {\n if (this.resultsContainer) {\n this.resultsContainer.style.display = 'block';\n this.isOpen = true;\n this.searchInput?.setAttribute('aria-expanded', 'true');\n }\n }\n\n /**\n * 검색 결과 닫기\n */\n private closeResults(): void {\n if (this.resultsContainer) {\n this.resultsContainer.style.display = 'none';\n this.isOpen = false;\n this.searchInput?.setAttribute('aria-expanded', 'false');\n this.setActiveIndex(-1);\n }\n }\n\n /**\n * 에러 표시\n */\n private showError(message: string): void {\n const resultsList = this.container?.querySelector('.kaf-results-list');\n const resultsMessage = this.container?.querySelector('.kaf-results-message');\n const resultsCount = this.container?.querySelector('.kaf-results-count');\n\n if (!resultsList || !resultsMessage || !resultsCount) return;\n\n // this.results를 비우지 않으면, 직전 검색의 결과가 여전히 배열에\n // 남아있는 채로 listbox만 지워진 상태가 된다. 그 상태에서 Enter를\n // 누르면 화면에도 스크린리더에도 없는 주소가 선택되어 버린다.\n this.results = [];\n resultsList.innerHTML = '';\n resultsMessage.innerHTML = `<div class=\"kaf-error\">${this.escapeHtml(message)}</div>`;\n\n this.openResults();\n\n resultsCount.textContent = '';\n resultsCount.textContent = message;\n\n this.setActiveIndex(-1);\n }\n\n /**\n * HTML 특수문자 이스케이프 (XSS 방지)\n */\n private escapeHtml(value: string): string {\n return String(value ?? '')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n /**\n * 리스너와 진행 중 요청을 정리하고 DOM을 비운다.\n * destroy()와 init()의 재마운트 양쪽이 공유하는 정리 로직이다.\n */\n private teardown(): void {\n for (const off of this.listeners) off();\n this.listeners = [];\n\n if (this.debounceTimer !== null) {\n clearTimeout(this.debounceTimer);\n this.debounceTimer = null;\n }\n\n this.abortController?.abort();\n this.abortController = null;\n\n if (this.container) {\n this.container.innerHTML = '';\n }\n\n this.selected = null;\n this.results = [];\n this.isOpen = false;\n this.activeIndex = -1;\n this.searchInput = null;\n this.resultsContainer = null;\n this.detailInput = null;\n }\n\n /**\n * 리스너와 진행 중 요청을 정리하고 DOM을 비운다.\n * 여러 번 호출해도 안전하다. init()으로 다시 초기화하면 완전히\n * 재사용할 수 있다 — destroyed 가드는 초기화되지 않은 인스턴스가 아니라\n * \"이 destroy() 호출 이후, 다음 init() 전까지\"만 selectAddress()를 막는다.\n */\n public destroy(): void {\n if (this.destroyed) return;\n this.destroyed = true;\n this.teardown();\n }\n\n /**\n * 선택된 주소를 돌려준다. 선택 전이면 null.\n * detailAddress는 상세주소 입력란의 현재 값이 반영된다.\n */\n public getSelectedAddress(): AddressResult | null {\n if (!this.selected) return null;\n return {\n ...this.selected,\n detailAddress: this.detailInput?.value ?? '',\n };\n }\n}\n"],"mappings":"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","names":[]}
|