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,251 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
type ComponentPropsWithRef,
|
|
10
|
+
type MouseEvent as ReactMouseEvent,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { cx } from '../lib/krds';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 아코디언 — KRDS `krds-accordion`.
|
|
17
|
+
*
|
|
18
|
+
* 헤더를 눌러 한 덩이씩 펼쳐 보는 목록. FAQ 는 `line`(킷 `type-line`)이 규격이다 — 표로 그리지 않는다.
|
|
19
|
+
* 기본은 한 번에 하나만 열리고, `multiple` 을 주면 여러 개가 함께 열린다.
|
|
20
|
+
* 부가 안내 한 덩이만 접었다 펴려면 `Disclosure` 를, 내용을 갈아 끼우려면 `Tab` 을 쓴다.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* <Accordion.Root line multiple toggleAll headingLevel={3}>
|
|
24
|
+
* <Accordion.Item title="신청 자격이 어떻게 되나요?">…</Accordion.Item>
|
|
25
|
+
* <Accordion.Item title="처리 기간은 얼마나 걸리나요?" defaultOpen>…</Accordion.Item>
|
|
26
|
+
* </Accordion.Root>
|
|
27
|
+
*
|
|
28
|
+
* 자세히: docs/krds/09-부품-노트.md#아코디언
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** 헤더 제목의 헤딩 단계. 페이지의 앞선 헤딩보다 한 단계 아래로 준다. */
|
|
32
|
+
export type AccordionHeadingLevel = 2 | 3 | 4 | 5 | 6;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 열림 상태.
|
|
36
|
+
*
|
|
37
|
+
* 항목 목록을 부모가 모르고도(등록 절차 없이) 「전체 열기」를 지원하려고 이렇게 잡았다.
|
|
38
|
+
* - `all: null` — 전체 열기/닫기를 아직 누르지 않았다 → 손대지 않은 항목은 제 `defaultOpen` 을 따른다
|
|
39
|
+
* - `all: true|false` — 전체 열기/닫기를 눌렀다 → 손대지 않은 항목은 전부 그 값을 따른다
|
|
40
|
+
* - `touched` — 헤더를 눌러 개별로 손댄 항목만. 언제나 `all` 보다 우선한다
|
|
41
|
+
*/
|
|
42
|
+
type AccordionOpenState = {
|
|
43
|
+
all: boolean | null;
|
|
44
|
+
touched: Record<string, boolean>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type AccordionContextValue = {
|
|
48
|
+
/** 이 항목이 열려 있나. 손댄 적 없으면 항목 제 `defaultOpen` 이 산다. */
|
|
49
|
+
isOpen: (key: string, defaultOpen: boolean) => boolean;
|
|
50
|
+
toggle: (key: string, defaultOpen: boolean) => void;
|
|
51
|
+
expandAll: () => void;
|
|
52
|
+
collapseAll: () => void;
|
|
53
|
+
/** 「전체 열기」를 누른 뒤 하나도 닫지 않은 상태. 전체 단추의 글자를 여기서 고른다. */
|
|
54
|
+
allOpen: boolean;
|
|
55
|
+
/** 다중 열림인가(킷 JS 의 `data-type="multiOpen"`). */
|
|
56
|
+
multiple: boolean;
|
|
57
|
+
headingLevel: AccordionHeadingLevel;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const AccordionContext = createContext<AccordionContextValue | null>(null);
|
|
61
|
+
|
|
62
|
+
function useAccordionContext(who: string): AccordionContextValue {
|
|
63
|
+
const ctx = useContext(AccordionContext);
|
|
64
|
+
if (!ctx) throw new Error(`${who} 은(는) <Accordion.Root> 안에서만 쓴다.`);
|
|
65
|
+
return ctx;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type AccordionRootProps = Omit<ComponentPropsWithRef<'div'>, 'onToggle'> & {
|
|
69
|
+
/** 여러 항목을 동시에 연다. 기본은 킷과 같은 단일 열림 — 하나를 열면 나머지가 닫힌다. */
|
|
70
|
+
multiple?: boolean;
|
|
71
|
+
/** 라인형(`type-line`). 상자 배경 없이 구분선만 남는다. FAQ 목록에 이 모양을 쓴다. */
|
|
72
|
+
line?: boolean;
|
|
73
|
+
/** 처음부터 전부 펼친다. `multiple` 일 때만 뜻이 있다. */
|
|
74
|
+
defaultAllOpen?: boolean;
|
|
75
|
+
/** 헤더 제목의 헤딩 단계. 기본 `h3`. 페이지의 앞선 헤딩보다 한 단계 아래로 준다. */
|
|
76
|
+
headingLevel?: AccordionHeadingLevel;
|
|
77
|
+
/** 목록 위 오른쪽에 「전체 열기 / 전체 닫기」 단추를 그린다. `multiple` 일 때만 그려진다. */
|
|
78
|
+
toggleAll?: boolean;
|
|
79
|
+
/** 항목을 열고 닫을 때. 뜻 있는 `id` 를 받으려면 항목마다 `id` 를 직접 준다. */
|
|
80
|
+
onToggle?: (id: string, open: boolean) => void;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function AccordionRoot({
|
|
84
|
+
multiple = false,
|
|
85
|
+
line,
|
|
86
|
+
defaultAllOpen,
|
|
87
|
+
headingLevel = 3,
|
|
88
|
+
toggleAll,
|
|
89
|
+
onToggle,
|
|
90
|
+
className,
|
|
91
|
+
children,
|
|
92
|
+
...rest
|
|
93
|
+
}: AccordionRootProps) {
|
|
94
|
+
const [state, setState] = useState<AccordionOpenState>(() => ({
|
|
95
|
+
// 단일 열림에서 「처음부터 전부 펼침」은 성립하지 않는다.
|
|
96
|
+
all: defaultAllOpen && multiple ? true : null,
|
|
97
|
+
touched: {},
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
const value = useMemo<AccordionContextValue>(() => {
|
|
101
|
+
const isOpen = (key: string, defaultOpen: boolean) =>
|
|
102
|
+
key in state.touched ? state.touched[key] : (state.all ?? defaultOpen);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
isOpen,
|
|
106
|
+
toggle: (key, defaultOpen) => {
|
|
107
|
+
const next = !isOpen(key, defaultOpen);
|
|
108
|
+
setState((prev) =>
|
|
109
|
+
multiple
|
|
110
|
+
? // 다중 열림 — 누른 항목만 뒤집는다. 나머지는 그대로 둔다.
|
|
111
|
+
{ all: prev.all, touched: { ...prev.touched, [key]: next } }
|
|
112
|
+
: // 단일 열림 — 누른 항목만 남긴다. `all: false` 라서 나머지는 전부 닫힌다.
|
|
113
|
+
{ all: false, touched: { [key]: next } },
|
|
114
|
+
);
|
|
115
|
+
onToggle?.(key, next);
|
|
116
|
+
},
|
|
117
|
+
expandAll: () => setState({ all: true, touched: {} }),
|
|
118
|
+
collapseAll: () => setState({ all: false, touched: {} }),
|
|
119
|
+
allOpen: state.all === true && Object.values(state.touched).every(Boolean),
|
|
120
|
+
multiple,
|
|
121
|
+
headingLevel,
|
|
122
|
+
};
|
|
123
|
+
}, [state, multiple, headingLevel, onToggle]);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<AccordionContext.Provider value={value}>
|
|
127
|
+
{toggleAll && multiple && (
|
|
128
|
+
// 킷에 전체 열기 단추 자리가 없다 — 목록 위 오른쪽에 Tailwind 로 둔다(mb-2 = 8px).
|
|
129
|
+
<div className="mb-2 flex justify-end">
|
|
130
|
+
<AccordionToggleAll />
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
<div
|
|
134
|
+
className={cx('krds-accordion', line && 'type-line', className)}
|
|
135
|
+
// 킷 JS 가 읽던 값. 우리 렌더에는 필요 없지만 DOM 만 봐도 모드를 알 수 있게 남긴다.
|
|
136
|
+
data-type={multiple ? 'multiOpen' : 'singleOpen'}
|
|
137
|
+
{...rest}
|
|
138
|
+
>
|
|
139
|
+
{children}
|
|
140
|
+
</div>
|
|
141
|
+
</AccordionContext.Provider>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type AccordionItemProps = Omit<ComponentPropsWithRef<'div'>, 'title'> & {
|
|
146
|
+
/** 헤더 제목. 헤더 전체가 단추가 된다. */
|
|
147
|
+
title: ReactNode;
|
|
148
|
+
/** 처음부터 이 항목을 펼친다. 단일 열림에서는 하나에만 준다. */
|
|
149
|
+
defaultOpen?: boolean;
|
|
150
|
+
/** 이 항목만 헤딩 단계를 달리한다. 보통은 `Accordion.Root` 쪽에서 정한다. */
|
|
151
|
+
headingLevel?: AccordionHeadingLevel;
|
|
152
|
+
/** 헤더 단추(`btn-accordion`)에 붙일 클래스. */
|
|
153
|
+
buttonClassName?: string;
|
|
154
|
+
/** 내용 영역(`accordion-body`)에 붙일 클래스. */
|
|
155
|
+
bodyClassName?: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export function AccordionItem({
|
|
159
|
+
title,
|
|
160
|
+
defaultOpen = false,
|
|
161
|
+
headingLevel,
|
|
162
|
+
buttonClassName,
|
|
163
|
+
bodyClassName,
|
|
164
|
+
id,
|
|
165
|
+
className,
|
|
166
|
+
children,
|
|
167
|
+
...rest
|
|
168
|
+
}: AccordionItemProps) {
|
|
169
|
+
const uid = useId();
|
|
170
|
+
const ctx = useAccordionContext('<Accordion.Item>');
|
|
171
|
+
|
|
172
|
+
// 킷 JS 는 `accordionHeader-id-*` / `accordionCollapse-id-*` 를 난수로 만든다.
|
|
173
|
+
// React 는 `useId()` 가 서버·클라이언트에서 같은 값을 주므로 그것을 쓴다.
|
|
174
|
+
const key = id ?? uid;
|
|
175
|
+
const headerId = `${key}-header`;
|
|
176
|
+
const panelId = `${key}-panel`;
|
|
177
|
+
|
|
178
|
+
const open = ctx.isOpen(key, defaultOpen);
|
|
179
|
+
const Heading = `h${headingLevel ?? ctx.headingLevel}` as 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div id={id} className={cx('accordion-item', open && 'active', className)} {...rest}>
|
|
183
|
+
<Heading className="accordion-header">
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
id={headerId}
|
|
187
|
+
className={cx('btn-accordion', open && 'active', buttonClassName)}
|
|
188
|
+
aria-expanded={open}
|
|
189
|
+
aria-controls={panelId}
|
|
190
|
+
onClick={() => ctx.toggle(key, defaultOpen)}
|
|
191
|
+
>
|
|
192
|
+
{title}
|
|
193
|
+
</button>
|
|
194
|
+
</Heading>
|
|
195
|
+
{/* `collapse` 는 킷 CSS 에 규칙이 없다. 킷 마크업과 맞추려고 그대로 둔다.
|
|
196
|
+
닫힘/열림은 `.accordion-item.active` 가 이 요소의 max-height·visibility 를 바꿔 만든다. */}
|
|
197
|
+
<div id={panelId} className="accordion-collapse collapse" role="region" aria-labelledby={headerId}>
|
|
198
|
+
<div className={cx('accordion-body', bodyClassName)}>{children}</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type AccordionToggleAllProps = Omit<ComponentPropsWithRef<'button'>, 'children'> & {
|
|
205
|
+
expandLabel?: string;
|
|
206
|
+
collapseLabel?: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 전체 열기 / 전체 닫기 — 킷에 없어 `krds-btn xsmall text` 로 보충한 단추.
|
|
211
|
+
*
|
|
212
|
+
* `Accordion.Root` 에 `toggleAll` 을 주면 제자리에 알아서 그려진다. 직접 쓸 때는
|
|
213
|
+
* `Accordion.Root` 안에 둔다(문맥을 읽는다). 단일 열림에서는 아무것도 그리지 않는다.
|
|
214
|
+
*/
|
|
215
|
+
export function AccordionToggleAll({
|
|
216
|
+
expandLabel = '전체 열기',
|
|
217
|
+
collapseLabel = '전체 닫기',
|
|
218
|
+
className,
|
|
219
|
+
onClick,
|
|
220
|
+
...rest
|
|
221
|
+
}: AccordionToggleAllProps) {
|
|
222
|
+
const ctx = useAccordionContext('<Accordion.ToggleAll>');
|
|
223
|
+
|
|
224
|
+
// 단일 열림에서는 「전체 열기」가 성립하지 않는다.
|
|
225
|
+
if (!ctx.multiple) return null;
|
|
226
|
+
|
|
227
|
+
const handleClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
|
|
228
|
+
onClick?.(event);
|
|
229
|
+
if (event.defaultPrevented) return;
|
|
230
|
+
if (ctx.allOpen) ctx.collapseAll();
|
|
231
|
+
else ctx.expandAll();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
className={cx('krds-btn xsmall text', className)}
|
|
238
|
+
onClick={handleClick}
|
|
239
|
+
{...rest}
|
|
240
|
+
>
|
|
241
|
+
{ctx.allOpen ? collapseLabel : expandLabel}
|
|
242
|
+
<i className={cx('svg-icon ico-angle', ctx.allOpen && 'rotate-180')} aria-hidden="true" />
|
|
243
|
+
</button>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const Accordion = {
|
|
248
|
+
Root: AccordionRoot,
|
|
249
|
+
Item: AccordionItem,
|
|
250
|
+
ToggleAll: AccordionToggleAll,
|
|
251
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx, type KrdsIcon, type KrdsTone } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 인라인 안내 상자 — KRDS 에 없다. shadcn `Alert` 형태에 KRDS 토큰을 입힌 보충 부품이다.
|
|
6
|
+
*
|
|
7
|
+
* 본문 흐름 안에서 「이 화면을 쓰기 전에 알아야 할 것」을 한 덩이로 알린다.
|
|
8
|
+
* 떴다 사라지는 알림은 `Toast`, 폼 필드 하나에 붙는 문구는 `Field` 의 `hint`·`message`,
|
|
9
|
+
* 재난·장애 배너는 `CriticalAlert` 다 — 그쪽에 이 상자를 갖다 쓰지 마라(규격이 금지한다).
|
|
10
|
+
* `AlertTitle` · `AlertDescription` 말고 다른 것을 바로 넣지 않는다.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <Alert tone="information">
|
|
14
|
+
* <AlertTitle>열람 신청 전에 확인하세요.</AlertTitle>
|
|
15
|
+
* <AlertDescription>비공개 기록물은 소속 기관 승인 뒤에 열립니다.</AlertDescription>
|
|
16
|
+
* </Alert>
|
|
17
|
+
* <Alert tone="danger" announce="assertive">
|
|
18
|
+
* <AlertTitle>저장하지 못했습니다.</AlertTitle>
|
|
19
|
+
* </Alert>
|
|
20
|
+
*
|
|
21
|
+
* 자세히: docs/krds/09-부품-노트.md#안내상자
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 색조. `KrdsTone` 에서 이 상자가 쓰는 다섯만 좁힌 것이다. */
|
|
25
|
+
export type AlertTone = Extract<KrdsTone, 'information' | 'warning' | 'danger' | 'success' | 'gray'>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 낭독 방식. 생략하면 role 이 없다(늘 떠 있는 안내의 기본값).
|
|
29
|
+
* `polite` = `role="status"`(새로 나타나는 안내) · `assertive` = `role="alert"`(긴급한 것에만).
|
|
30
|
+
*/
|
|
31
|
+
export type AlertAnnounce = 'polite' | 'assertive';
|
|
32
|
+
|
|
33
|
+
const ROLE: Record<AlertAnnounce, 'status' | 'alert'> = {
|
|
34
|
+
polite: 'status',
|
|
35
|
+
assertive: 'alert',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 색조별 유틸리티. 선 `border-*-line` · 면 `bg-*-surface` · 글자 `text-*` · mask 아이콘 색 `tint`.
|
|
40
|
+
* 완성된 문자열로 적는다(Tailwind 는 글자 그대로 적힌 클래스만 만든다).
|
|
41
|
+
*/
|
|
42
|
+
const TONE: Record<AlertTone, { box: string; tint: string; icon: KrdsIcon | null }> = {
|
|
43
|
+
information: {
|
|
44
|
+
box: 'border-information-line bg-information-surface text-information',
|
|
45
|
+
tint: 'bg-information',
|
|
46
|
+
icon: 'information-fill',
|
|
47
|
+
},
|
|
48
|
+
// 경고 아이콘은 킷에 없다 — 위 「경고에 아이콘이 없는 것은 일부러다」 참고.
|
|
49
|
+
warning: { box: 'border-warning-line bg-warning-surface text-warning', tint: 'bg-warning', icon: null },
|
|
50
|
+
danger: { box: 'border-danger-line bg-danger-surface text-danger', tint: 'bg-danger', icon: 'error-fill' },
|
|
51
|
+
success: { box: 'border-success-line bg-success-surface text-success', tint: 'bg-success', icon: 'success-fill' },
|
|
52
|
+
gray: { box: 'border-line bg-surface-subtler text-fg', tint: 'bg-fg', icon: null },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type AlertProps = Omit<ComponentPropsWithRef<'div'>, 'title'> & {
|
|
56
|
+
/** 기본 `information`. 선·면·글자색과 기본 아이콘이 여기서 정해진다. */
|
|
57
|
+
tone?: AlertTone;
|
|
58
|
+
/** 킷 아이콘 이름(`ico-` 뺀 것). 색조 기본값을 덮는다. `null` 이면 그리지 않는다. */
|
|
59
|
+
icon?: KrdsIcon | null;
|
|
60
|
+
/** 낭독 방식. 기본은 role 없음. `assertive`(=`role="alert"`)는 긴급한 것에만. */
|
|
61
|
+
announce?: AlertAnnounce;
|
|
62
|
+
/** `AlertTitle` · `AlertDescription` 을 넣는다. */
|
|
63
|
+
children?: ReactNode;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function Alert({ tone = 'information', icon, announce, className, children, ...rest }: AlertProps) {
|
|
67
|
+
const t = TONE[tone];
|
|
68
|
+
// `undefined` 는 「색조 기본값을 쓴다」, `null` 은 「그리지 않는다」. 둘을 구분해야 한다.
|
|
69
|
+
const iconName = icon === undefined ? t.icon : icon;
|
|
70
|
+
|
|
71
|
+
const iconNode = iconName ? (
|
|
72
|
+
<i
|
|
73
|
+
className={cx(
|
|
74
|
+
'svg-icon',
|
|
75
|
+
`ico-${iconName}`,
|
|
76
|
+
// shadcn 이 자식 svg 에 거는 16px + 아래로 2px 자리. 킷 `.svg-icon` 기본이 large 라 줄여야 한다.
|
|
77
|
+
'col-start-1 row-start-1 size-4 translate-y-0.5',
|
|
78
|
+
// `-fill` 은 background-image 라 색을 못 바꾼다(함정 1).
|
|
79
|
+
!iconName.endsWith('-fill') && t.tint,
|
|
80
|
+
)}
|
|
81
|
+
aria-hidden="true"
|
|
82
|
+
/>
|
|
83
|
+
) : null;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
role={announce ? ROLE[announce] : undefined}
|
|
88
|
+
className={cx(
|
|
89
|
+
'relative grid w-full items-start gap-y-0.5 rounded-lg border border-solid px-4 py-3 text-sm',
|
|
90
|
+
// 아이콘이 있을 때만 첫 칸이 열린다 — shadcn 의 `has-[>svg]:` 분기를 코드로 옮긴 것.
|
|
91
|
+
iconNode ? 'grid-cols-[calc(var(--spacing)*4)_1fr] gap-x-3' : 'grid-cols-[0_1fr]',
|
|
92
|
+
t.box,
|
|
93
|
+
className,
|
|
94
|
+
)}
|
|
95
|
+
{...rest}
|
|
96
|
+
>
|
|
97
|
+
{iconNode}
|
|
98
|
+
{children}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 안내 제목 한 줄. 둘째 칸에 놓인다. 색은 상자에서 물려받는다. */
|
|
104
|
+
export function AlertTitle({ className, ...rest }: ComponentPropsWithRef<'div'>) {
|
|
105
|
+
return <div className={cx('col-start-2 min-h-4 font-bold', className)} {...rest} />;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 안내 본문. 여러 줄·목록을 넣어도 `gap-1` 로 벌어진다. 색은 상자에서 물려받는다. */
|
|
109
|
+
export function AlertDescription({ className, ...rest }: ComponentPropsWithRef<'div'>) {
|
|
110
|
+
return <div className={cx('col-start-2 grid justify-items-start gap-1 text-sm', className)} {...rest} />;
|
|
111
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef } from 'react';
|
|
2
|
+
import { cx, type KrdsSize, type KrdsTone } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 아바타(사용자 표식) — KRDS 킷에 없다. KRDS 토큰을 입힌 보충 부품이다.
|
|
6
|
+
*
|
|
7
|
+
* 사람 이름 옆에 놓아 목록에서 행을 구분한다. 스스로 누를 수 없는 `<span>` 이다 —
|
|
8
|
+
* 누르는 일은 감싸는 `<Link>`·`<button>` 이 한다.
|
|
9
|
+
* 이름이 곁에 이미 적혀 있으면 `decorative` 를 켠다(안 켜면 이름이 두 번 읽힌다).
|
|
10
|
+
* 여러 사람을 겹쳐 늘어놓을 때는 `AvatarGroup` 을 쓴다.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <Avatar name="홍길동" />
|
|
14
|
+
* <Avatar name="홍길동" src="/files/1.jpg" size="small" />
|
|
15
|
+
* <span className="flex items-center gap-2">
|
|
16
|
+
* <Avatar name={p.author} size="xsmall" decorative /> {p.author}
|
|
17
|
+
* </span>
|
|
18
|
+
* <AvatarGroup label="참여자" items={members} max={4} />
|
|
19
|
+
*
|
|
20
|
+
* 자세히: docs/krds/09-부품-노트.md#아바타
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 채움 방식. 킷 배지의 `bg-light-*` · `bg-*` 짝을 유틸리티로 옮긴 것이다. */
|
|
24
|
+
export type AvatarFill = 'light' | 'solid';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 크기 → 상자·글자·아이콘·겹침. 상자 높이는 버튼·입력칸과 같은 눈금이고 글자는 레이블 축이다.
|
|
28
|
+
* 아이콘은 상자의 절반, 겹침 폭은 원의 1/4쯤이다.
|
|
29
|
+
*/
|
|
30
|
+
const SIZE: Record<KrdsSize, { box: string; text: string; icon: string; overlap: string }> = {
|
|
31
|
+
xsmall: { box: 'size-8', text: 'text-label-xs', icon: 'size-4', overlap: '-ml-2' }, // 32 / 13 / 16
|
|
32
|
+
small: { box: 'size-10', text: 'text-label-sm', icon: 'size-5', overlap: '-ml-2' }, // 40 / 15 / 20
|
|
33
|
+
medium: { box: 'size-12', text: 'text-label-md', icon: 'size-6', overlap: '-ml-3' }, // 48 / 17 / 24
|
|
34
|
+
large: { box: 'size-14', text: 'text-label-lg', icon: 'size-7', overlap: '-ml-4' }, // 56 / 19 / 28
|
|
35
|
+
xlarge: { box: 'size-16', text: 'text-label-lg', icon: 'size-8', overlap: '-ml-4' }, // 64 / 19 / 32
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 옅은 채움. 킷 배지 `bg-light-*` 와 같은 색 짝이다(대비를 킷이 이미 검증했다).
|
|
40
|
+
* 완성된 문자열로 적는다 — Tailwind 는 글자 그대로 적힌 클래스만 만든다.
|
|
41
|
+
*/
|
|
42
|
+
const TONE_LIGHT: Record<KrdsTone, string> = {
|
|
43
|
+
primary: 'bg-primary-surface text-primary',
|
|
44
|
+
secondary: 'bg-secondary-surface text-secondary',
|
|
45
|
+
gray: 'bg-surface-subtle text-fg-subtle',
|
|
46
|
+
point: 'bg-point-surface text-point',
|
|
47
|
+
danger: 'bg-danger-surface text-danger',
|
|
48
|
+
warning: 'bg-warning-surface text-warning',
|
|
49
|
+
success: 'bg-success-surface text-success',
|
|
50
|
+
information: 'bg-information-surface text-information',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 꽉 찬 채움. 킷 배지 `bg-*`(element 바탕 + 흰 글자)와 같은 짝이다.
|
|
55
|
+
* gray 만 solid 토큰이 없어 `bg-fg-subtle` 로 잇는다.
|
|
56
|
+
*/
|
|
57
|
+
const TONE_SOLID: Record<KrdsTone, string> = {
|
|
58
|
+
primary: 'bg-primary-solid text-fg-inverse',
|
|
59
|
+
secondary: 'bg-secondary-solid text-fg-inverse',
|
|
60
|
+
gray: 'bg-fg-subtle text-fg-inverse',
|
|
61
|
+
point: 'bg-point-solid text-fg-inverse',
|
|
62
|
+
danger: 'bg-danger-solid text-fg-inverse',
|
|
63
|
+
warning: 'bg-warning-solid text-fg-inverse',
|
|
64
|
+
success: 'bg-success-solid text-fg-inverse',
|
|
65
|
+
information: 'bg-information-solid text-fg-inverse',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** 한글 — 완성형 가나다(AC00~D7A3)와 호환 자모(3131~318E). */
|
|
69
|
+
const HANGUL = /[ㄱ-ㆎ가-힣]/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 이름에서 이니셜을 뽑는다. 한글은 성 한 글자, 서양식 이름은 첫 두 낱말의 머리글자다.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* avatarInitials('홍길동') // '홍'
|
|
76
|
+
* avatarInitials('남궁 민수') // '남'
|
|
77
|
+
* avatarInitials('Hong Gildong') // 'HG'
|
|
78
|
+
* avatarInitials('gildong') // 'G'
|
|
79
|
+
*/
|
|
80
|
+
export function avatarInitials(name: string): string {
|
|
81
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
82
|
+
const first = Array.from(words[0] ?? '')[0] ?? '';
|
|
83
|
+
if (!first) return '';
|
|
84
|
+
if (HANGUL.test(first)) return first;
|
|
85
|
+
const second = words.length > 1 ? (Array.from(words[1] ?? '')[0] ?? '') : '';
|
|
86
|
+
return (first + second).toUpperCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type AvatarBase = {
|
|
90
|
+
/** 기본 medium(48px). 같은 줄의 버튼·입력칸과 **같은 값**을 준다. */
|
|
91
|
+
size?: KrdsSize;
|
|
92
|
+
/** 기본 gray. 뜻이 있을 때만 다른 색을 준다 — 이름 해시로 색을 흩뿌리지 않는다. */
|
|
93
|
+
tone?: KrdsTone;
|
|
94
|
+
fill?: AvatarFill;
|
|
95
|
+
/** 프로필 사진 주소. 불러오기에 실패하면 밑의 이니셜이 드러난다. */
|
|
96
|
+
src?: string;
|
|
97
|
+
/** 이니셜을 직접 준다. 생략하면 `name` 에서 뽑는다. */
|
|
98
|
+
initials?: string;
|
|
99
|
+
/** 겹쳐 놓을 때 서로 떨어져 보이도록 바탕색 테두리를 두른다. `AvatarGroup` 이 켜 준다. */
|
|
100
|
+
bordered?: boolean;
|
|
101
|
+
className?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** 아바타 하나가 사람을 대신하는 자리. 보조기술이 이름을 읽는다. */
|
|
105
|
+
export type AvatarProps = Omit<ComponentPropsWithRef<'span'>, 'children'> &
|
|
106
|
+
AvatarBase & {
|
|
107
|
+
decorative?: false;
|
|
108
|
+
/** 사람 이름. 이니셜과 보조기술 이름이 여기서 나온다. */
|
|
109
|
+
name: string;
|
|
110
|
+
/** 읽을 문구를 이름과 다르게 하고 싶을 때(예: `홍길동 (관리자)`). */
|
|
111
|
+
label?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** 곁에 이름이 이미 적혀 있는 자리. 보조기술에서 감춘다(이름이 두 번 읽히지 않게). */
|
|
115
|
+
export type DecorativeAvatarProps = Omit<ComponentPropsWithRef<'span'>, 'children'> &
|
|
116
|
+
AvatarBase & {
|
|
117
|
+
decorative: true;
|
|
118
|
+
/** 이니셜을 뽑는 데만 쓴다. 읽히지는 않는다. */
|
|
119
|
+
name?: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** 두 갈래를 한 번에 꺼내기 위한 내부 형. 밖으로 내보내지 않는다. */
|
|
123
|
+
type AvatarAnyProps = Omit<ComponentPropsWithRef<'span'>, 'children'> &
|
|
124
|
+
AvatarBase & { decorative?: boolean; name?: string; label?: string };
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 사용자 표식 하나. 이니셜을 깔고 `src` 가 있으면 그 위에 사진을 덮는다.
|
|
128
|
+
* 이름도 이미지도 없으면 킷 아이콘 `ico-my` 를 그린다(비회원 글 등).
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* <Avatar name="홍길동" size="small" src={user.photo} />
|
|
132
|
+
*/
|
|
133
|
+
export function Avatar(props: AvatarProps | DecorativeAvatarProps) {
|
|
134
|
+
const {
|
|
135
|
+
size = 'medium',
|
|
136
|
+
tone = 'gray',
|
|
137
|
+
fill = 'light',
|
|
138
|
+
src,
|
|
139
|
+
initials,
|
|
140
|
+
bordered,
|
|
141
|
+
decorative = false,
|
|
142
|
+
name,
|
|
143
|
+
label,
|
|
144
|
+
className,
|
|
145
|
+
...rest
|
|
146
|
+
} = props as AvatarAnyProps;
|
|
147
|
+
|
|
148
|
+
const metric = SIZE[size];
|
|
149
|
+
const text = initials ?? (name ? avatarInitials(name) : '');
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<span
|
|
153
|
+
// 장식용이면 통째로 감춘다. 아니면 그림 하나로 읽히고 이름은 aria-label 이 준다.
|
|
154
|
+
role={decorative ? undefined : 'img'}
|
|
155
|
+
aria-label={decorative ? undefined : (label ?? name)}
|
|
156
|
+
aria-hidden={decorative ? true : undefined}
|
|
157
|
+
className={cx(
|
|
158
|
+
// relative/overflow-hidden — 사진을 원 안에 가둔다. shrink-0 — 좁은 flex 줄에서 눌려 찌그러지지 않게.
|
|
159
|
+
'relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full align-middle font-bold leading-none',
|
|
160
|
+
metric.box,
|
|
161
|
+
metric.text,
|
|
162
|
+
fill === 'solid' ? TONE_SOLID[tone] : TONE_LIGHT[tone],
|
|
163
|
+
// ★ 함정 4 — outline-solid 를 빼면 폭만 있고 선이 없을 수 있다
|
|
164
|
+
bordered && 'outline-2 outline-solid outline-bg',
|
|
165
|
+
className,
|
|
166
|
+
)}
|
|
167
|
+
{...rest}
|
|
168
|
+
>
|
|
169
|
+
{text ? (
|
|
170
|
+
// role="img" 는 자손을 읽지 않지만, 그 규칙을 안 지키는 보조기술이 있어 명시해 둔다.
|
|
171
|
+
<span aria-hidden="true">{text}</span>
|
|
172
|
+
) : (
|
|
173
|
+
<i className={cx('svg-icon ico-my bg-current', metric.icon)} aria-hidden="true" />
|
|
174
|
+
)}
|
|
175
|
+
{src && (
|
|
176
|
+
/* 이니셜 위에 덮는다. 실패하면 아무것도 안 그려져 밑의 이니셜이 드러난다(함정 2).
|
|
177
|
+
프레임워크의 이미지 부품이 아니라 맨 `<img>` 다 — 프로필 사진은 기관이 올린 파일이라 호스트가
|
|
178
|
+
미리 정해져 있지 않다. 저장소 eslint 설정이 `no-img-element` 를 경고로 낮춰 둔 이유가 이것이다. */
|
|
179
|
+
<img
|
|
180
|
+
src={src}
|
|
181
|
+
alt=""
|
|
182
|
+
loading="lazy"
|
|
183
|
+
decoding="async"
|
|
184
|
+
className="absolute inset-0 size-full object-cover"
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
</span>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 묶음에 넣을 한 사람. */
|
|
192
|
+
export type AvatarGroupItem = {
|
|
193
|
+
name: string;
|
|
194
|
+
src?: string;
|
|
195
|
+
tone?: KrdsTone;
|
|
196
|
+
/** 읽을 문구를 이름과 다르게 할 때. */
|
|
197
|
+
label?: string;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type AvatarGroupProps = Omit<ComponentPropsWithRef<'ul'>, 'children'> & {
|
|
201
|
+
items: AvatarGroupItem[];
|
|
202
|
+
/** 몇 칸까지 보일지. 나머지는 `+N` 한 칸으로 접는다. 기본 4. */
|
|
203
|
+
max?: number;
|
|
204
|
+
/** 기본 small(40px) — 목록 안에 들어가는 일이 많아 하나짜리보다 작다. */
|
|
205
|
+
size?: KrdsSize;
|
|
206
|
+
fill?: AvatarFill;
|
|
207
|
+
/** 모든 칸의 기본 색. 항목이 `tone` 을 주면 그것이 이긴다. */
|
|
208
|
+
tone?: KrdsTone;
|
|
209
|
+
/** 겹치지 않고 나란히 놓는다. */
|
|
210
|
+
spaced?: boolean;
|
|
211
|
+
/** 묶음 전체의 이름(`<ul>` 의 aria-label). 예: 「참여자」 */
|
|
212
|
+
label?: string;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 아바타 묶음 — 담당자·참여자를 겹쳐 늘어놓는다.
|
|
217
|
+
*
|
|
218
|
+
* `children` 이 아니라 `items` 를 받는다. 겹침 간격·테두리·`+N` 칸을 묶음이 정해야 하기 때문이다.
|
|
219
|
+
* `<ul>/<li>` 라 스크린리더가 개수를 세어 준다 — 각 칸의 이름도 그대로 읽힌다.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* <AvatarGroup label="참여자" items={members} max={4} />
|
|
223
|
+
*/
|
|
224
|
+
export function AvatarGroup({
|
|
225
|
+
items,
|
|
226
|
+
max = 4,
|
|
227
|
+
size = 'small',
|
|
228
|
+
fill = 'light',
|
|
229
|
+
tone = 'gray',
|
|
230
|
+
spaced,
|
|
231
|
+
label,
|
|
232
|
+
className,
|
|
233
|
+
...rest
|
|
234
|
+
}: AvatarGroupProps) {
|
|
235
|
+
// 0·음수·소수를 막는다. 부모가 화면 폭에서 계산한 값을 그대로 넘기는 일이 흔하다.
|
|
236
|
+
const limit = Math.max(1, Math.trunc(max) || 1);
|
|
237
|
+
const shown = items.slice(0, limit);
|
|
238
|
+
const rest_ = items.length - shown.length;
|
|
239
|
+
const step = spaced ? 'ml-2' : SIZE[size].overlap;
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<ul aria-label={label} className={cx('flex items-center', className)} {...rest}>
|
|
243
|
+
{shown.map((it, i) => (
|
|
244
|
+
<li key={`${it.name}-${i}`} className={cx('shrink-0 first:ml-0', step)}>
|
|
245
|
+
<Avatar
|
|
246
|
+
name={it.name}
|
|
247
|
+
label={it.label}
|
|
248
|
+
src={it.src}
|
|
249
|
+
size={size}
|
|
250
|
+
fill={fill}
|
|
251
|
+
tone={it.tone ?? tone}
|
|
252
|
+
bordered
|
|
253
|
+
/>
|
|
254
|
+
</li>
|
|
255
|
+
))}
|
|
256
|
+
{rest_ > 0 && (
|
|
257
|
+
<li className={cx('shrink-0 first:ml-0', step)}>
|
|
258
|
+
{/* 화면에는 `+3`, 보조기술에는 「외 3명」. 숫자는 tabular-nums 로 폭을 고정한다. */}
|
|
259
|
+
<Avatar
|
|
260
|
+
name={`외 ${rest_}명`}
|
|
261
|
+
initials={`+${rest_}`}
|
|
262
|
+
size={size}
|
|
263
|
+
fill={fill}
|
|
264
|
+
tone="gray"
|
|
265
|
+
bordered
|
|
266
|
+
className="tabular-nums"
|
|
267
|
+
/>
|
|
268
|
+
</li>
|
|
269
|
+
)}
|
|
270
|
+
</ul>
|
|
271
|
+
);
|
|
272
|
+
}
|