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.
Files changed (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/accordion/index.d.ts +63 -0
  4. package/dist/accordion/index.d.ts.map +1 -0
  5. package/dist/accordion/index.js +145 -0
  6. package/dist/accordion/index.js.map +1 -0
  7. package/dist/alert/index.d.ts +44 -0
  8. package/dist/alert/index.d.ts.map +1 -0
  9. package/dist/alert/index.js +66 -0
  10. package/dist/alert/index.js.map +1 -0
  11. package/dist/avatar/index.d.ts +102 -0
  12. package/dist/avatar/index.d.ts.map +1 -0
  13. package/dist/avatar/index.js +146 -0
  14. package/dist/avatar/index.js.map +1 -0
  15. package/dist/badge/index.d.ts +49 -0
  16. package/dist/badge/index.d.ts.map +1 -0
  17. package/dist/badge/index.js +41 -0
  18. package/dist/badge/index.js.map +1 -0
  19. package/dist/breadcrumb/index.d.ts +43 -0
  20. package/dist/breadcrumb/index.d.ts.map +1 -0
  21. package/dist/breadcrumb/index.js +43 -0
  22. package/dist/breadcrumb/index.js.map +1 -0
  23. package/dist/button/index.d.ts +78 -0
  24. package/dist/button/index.d.ts.map +1 -0
  25. package/dist/button/index.js +83 -0
  26. package/dist/button/index.js.map +1 -0
  27. package/dist/checkbox/index.d.ts +97 -0
  28. package/dist/checkbox/index.d.ts.map +1 -0
  29. package/dist/checkbox/index.js +110 -0
  30. package/dist/checkbox/index.js.map +1 -0
  31. package/dist/combobox/index.d.ts +135 -0
  32. package/dist/combobox/index.d.ts.map +1 -0
  33. package/dist/combobox/index.js +416 -0
  34. package/dist/combobox/index.js.map +1 -0
  35. package/dist/context-menu/index.d.ts +94 -0
  36. package/dist/context-menu/index.d.ts.map +1 -0
  37. package/dist/context-menu/index.js +399 -0
  38. package/dist/context-menu/index.js.map +1 -0
  39. package/dist/critical-alert/index.d.ts +39 -0
  40. package/dist/critical-alert/index.d.ts.map +1 -0
  41. package/dist/critical-alert/index.js +50 -0
  42. package/dist/critical-alert/index.js.map +1 -0
  43. package/dist/data-table/index.d.ts +94 -0
  44. package/dist/data-table/index.d.ts.map +1 -0
  45. package/dist/data-table/index.js +128 -0
  46. package/dist/data-table/index.js.map +1 -0
  47. package/dist/disclosure/index.d.ts +35 -0
  48. package/dist/disclosure/index.d.ts.map +1 -0
  49. package/dist/disclosure/index.js +54 -0
  50. package/dist/disclosure/index.js.map +1 -0
  51. package/dist/drawer/index.d.ts +106 -0
  52. package/dist/drawer/index.d.ts.map +1 -0
  53. package/dist/drawer/index.js +352 -0
  54. package/dist/drawer/index.js.map +1 -0
  55. package/dist/empty-state/index.d.ts +86 -0
  56. package/dist/empty-state/index.d.ts.map +1 -0
  57. package/dist/empty-state/index.js +138 -0
  58. package/dist/empty-state/index.js.map +1 -0
  59. package/dist/env.d.js +1 -0
  60. package/dist/env.d.js.map +1 -0
  61. package/dist/field/index.d.ts +149 -0
  62. package/dist/field/index.d.ts.map +1 -0
  63. package/dist/field/index.js +121 -0
  64. package/dist/field/index.js.map +1 -0
  65. package/dist/file-upload/index.d.ts +140 -0
  66. package/dist/file-upload/index.d.ts.map +1 -0
  67. package/dist/file-upload/index.js +249 -0
  68. package/dist/file-upload/index.js.map +1 -0
  69. package/dist/footer/index.d.ts +155 -0
  70. package/dist/footer/index.d.ts.map +1 -0
  71. package/dist/footer/index.js +186 -0
  72. package/dist/footer/index.js.map +1 -0
  73. package/dist/header/index.d.ts +170 -0
  74. package/dist/header/index.d.ts.map +1 -0
  75. package/dist/header/index.js +258 -0
  76. package/dist/header/index.js.map +1 -0
  77. package/dist/identifier/index.d.ts +38 -0
  78. package/dist/identifier/index.d.ts.map +1 -0
  79. package/dist/identifier/index.js +55 -0
  80. package/dist/identifier/index.js.map +1 -0
  81. package/dist/in-page-navigation/index.d.ts +71 -0
  82. package/dist/in-page-navigation/index.d.ts.map +1 -0
  83. package/dist/in-page-navigation/index.js +137 -0
  84. package/dist/in-page-navigation/index.js.map +1 -0
  85. package/dist/index.d.ts +57 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +47 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/input-group/index.d.ts +66 -0
  90. package/dist/input-group/index.d.ts.map +1 -0
  91. package/dist/input-group/index.js +85 -0
  92. package/dist/input-group/index.js.map +1 -0
  93. package/dist/lib/krds.d.ts +45 -0
  94. package/dist/lib/krds.d.ts.map +1 -0
  95. package/dist/lib/krds.js +11 -0
  96. package/dist/lib/krds.js.map +1 -0
  97. package/dist/lib/link.d.ts +34 -0
  98. package/dist/lib/link.d.ts.map +1 -0
  99. package/dist/lib/link.js +8 -0
  100. package/dist/lib/link.js.map +1 -0
  101. package/dist/main-menu/index.d.ts +123 -0
  102. package/dist/main-menu/index.d.ts.map +1 -0
  103. package/dist/main-menu/index.js +613 -0
  104. package/dist/main-menu/index.js.map +1 -0
  105. package/dist/masthead/index.d.ts +25 -0
  106. package/dist/masthead/index.d.ts.map +1 -0
  107. package/dist/masthead/index.js +21 -0
  108. package/dist/masthead/index.js.map +1 -0
  109. package/dist/modal/index.d.ts +105 -0
  110. package/dist/modal/index.d.ts.map +1 -0
  111. package/dist/modal/index.js +285 -0
  112. package/dist/modal/index.js.map +1 -0
  113. package/dist/pagination/index.d.ts +58 -0
  114. package/dist/pagination/index.d.ts.map +1 -0
  115. package/dist/pagination/index.js +82 -0
  116. package/dist/pagination/index.js.map +1 -0
  117. package/dist/progress/index.d.ts +58 -0
  118. package/dist/progress/index.d.ts.map +1 -0
  119. package/dist/progress/index.js +125 -0
  120. package/dist/progress/index.js.map +1 -0
  121. package/dist/radio/index.d.ts +111 -0
  122. package/dist/radio/index.d.ts.map +1 -0
  123. package/dist/radio/index.js +136 -0
  124. package/dist/radio/index.js.map +1 -0
  125. package/dist/resize/index.d.ts +118 -0
  126. package/dist/resize/index.d.ts.map +1 -0
  127. package/dist/resize/index.js +342 -0
  128. package/dist/resize/index.js.map +1 -0
  129. package/dist/scroll-area/index.d.ts +40 -0
  130. package/dist/scroll-area/index.d.ts.map +1 -0
  131. package/dist/scroll-area/index.js +52 -0
  132. package/dist/scroll-area/index.js.map +1 -0
  133. package/dist/select/index.d.ts +67 -0
  134. package/dist/select/index.d.ts.map +1 -0
  135. package/dist/select/index.js +61 -0
  136. package/dist/select/index.js.map +1 -0
  137. package/dist/separator/index.d.ts +47 -0
  138. package/dist/separator/index.d.ts.map +1 -0
  139. package/dist/separator/index.js +61 -0
  140. package/dist/separator/index.js.map +1 -0
  141. package/dist/side-navigation/index.d.ts +78 -0
  142. package/dist/side-navigation/index.d.ts.map +1 -0
  143. package/dist/side-navigation/index.js +188 -0
  144. package/dist/side-navigation/index.js.map +1 -0
  145. package/dist/skeleton/index.d.ts +76 -0
  146. package/dist/skeleton/index.d.ts.map +1 -0
  147. package/dist/skeleton/index.js +78 -0
  148. package/dist/skeleton/index.js.map +1 -0
  149. package/dist/skip-link/index.d.ts +46 -0
  150. package/dist/skip-link/index.d.ts.map +1 -0
  151. package/dist/skip-link/index.js +37 -0
  152. package/dist/skip-link/index.js.map +1 -0
  153. package/dist/spinner/index.d.ts +58 -0
  154. package/dist/spinner/index.d.ts.map +1 -0
  155. package/dist/spinner/index.js +36 -0
  156. package/dist/spinner/index.js.map +1 -0
  157. package/dist/step-indicator/index.d.ts +41 -0
  158. package/dist/step-indicator/index.d.ts.map +1 -0
  159. package/dist/step-indicator/index.js +63 -0
  160. package/dist/step-indicator/index.js.map +1 -0
  161. package/dist/structured-list/index.d.ts +150 -0
  162. package/dist/structured-list/index.d.ts.map +1 -0
  163. package/dist/structured-list/index.js +194 -0
  164. package/dist/structured-list/index.js.map +1 -0
  165. package/dist/tab/index.d.ts +108 -0
  166. package/dist/tab/index.d.ts.map +1 -0
  167. package/dist/tab/index.js +198 -0
  168. package/dist/tab/index.js.map +1 -0
  169. package/dist/table/index.d.ts +75 -0
  170. package/dist/table/index.d.ts.map +1 -0
  171. package/dist/table/index.js +91 -0
  172. package/dist/table/index.js.map +1 -0
  173. package/dist/tag/index.d.ts +71 -0
  174. package/dist/tag/index.d.ts.map +1 -0
  175. package/dist/tag/index.js +45 -0
  176. package/dist/tag/index.js.map +1 -0
  177. package/dist/text-input/index.d.ts +69 -0
  178. package/dist/text-input/index.d.ts.map +1 -0
  179. package/dist/text-input/index.js +70 -0
  180. package/dist/text-input/index.js.map +1 -0
  181. package/dist/text-list/index.d.ts +71 -0
  182. package/dist/text-list/index.d.ts.map +1 -0
  183. package/dist/text-list/index.js +79 -0
  184. package/dist/text-list/index.js.map +1 -0
  185. package/dist/textarea/index.d.ts +29 -0
  186. package/dist/textarea/index.d.ts.map +1 -0
  187. package/dist/textarea/index.js +69 -0
  188. package/dist/textarea/index.js.map +1 -0
  189. package/dist/toast/index.d.ts +76 -0
  190. package/dist/toast/index.d.ts.map +1 -0
  191. package/dist/toast/index.js +183 -0
  192. package/dist/toast/index.js.map +1 -0
  193. package/dist/tooltip/index.d.ts +84 -0
  194. package/dist/tooltip/index.d.ts.map +1 -0
  195. package/dist/tooltip/index.js +329 -0
  196. package/dist/tooltip/index.js.map +1 -0
  197. package/package.json +57 -0
  198. package/src/accordion/index.tsx +251 -0
  199. package/src/alert/index.tsx +111 -0
  200. package/src/avatar/index.tsx +272 -0
  201. package/src/badge/index.tsx +91 -0
  202. package/src/breadcrumb/index.tsx +125 -0
  203. package/src/button/index.tsx +190 -0
  204. package/src/checkbox/index.tsx +247 -0
  205. package/src/combobox/index.tsx +700 -0
  206. package/src/context-menu/index.tsx +623 -0
  207. package/src/critical-alert/index.tsx +102 -0
  208. package/src/data-table/index.tsx +289 -0
  209. package/src/disclosure/index.tsx +87 -0
  210. package/src/drawer/index.tsx +516 -0
  211. package/src/empty-state/index.tsx +256 -0
  212. package/src/env.d.ts +10 -0
  213. package/src/field/index.tsx +307 -0
  214. package/src/file-upload/index.tsx +492 -0
  215. package/src/footer/index.tsx +393 -0
  216. package/src/header/index.tsx +498 -0
  217. package/src/identifier/index.tsx +96 -0
  218. package/src/in-page-navigation/index.tsx +258 -0
  219. package/src/index.ts +72 -0
  220. package/src/input-group/index.tsx +197 -0
  221. package/src/lib/krds.ts +75 -0
  222. package/src/lib/link.tsx +35 -0
  223. package/src/main-menu/index.tsx +912 -0
  224. package/src/masthead/index.tsx +50 -0
  225. package/src/modal/index.tsx +457 -0
  226. package/src/pagination/index.tsx +198 -0
  227. package/src/progress/index.tsx +198 -0
  228. package/src/radio/index.tsx +283 -0
  229. package/src/resize/index.tsx +541 -0
  230. package/src/scroll-area/index.tsx +96 -0
  231. package/src/select/index.tsx +136 -0
  232. package/src/separator/index.tsx +107 -0
  233. package/src/side-navigation/index.tsx +336 -0
  234. package/src/skeleton/index.tsx +172 -0
  235. package/src/skip-link/index.tsx +89 -0
  236. package/src/spinner/index.tsx +98 -0
  237. package/src/step-indicator/index.tsx +122 -0
  238. package/src/structured-list/index.tsx +395 -0
  239. package/src/tab/index.tsx +348 -0
  240. package/src/table/index.tsx +186 -0
  241. package/src/tag/index.tsx +127 -0
  242. package/src/text-input/index.tsx +156 -0
  243. package/src/text-list/index.tsx +177 -0
  244. package/src/textarea/index.tsx +113 -0
  245. package/src/toast/index.tsx +311 -0
  246. package/src/tooltip/index.tsx +496 -0
  247. package/styles/krds-tailwind.css +335 -0
@@ -0,0 +1,156 @@
1
+ import type { ComponentPropsWithRef } from 'react';
2
+ import { Field, type FieldProps } from '../field';
3
+ import { cx, type KrdsFieldSize } from '../lib/krds';
4
+
5
+ /**
6
+ * 텍스트 입력 — KRDS `krds-input`.
7
+ *
8
+ * `TextInput` 은 `<input>` 하나만 그린다. 레이블·도움말까지 한꺼번에 필요하면 {@link TextField},
9
+ * 골격을 조각으로 짤 때는 `ui/field` 의 `Field` 를 쓴다.
10
+ * 크기 기본이 medium(48px)이라 같은 줄의 버튼과 어긋나지 않는다(킷 기본은 large 56px).
11
+ * **`size` 를 `<textarea>` 에 붙이면 높이가 48px 로 찌부러진다** — 그래서 `ui/textarea` 에는 없다.
12
+ * 돋보기가 겹친 검색칸은 {@link SearchInput}, 제안 목록까지 필요하면 `ui/combobox` 다.
13
+ *
14
+ * @example
15
+ * <TextField id="consult_name" label="성명" hint="주민등록상 성명" required placeholder="예) 김모루" />
16
+ * <InputGroup>
17
+ * <InputGroupItem><TextInput id="q" /></InputGroupItem>
18
+ * <Button variant="primary" type="submit">검색</Button>
19
+ * </InputGroup>
20
+ *
21
+ * 자세히: docs/krds/09-부품-노트.md#텍스트입력
22
+ */
23
+
24
+ /* ────────────────────────────── 입력칸 ────────────────────────────── */
25
+
26
+ /**
27
+ * 입력칸 하나 — `<input class="krds-input">`. 골격은 `ui/field` 가 맡는다.
28
+ * HTML `size` 속성(글자 수)은 지원하지 않는다 — 너비는 `className` 으로 잡는다.
29
+ */
30
+ export type TextInputProps = Omit<ComponentPropsWithRef<'input'>, 'size'> & {
31
+ /** 기본 medium(48px). 같은 줄의 버튼과 같은 값을 준다. */
32
+ size?: KrdsFieldSize;
33
+ };
34
+
35
+ export function TextInput({ size = 'medium', type = 'text', className, ...rest }: TextInputProps) {
36
+ return <input type={type} className={cx('krds-input', size, className)} {...rest} />;
37
+ }
38
+
39
+ /* ────────────────────────────── 레이블·도움말까지 붙은 완성형 ────────────────────────────── */
40
+
41
+ /**
42
+ * 레이블 · 도움말까지 붙은 텍스트 한 줄 — `Field` + `TextInput` 을 조립한 편의 부품이다.
43
+ *
44
+ * 골격 prop(`hint` · `state` · `message` · `error` · `required` · `action` · `deletable` …)은
45
+ * `Field` 의 것을 그대로 받고 나머지는 `<input>` 으로 간다.
46
+ * 컨트롤이 입력칸이 아니거나 한 칸에 요소가 여럿이면 `Field` 를 직접 쓴다.
47
+ */
48
+ export type TextFieldProps = Omit<TextInputProps, 'className'> &
49
+ Pick<
50
+ FieldProps,
51
+ | 'label'
52
+ | 'hideLabel'
53
+ | 'hint'
54
+ | 'state'
55
+ | 'message'
56
+ | 'error'
57
+ | 'action'
58
+ | 'deletable'
59
+ | 'describedBy'
60
+ | 'contentProps'
61
+ | 'standalone'
62
+ > & {
63
+ /** `<label for>` 로 이어야 하므로 필수다(서버 컴포넌트라 `useId` 를 쓸 수 없다). */
64
+ id: string;
65
+ /** `.form-group` 에 붙는다. 바깥 여백은 여기서 조절한다. */
66
+ className?: string;
67
+ /** `<input>` 에 붙는다(너비 고정 등). */
68
+ inputClassName?: string;
69
+ };
70
+
71
+ export function TextField({
72
+ id,
73
+ label,
74
+ hideLabel,
75
+ hint,
76
+ state,
77
+ message,
78
+ error,
79
+ required,
80
+ action,
81
+ deletable,
82
+ describedBy,
83
+ contentProps,
84
+ standalone,
85
+ className,
86
+ inputClassName,
87
+ size = 'medium',
88
+ 'aria-describedby': ariaDescribedBy,
89
+ ...rest
90
+ }: TextFieldProps) {
91
+ return (
92
+ <Field
93
+ id={id}
94
+ label={label}
95
+ hideLabel={hideLabel}
96
+ hint={hint}
97
+ state={state}
98
+ message={message}
99
+ error={error}
100
+ required={required}
101
+ // 바깥에서 준 aria-describedby 는 버리지 않는다 — `Field` 가 도움말·메시지 id 를 뒤에 잇는다.
102
+ describedBy={cx(describedBy, ariaDescribedBy) || undefined}
103
+ action={action}
104
+ deletable={deletable}
105
+ contentProps={contentProps}
106
+ standalone={standalone}
107
+ className={className}
108
+ >
109
+ {(a) => <TextInput {...a} size={size} className={inputClassName} {...rest} />}
110
+ </Field>
111
+ );
112
+ }
113
+
114
+ /* ────────────────────────────── 검색 입력 ────────────────────────────── */
115
+
116
+ /**
117
+ * 돋보기가 겹쳐진 검색 입력 — 킷 `sch-input`.
118
+ *
119
+ * 기본이 `type="submit"` 이라 `<form action>` 안에 두면 JS 없이도 검색이 된다.
120
+ * 실시간 제안 목록·검색어 삭제까지 필요하면 `ui/combobox` 의 `SearchCombobox` 다.
121
+ *
122
+ * @example
123
+ * <form action="/search">
124
+ * <SearchInput name="q" placeholder="검색어를 입력하세요" />
125
+ * </form>
126
+ */
127
+ export type SearchInputProps = Omit<TextInputProps, 'type'> & {
128
+ /** 돋보기 버튼의 접근 가능한 이름. 아이콘만 있는 버튼이라 반드시 있어야 한다. */
129
+ buttonLabel?: string;
130
+ /** 버튼에 그대로 흘릴 속성(onClick 등). */
131
+ buttonProps?: Omit<ComponentPropsWithRef<'button'>, 'type' | 'className' | 'children'>;
132
+ /** 버튼 종류. 기본 `submit`. */
133
+ buttonType?: 'submit' | 'button';
134
+ /** `.sch-input` 감싸개에 붙는다. 너비·여백은 여기서 조절한다. */
135
+ wrapperClassName?: string;
136
+ };
137
+
138
+ export function SearchInput({
139
+ size = 'medium',
140
+ buttonLabel = '검색',
141
+ buttonProps,
142
+ buttonType = 'submit',
143
+ wrapperClassName,
144
+ className,
145
+ ...rest
146
+ }: SearchInputProps) {
147
+ return (
148
+ <div className={cx('sch-input', wrapperClassName)}>
149
+ <TextInput type="text" size={size} className={className} {...rest} />
150
+ <button type={buttonType} className="krds-btn medium icon ico-search" {...buttonProps}>
151
+ <span className="sr-only">{buttonLabel}</span>
152
+ <i className="svg-icon ico-sch" aria-hidden="true" />
153
+ </button>
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,177 @@
1
+ import { Children, cloneElement, isValidElement } from 'react';
2
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
3
+ import { cx } from '../lib/krds';
4
+
5
+ /**
6
+ * 텍스트 목록 — KRDS `krds-info-list`.
7
+ *
8
+ * 안내 문구 묶음·절차·요건처럼 **줄 단위로 끊어 읽는 글**에 쓴다.
9
+ * 순서가 없으면 `TextList`, 순서·순위가 있으면 `OrderedTextList` 다(번호는 부품이 채운다).
10
+ * 들여쓰기·간격·글자 크기는 킷이 **DOM 깊이**로 정한다 — 중첩 목록은 `TextListItem` 의
11
+ * **직계 자식**으로 넣어야 하고, 사이에 `<div>` 를 하나만 끼워도 깊이 1 로 돌아간다.
12
+ * 수준은 3 이내다. 여러 차원의 자료는 표, 긴 서술은 문단으로 간다.
13
+ *
14
+ * @example
15
+ * <TextList marker="dot">
16
+ * <TextListItem>
17
+ * 신청 자격
18
+ * <TextList marker="dash">
19
+ * <TextListItem>만 18세 이상</TextListItem>
20
+ * </TextList>
21
+ * </TextListItem>
22
+ * </TextList>
23
+ * <OrderedTextList numbering="decimal">
24
+ * <TextListItem>신청서를 내려받습니다.</TextListItem>
25
+ * <TextListItem>작성해 방문 접수합니다.</TextListItem>
26
+ * </OrderedTextList>
27
+ *
28
+ * 자세히: docs/krds/09-부품-노트.md#텍스트목록
29
+ */
30
+
31
+ /**
32
+ * 글머리 기호. `dot` ● (1수준) · `dash` - (2수준) · `circle` ○ (3수준)이 킷 샘플의 관례다.
33
+ * **1수준에 `circle` 을 주면 들여쓰기가 얕아진다** — 킷이 ○ 를 막내 수준으로 전제한다.
34
+ */
35
+ export type TextListMarker = 'dot' | 'dash' | 'circle';
36
+
37
+ /** 뜻 → 킷 클래스. 킷의 `decimal` 은 숫자가 아니라 **채운 점(●)** 이라 이름을 한 겹 감쌌다. */
38
+ const MARKER_CLASS: Record<TextListMarker, string> = {
39
+ dot: 'decimal',
40
+ dash: 'dash',
41
+ circle: 'hollow',
42
+ };
43
+
44
+ export type TextListProps = ComponentPropsWithRef<'ul'> & {
45
+ /** 글머리 기호. 기본 `dot`(●). */
46
+ marker?: TextListMarker;
47
+ };
48
+
49
+ /** 순서 없는 텍스트 목록. 순서·순위가 있으면 {@link OrderedTextList} 로 간다. */
50
+ export function TextList({
51
+ marker = 'dot',
52
+ className,
53
+ children,
54
+ role = 'list',
55
+ ...rest
56
+ }: TextListProps) {
57
+ return (
58
+ <ul role={role} className={cx('krds-info-list', MARKER_CLASS[marker], className)} {...rest}>
59
+ {children}
60
+ </ul>
61
+ );
62
+ }
63
+
64
+ /** 번호 표기 방식. 킷 샘플의 수준별 관례는 1수준 `1. ` · 2수준 `a. ` · 3수준 `①` 이다. */
65
+ export type TextListNumbering = 'decimal' | 'alpha' | 'circled' | 'hangul';
66
+
67
+ /** 가·나·다… 14자. 넘으면 숫자로 되돌린다. */
68
+ const HANGUL_ORDINALS = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'];
69
+
70
+ /** a, b, … z, aa, ab … (26 넘어가면 두 글자). */
71
+ function alphaOrdinal(n: number): string {
72
+ let out = '';
73
+ let v = n;
74
+ while (v > 0) {
75
+ const r = (v - 1) % 26;
76
+ out = String.fromCharCode(97 + r) + out;
77
+ v = Math.floor((v - 1) / 26);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** 번호 글자를 만든다. `decimal`·`alpha`·`hangul` 은 뒤에 공백이 붙고 `circled` 는 붙지 않는다. */
83
+ function formatOrdinal(kind: TextListNumbering, n: number): string {
84
+ switch (kind) {
85
+ // ①~⑳ 는 U+2460 부터 20자뿐이다. 넘어가면 숫자로 되돌린다.
86
+ case 'circled':
87
+ return n >= 1 && n <= 20 ? String.fromCharCode(0x2460 + n - 1) : `${n}. `;
88
+ case 'alpha':
89
+ return `${alphaOrdinal(n)}. `;
90
+ case 'hangul':
91
+ return n >= 1 && n <= HANGUL_ORDINALS.length ? `${HANGUL_ORDINALS[n - 1]}. ` : `${n}. `;
92
+ default:
93
+ return `${n}. `;
94
+ }
95
+ }
96
+
97
+ export type OrderedTextListProps = ComponentPropsWithRef<'ol'> & {
98
+ /**
99
+ * 자식 항목에 번호 글자를 자동으로 채운다. 기본 `decimal`. 항목이 `num` 을 주면 그쪽이 이긴다.
100
+ * `false` 면 채우지 않는다 — 이때 `num` 을 안 주면 화면에 기호가 하나도 안 나온다.
101
+ */
102
+ numbering?: TextListNumbering | false;
103
+ };
104
+
105
+ /**
106
+ * 순서 있는 텍스트 목록. **킷은 번호를 CSS 로 그리지 않아** 이 부품이 자식을 세어 채운다.
107
+ *
108
+ * 번호 자리는 들여쓰기 폭만큼이라 **넘치면 본문 글자와 겹친다** —
109
+ * 깊이 3 에서 두 자리 숫자를 쓰면 겹치므로 그 수준은 `circled` 가 안전하다.
110
+ */
111
+ export function OrderedTextList({
112
+ numbering = 'decimal',
113
+ className,
114
+ children,
115
+ role = 'list',
116
+ ...rest
117
+ }: OrderedTextListProps) {
118
+ let seq = 0;
119
+ const numbered =
120
+ numbering === false
121
+ ? children
122
+ : Children.map(children, (child) => {
123
+ // TextListItem 이 아닌 것(문자열·조건부 false·다른 요소)은 세지도 건드리지도 않는다.
124
+ if (!isValidElement<TextListItemProps>(child) || child.type !== TextListItem) return child;
125
+ seq += 1;
126
+ // 직접 적은 번호가 우선이다. 순서는 이미 셌으니 다음 항목이 이어받는다.
127
+ if (child.props.num !== undefined && child.props.num !== null) return child;
128
+ return cloneElement(child, { num: formatOrdinal(numbering, seq) });
129
+ });
130
+
131
+ return (
132
+ <ol role={role} className={cx('krds-info-list', 'ordered', className)} {...rest}>
133
+ {numbered}
134
+ </ol>
135
+ );
136
+ }
137
+
138
+ export type TextListItemProps = ComponentPropsWithRef<'li'> & {
139
+ /**
140
+ * 번호 글자. 보통 `OrderedTextList` 가 채워 주므로 직접 줄 일이 드물다
141
+ * (조문 번호처럼 연속이 아닌 번호를 쓸 때만). 순서 없는 목록에는 주지 않는다.
142
+ */
143
+ num?: ReactNode;
144
+ };
145
+
146
+ /**
147
+ * 목록 항목 — `<li>`. 모양은 전부 부모 목록의 **DOM 깊이**에서 온다.
148
+ * 중첩 목록은 이 안에 **바로** 넣는다 — 사이에 요소를 끼우면 깊이가 깨진다.
149
+ */
150
+ export function TextListItem({
151
+ num,
152
+ className,
153
+ children,
154
+ role = 'listitem',
155
+ ...rest
156
+ }: TextListItemProps) {
157
+ return (
158
+ <li role={role} className={className} {...rest}>
159
+ {num !== undefined && num !== null && <span className="num">{num}</span>}
160
+ {children}
161
+ </li>
162
+ );
163
+ }
164
+
165
+ /**
166
+ * 항목에 딸린 보조 설명 — 킷 `info-txt`. **`TextListItem` 의 직계 자식일 때만** 여백이 붙는다.
167
+ * 항목 본문을 한 줄 더 잇는 용도지 독립한 문단이 아니다.
168
+ */
169
+ export function TextListNote({ className, children, ...rest }: ComponentPropsWithRef<'p'>) {
170
+ return (
171
+ <p className={cx('info-txt', className)} {...rest}>
172
+ {children}
173
+ </p>
174
+ );
175
+ }
176
+
177
+ // 킷에 모양이 없어 지원하지 않는 것: `fraction`(클래스 이름만 있고 규칙이 0건이다).
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useId,
5
+ useState,
6
+ type ChangeEvent,
7
+ type ComponentPropsWithRef,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { cx, stateClass, type KrdsFieldState } from '../lib/krds';
11
+
12
+ /**
13
+ * 텍스트 영역 — KRDS `textarea-wrap` + `krds-input`.
14
+ *
15
+ * **`maxLength` 를 주면 글자 수 표시가 저절로 켜진다**(규격 요구) — 끄려면 `showCount={false}`.
16
+ * 크기 prop 이 없다. 킷 크기 클래스를 붙이면 높이가 한 줄 입력칸(48px)으로 찌부러지기 때문이다 —
17
+ * 높이는 `className="h-60"` 처럼 유틸리티로 준다(킷 기본은 144px). `rows` 는 아무 영향이 없다.
18
+ * `error` 의 붉은 글씨·아이콘은 `.form-group` 안에서만 나오므로 `ui/field` 의 `Field` 안에 둔다.
19
+ *
20
+ * @example
21
+ * <Field id="content" label="내용">
22
+ * {(a) => <Textarea {...a} maxLength={500} placeholder="500자 이내로 입력하세요" />}
23
+ * </Field>
24
+ *
25
+ * 자세히: docs/krds/09-부품-노트.md#텍스트영역
26
+ */
27
+ export type TextareaProps = ComponentPropsWithRef<'textarea'> & {
28
+ /** 필드 상태. */
29
+ state?: KrdsFieldState;
30
+ /** 오류 문구. 주면 글자 수와 한 줄에 나란히 놓고 `aria-invalid`·`aria-describedby` 를 건다. */
31
+ error?: ReactNode;
32
+ /** 글자 수 표시. 기본값은 `maxLength` 가 있으면 켬(규격 요구). */
33
+ showCount?: boolean;
34
+ /** 감싸개에 붙일 클래스(바깥 여백 등). `className` 은 `<textarea>` 로 간다. */
35
+ wrapClassName?: string;
36
+ };
37
+
38
+ /** `value`·`defaultValue` 가 받아들이는 세 형태를 글자열로 맞춘다. */
39
+ function toText(value: TextareaProps['value'] | TextareaProps['defaultValue']): string {
40
+ if (value == null) return '';
41
+ return Array.isArray(value) ? value.join('') : String(value);
42
+ }
43
+
44
+ export function Textarea({
45
+ state,
46
+ error,
47
+ showCount,
48
+ wrapClassName,
49
+ className,
50
+ maxLength,
51
+ value,
52
+ defaultValue,
53
+ onChange,
54
+ 'aria-describedby': ariaDescribedBy,
55
+ ...rest
56
+ }: TextareaProps) {
57
+ const uid = useId();
58
+ const countId = `${uid}-count`;
59
+ const errorId = `${uid}-error`;
60
+
61
+ // 규격: 글자 수 제한이 있으면 글자 수를 보여 준다. 세는 값은 브라우저와 같은 UTF-16 길이다.
62
+ const counting = showCount ?? maxLength !== undefined;
63
+ const controlled = value !== undefined;
64
+
65
+ const [typed, setTyped] = useState(() => toText(defaultValue));
66
+ const length = (controlled ? toText(value) : typed).length;
67
+
68
+ function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
69
+ // 세지 않거나 바깥에서 값을 쥐고 있으면 여기서 다시 그릴 이유가 없다.
70
+ if (counting && !controlled) setTyped(event.target.value);
71
+ onChange?.(event);
72
+ }
73
+
74
+ const fieldState = state ?? (error ? 'error' : undefined);
75
+
76
+ // 도움말·글자 수를 스크린리더에 연결한다. 바깥에서 준 aria-describedby 는 버리지 않고 앞에 둔다.
77
+ const describedBy =
78
+ cx(ariaDescribedBy, error ? errorId : undefined, counting ? countId : undefined) || undefined;
79
+
80
+ const count = counting ? (
81
+ <p className="textarea-count" id={countId}>
82
+ {/* 킷 샘플에는 없는 보충 — 숫자만으로는 무엇인지 읽히지 않는다. */}
83
+ <span className="sr-only">입력 글자 수</span>
84
+ <span className="count-now">{length}</span>
85
+ {maxLength !== undefined && <span className="count-total">/{maxLength}</span>}
86
+ </p>
87
+ ) : null;
88
+
89
+ return (
90
+ <div className={cx('textarea-wrap', stateClass(fieldState), wrapClassName)}>
91
+ <textarea
92
+ className={cx('krds-input', className)}
93
+ maxLength={maxLength}
94
+ value={value}
95
+ defaultValue={defaultValue}
96
+ onChange={handleChange}
97
+ aria-describedby={describedBy}
98
+ aria-invalid={fieldState === 'error' || undefined}
99
+ {...rest}
100
+ />
101
+ {error ? (
102
+ <div className="textarea-bottom">
103
+ <p className="form-hint-invalid" id={errorId}>
104
+ {error}
105
+ </p>
106
+ {count}
107
+ </div>
108
+ ) : (
109
+ count
110
+ )}
111
+ </div>
112
+ );
113
+ }