moru-krds-react 0.1.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/accordion/index.d.ts +63 -0
- package/dist/accordion/index.d.ts.map +1 -0
- package/dist/accordion/index.js +145 -0
- package/dist/accordion/index.js.map +1 -0
- package/dist/alert/index.d.ts +44 -0
- package/dist/alert/index.d.ts.map +1 -0
- package/dist/alert/index.js +66 -0
- package/dist/alert/index.js.map +1 -0
- package/dist/avatar/index.d.ts +102 -0
- package/dist/avatar/index.d.ts.map +1 -0
- package/dist/avatar/index.js +146 -0
- package/dist/avatar/index.js.map +1 -0
- package/dist/badge/index.d.ts +49 -0
- package/dist/badge/index.d.ts.map +1 -0
- package/dist/badge/index.js +41 -0
- package/dist/badge/index.js.map +1 -0
- package/dist/breadcrumb/index.d.ts +43 -0
- package/dist/breadcrumb/index.d.ts.map +1 -0
- package/dist/breadcrumb/index.js +43 -0
- package/dist/breadcrumb/index.js.map +1 -0
- package/dist/button/index.d.ts +78 -0
- package/dist/button/index.d.ts.map +1 -0
- package/dist/button/index.js +83 -0
- package/dist/button/index.js.map +1 -0
- package/dist/checkbox/index.d.ts +97 -0
- package/dist/checkbox/index.d.ts.map +1 -0
- package/dist/checkbox/index.js +110 -0
- package/dist/checkbox/index.js.map +1 -0
- package/dist/combobox/index.d.ts +135 -0
- package/dist/combobox/index.d.ts.map +1 -0
- package/dist/combobox/index.js +416 -0
- package/dist/combobox/index.js.map +1 -0
- package/dist/context-menu/index.d.ts +94 -0
- package/dist/context-menu/index.d.ts.map +1 -0
- package/dist/context-menu/index.js +399 -0
- package/dist/context-menu/index.js.map +1 -0
- package/dist/critical-alert/index.d.ts +39 -0
- package/dist/critical-alert/index.d.ts.map +1 -0
- package/dist/critical-alert/index.js +50 -0
- package/dist/critical-alert/index.js.map +1 -0
- package/dist/data-table/index.d.ts +94 -0
- package/dist/data-table/index.d.ts.map +1 -0
- package/dist/data-table/index.js +128 -0
- package/dist/data-table/index.js.map +1 -0
- package/dist/disclosure/index.d.ts +35 -0
- package/dist/disclosure/index.d.ts.map +1 -0
- package/dist/disclosure/index.js +54 -0
- package/dist/disclosure/index.js.map +1 -0
- package/dist/drawer/index.d.ts +106 -0
- package/dist/drawer/index.d.ts.map +1 -0
- package/dist/drawer/index.js +352 -0
- package/dist/drawer/index.js.map +1 -0
- package/dist/empty-state/index.d.ts +86 -0
- package/dist/empty-state/index.d.ts.map +1 -0
- package/dist/empty-state/index.js +138 -0
- package/dist/empty-state/index.js.map +1 -0
- package/dist/env.d.js +1 -0
- package/dist/env.d.js.map +1 -0
- package/dist/field/index.d.ts +149 -0
- package/dist/field/index.d.ts.map +1 -0
- package/dist/field/index.js +121 -0
- package/dist/field/index.js.map +1 -0
- package/dist/file-upload/index.d.ts +140 -0
- package/dist/file-upload/index.d.ts.map +1 -0
- package/dist/file-upload/index.js +249 -0
- package/dist/file-upload/index.js.map +1 -0
- package/dist/footer/index.d.ts +155 -0
- package/dist/footer/index.d.ts.map +1 -0
- package/dist/footer/index.js +186 -0
- package/dist/footer/index.js.map +1 -0
- package/dist/header/index.d.ts +170 -0
- package/dist/header/index.d.ts.map +1 -0
- package/dist/header/index.js +258 -0
- package/dist/header/index.js.map +1 -0
- package/dist/identifier/index.d.ts +38 -0
- package/dist/identifier/index.d.ts.map +1 -0
- package/dist/identifier/index.js +55 -0
- package/dist/identifier/index.js.map +1 -0
- package/dist/in-page-navigation/index.d.ts +71 -0
- package/dist/in-page-navigation/index.d.ts.map +1 -0
- package/dist/in-page-navigation/index.js +137 -0
- package/dist/in-page-navigation/index.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/input-group/index.d.ts +66 -0
- package/dist/input-group/index.d.ts.map +1 -0
- package/dist/input-group/index.js +85 -0
- package/dist/input-group/index.js.map +1 -0
- package/dist/lib/krds.d.ts +45 -0
- package/dist/lib/krds.d.ts.map +1 -0
- package/dist/lib/krds.js +11 -0
- package/dist/lib/krds.js.map +1 -0
- package/dist/lib/link.d.ts +34 -0
- package/dist/lib/link.d.ts.map +1 -0
- package/dist/lib/link.js +8 -0
- package/dist/lib/link.js.map +1 -0
- package/dist/main-menu/index.d.ts +123 -0
- package/dist/main-menu/index.d.ts.map +1 -0
- package/dist/main-menu/index.js +613 -0
- package/dist/main-menu/index.js.map +1 -0
- package/dist/masthead/index.d.ts +25 -0
- package/dist/masthead/index.d.ts.map +1 -0
- package/dist/masthead/index.js +21 -0
- package/dist/masthead/index.js.map +1 -0
- package/dist/modal/index.d.ts +105 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +285 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/pagination/index.d.ts +58 -0
- package/dist/pagination/index.d.ts.map +1 -0
- package/dist/pagination/index.js +82 -0
- package/dist/pagination/index.js.map +1 -0
- package/dist/progress/index.d.ts +58 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/index.js +125 -0
- package/dist/progress/index.js.map +1 -0
- package/dist/radio/index.d.ts +111 -0
- package/dist/radio/index.d.ts.map +1 -0
- package/dist/radio/index.js +136 -0
- package/dist/radio/index.js.map +1 -0
- package/dist/resize/index.d.ts +118 -0
- package/dist/resize/index.d.ts.map +1 -0
- package/dist/resize/index.js +342 -0
- package/dist/resize/index.js.map +1 -0
- package/dist/scroll-area/index.d.ts +40 -0
- package/dist/scroll-area/index.d.ts.map +1 -0
- package/dist/scroll-area/index.js +52 -0
- package/dist/scroll-area/index.js.map +1 -0
- package/dist/select/index.d.ts +67 -0
- package/dist/select/index.d.ts.map +1 -0
- package/dist/select/index.js +61 -0
- package/dist/select/index.js.map +1 -0
- package/dist/separator/index.d.ts +47 -0
- package/dist/separator/index.d.ts.map +1 -0
- package/dist/separator/index.js +61 -0
- package/dist/separator/index.js.map +1 -0
- package/dist/side-navigation/index.d.ts +78 -0
- package/dist/side-navigation/index.d.ts.map +1 -0
- package/dist/side-navigation/index.js +188 -0
- package/dist/side-navigation/index.js.map +1 -0
- package/dist/skeleton/index.d.ts +76 -0
- package/dist/skeleton/index.d.ts.map +1 -0
- package/dist/skeleton/index.js +78 -0
- package/dist/skeleton/index.js.map +1 -0
- package/dist/skip-link/index.d.ts +46 -0
- package/dist/skip-link/index.d.ts.map +1 -0
- package/dist/skip-link/index.js +37 -0
- package/dist/skip-link/index.js.map +1 -0
- package/dist/spinner/index.d.ts +58 -0
- package/dist/spinner/index.d.ts.map +1 -0
- package/dist/spinner/index.js +36 -0
- package/dist/spinner/index.js.map +1 -0
- package/dist/step-indicator/index.d.ts +41 -0
- package/dist/step-indicator/index.d.ts.map +1 -0
- package/dist/step-indicator/index.js +63 -0
- package/dist/step-indicator/index.js.map +1 -0
- package/dist/structured-list/index.d.ts +150 -0
- package/dist/structured-list/index.d.ts.map +1 -0
- package/dist/structured-list/index.js +194 -0
- package/dist/structured-list/index.js.map +1 -0
- package/dist/tab/index.d.ts +108 -0
- package/dist/tab/index.d.ts.map +1 -0
- package/dist/tab/index.js +198 -0
- package/dist/tab/index.js.map +1 -0
- package/dist/table/index.d.ts +75 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/table/index.js +91 -0
- package/dist/table/index.js.map +1 -0
- package/dist/tag/index.d.ts +71 -0
- package/dist/tag/index.d.ts.map +1 -0
- package/dist/tag/index.js +45 -0
- package/dist/tag/index.js.map +1 -0
- package/dist/text-input/index.d.ts +69 -0
- package/dist/text-input/index.d.ts.map +1 -0
- package/dist/text-input/index.js +70 -0
- package/dist/text-input/index.js.map +1 -0
- package/dist/text-list/index.d.ts +71 -0
- package/dist/text-list/index.d.ts.map +1 -0
- package/dist/text-list/index.js +79 -0
- package/dist/text-list/index.js.map +1 -0
- package/dist/textarea/index.d.ts +29 -0
- package/dist/textarea/index.d.ts.map +1 -0
- package/dist/textarea/index.js +69 -0
- package/dist/textarea/index.js.map +1 -0
- package/dist/toast/index.d.ts +76 -0
- package/dist/toast/index.d.ts.map +1 -0
- package/dist/toast/index.js +183 -0
- package/dist/toast/index.js.map +1 -0
- package/dist/tooltip/index.d.ts +84 -0
- package/dist/tooltip/index.d.ts.map +1 -0
- package/dist/tooltip/index.js +329 -0
- package/dist/tooltip/index.js.map +1 -0
- package/package.json +57 -0
- package/src/accordion/index.tsx +251 -0
- package/src/alert/index.tsx +111 -0
- package/src/avatar/index.tsx +272 -0
- package/src/badge/index.tsx +91 -0
- package/src/breadcrumb/index.tsx +125 -0
- package/src/button/index.tsx +190 -0
- package/src/checkbox/index.tsx +247 -0
- package/src/combobox/index.tsx +700 -0
- package/src/context-menu/index.tsx +623 -0
- package/src/critical-alert/index.tsx +102 -0
- package/src/data-table/index.tsx +289 -0
- package/src/disclosure/index.tsx +87 -0
- package/src/drawer/index.tsx +516 -0
- package/src/empty-state/index.tsx +256 -0
- package/src/env.d.ts +10 -0
- package/src/field/index.tsx +307 -0
- package/src/file-upload/index.tsx +492 -0
- package/src/footer/index.tsx +393 -0
- package/src/header/index.tsx +498 -0
- package/src/identifier/index.tsx +96 -0
- package/src/in-page-navigation/index.tsx +258 -0
- package/src/index.ts +72 -0
- package/src/input-group/index.tsx +197 -0
- package/src/lib/krds.ts +75 -0
- package/src/lib/link.tsx +35 -0
- package/src/main-menu/index.tsx +912 -0
- package/src/masthead/index.tsx +50 -0
- package/src/modal/index.tsx +457 -0
- package/src/pagination/index.tsx +198 -0
- package/src/progress/index.tsx +198 -0
- package/src/radio/index.tsx +283 -0
- package/src/resize/index.tsx +541 -0
- package/src/scroll-area/index.tsx +96 -0
- package/src/select/index.tsx +136 -0
- package/src/separator/index.tsx +107 -0
- package/src/side-navigation/index.tsx +336 -0
- package/src/skeleton/index.tsx +172 -0
- package/src/skip-link/index.tsx +89 -0
- package/src/spinner/index.tsx +98 -0
- package/src/step-indicator/index.tsx +122 -0
- package/src/structured-list/index.tsx +395 -0
- package/src/tab/index.tsx +348 -0
- package/src/table/index.tsx +186 -0
- package/src/tag/index.tsx +127 -0
- package/src/text-input/index.tsx +156 -0
- package/src/text-list/index.tsx +177 -0
- package/src/textarea/index.tsx +113 -0
- package/src/toast/index.tsx +311 -0
- package/src/tooltip/index.tsx +496 -0
- package/styles/krds-tailwind.css +335 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ComponentPropsWithRef,
|
|
11
|
+
type FocusEvent as ReactFocusEvent,
|
|
12
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { cx, type KrdsFieldSize, type KrdsIcon } from "../lib/krds";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 콤보박스 — 입력하면 목록이 좁혀지는 자동완성. KRDS 킷에 없는 보충 부품이다.
|
|
19
|
+
*
|
|
20
|
+
* 껍데기는 킷 드롭다운(`krds-drop-wrap`), 동작은 WAI-ARIA 1.2 콤보박스 패턴이다.
|
|
21
|
+
* ↓↑ 는 양 끝에서 순환하고 Esc 는 닫기만 하며 값은 건드리지 않는다.
|
|
22
|
+
* 검색칸(돋보기 · 검색어 삭제 · 실행 버튼)이 필요하면 {@link SearchCombobox} 를 쓴다.
|
|
23
|
+
* 최근·인기·제안처럼 종류가 갈리면 `options` 대신 `groups` 로 계층화한다(규격 요구).
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* <Combobox label="기관명" options={hits} onSelect={(o) => setOrg(o.value)} />
|
|
27
|
+
* <SearchCombobox
|
|
28
|
+
* groups={[
|
|
29
|
+
* { label: '최근 검색어', options: recent },
|
|
30
|
+
* { label: '인기 검색어', options: popular },
|
|
31
|
+
* ]}
|
|
32
|
+
* emptyMessage="일치하는 검색어가 없습니다."
|
|
33
|
+
* onSearch={run}
|
|
34
|
+
* />
|
|
35
|
+
*
|
|
36
|
+
* 자세히: docs/krds/09-부품-노트.md#콤보박스
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/* ────────────────────────────── 자료 모양 ────────────────────────────── */
|
|
40
|
+
|
|
41
|
+
/** 제안 항목 하나. */
|
|
42
|
+
export type ComboboxOption = {
|
|
43
|
+
/** 고르면 입력칸에 들어갈 글자. */
|
|
44
|
+
value: string;
|
|
45
|
+
/** 화면에 보일 것. 없으면 `value` 를 그대로 쓴다(일치 글자 강조를 넣을 자리). */
|
|
46
|
+
label?: ReactNode;
|
|
47
|
+
/** 오른쪽 끝 보조 설명(분류·결과 수 등). */
|
|
48
|
+
description?: ReactNode;
|
|
49
|
+
/** 앞 아이콘 이름(`ico-` 뺀 것). 최근 검색어에 `log`, 인기 검색어에 `like` 같은 식. */
|
|
50
|
+
icon?: KrdsIcon;
|
|
51
|
+
/** 보이되 고를 수 없다. ↑↓ 순회에서도 건너뛴다. */
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** 제안 묶음. 규격이 요구하는 계층화의 단위다 — 최근 / 인기 / 제안이 각각 한 묶음이다. */
|
|
56
|
+
export type ComboboxGroup = {
|
|
57
|
+
/** 묶음 제목. `<h3>` 으로 그리고 `role="group"` 의 이름으로도 쓴다. */
|
|
58
|
+
label: string;
|
|
59
|
+
options: readonly ComboboxOption[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** 내부용 — 평평하게 편 항목. `id` 가 `aria-activedescendant` 로 가리킬 자리다. */
|
|
63
|
+
type FlatOption = ComboboxOption & { id: string };
|
|
64
|
+
|
|
65
|
+
/* ────────────────────────────── Props ────────────────────────────── */
|
|
66
|
+
|
|
67
|
+
type ComboboxOwnProps = {
|
|
68
|
+
/** 입력칸의 접근 가능한 이름. 보이는 `<label>` 이 있으면 `labelledBy` 를 대신 준다. */
|
|
69
|
+
label?: string;
|
|
70
|
+
/** 보이는 `<label>` 의 id. `label` 과 함께 줄 수 없다. */
|
|
71
|
+
labelledBy?: string;
|
|
72
|
+
/** `<input>` 의 id. 생략하면 `useId()` 로 만든다. */
|
|
73
|
+
id?: string;
|
|
74
|
+
/** 기본 medium(48px). 같은 줄의 버튼과 같은 값을 준다. */
|
|
75
|
+
size?: KrdsFieldSize;
|
|
76
|
+
|
|
77
|
+
/** 한 덩어리 목록. `groups` 를 함께 주면 `groups` 만 그린다. */
|
|
78
|
+
options?: readonly ComboboxOption[];
|
|
79
|
+
/** 제목이 붙은 묶음 목록(계층화). */
|
|
80
|
+
groups?: readonly ComboboxGroup[];
|
|
81
|
+
|
|
82
|
+
/** 제어 입력값. 주면 제어 부품이 된다. */
|
|
83
|
+
value?: string;
|
|
84
|
+
/** 비제어 초기값. */
|
|
85
|
+
defaultValue?: string;
|
|
86
|
+
/** 입력값이 바뀔 때(사람이 치거나 항목을 골랐을 때 모두). */
|
|
87
|
+
onValueChange?: (value: string) => void;
|
|
88
|
+
/** 항목을 골랐을 때. 입력값을 `option.value` 로 바꾼 **뒤** 부른다. */
|
|
89
|
+
onSelect?: (option: ComboboxOption) => void;
|
|
90
|
+
|
|
91
|
+
/** 초점이 들어오면 값이 비어 있어도 목록을 연다. 기본 true(규격의 Focusin 동작). */
|
|
92
|
+
openOnFocus?: boolean;
|
|
93
|
+
/** 처음부터 펼친 채로 그린다. 비제어 초깃값이라 사람이 닫으면 다시 열리지 않는다(문서·시각 시험용). */
|
|
94
|
+
defaultOpen?: boolean;
|
|
95
|
+
/** 열 때 첫 항목을 미리 활성으로 둔다. 기본 false — 치던 글자가 Enter 로 바뀌는 사고를 막는다. */
|
|
96
|
+
autoHighlight?: boolean;
|
|
97
|
+
|
|
98
|
+
/** 결과가 없을 때 보일 글. **글자를 한 자 이상 친 뒤에만** 뜬다. 주지 않으면 목록이 열리지 않는다. */
|
|
99
|
+
emptyMessage?: ReactNode;
|
|
100
|
+
/** 목록 위 고정 영역(킷 `.drop-top`). 「검색 도움말」 같은 것. */
|
|
101
|
+
header?: ReactNode;
|
|
102
|
+
/** 목록 아래 고정 영역(킷 `.drop-bottom`). 「검색 기록 전체 삭제」 같은 것. */
|
|
103
|
+
footer?: ReactNode;
|
|
104
|
+
|
|
105
|
+
/** 목록의 접근 가능한 이름. 기본 「제안 목록」. */
|
|
106
|
+
listLabel?: string;
|
|
107
|
+
/** 입력칸 오른쪽에 놓일 것(검색 버튼 등). 목록보다 뒤에 그려진다(규격의 읽기 순서). */
|
|
108
|
+
action?: ReactNode;
|
|
109
|
+
/** 입력칸 줄을 킷 `.sch-input`(돋보기 겹침 규칙)으로 감싼다. {@link SearchCombobox} 가 켠다. */
|
|
110
|
+
searchLayout?: boolean;
|
|
111
|
+
|
|
112
|
+
/** `.krds-drop-wrap` 감싸개에 붙는다. 바깥 여백·너비는 여기서 조절한다. */
|
|
113
|
+
className?: string;
|
|
114
|
+
/** `<input>` 에 붙는다. */
|
|
115
|
+
inputClassName?: string;
|
|
116
|
+
/** `.drop-menu` 에 붙는다. */
|
|
117
|
+
menuClassName?: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** `<input>` 에서 부품이 직접 다루는 것은 뺀다. `onSelect` 는 DOM 이벤트와 이름이 겹쳐 막았다. */
|
|
121
|
+
type ComboboxInputProps = Omit<
|
|
122
|
+
ComponentPropsWithRef<"input">,
|
|
123
|
+
| "size"
|
|
124
|
+
| "value"
|
|
125
|
+
| "defaultValue"
|
|
126
|
+
| "onSelect"
|
|
127
|
+
| "role"
|
|
128
|
+
| "children"
|
|
129
|
+
| "list"
|
|
130
|
+
| "type"
|
|
131
|
+
>;
|
|
132
|
+
|
|
133
|
+
type ComboboxBaseProps = ComboboxInputProps & ComboboxOwnProps;
|
|
134
|
+
|
|
135
|
+
/** 이름 없는 콤보박스를 만들 수 없게 타입으로 막는다 — `label` 이나 `labelledBy` 중 하나는 필수다. */
|
|
136
|
+
export type ComboboxProps = ComboboxBaseProps &
|
|
137
|
+
(
|
|
138
|
+
| { label: string; labelledBy?: never }
|
|
139
|
+
| { label?: never; labelledBy: string }
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
/* ────────────────────────────── 부품 ────────────────────────────── */
|
|
143
|
+
|
|
144
|
+
export function Combobox(props: ComboboxProps) {
|
|
145
|
+
// 이름 유니온은 판별자가 없어 그대로 구조 분해가 안 된다. 넓은 쪽으로 한 번 받아 쓴다.
|
|
146
|
+
const {
|
|
147
|
+
label,
|
|
148
|
+
labelledBy,
|
|
149
|
+
id: idProp,
|
|
150
|
+
size = "medium",
|
|
151
|
+
options,
|
|
152
|
+
groups,
|
|
153
|
+
value,
|
|
154
|
+
defaultValue,
|
|
155
|
+
onValueChange,
|
|
156
|
+
onSelect,
|
|
157
|
+
openOnFocus = true,
|
|
158
|
+
defaultOpen = false,
|
|
159
|
+
autoHighlight = false,
|
|
160
|
+
emptyMessage,
|
|
161
|
+
header,
|
|
162
|
+
footer,
|
|
163
|
+
listLabel = "제안 목록",
|
|
164
|
+
action,
|
|
165
|
+
searchLayout,
|
|
166
|
+
className,
|
|
167
|
+
inputClassName,
|
|
168
|
+
menuClassName,
|
|
169
|
+
onChange,
|
|
170
|
+
onFocus,
|
|
171
|
+
onKeyDown,
|
|
172
|
+
onBlur,
|
|
173
|
+
autoComplete = "off",
|
|
174
|
+
ref: forwardedRef,
|
|
175
|
+
...rest
|
|
176
|
+
}: ComboboxBaseProps = props;
|
|
177
|
+
|
|
178
|
+
const reactId = useId();
|
|
179
|
+
const id = idProp ?? `${reactId}-input`;
|
|
180
|
+
const listId = `${reactId}-list`;
|
|
181
|
+
|
|
182
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
183
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
184
|
+
|
|
185
|
+
// `defaultOpen` 은 초깃값일 뿐이다 — 닫은 뒤에는 보통 부품과 똑같이 움직인다.
|
|
186
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
187
|
+
const [activeIndex, setActiveIndex] = useState(defaultOpen && autoHighlight ? 0 : -1);
|
|
188
|
+
const [innerValue, setInnerValue] = useState(defaultValue ?? "");
|
|
189
|
+
|
|
190
|
+
const isControlled = value !== undefined;
|
|
191
|
+
const text = isControlled ? value : innerValue;
|
|
192
|
+
|
|
193
|
+
const setText = useCallback(
|
|
194
|
+
(next: string) => {
|
|
195
|
+
if (!isControlled) setInnerValue(next);
|
|
196
|
+
onValueChange?.(next);
|
|
197
|
+
},
|
|
198
|
+
[isControlled, onValueChange],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
/** 묶음이 있으면 묶음, 없으면 제목 없는 묶음 하나. 아래 계산을 한 갈래로 만들려는 정규화다. */
|
|
202
|
+
const sections = useMemo<
|
|
203
|
+
ReadonlyArray<{ label?: string; options: readonly ComboboxOption[] }>
|
|
204
|
+
>(
|
|
205
|
+
() => (groups?.length ? groups : [{ options: options ?? [] }]),
|
|
206
|
+
[groups, options],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
/** 평평하게 편 항목. 이 순서가 곧 ↑↓ 순서다. `id` 는 `aria-activedescendant` 가 가리킨다. */
|
|
210
|
+
const flat = useMemo<FlatOption[]>(
|
|
211
|
+
() =>
|
|
212
|
+
sections.flatMap((section, si) =>
|
|
213
|
+
section.options.map((option, oi) => ({
|
|
214
|
+
...option,
|
|
215
|
+
id: `${reactId}-o-${si}-${oi}`,
|
|
216
|
+
})),
|
|
217
|
+
),
|
|
218
|
+
[sections, reactId],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 묶음 si 의 첫 항목이 `flat` 에서 몇 번째인지. 묶음별로 그릴 때 전역 번호를 되찾는 데 쓴다
|
|
223
|
+
* (전역 번호가 곧 ↑↓ 순서라 묶음 안 번호로는 안 된다). 묶음은 서넛뿐이라 앞부분을 다시 세도 된다.
|
|
224
|
+
*/
|
|
225
|
+
const offsets = useMemo(
|
|
226
|
+
() =>
|
|
227
|
+
sections.map((_, si) =>
|
|
228
|
+
sections.slice(0, si).reduce((n, s) => n + s.options.length, 0),
|
|
229
|
+
),
|
|
230
|
+
[sections],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 빈 안내는 **친 글자가 있을 때만** 연다. 초점만으로 여는 자리(규격의 Focusin)는
|
|
235
|
+
* 「인기 검색어·이전 검색 기록」을 보여 주는 자리라, 항목이 하나도 없으면 보여 줄 것이 없다 —
|
|
236
|
+
* 예전에는 `emptyMessage` 만 주면 `options={[]}` 인 화면에서 초점을 줄 때마다 빈 상자가 떴다.
|
|
237
|
+
*/
|
|
238
|
+
const expanded =
|
|
239
|
+
open && (flat.length > 0 || (emptyMessage != null && text.length > 0));
|
|
240
|
+
const activeOption =
|
|
241
|
+
expanded && activeIndex >= 0 ? flat[activeIndex] : undefined;
|
|
242
|
+
|
|
243
|
+
const closeMenu = useCallback(() => {
|
|
244
|
+
setOpen(false);
|
|
245
|
+
setActiveIndex(-1);
|
|
246
|
+
}, []);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* ↑↓ 이동. **양 끝에서 순환한다** — 규격 표가 못 박은 동작이다.
|
|
250
|
+
* `disabled` 는 건너뛰고, 전부 disabled 면 한 바퀴만 돌고 제자리에 둔다.
|
|
251
|
+
*/
|
|
252
|
+
const move = useCallback(
|
|
253
|
+
(delta: number) => {
|
|
254
|
+
if (!flat.length) return;
|
|
255
|
+
setActiveIndex((prev) => {
|
|
256
|
+
// 아직 아무 데도 없으면 ↓ 는 첫 항목, ↑ 는 마지막 항목에서 시작하게 자리를 잡아 둔다.
|
|
257
|
+
let next = prev < 0 ? (delta > 0 ? -1 : 0) : prev;
|
|
258
|
+
for (let step = 0; step < flat.length; step += 1) {
|
|
259
|
+
next = (next + delta + flat.length) % flat.length;
|
|
260
|
+
if (!flat[next].disabled) return next;
|
|
261
|
+
}
|
|
262
|
+
return prev;
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
[flat],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
/** 항목을 고른다. 입력값을 바꾸고 닫는다(규격: Click/Enter 는 그 항목으로 검색 실행). */
|
|
269
|
+
const select = useCallback(
|
|
270
|
+
(option: ComboboxOption) => {
|
|
271
|
+
if (option.disabled) return;
|
|
272
|
+
setText(option.value);
|
|
273
|
+
closeMenu();
|
|
274
|
+
onSelect?.(option);
|
|
275
|
+
inputRef.current?.focus();
|
|
276
|
+
},
|
|
277
|
+
[setText, closeMenu, onSelect],
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// 활성 항목이 스크롤 밖으로 나가지 않게 따라간다. `block:'nearest'` 라 필요할 때만 움직인다.
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (!activeOption) return;
|
|
283
|
+
document
|
|
284
|
+
.getElementById(activeOption.id)
|
|
285
|
+
?.scrollIntoView({ block: "nearest" });
|
|
286
|
+
}, [activeOption]);
|
|
287
|
+
|
|
288
|
+
// 바깥을 누르면 닫는다. click 이 아니라 pointerdown 이라 드래그로 나가도 닫힌다.
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (!expanded) return;
|
|
291
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
292
|
+
if (!wrapRef.current?.contains(e.target as Node)) closeMenu();
|
|
293
|
+
};
|
|
294
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
295
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
296
|
+
}, [expanded, closeMenu]);
|
|
297
|
+
|
|
298
|
+
const handleKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
299
|
+
onKeyDown?.(e);
|
|
300
|
+
if (e.defaultPrevented) return;
|
|
301
|
+
|
|
302
|
+
// ★ 한글 조합 중에는 어떤 키도 가로채지 않는다(함정 4). 조합을 끝내는 Enter 가 검색을 실행해 버린다.
|
|
303
|
+
if (e.nativeEvent.isComposing) return;
|
|
304
|
+
|
|
305
|
+
switch (e.key) {
|
|
306
|
+
case "ArrowDown":
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
// 규격: 「방향키 ↓ → 제안 목록의 가장 첫 번째 요소로」. 닫혀 있으면 열면서 첫 항목으로 간다.
|
|
309
|
+
if (!expanded) {
|
|
310
|
+
setOpen(true);
|
|
311
|
+
setActiveIndex(flat.findIndex((o) => !o.disabled));
|
|
312
|
+
} else {
|
|
313
|
+
move(1);
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
case "ArrowUp":
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
if (!expanded) {
|
|
319
|
+
setOpen(true);
|
|
320
|
+
setActiveIndex(flat.length - 1);
|
|
321
|
+
} else {
|
|
322
|
+
move(-1);
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
case "Enter":
|
|
326
|
+
// 활성 항목이 있을 때만 가로챈다 — 없으면 폼 제출(=검색 실행)이 그대로 지나가야 한다.
|
|
327
|
+
if (activeOption) {
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
select(activeOption);
|
|
330
|
+
} else {
|
|
331
|
+
closeMenu();
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
case "Escape":
|
|
335
|
+
// 규격: 「창이 닫히면서 초점이 검색어 입력 필드로 이동」. 값은 건드리지 않는다(함정 3).
|
|
336
|
+
// 초점은 이미 입력칸에 있으므로 닫기만 하면 규격을 만족한다.
|
|
337
|
+
if (expanded) {
|
|
338
|
+
e.preventDefault();
|
|
339
|
+
e.stopPropagation();
|
|
340
|
+
closeMenu();
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
case "Tab":
|
|
344
|
+
// 열어 둔 채 다음 요소로 넘어가면 유령 상자가 남는다.
|
|
345
|
+
closeMenu();
|
|
346
|
+
break;
|
|
347
|
+
default:
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// 부품 밖으로 초점이 나가면 닫는다. relatedTarget 이 없으면(브라우저 UI 로 이동) 그대로 둔다.
|
|
353
|
+
const handleBlur = (e: ReactFocusEvent<HTMLInputElement>) => {
|
|
354
|
+
onBlur?.(e);
|
|
355
|
+
const next = e.relatedTarget as Node | null;
|
|
356
|
+
if (next && !wrapRef.current?.contains(next)) closeMenu();
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 소리로 알리는 제안 개수(함정 5). 닫혀 있으면 빈 글자라 아무것도 읽지 않는다 —
|
|
361
|
+
* 닫힘 자체는 알리지 않는다(모든 Esc 마다 떠드는 화면이 된다).
|
|
362
|
+
*/
|
|
363
|
+
const status = !expanded
|
|
364
|
+
? ""
|
|
365
|
+
: flat.length
|
|
366
|
+
? `제안 ${flat.length}건`
|
|
367
|
+
: "제안 없음";
|
|
368
|
+
|
|
369
|
+
/** 바깥에서 온 ref 와 부품이 쓰는 ref 를 함께 채운다. React 19 라 forwardRef 가 필요 없다. */
|
|
370
|
+
const setInputRef = (node: HTMLInputElement | null) => {
|
|
371
|
+
inputRef.current = node;
|
|
372
|
+
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
373
|
+
else if (forwardedRef)
|
|
374
|
+
(forwardedRef as { current: HTMLInputElement | null }).current = node;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const renderItem = (option: FlatOption, index: number) => (
|
|
378
|
+
<Item
|
|
379
|
+
key={option.id}
|
|
380
|
+
option={option}
|
|
381
|
+
index={index}
|
|
382
|
+
active={index === activeIndex}
|
|
383
|
+
onPick={select}
|
|
384
|
+
onHover={setActiveIndex}
|
|
385
|
+
/>
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const row = (
|
|
389
|
+
<>
|
|
390
|
+
<input
|
|
391
|
+
ref={setInputRef}
|
|
392
|
+
id={id}
|
|
393
|
+
// `search` 로 두면 웹킷이 제 지우기 버튼을 그려 돋보기와 겹친다 — 킷 샘플도 text 다.
|
|
394
|
+
type="text"
|
|
395
|
+
className={cx("krds-input", size, inputClassName)}
|
|
396
|
+
/* ── WAI-ARIA 1.2 콤보박스 배선 ── */
|
|
397
|
+
role="combobox"
|
|
398
|
+
aria-label={label}
|
|
399
|
+
aria-labelledby={labelledBy}
|
|
400
|
+
aria-expanded={expanded}
|
|
401
|
+
aria-controls={listId}
|
|
402
|
+
aria-autocomplete="list"
|
|
403
|
+
aria-activedescendant={activeOption?.id}
|
|
404
|
+
// 브라우저 자동완성 상자가 우리 목록 위에 겹치는 것을 막는다.
|
|
405
|
+
autoComplete={autoComplete}
|
|
406
|
+
value={text}
|
|
407
|
+
onChange={(e) => {
|
|
408
|
+
onChange?.(e);
|
|
409
|
+
setText(e.target.value);
|
|
410
|
+
// 규격: 「Keyup — 한 글자 이상 입력되면 … 검색어 제안으로 변경된다」. 칠 때마다 연다.
|
|
411
|
+
setOpen(true);
|
|
412
|
+
setActiveIndex(autoHighlight ? 0 : -1);
|
|
413
|
+
}}
|
|
414
|
+
onFocus={(e) => {
|
|
415
|
+
onFocus?.(e);
|
|
416
|
+
// 규격: 「Focusin — 값이 비어 있으면 인기 검색어와 이전 검색 기록이 제공된다」.
|
|
417
|
+
if (openOnFocus) setOpen(true);
|
|
418
|
+
}}
|
|
419
|
+
onKeyDown={handleKeyDown}
|
|
420
|
+
onBlur={handleBlur}
|
|
421
|
+
{...rest}
|
|
422
|
+
/>
|
|
423
|
+
|
|
424
|
+
{/*
|
|
425
|
+
★ 목록은 입력칸의 「다음 요소」다 — 규격의 읽기 순서 요구(함정 1). action 보다 앞에 둔다.
|
|
426
|
+
여는 방법은 인라인 display:block — 킷에 여는 클래스가 없다(보충 1).
|
|
427
|
+
onMouseDown 을 막아 항목을 눌러도 입력칸이 초점을 잃지 않게 한다(닫혔다 열리는 깜빡임 방지).
|
|
428
|
+
*/}
|
|
429
|
+
<div
|
|
430
|
+
className={cx("drop-menu w-full mt-1 before:hidden", menuClassName)}
|
|
431
|
+
style={expanded ? { display: "block" } : undefined}
|
|
432
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
433
|
+
>
|
|
434
|
+
<div className="drop-in">
|
|
435
|
+
{header && <div className="drop-top">{header}</div>}
|
|
436
|
+
|
|
437
|
+
{/*
|
|
438
|
+
역할 구조: listbox → (group → option) 또는 listbox → option.
|
|
439
|
+
묶음이 있을 땐 감싸개를 div(listbox)로 두고 `<ul class="drop-list">` 는 role="none" 으로 비운다 —
|
|
440
|
+
listbox 의 자식은 option 이나 group 만 허용되므로 ul/li 의 목록 역할을 지워야 한다.
|
|
441
|
+
제목 `<h3>` 은 group 안에 두고 aria-labelledby 로 그 group 의 이름이 되게 한다.
|
|
442
|
+
규격의 「제목 구조로 계층화」와 ARIA 구조를 동시에 만족시키는 배치다.
|
|
443
|
+
*/}
|
|
444
|
+
{groups?.length ? (
|
|
445
|
+
<div
|
|
446
|
+
id={listId}
|
|
447
|
+
role="listbox"
|
|
448
|
+
aria-label={listLabel}
|
|
449
|
+
className="w-full max-h-80 overflow-y-auto flex flex-col gap-2"
|
|
450
|
+
>
|
|
451
|
+
{sections.map((section, si) => (
|
|
452
|
+
<div
|
|
453
|
+
key={section.label ?? si}
|
|
454
|
+
role="group"
|
|
455
|
+
aria-labelledby={`${reactId}-g-${si}`}
|
|
456
|
+
className="w-full"
|
|
457
|
+
>
|
|
458
|
+
{/* 킷에 목록 소제목 규칙이 없다(보충 5) — 유틸리티로 그린다. 좌우 16px 은 .item-link 와 맞춘 값. */}
|
|
459
|
+
<h3
|
|
460
|
+
id={`${reactId}-g-${si}`}
|
|
461
|
+
className="px-4 py-1 text-xs font-bold text-fg-subtle"
|
|
462
|
+
>
|
|
463
|
+
{section.label}
|
|
464
|
+
</h3>
|
|
465
|
+
<ul className="drop-list" role="none">
|
|
466
|
+
{section.options.map((_, oi) =>
|
|
467
|
+
renderItem(flat[offsets[si] + oi], offsets[si] + oi),
|
|
468
|
+
)}
|
|
469
|
+
</ul>
|
|
470
|
+
</div>
|
|
471
|
+
))}
|
|
472
|
+
</div>
|
|
473
|
+
) : (
|
|
474
|
+
<ul
|
|
475
|
+
id={listId}
|
|
476
|
+
role="listbox"
|
|
477
|
+
aria-label={listLabel}
|
|
478
|
+
className="drop-list max-h-80 overflow-y-auto"
|
|
479
|
+
>
|
|
480
|
+
{flat.map(renderItem)}
|
|
481
|
+
</ul>
|
|
482
|
+
)}
|
|
483
|
+
|
|
484
|
+
{/* 빈 결과 안내. listbox 밖이라 option 개수에 섞이지 않는다. */}
|
|
485
|
+
{flat.length === 0 && emptyMessage != null && (
|
|
486
|
+
<p className="w-full px-4 py-3 text-sm text-fg-subtle">
|
|
487
|
+
{emptyMessage}
|
|
488
|
+
</p>
|
|
489
|
+
)}
|
|
490
|
+
|
|
491
|
+
{footer && <div className="drop-bottom">{footer}</div>}
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
{action}
|
|
496
|
+
</>
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
<div
|
|
501
|
+
ref={wrapRef}
|
|
502
|
+
// `krds-drop-wrap` 이 있어야 `.drop-menu`·`.item-link` 규칙이 걸린다(전부 후손 선택자).
|
|
503
|
+
// `drop-left` 도 킷 클래스다 — 가운데 정렬을 왼쪽 맞춤으로 되돌린다.
|
|
504
|
+
className={cx("krds-drop-wrap drop-left w-full", className)}
|
|
505
|
+
>
|
|
506
|
+
{searchLayout ? <div className="sch-input w-full">{row}</div> : row}
|
|
507
|
+
{/* 함정 5 — 눈에는 안 보이고 소리로만 개수 변화를 알린다. */}
|
|
508
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
509
|
+
{status}
|
|
510
|
+
</span>
|
|
511
|
+
</div>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** 제안 항목 하나 — 킷 `.item-link`. 초점을 받으면 안 되므로 `<li role="option">` 이다. */
|
|
516
|
+
function Item({
|
|
517
|
+
option,
|
|
518
|
+
active,
|
|
519
|
+
index,
|
|
520
|
+
onPick,
|
|
521
|
+
onHover,
|
|
522
|
+
}: {
|
|
523
|
+
option: FlatOption;
|
|
524
|
+
active: boolean;
|
|
525
|
+
index: number;
|
|
526
|
+
onPick: (option: ComboboxOption) => void;
|
|
527
|
+
onHover: (index: number) => void;
|
|
528
|
+
}) {
|
|
529
|
+
return (
|
|
530
|
+
<li
|
|
531
|
+
id={option.id}
|
|
532
|
+
role="option"
|
|
533
|
+
aria-selected={active}
|
|
534
|
+
aria-disabled={option.disabled || undefined}
|
|
535
|
+
// `.active` 는 킷 클래스다(굵게 + secondary 배경). 활성 표시를 새로 그리지 않는다.
|
|
536
|
+
className={cx(
|
|
537
|
+
"item-link",
|
|
538
|
+
active && "active",
|
|
539
|
+
option.disabled
|
|
540
|
+
? "text-fg-disabled cursor-not-allowed"
|
|
541
|
+
: "cursor-pointer",
|
|
542
|
+
)}
|
|
543
|
+
onClick={() => onPick(option)}
|
|
544
|
+
// 마우스를 올리면 활성도 따라온다 — 키보드로 고른 자리와 마우스가 가리키는 자리가 갈라지지 않게.
|
|
545
|
+
onMouseMove={() => {
|
|
546
|
+
if (!option.disabled) onHover(index);
|
|
547
|
+
}}
|
|
548
|
+
>
|
|
549
|
+
{option.icon && (
|
|
550
|
+
<i className={`svg-icon ico-${option.icon}`} aria-hidden="true" />
|
|
551
|
+
)}
|
|
552
|
+
{/* 항목 높이가 48px 고정이라 줄바꿈이 나면 넘친다. 규격이 금지한 말줄임은 「입력 필드」쪽 이야기다. */}
|
|
553
|
+
<span className="min-w-0 flex-1 truncate">
|
|
554
|
+
{option.label ?? option.value}
|
|
555
|
+
</span>
|
|
556
|
+
{option.description && (
|
|
557
|
+
<span className="shrink-0 text-sm text-fg-subtle">
|
|
558
|
+
{option.description}
|
|
559
|
+
</span>
|
|
560
|
+
)}
|
|
561
|
+
</li>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/* ────────────────────────────── 검색어 자동완성 ────────────────────────────── */
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 검색어 자동완성 — {@link Combobox} + 킷 `sch-input`(돋보기 겹침) + 검색어 삭제 버튼.
|
|
569
|
+
*
|
|
570
|
+
* KRDS 검색 규격의 구성 요소 5(검색어 삭제)·7(실시간 제안)을 한 부품으로 채운다.
|
|
571
|
+
* 규격이 정한 이름 셋이 기본값이다 — 입력 「검색어」·삭제 「검색어 전체 삭제」·실행 「검색」.
|
|
572
|
+
* `buttonType` 기본이 `submit` 이라 `<form action>` 안에 두면 JS 없이도 검색이 된다.
|
|
573
|
+
*
|
|
574
|
+
* @example
|
|
575
|
+
* <form action="/search">
|
|
576
|
+
* <SearchCombobox name="q" groups={suggestions} onSearch={run} />
|
|
577
|
+
* </form>
|
|
578
|
+
*/
|
|
579
|
+
export type SearchComboboxProps = Omit<
|
|
580
|
+
ComboboxBaseProps,
|
|
581
|
+
"action" | "searchLayout" | "label" | "labelledBy"
|
|
582
|
+
> & {
|
|
583
|
+
/** 입력칸의 접근 가능한 이름. 규격이 정한 기본값 「검색어」. */
|
|
584
|
+
label?: string;
|
|
585
|
+
/** 실행 버튼의 이름(`sr-only`). 규격이 정한 기본값 「검색」. */
|
|
586
|
+
buttonLabel?: string;
|
|
587
|
+
/** 삭제 버튼의 이름(`sr-only`). 규격이 정한 기본값 「검색어 전체 삭제」. */
|
|
588
|
+
clearLabel?: string;
|
|
589
|
+
/** 검색어 삭제 버튼을 둔다. 기본 true — 규격 구성 요소 5는 선택이 아니다. */
|
|
590
|
+
clearable?: boolean;
|
|
591
|
+
/** 삭제 버튼을 누른 뒤. 입력값은 부품이 이미 비운 뒤 부른다. */
|
|
592
|
+
onClear?: () => void;
|
|
593
|
+
/**
|
|
594
|
+
* 검색 실행. Enter(활성 항목이 없을 때)·돋보기 클릭·제안 선택 세 곳에서 부른다.
|
|
595
|
+
* `preventDefault` 를 하지 않으므로 `<form>` 안이면 폼 제출도 함께 일어난다.
|
|
596
|
+
*/
|
|
597
|
+
onSearch?: (query: string) => void;
|
|
598
|
+
/** 기본 submit — `<form action>` 안에 두면 JS 없이도 검색이 된다. */
|
|
599
|
+
buttonType?: "submit" | "button";
|
|
600
|
+
buttonProps?: Omit<
|
|
601
|
+
ComponentPropsWithRef<"button">,
|
|
602
|
+
"type" | "className" | "children"
|
|
603
|
+
>;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
export function SearchCombobox({
|
|
607
|
+
label = "검색어",
|
|
608
|
+
buttonLabel = "검색",
|
|
609
|
+
clearLabel = "검색어 전체 삭제",
|
|
610
|
+
clearable = true,
|
|
611
|
+
onClear,
|
|
612
|
+
onSearch,
|
|
613
|
+
buttonType = "submit",
|
|
614
|
+
buttonProps,
|
|
615
|
+
value,
|
|
616
|
+
defaultValue,
|
|
617
|
+
onValueChange,
|
|
618
|
+
onSelect,
|
|
619
|
+
onKeyDown,
|
|
620
|
+
inputClassName,
|
|
621
|
+
ref,
|
|
622
|
+
...rest
|
|
623
|
+
}: SearchComboboxProps) {
|
|
624
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
625
|
+
const [innerValue, setInnerValue] = useState(defaultValue ?? "");
|
|
626
|
+
const text = value !== undefined ? value : innerValue;
|
|
627
|
+
|
|
628
|
+
const handleValueChange = (next: string) => {
|
|
629
|
+
if (value === undefined) setInnerValue(next);
|
|
630
|
+
onValueChange?.(next);
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const setRef = (node: HTMLInputElement | null) => {
|
|
634
|
+
inputRef.current = node;
|
|
635
|
+
if (typeof ref === "function") ref(node);
|
|
636
|
+
else if (ref) (ref as { current: HTMLInputElement | null }).current = node;
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
return (
|
|
640
|
+
<Combobox
|
|
641
|
+
{...rest}
|
|
642
|
+
ref={setRef}
|
|
643
|
+
label={label}
|
|
644
|
+
searchLayout
|
|
645
|
+
value={text}
|
|
646
|
+
onValueChange={handleValueChange}
|
|
647
|
+
onSelect={(option) => {
|
|
648
|
+
onSelect?.(option);
|
|
649
|
+
// 규격: 「Click — 선택된 항목으로 검색 실행」 / 「Enter — 초점을 가진 항목으로 검색 실행」.
|
|
650
|
+
onSearch?.(option.value);
|
|
651
|
+
}}
|
|
652
|
+
onKeyDown={(e) => {
|
|
653
|
+
onKeyDown?.(e);
|
|
654
|
+
if (
|
|
655
|
+
e.defaultPrevented ||
|
|
656
|
+
e.key !== "Enter" ||
|
|
657
|
+
e.nativeEvent.isComposing
|
|
658
|
+
)
|
|
659
|
+
return;
|
|
660
|
+
// 활성 항목이 있으면 Combobox 가 「항목 선택」으로 처리한다 — 여기서 두 번 실행하지 않는다.
|
|
661
|
+
if (e.currentTarget.getAttribute("aria-activedescendant")) return;
|
|
662
|
+
onSearch?.(e.currentTarget.value);
|
|
663
|
+
}}
|
|
664
|
+
// 킷 여백(48px)은 돋보기 하나 몫이다. 삭제 버튼까지 둘이라 80px 로 넓힌다(값이 없어도 유지).
|
|
665
|
+
inputClassName={cx(clearable && "pr-20", inputClassName)}
|
|
666
|
+
action={
|
|
667
|
+
// `.sch-input .ico-search{position:absolute;top:50%;right:1.6rem;transform:translateY(-50%)}`
|
|
668
|
+
// 킷의 그 위치 규칙을 감싸개가 받는다. gap-2(8px)는 킷 `--krds-gap-3` 과 같은 값.
|
|
669
|
+
<div className="ico-search flex items-center gap-2">
|
|
670
|
+
{clearable && text.length > 0 && (
|
|
671
|
+
<button
|
|
672
|
+
type="button"
|
|
673
|
+
className="krds-btn medium icon"
|
|
674
|
+
onClick={() => {
|
|
675
|
+
handleValueChange("");
|
|
676
|
+
onClear?.();
|
|
677
|
+
inputRef.current?.focus();
|
|
678
|
+
}}
|
|
679
|
+
>
|
|
680
|
+
<span className="sr-only">{clearLabel}</span>
|
|
681
|
+
<i className="svg-icon ico-del" aria-hidden="true" />
|
|
682
|
+
</button>
|
|
683
|
+
)}
|
|
684
|
+
<button
|
|
685
|
+
type={buttonType}
|
|
686
|
+
className="krds-btn medium icon"
|
|
687
|
+
{...buttonProps}
|
|
688
|
+
onClick={(e) => {
|
|
689
|
+
buttonProps?.onClick?.(e);
|
|
690
|
+
onSearch?.(text);
|
|
691
|
+
}}
|
|
692
|
+
>
|
|
693
|
+
<span className="sr-only">{buttonLabel}</span>
|
|
694
|
+
<i className="svg-icon ico-sch" aria-hidden="true" />
|
|
695
|
+
</button>
|
|
696
|
+
</div>
|
|
697
|
+
}
|
|
698
|
+
/>
|
|
699
|
+
);
|
|
700
|
+
}
|