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,91 @@
1
+ import type { ComponentPropsWithRef } from 'react';
2
+ import { cx, type KrdsTone } from '../lib/krds';
3
+
4
+ /**
5
+ * 배지 — KRDS `krds-badge`.
6
+ *
7
+ * 상태·분류를 한 낱말로 표시한다. 누르는 것이 아니다 —
8
+ * 필터 조건처럼 누르거나 지우는 표시는 `ui/tag` 의 `Tag` · `TagButton` 이다.
9
+ * 여럿을 늘어놓을 때는 `BadgeWrap` 으로 감싼다(간격은 킷이 잡는다).
10
+ *
11
+ * @example
12
+ * <Badge tone="success">승인</Badge>
13
+ * <Badge tone="primary" fill="solid" number>5</Badge>
14
+ * <BadgeWrap>
15
+ * <Badge tone="danger">반려</Badge>
16
+ * <Badge tone="gray">보관</Badge>
17
+ * </BadgeWrap>
18
+ *
19
+ * 자세히: docs/krds/09-부품-노트.md#배지
20
+ */
21
+
22
+ /** 채움 방식. 킷 클래스는 각각 `bg-*` · `bg-light-*` · `outline-*`. */
23
+ export type BadgeFill = 'solid' | 'light' | 'outline';
24
+
25
+ /** 배지 크기. 킷에 규칙이 있는 것은 `large` 하나뿐이라 그것만 연다. */
26
+ export type BadgeSize = 'large';
27
+
28
+ export type BadgeProps = ComponentPropsWithRef<'span'> & {
29
+ /** 색조. 기본 `gray`. */
30
+ tone?: KrdsTone | 'disabled';
31
+ /** 채움 방식. 기본 `light`. */
32
+ fill?: BadgeFill;
33
+ /** 크기. 생략하면 킷 기본 크기. */
34
+ size?: BadgeSize;
35
+ /** 숫자 배지(`number`). 알림 개수처럼 수를 담을 때. */
36
+ number?: boolean;
37
+ /** 점 배지(`dot`). 글자 없이 점만 찍는다 — 곁에 `sr-only` 설명이나 부모 `aria-label` 이 필요하다. */
38
+ dot?: boolean;
39
+ };
40
+
41
+ const FILL_PREFIX: Record<BadgeFill, string> = {
42
+ solid: 'bg-',
43
+ light: 'bg-light-',
44
+ outline: 'outline-',
45
+ };
46
+
47
+ export function Badge({
48
+ tone = 'gray',
49
+ fill = 'light',
50
+ size,
51
+ number,
52
+ dot,
53
+ className,
54
+ children,
55
+ ...rest
56
+ }: BadgeProps) {
57
+ return (
58
+ <span
59
+ className={cx(
60
+ 'krds-badge',
61
+ size,
62
+ `${FILL_PREFIX[fill]}${tone}`,
63
+ number && 'number',
64
+ dot && 'dot',
65
+ className,
66
+ )}
67
+ {...rest}
68
+ >
69
+ {children}
70
+ </span>
71
+ );
72
+ }
73
+
74
+ /**
75
+ * 배지 묶음 — 킷 `krds-badge-wrap`.
76
+ *
77
+ * 배지를 여럿 늘어놓을 때 간격을 킷이 잡아 준다. `ml-*` 를 손으로 붙이지 않는다.
78
+ *
79
+ * @example
80
+ * <BadgeWrap>
81
+ * <Badge tone="primary">공지</Badge>
82
+ * <Badge tone="gray">보관</Badge>
83
+ * </BadgeWrap>
84
+ */
85
+ export function BadgeWrap({ className, children, ...rest }: ComponentPropsWithRef<'div'>) {
86
+ return (
87
+ <div className={cx('krds-badge-wrap', className)} {...rest}>
88
+ {children}
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,125 @@
1
+ import { UiLink } from '../lib/link';
2
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
3
+ import { cx } from '../lib/krds';
4
+
5
+ /**
6
+ * 브레드크럼 — KRDS `krds-breadcrumb-wrap`.
7
+ *
8
+ * 홈은 부품이 늘 앞에 붙이므로 `items` 에는 **홈 다음부터**의 경로만 넣는다.
9
+ * 주소가 없는 칸(`href` 생략)은 링크가 아니라 계층 표시로만 그려진다.
10
+ * 메인·랜딩 화면과 1수준 사이트에는 쓰지 않는다 — `items` 가 비면 아무것도 그리지 않는다.
11
+ * 여백은 킷이 잡으므로 `mb-*` 를 더하지 마라.
12
+ *
13
+ * @example
14
+ * <Breadcrumb
15
+ * items={[
16
+ * { label: '정보공개', href: '/open' },
17
+ * { label: '사전정보공표' },
18
+ * ]}
19
+ * />
20
+ *
21
+ * 자세히: docs/krds/09-부품-노트.md#브레드크럼
22
+ */
23
+
24
+ /** 경로 한 칸. */
25
+ export type BreadcrumbItem = {
26
+ /** 화면에 보이는 이름. */
27
+ label: ReactNode;
28
+ /** 이동 주소. 없으면 링크로 그리지 않는다 — 마지막 칸은 현재 화면, 가운데 칸은 흐린 글자가 된다. */
29
+ href?: string;
30
+ /** 바깥 주소 — `UiLink` 대신 맨 `<a>` 로 그린다. */
31
+ external?: boolean;
32
+ };
33
+
34
+ export type BreadcrumbProps = Omit<ComponentPropsWithRef<'nav'>, 'children'> & {
35
+ /** 홈 다음부터의 경로. 홈은 부품이 늘 앞에 붙인다. */
36
+ items: BreadcrumbItem[];
37
+ /** 홈 칸 이름. 다국어는 화면에서 넘긴다. */
38
+ homeLabel?: string;
39
+ /** 홈 칸 주소. */
40
+ homeHref?: string;
41
+ /** `<nav>` 의 이름. 기본 「현재 경로」(킷 샘플 문구). */
42
+ label?: string;
43
+ /** PC 에서 보일 최대 칸 수 — 홈 포함. 기본 4. 넘으면 말줄임으로 줄이되 계층은 `sr-only` 로 남는다. */
44
+ maxItems?: number;
45
+ className?: string;
46
+ };
47
+
48
+ export function Breadcrumb({
49
+ items,
50
+ homeLabel = '홈',
51
+ homeHref = '/',
52
+ label = '현재 경로',
53
+ maxItems = 4,
54
+ className,
55
+ ...rest
56
+ }: BreadcrumbProps) {
57
+ // 규격: 메인 화면에는 브레드크럼을 쓰지 않는다. 홈 하나만 남는 경우가 그것이다.
58
+ if (!items.length) return null;
59
+
60
+ // 홈 한 칸은 반드시 남으므로 최소 2. 뒤에 남길 칸 수 = 상한 - 홈.
61
+ const max = Math.max(2, Math.floor(maxItems));
62
+ const tail = max - 1;
63
+ // 홈까지 세어 상한을 넘으면 줄인다. 예) 상한 4 · items 4개 → 홈 · … · 뒤 3개.
64
+ const collapsed = items.length > tail;
65
+ const hidden = collapsed ? items.slice(0, items.length - tail) : [];
66
+ const shown = collapsed ? items.slice(items.length - tail) : items;
67
+
68
+ const crumb = (item: BreadcrumbItem, key: string, isLast: boolean, offscreen: boolean) => {
69
+ const { label: text, href, external } = item;
70
+
71
+ // 주소가 없으면 `<span class="txt">` 다(킷의 `a.txt.disabled` 는 밑줄이 남아 쓰지 않는다).
72
+ // 흐린 글자색만 보충한다 — 배경·둥글기·여백은 `li .txt` 가 span 에도 걸어 준다.
73
+ const inner = href ? (
74
+ external ? (
75
+ <a className="txt" href={href}>
76
+ {text}
77
+ </a>
78
+ ) : (
79
+ <UiLink className="txt" href={href}>
80
+ {text}
81
+ </UiLink>
82
+ )
83
+ ) : (
84
+ <span
85
+ // 링크가 아닌 중간 단계. `text-fg-disabled`(#8a949e, 흰 바탕 3.08:1)는 **비활성 컨트롤 전용**이라
86
+ // 읽으라고 둔 글자에 쓰면 WCAG 1.4.3(4.5:1) 위반이다 — axe 실측으로 걸렸다. 흐리게는 `subtle`(8.68:1)로.
87
+ className={cx('txt', !isLast && 'text-fg-subtle')}
88
+ aria-current={isLast ? 'page' : undefined}
89
+ >
90
+ {text}
91
+ </span>
92
+ );
93
+
94
+ return (
95
+ <li key={key} className={cx(offscreen && 'sr-only')}>
96
+ {inner}
97
+ </li>
98
+ );
99
+ };
100
+
101
+ return (
102
+ <nav className={cx('krds-breadcrumb-wrap', className)} aria-label={label} {...rest}>
103
+ <ol className="breadcrumb">
104
+ {/* 홈 — 집 아이콘은 킷이 `.home .txt::before` 로 그린다. */}
105
+ <li className="home">
106
+ <UiLink className="txt" href={homeHref}>
107
+ {homeLabel}
108
+ </UiLink>
109
+ </li>
110
+
111
+ {hidden.map((item, i) => crumb(item, `hidden-${i}`, false, true))}
112
+
113
+ {collapsed && (
114
+ // PC 말줄임 칸. 킷에는 PC 용 말줄임 규칙이 없어 `ico-ellipsis` 로 직접 그린다.
115
+ // 계층은 위의 `sr-only` 칸들이 읽어 주므로 이 칸은 장식이다.
116
+ <li aria-hidden="true">
117
+ <i className="svg-icon ico-ellipsis size-4" />
118
+ </li>
119
+ )}
120
+
121
+ {shown.map((item, i) => crumb(item, `shown-${i}`, i === shown.length - 1, false))}
122
+ </ol>
123
+ </nav>
124
+ );
125
+ }
@@ -0,0 +1,190 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
2
+ import { cx, type KrdsIcon, type KrdsSize } from '../lib/krds';
3
+
4
+ /**
5
+ * 버튼 — KRDS `krds-btn`.
6
+ *
7
+ * 이동은 `LinkButton`, 동작은 `Button` 을 쓴다(규격상 다른 것이다).
8
+ * 같은 줄의 입력칸과 같은 `size` 를 준다(기본 medium · 48px).
9
+ * 글자 없이 아이콘만 두려면 `iconOnly` 와 `label` 을 함께 준다.
10
+ * 버튼 여럿은 `ButtonGroup` 으로 묶는다 — 제출은 가장 오른쪽이다.
11
+ *
12
+ * @example
13
+ * <Button variant="primary">저장</Button>
14
+ * <Button size="small" variant="secondary" icon="down">내려받기</Button>
15
+ * <Button iconOnly icon="sch" label="검색" />
16
+ * <LinkButton href="/notice" icon="angle" iconDirection="right">공지 전체보기</LinkButton>
17
+ *
18
+ * 자세히: docs/krds/09-부품-노트.md#버튼
19
+ */
20
+
21
+ /** 위계. 한 화면에 최상위 강조(`primary`)는 하나만 둔다. */
22
+ export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'text' | 'link';
23
+
24
+ type BaseProps = {
25
+ /** 기본 medium(48px). 같은 줄의 입력칸과 같은 값을 준다. */
26
+ size?: KrdsSize;
27
+ /** 위계. 생략하면 킷 기본 버튼. */
28
+ variant?: ButtonVariant;
29
+ /** 킷 아이콘 이름(`ico-` 뺀 것). 예: `sch` · `down` · `go` */
30
+ icon?: KrdsIcon;
31
+ /** 아이콘 회전. 방향은 이름의 일부가 아니라 별도 클래스다 — `icon="angle right"` 로 쓰지 마라. */
32
+ iconDirection?: 'up' | 'down' | 'left' | 'right';
33
+ /** 아이콘 위치. 기본 `end` — 킷 샘플은 xlarge 만 앞에 둔다. */
34
+ iconPosition?: 'start' | 'end';
35
+ /** 테두리 있는 아이콘 버튼(`krds-btn icon border`). iconOnly 일 때만 뜻이 있다. */
36
+ bordered?: boolean;
37
+ className?: string;
38
+ children?: ReactNode;
39
+ };
40
+
41
+ export type ButtonProps = Omit<ComponentPropsWithRef<'button'>, 'children'> &
42
+ BaseProps & {
43
+ /** 글자 없이 아이콘만 두려면 `true` 와 함께 `label`·`icon` 을 준다({@link IconButtonProps}). */
44
+ iconOnly?: false;
45
+ };
46
+
47
+ export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'children'> &
48
+ BaseProps & {
49
+ iconOnly: true;
50
+ /** 스크린리더가 읽을 이름. 아이콘 버튼에는 반드시 있어야 한다. */
51
+ label: string;
52
+ icon: KrdsIcon;
53
+ };
54
+
55
+ export function Button(props: ButtonProps | IconButtonProps) {
56
+ const {
57
+ size = 'medium',
58
+ variant,
59
+ icon,
60
+ iconDirection,
61
+ iconPosition = 'end',
62
+ bordered,
63
+ className,
64
+ children,
65
+ type = 'button',
66
+ ...rest
67
+ } = props as ButtonProps & Partial<IconButtonProps>;
68
+
69
+ const iconOnly = 'iconOnly' in props && props.iconOnly === true;
70
+ const label = 'label' in props ? props.label : undefined;
71
+
72
+ const cls = cx(
73
+ 'krds-btn',
74
+ size,
75
+ variant,
76
+ iconOnly && 'icon',
77
+ iconOnly && bordered && 'border',
78
+ className,
79
+ );
80
+
81
+ const iconNode = icon ? (
82
+ <i className={cx(`svg-icon ico-${icon}`, iconDirection)} aria-hidden="true" />
83
+ ) : null;
84
+
85
+ return (
86
+ <button type={type} className={cls} {...rest}>
87
+ {iconOnly ? (
88
+ <>
89
+ <span className="sr-only">{label}</span>
90
+ {iconNode}
91
+ </>
92
+ ) : (
93
+ <>
94
+ {iconPosition === 'start' && iconNode}
95
+ {children}
96
+ {iconPosition === 'end' && iconNode}
97
+ </>
98
+ )}
99
+ </button>
100
+ );
101
+ }
102
+
103
+ /**
104
+ * 링크 — KRDS `krds-btn link`. **이동에만 쓴다**(동작은 `Button`).
105
+ *
106
+ * 밑줄은 킷이 안쪽 `<span class="underline">` 에 건다 — `hiddenUnderline` · `noUnderline` 로 끈다.
107
+ * `target="_blank"` 면 「새 창 열림」 안내가 `title` 로 붙는다.
108
+ */
109
+ export type LinkButtonProps = Omit<ComponentPropsWithRef<'a'>, 'children'> &
110
+ // `variant`·`bordered` 는 버튼 전용이라 뺀다 — 안 빼면 `<a variant="primary">` 로 새어 나간다.
111
+ Omit<BaseProps, 'variant' | 'bordered'> & {
112
+ /** `basic` = 본문 글자색 링크 · `pure` = 가상클래스에서도 색 유지 */
113
+ linkStyle?: 'basic' | 'pure';
114
+ /** 비활성. 킷 클래스 `.disabled` + `aria-disabled` 로 알린다. 이동까지 막으려면 `href` 를 빼라. */
115
+ disabled?: boolean;
116
+ /** 밑줄을 평소엔 감추고 hover/focus 에서만 보인다(`hidden-underline`). */
117
+ hiddenUnderline?: boolean;
118
+ /** 밑줄 자체를 쓰지 않는다. 킷 샘플의 「밑줄 없음」 형태. */
119
+ noUnderline?: boolean;
120
+ };
121
+
122
+ export function LinkButton({
123
+ size = 'medium',
124
+ icon,
125
+ iconDirection,
126
+ iconPosition = 'end',
127
+ linkStyle,
128
+ hiddenUnderline,
129
+ noUnderline,
130
+ disabled,
131
+ className,
132
+ children,
133
+ target,
134
+ title,
135
+ ...rest
136
+ }: LinkButtonProps) {
137
+ const cls = cx('krds-btn', size, 'link', linkStyle, disabled && 'disabled', className);
138
+ const iconNode = icon ? (
139
+ <i className={cx(`svg-icon ico-${icon}`, iconDirection)} aria-hidden="true" />
140
+ ) : null;
141
+
142
+ return (
143
+ <a
144
+ className={cls}
145
+ aria-disabled={disabled || undefined}
146
+ target={target}
147
+ // 새 창으로 열리면 미리 알린다 — KRDS 접근성 요구.
148
+ title={title ?? (target === '_blank' ? '새 창 열림' : undefined)}
149
+ {...rest}
150
+ >
151
+ {iconPosition === 'start' && iconNode}
152
+ {noUnderline ? (
153
+ <span>{children}</span>
154
+ ) : (
155
+ <span className={cx('underline', hiddenUnderline && 'hidden-underline')}>{children}</span>
156
+ )}
157
+ {iconPosition === 'end' && iconNode}
158
+ </a>
159
+ );
160
+ }
161
+
162
+ /**
163
+ * 버튼 묶음 — 킷 `btn-group`. 기본 오른쪽 정렬이고 **제출 버튼이 가장 오른쪽**이다.
164
+ *
165
+ * @example
166
+ * <ButtonGroup>
167
+ * <Button variant="tertiary">취소</Button>
168
+ * <Button variant="primary" type="submit">제출</Button>
169
+ * </ButtonGroup>
170
+ */
171
+ export function ButtonGroup({
172
+ align = 'end',
173
+ className,
174
+ children,
175
+ ...rest
176
+ }: ComponentPropsWithRef<'div'> & { align?: 'start' | 'center' | 'end' | 'between' }) {
177
+ const justify =
178
+ align === 'start'
179
+ ? 'justify-start'
180
+ : align === 'center'
181
+ ? 'justify-center'
182
+ : align === 'between'
183
+ ? 'justify-between'
184
+ : 'justify-end';
185
+ return (
186
+ <div className={cx('btn-group flex flex-wrap items-center gap-2', justify, className)} {...rest}>
187
+ {children}
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,247 @@
1
+ import type { ComponentPropsWithRef, ReactNode, Ref } from 'react';
2
+ import { cx, type KrdsSize } from '../lib/krds';
3
+
4
+ /**
5
+ * 체크박스 — KRDS `krds-form-check` · `krds-check-area` · `krds-form-chip`.
6
+ *
7
+ * `id` 가 필수다 — 킷이 `input ~ label` 형제 선택자로 네모를 그려서 `<label for>` 로만 이을 수 있다.
8
+ * **`<label>` 안에 `<input>` 을 넣으면 네모가 아예 안 그려진다.**
9
+ * 여러 개는 `CheckArea`(배치만) 나 `CheckboxGroup`(fieldset + legend + 도움말)으로 묶는다.
10
+ * 한 값만 고르는 자리는 `ui/radio`, 검색 필터처럼 알약 꼴이 필요하면 `CheckboxChip` 이다.
11
+ *
12
+ * @example
13
+ * <CheckboxGroup legend="수신 동의" hint="언제든 해지할 수 있습니다.">
14
+ * <Checkbox id="agree-sms" name="agree" value="sms" label="문자" />
15
+ * <Checkbox id="agree-mail" name="agree" value="mail" label="이메일" description="주 1회 발송" />
16
+ * </CheckboxGroup>
17
+ * <Checkbox id="agree-all" label="전체 선택" indeterminate={some} />
18
+ *
19
+ * 자세히: docs/krds/09-부품-노트.md#체크박스
20
+ */
21
+
22
+ /** 체크박스 크기. 킷에 있는 둘뿐이다 — 네모 20px / 24px, 글자 17px / 19px. */
23
+ export type CheckboxSize = Extract<KrdsSize, 'medium' | 'large'>;
24
+
25
+ /** 칩 크기. 킷에 있는 셋. 높이가 버튼·입력칸과 같은 눈금이다. */
26
+ export type CheckboxChipSize = Extract<KrdsSize, 'small' | 'medium' | 'large'>;
27
+
28
+ /**
29
+ * 중간 상태 표시 — 킷에 `:indeterminate` 규칙이 없어 보충했다. 네모만 primary 로 채운다.
30
+ * 「일부 선택됨」은 화면 쪽에서 글로도 알려야 규격을 채운다.
31
+ */
32
+ const INDETERMINATE_MARK =
33
+ '[&>input:indeterminate~label]:before:bg-primary-solid ' +
34
+ '[&>input:indeterminate~label]:before:border-primary-line';
35
+
36
+ /**
37
+ * `indeterminate` 는 속성이 아니라 DOM 프로퍼티라 ref 콜백에서 넣는다.
38
+ * `true` 일 때만 만들고(서버 컴포넌트가 함수를 실어 보내지 않게), 떼어낼 때 프로퍼티를 되돌린다.
39
+ */
40
+ function indeterminateRef(ref: Ref<HTMLInputElement> | undefined): Ref<HTMLInputElement> {
41
+ return (node: HTMLInputElement | null) => {
42
+ if (node) node.indeterminate = true;
43
+ const inner = typeof ref === 'function' ? ref(node) : undefined;
44
+ if (ref && typeof ref !== 'function') ref.current = node;
45
+
46
+ return () => {
47
+ if (node) node.indeterminate = false;
48
+ if (typeof inner === 'function') inner();
49
+ else if (typeof ref === 'function') ref(null);
50
+ else if (ref) ref.current = null;
51
+ };
52
+ };
53
+ }
54
+
55
+ type CheckboxBaseProps = {
56
+ /** `<label for>` 로 이을 id. 필수다. 목록에서는 값에서 만든다(`agree-${item.code}`). */
57
+ id: string;
58
+ /** 레이블. `for`/`id` 로 이어져 레이블 클릭으로도 토글된다. */
59
+ label: ReactNode;
60
+ /** `.krds-form-check` 감싸개에 붙는다. `...rest` 는 `<input>` 으로 흘린다. */
61
+ className?: string;
62
+ };
63
+
64
+ export type CheckboxProps = Omit<
65
+ ComponentPropsWithRef<'input'>,
66
+ 'type' | 'size' | 'children' | 'className' | 'id'
67
+ > &
68
+ CheckboxBaseProps & {
69
+ size?: CheckboxSize;
70
+ /** 부가 설명. 레이블 바로 다음에 놓여 들여쓰기가 걸린다. */
71
+ description?: ReactNode;
72
+ /** 중간 상태. 「전체 선택」에서 하위 일부만 선택되었을 때 켠다. */
73
+ indeterminate?: boolean;
74
+ };
75
+
76
+ export function Checkbox({
77
+ id,
78
+ label,
79
+ description,
80
+ size = 'medium',
81
+ indeterminate,
82
+ className,
83
+ ref,
84
+ ...rest
85
+ }: CheckboxProps) {
86
+ // 참일 때만 표시도 ref 도 만든다 — `indeterminate={false}` 는 그냥 보통 체크박스다.
87
+ const mixed = indeterminate === true;
88
+
89
+ return (
90
+ <div className={cx('krds-form-check', size, mixed && INDETERMINATE_MARK, className)}>
91
+ <input
92
+ type="checkbox"
93
+ id={id}
94
+ ref={mixed ? indeterminateRef(ref) : ref}
95
+ {...rest}
96
+ />
97
+ <label htmlFor={id}>{label}</label>
98
+ {description != null && (
99
+ // 킷 규칙이 `label + .krds-form-check-cnt` 라 레이블 바로 뒤에 붙어야 한다.
100
+ <div className="krds-form-check-cnt">
101
+ <p className="krds-form-check-p">{description}</p>
102
+ </div>
103
+ )}
104
+ </div>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * 칩 꼴 체크박스 — 킷 `krds-form-chip`. 네모 대신 테두리 알약을 눌러 고른다.
110
+ *
111
+ * 검색 필터처럼 여러 값을 한 줄에 늘어놓을 때 쓴다. 높이가 버튼·입력칸과 같은 눈금이다.
112
+ *
113
+ * @example
114
+ * <CheckArea layout="inline">
115
+ * <CheckboxChip id="f-open" label="공개" />
116
+ * <CheckboxChip id="f-closed" label="비공개" />
117
+ * </CheckArea>
118
+ */
119
+ export type CheckboxChipProps = Omit<
120
+ ComponentPropsWithRef<'input'>,
121
+ 'type' | 'size' | 'children' | 'className' | 'id'
122
+ > &
123
+ CheckboxBaseProps & {
124
+ size?: CheckboxChipSize;
125
+ };
126
+
127
+ export function CheckboxChip({
128
+ id,
129
+ label,
130
+ size = 'medium',
131
+ className,
132
+ ...rest
133
+ }: CheckboxChipProps) {
134
+ return (
135
+ <div className={cx('krds-form-chip', size, className)}>
136
+ <input type="checkbox" className="checkbox" id={id} {...rest} />
137
+ <label className="krds-form-chip-outline" htmlFor={id}>
138
+ {label}
139
+ </label>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ /**
145
+ * 배치. `column` 세로(20px) · `row` 가로(20px) · `inline` 가로 좁은 간격(12px).
146
+ * `inline` 은 칩처럼 폭이 넓은 항목을 한 줄에 여럿 놓을 때만 쓴다.
147
+ */
148
+ export type CheckAreaLayout = 'column' | 'row' | 'inline';
149
+
150
+ export type CheckAreaProps = ComponentPropsWithRef<'div'> & {
151
+ /** 배치. 기본 `column` — 규격이 수직 배치를 요구한다. */
152
+ layout?: CheckAreaLayout;
153
+ };
154
+
155
+ /**
156
+ * 체크박스 묶음 영역 — 킷 `krds-check-area`. 배치만 맡는다.
157
+ *
158
+ * 기본이 세로다(규격). 가로가 필요하면 `layout="row"` 를 명시한다.
159
+ * 그룹 이름·도움말·오류까지 필요하면 `CheckboxGroup` 을 쓴다.
160
+ */
161
+ export function CheckArea({ layout = 'column', className, children, ...rest }: CheckAreaProps) {
162
+ return (
163
+ <div
164
+ className={cx(
165
+ 'krds-check-area',
166
+ layout === 'column' && 'chk-column',
167
+ layout === 'inline' && 'krds-form-checks-inline',
168
+ className,
169
+ )}
170
+ {...rest}
171
+ >
172
+ {children}
173
+ </div>
174
+ );
175
+ }
176
+
177
+ export type CheckboxGroupProps = Omit<ComponentPropsWithRef<'fieldset'>, 'children' | 'title'> & {
178
+ /** 묶음의 접근 가능한 이름(`<legend>`). 킷이 화면에서 감추고 스크린리더에만 남긴다. */
179
+ legend: string;
180
+ /** 보이는 제목. 생략하면 `legend` 를 쓰고, `null` 을 주면 그리지 않는다. */
181
+ title?: ReactNode | null;
182
+ /** 도움말. 킷이 회색 정보 아이콘을 앞에 붙인다. */
183
+ hint?: ReactNode;
184
+ /** 오류 메시지. 붉은 글자 + 오류 아이콘. 색만으로 알리지 말고 반드시 문구를 넣는다. */
185
+ error?: ReactNode;
186
+ /** 배치. 기본 `column`. */
187
+ layout?: CheckAreaLayout;
188
+ children?: ReactNode;
189
+ };
190
+
191
+ /**
192
+ * 체크박스 묶음 — `<fieldset class="form-group">` + `krds-check-area`.
193
+ *
194
+ * 그룹 이름(`legend`) · 보이는 소제목 · 도움말 · 오류 메시지를 한 골격으로 묶는다.
195
+ * `id` 를 주면 도움말·오류가 `aria-describedby` 로 이어진다.
196
+ *
197
+ * @example
198
+ * <CheckboxGroup id="agree" legend="수신 동의" hint="언제든 해지할 수 있습니다.">
199
+ * <Checkbox id="agree-sms" label="문자" />
200
+ * </CheckboxGroup>
201
+ */
202
+ export function CheckboxGroup({
203
+ legend,
204
+ title,
205
+ hint,
206
+ error,
207
+ layout = 'column',
208
+ id,
209
+ className,
210
+ children,
211
+ ...rest
212
+ }: CheckboxGroupProps) {
213
+ const hintId = id && hint != null ? `${id}-hint` : undefined;
214
+ const errorId = id && error != null ? `${id}-error` : undefined;
215
+ const describedBy = cx(hintId, errorId) || undefined;
216
+ const visibleTitle = title === undefined ? legend : title;
217
+
218
+ return (
219
+ <fieldset
220
+ id={id}
221
+ className={cx('form-group', className)}
222
+ aria-describedby={describedBy}
223
+ {...rest}
224
+ >
225
+ {/* legend 는 fieldset 의 첫 자식이어야 한다. 킷이 화면에서 감추지만 이름은 그대로 읽힌다. */}
226
+ <legend>{legend}</legend>
227
+ <div className="form-conts">
228
+ {visibleTitle != null && (
229
+ <p className="form-label" aria-hidden="true">
230
+ {visibleTitle}
231
+ </p>
232
+ )}
233
+ <CheckArea layout={layout}>{children}</CheckArea>
234
+ </div>
235
+ {hint != null && (
236
+ <p id={hintId} className="form-hint">
237
+ {hint}
238
+ </p>
239
+ )}
240
+ {error != null && (
241
+ <p id={errorId} className="form-hint-invalid">
242
+ {error}
243
+ </p>
244
+ )}
245
+ </fieldset>
246
+ );
247
+ }