sh-ui-cli 0.46.0 → 0.47.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/data/changelog/versions.json +13 -0
- package/data/registry/react/components/accordion/index.module.tsx +97 -0
- package/data/registry/react/components/accordion/styles.module.css +111 -0
- package/data/registry/react/components/avatar/index.module.tsx +73 -0
- package/data/registry/react/components/avatar/styles.module.css +36 -0
- package/data/registry/react/components/badge/index.module.tsx +40 -0
- package/data/registry/react/components/badge/styles.module.css +57 -0
- package/data/registry/react/components/breadcrumb/index.module.tsx +152 -0
- package/data/registry/react/components/breadcrumb/styles.module.css +82 -0
- package/data/registry/react/components/calendar/index.module.tsx +806 -0
- package/data/registry/react/components/calendar/styles.module.css +213 -0
- package/data/registry/react/components/carousel/index.module.tsx +430 -0
- package/data/registry/react/components/carousel/styles.module.css +155 -0
- package/data/registry/react/components/checkbox/index.module.tsx +96 -0
- package/data/registry/react/components/checkbox/styles.module.css +75 -0
- package/data/registry/react/components/code-editor/index.module.tsx +230 -0
- package/data/registry/react/components/code-editor/styles.module.css +76 -0
- package/data/registry/react/components/code-panel/index.module.tsx +191 -0
- package/data/registry/react/components/code-panel/styles.module.css +124 -0
- package/data/registry/react/components/color-picker/index.module.tsx +467 -0
- package/data/registry/react/components/color-picker/styles.module.css +166 -0
- package/data/registry/react/components/combobox/index.module.tsx +165 -0
- package/data/registry/react/components/combobox/styles.module.css +151 -0
- package/data/registry/react/components/context-menu/index.module.tsx +251 -0
- package/data/registry/react/components/context-menu/styles.module.css +140 -0
- package/data/registry/react/components/date-picker/index.module.tsx +520 -0
- package/data/registry/react/components/date-picker/styles.module.css +103 -0
- package/data/registry/react/components/dialog/index.module.tsx +95 -0
- package/data/registry/react/components/dialog/styles.module.css +127 -0
- package/data/registry/react/components/dropdown-menu/index.module.tsx +255 -0
- package/data/registry/react/components/dropdown-menu/styles.module.css +150 -0
- package/data/registry/react/components/file-upload/index.module.tsx +487 -0
- package/data/registry/react/components/file-upload/styles.module.css +170 -0
- package/data/registry/react/components/form/index.module.tsx +61 -0
- package/data/registry/react/components/form/styles.module.css +47 -0
- package/data/registry/react/components/header/index.module.tsx +805 -0
- package/data/registry/react/components/header/styles.module.css +350 -0
- package/data/registry/react/components/label/index.module.tsx +52 -0
- package/data/registry/react/components/label/styles.module.css +90 -0
- package/data/registry/react/components/markdown-editor/index.module.tsx +119 -0
- package/data/registry/react/components/markdown-editor/styles.module.css +160 -0
- package/data/registry/react/components/menubar/index.module.tsx +32 -0
- package/data/registry/react/components/menubar/styles.module.css +45 -0
- package/data/registry/react/components/numeric-input/index.module.tsx +148 -0
- package/data/registry/react/components/numeric-input/styles.module.css +56 -0
- package/data/registry/react/components/page-toc/index.module.tsx +174 -0
- package/data/registry/react/components/page-toc/styles.module.css +82 -0
- package/data/registry/react/components/pagination/index.module.tsx +269 -0
- package/data/registry/react/components/pagination/styles.module.css +105 -0
- package/data/registry/react/components/popover/index.module.tsx +113 -0
- package/data/registry/react/components/popover/styles.module.css +65 -0
- package/data/registry/react/components/progress/index.module.tsx +54 -0
- package/data/registry/react/components/progress/styles.module.css +41 -0
- package/data/registry/react/components/radio/index.module.tsx +65 -0
- package/data/registry/react/components/radio/styles.module.css +80 -0
- package/data/registry/react/components/rich-text-editor/index.module.tsx +348 -0
- package/data/registry/react/components/rich-text-editor/styles.module.css +196 -0
- package/data/registry/react/components/select/index.module.tsx +234 -0
- package/data/registry/react/components/select/styles.module.css +193 -0
- package/data/registry/react/components/separator/index.module.tsx +46 -0
- package/data/registry/react/components/separator/styles.module.css +15 -0
- package/data/registry/react/components/sidebar/index.module.tsx +1067 -0
- package/data/registry/react/components/sidebar/styles.module.css +502 -0
- package/data/registry/react/components/skeleton/index.module.tsx +22 -0
- package/data/registry/react/components/skeleton/styles.module.css +24 -0
- package/data/registry/react/components/slider/index.module.tsx +298 -0
- package/data/registry/react/components/slider/styles.module.css +64 -0
- package/data/registry/react/components/spinner/index.module.tsx +38 -0
- package/data/registry/react/components/spinner/styles.module.css +37 -0
- package/data/registry/react/components/switch/index.module.tsx +39 -0
- package/data/registry/react/components/switch/styles.module.css +83 -0
- package/data/registry/react/components/tabs/index.module.tsx +91 -0
- package/data/registry/react/components/tabs/styles.module.css +148 -0
- package/data/registry/react/components/textarea/index.module.tsx +23 -0
- package/data/registry/react/components/textarea/styles.module.css +54 -0
- package/data/registry/react/components/toast/index.module.tsx +258 -0
- package/data/registry/react/components/toast/styles.module.css +290 -0
- package/data/registry/react/components/toggle/index.module.tsx +131 -0
- package/data/registry/react/components/toggle/styles.module.css +85 -0
- package/data/registry/react/components/tooltip/index.module.tsx +83 -0
- package/data/registry/react/components/tooltip/styles.module.css +44 -0
- package/data/registry/react/registry.json +560 -0
- package/package.json +1 -1
- package/src/api.d.ts +4 -3
- package/src/constants.js +4 -3
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import styles from "./styles.module.css";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import { cn } from "@SH_UI_UTILS@";
|
|
6
|
+
/* ───────── Pagination (nav) ─────────
|
|
7
|
+
* 시맨틱: <nav aria-label="Pagination"><ul>...</ul></nav>.
|
|
8
|
+
* 현재 페이지 링크에 aria-current="page"를 부여해 스크린리더가 위치를 읽게 한다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 페이지 분할 내비게이션의 시맨틱 컨테이너(`<nav aria-label="Pagination">`).
|
|
13
|
+
* 자식 구조: PaginationContent > PaginationItem × n > PaginationLink/Previous/Next/Ellipsis.
|
|
14
|
+
*/
|
|
15
|
+
export const Pagination = React.forwardRef<
|
|
16
|
+
HTMLElement,
|
|
17
|
+
React.HTMLAttributes<HTMLElement>
|
|
18
|
+
>(function Pagination({ className, ...props }, ref) {
|
|
19
|
+
return (
|
|
20
|
+
<nav
|
|
21
|
+
ref={ref}
|
|
22
|
+
aria-label="Pagination"
|
|
23
|
+
className={cn(styles.pagination, className)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/* ───────── Content (ul) ───────── */
|
|
30
|
+
|
|
31
|
+
/** 페이지 항목들을 담는 정렬되지 않은 리스트(`<ul>`). */
|
|
32
|
+
export const PaginationContent = React.forwardRef<
|
|
33
|
+
HTMLUListElement,
|
|
34
|
+
React.HTMLAttributes<HTMLUListElement>
|
|
35
|
+
>(function PaginationContent({ className, ...props }, ref) {
|
|
36
|
+
return (
|
|
37
|
+
<ul
|
|
38
|
+
ref={ref}
|
|
39
|
+
className={cn(styles.pagination__content, className)}
|
|
40
|
+
{...props}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/* ───────── Item (li) ───────── */
|
|
46
|
+
|
|
47
|
+
/** 한 페이지 슬롯(`<li>`). PaginationLink/Previous/Next/Ellipsis를 자식으로 둔다. */
|
|
48
|
+
export const PaginationItem = React.forwardRef<
|
|
49
|
+
HTMLLIElement,
|
|
50
|
+
React.LiHTMLAttributes<HTMLLIElement>
|
|
51
|
+
>(function PaginationItem({ className, ...props }, ref) {
|
|
52
|
+
return (
|
|
53
|
+
<li
|
|
54
|
+
ref={ref}
|
|
55
|
+
className={cn(styles.pagination__item, className)}
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/* ───────── Link ─────────
|
|
62
|
+
* 숫자 페이지 링크. isActive일 때 aria-current="page" + 시각 강조.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
export interface PaginationLinkProps
|
|
66
|
+
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
67
|
+
/**
|
|
68
|
+
* 현재 페이지 표시. `true`면 `aria-current="page"`가 자동 부여되고 시각적으로 강조된다.
|
|
69
|
+
* @default false
|
|
70
|
+
*/
|
|
71
|
+
isActive?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* 크기.
|
|
74
|
+
* - `sm` — 컴팩트
|
|
75
|
+
* - `md` — 일반 (기본)
|
|
76
|
+
*
|
|
77
|
+
* @default "md"
|
|
78
|
+
*/
|
|
79
|
+
size?: "sm" | "md";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 숫자 페이지 링크. `isActive`이면 `aria-current="page"`가 자동 부여되어
|
|
84
|
+
* 스크린리더가 현재 위치를 읽는다.
|
|
85
|
+
*/
|
|
86
|
+
export const PaginationLink = React.forwardRef<
|
|
87
|
+
HTMLAnchorElement,
|
|
88
|
+
PaginationLinkProps
|
|
89
|
+
>(function PaginationLink(
|
|
90
|
+
{ className, isActive, size = "md", ...props },
|
|
91
|
+
ref,
|
|
92
|
+
) {
|
|
93
|
+
return (
|
|
94
|
+
<a
|
|
95
|
+
ref={ref}
|
|
96
|
+
aria-current={isActive ? "page" : undefined}
|
|
97
|
+
data-active={isActive ? "" : undefined}
|
|
98
|
+
data-size={size}
|
|
99
|
+
className={cn(styles.pagination__link, className)}
|
|
100
|
+
{...props}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/* ───────── Previous / Next ─────────
|
|
106
|
+
* 아이콘 + 레이블. 레이블이 없는 아이콘 버튼에는 aria-label로 의미 전달.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/** "이전 페이지" 링크. 화살표 아이콘과 라벨이 함께 렌더되며 `aria-label`이 자동 부여된다. */
|
|
110
|
+
export const PaginationPrevious = React.forwardRef<
|
|
111
|
+
HTMLAnchorElement,
|
|
112
|
+
PaginationLinkProps
|
|
113
|
+
>(function PaginationPrevious({ className, children, ...props }, ref) {
|
|
114
|
+
return (
|
|
115
|
+
<PaginationLink
|
|
116
|
+
ref={ref}
|
|
117
|
+
aria-label="이전 페이지"
|
|
118
|
+
className={cn(styles.pagination__nav, className)}
|
|
119
|
+
{...props}
|
|
120
|
+
>
|
|
121
|
+
<ChevronLeftIcon />
|
|
122
|
+
{children ?? <span>이전</span>}
|
|
123
|
+
</PaginationLink>
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** "다음 페이지" 링크. */
|
|
128
|
+
export const PaginationNext = React.forwardRef<
|
|
129
|
+
HTMLAnchorElement,
|
|
130
|
+
PaginationLinkProps
|
|
131
|
+
>(function PaginationNext({ className, children, ...props }, ref) {
|
|
132
|
+
return (
|
|
133
|
+
<PaginationLink
|
|
134
|
+
ref={ref}
|
|
135
|
+
aria-label="다음 페이지"
|
|
136
|
+
className={cn(styles.pagination__nav, className)}
|
|
137
|
+
{...props}
|
|
138
|
+
>
|
|
139
|
+
{children ?? <span>다음</span>}
|
|
140
|
+
<ChevronRightIcon />
|
|
141
|
+
</PaginationLink>
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/* ───────── Ellipsis — 생략 표시 ───────── */
|
|
146
|
+
|
|
147
|
+
/** 페이지 사이 생략을 표현하는 점 3개. 시각만 표현하고 스크린리더에는 무시된다. */
|
|
148
|
+
export const PaginationEllipsis = React.forwardRef<
|
|
149
|
+
HTMLSpanElement,
|
|
150
|
+
React.HTMLAttributes<HTMLSpanElement>
|
|
151
|
+
>(function PaginationEllipsis({ className, ...props }, ref) {
|
|
152
|
+
return (
|
|
153
|
+
<span
|
|
154
|
+
ref={ref}
|
|
155
|
+
role="presentation"
|
|
156
|
+
aria-hidden="true"
|
|
157
|
+
className={cn(styles.pagination__ellipsis, className)}
|
|
158
|
+
{...props}
|
|
159
|
+
>
|
|
160
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
|
|
161
|
+
<circle cx="3" cy="8" r="1.25" />
|
|
162
|
+
<circle cx="8" cy="8" r="1.25" />
|
|
163
|
+
<circle cx="13" cy="8" r="1.25" />
|
|
164
|
+
</svg>
|
|
165
|
+
<span className={styles.pagination__sr}>더 많은 페이지</span>
|
|
166
|
+
</span>
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/* ───────── 페이지 범위 유틸 ─────────
|
|
171
|
+
* 현재 페이지 주변 siblings개 + 양 끝 1개 + 필요한 곳에 "dots"를 넣어
|
|
172
|
+
* 렌더할 토큰 배열을 돌려준다.
|
|
173
|
+
*
|
|
174
|
+
* 예: page=5, totalPages=10, siblings=1 → [1, "dots", 4, 5, 6, "dots", 10]
|
|
175
|
+
*
|
|
176
|
+
* page·totalPages는 1-based를 가정한다.
|
|
177
|
+
*/
|
|
178
|
+
|
|
179
|
+
export type PaginationToken = number | "dots";
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 1-based `page`/`totalPages`로부터 렌더할 토큰 배열을 만든다.
|
|
183
|
+
* 양 끝과 현재 주변 `siblings`개를 보여주고, 끊긴 구간엔 `"dots"`를 넣어준다.
|
|
184
|
+
*
|
|
185
|
+
* @param args.page - 1-based 현재 페이지.
|
|
186
|
+
* @param args.siblings - 현재 페이지 양옆에 보일 페이지 개수.
|
|
187
|
+
* @returns 렌더 토큰 배열. 숫자 또는 문자열 `"dots"`로 구성.
|
|
188
|
+
* @example
|
|
189
|
+
* getPaginationRange({ page: 5, totalPages: 10, siblings: 1 })
|
|
190
|
+
* // [1, "dots", 4, 5, 6, "dots", 10]
|
|
191
|
+
*/
|
|
192
|
+
export function getPaginationRange({
|
|
193
|
+
page,
|
|
194
|
+
totalPages,
|
|
195
|
+
siblings = 1,
|
|
196
|
+
}: {
|
|
197
|
+
/** 1-based 현재 페이지. */
|
|
198
|
+
page: number;
|
|
199
|
+
/** 전체 페이지 수. */
|
|
200
|
+
totalPages: number;
|
|
201
|
+
/**
|
|
202
|
+
* 현재 페이지 양옆에 보일 페이지 개수.
|
|
203
|
+
* @default 1
|
|
204
|
+
*/
|
|
205
|
+
siblings?: number;
|
|
206
|
+
}): PaginationToken[] {
|
|
207
|
+
if (totalPages <= 0) return [];
|
|
208
|
+
|
|
209
|
+
// 끝점 2개(첫/마지막) + 현재 주변 (2*siblings + 1) + dots 2개가 총 페이지 수보다 크거나 같으면
|
|
210
|
+
// 생략 없이 전부 보여준다.
|
|
211
|
+
const totalSlots = siblings * 2 + 5;
|
|
212
|
+
if (totalPages <= totalSlots) {
|
|
213
|
+
return range(1, totalPages);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const leftSibling = Math.max(page - siblings, 1);
|
|
217
|
+
const rightSibling = Math.min(page + siblings, totalPages);
|
|
218
|
+
|
|
219
|
+
const showLeftDots = leftSibling > 2;
|
|
220
|
+
const showRightDots = rightSibling < totalPages - 1;
|
|
221
|
+
|
|
222
|
+
// 왼쪽만 닫혀있음 → [1 ... right side]
|
|
223
|
+
if (!showLeftDots && showRightDots) {
|
|
224
|
+
const leftCount = 3 + 2 * siblings;
|
|
225
|
+
return [...range(1, leftCount), "dots", totalPages];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 오른쪽만 닫혀있음 → [1 ... left side]
|
|
229
|
+
if (showLeftDots && !showRightDots) {
|
|
230
|
+
const rightCount = 3 + 2 * siblings;
|
|
231
|
+
return [1, "dots", ...range(totalPages - rightCount + 1, totalPages)];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 양쪽 모두 생략
|
|
235
|
+
return [1, "dots", ...range(leftSibling, rightSibling), "dots", totalPages];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function range(start: number, end: number): number[] {
|
|
239
|
+
const length = end - start + 1;
|
|
240
|
+
return Array.from({ length }, (_, i) => start + i);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ChevronLeftIcon() {
|
|
244
|
+
return (
|
|
245
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
246
|
+
<path
|
|
247
|
+
d="M10 4l-4 4 4 4"
|
|
248
|
+
stroke="currentColor"
|
|
249
|
+
strokeWidth="1.5"
|
|
250
|
+
strokeLinecap="round"
|
|
251
|
+
strokeLinejoin="round"
|
|
252
|
+
/>
|
|
253
|
+
</svg>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ChevronRightIcon() {
|
|
258
|
+
return (
|
|
259
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
260
|
+
<path
|
|
261
|
+
d="M6 4l4 4-4 4"
|
|
262
|
+
stroke="currentColor"
|
|
263
|
+
strokeWidth="1.5"
|
|
264
|
+
strokeLinecap="round"
|
|
265
|
+
strokeLinejoin="round"
|
|
266
|
+
/>
|
|
267
|
+
</svg>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
.pagination {
|
|
2
|
+
display: flex;
|
|
3
|
+
justify-content: center;
|
|
4
|
+
font-size: var(--text-sm);
|
|
5
|
+
color: var(--foreground);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.pagination__content {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-wrap: wrap;
|
|
11
|
+
align-items: center;
|
|
12
|
+
gap: 0.25rem;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
list-style: none;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.pagination__item {
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.pagination__link {
|
|
24
|
+
display: inline-flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: center;
|
|
27
|
+
gap: 0.375rem;
|
|
28
|
+
min-width: 2.25rem;
|
|
29
|
+
height: 2.25rem;
|
|
30
|
+
padding: 0 0.75rem;
|
|
31
|
+
border-radius: calc(var(--radius) - 2px);
|
|
32
|
+
border: var(--border-width) solid transparent;
|
|
33
|
+
background: transparent;
|
|
34
|
+
color: var(--foreground);
|
|
35
|
+
text-decoration: none;
|
|
36
|
+
transition:
|
|
37
|
+
background-color var(--duration-fast),
|
|
38
|
+
border-color var(--duration-fast),
|
|
39
|
+
color var(--duration-fast);
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
user-select: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.pagination__link[data-size="sm"] {
|
|
45
|
+
min-width: 2rem;
|
|
46
|
+
height: 2rem;
|
|
47
|
+
padding: 0 0.5rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.pagination__link:hover {
|
|
51
|
+
background: var(--background-muted);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.pagination__link:focus-visible {
|
|
55
|
+
outline: var(--border-width-strong) solid var(--foreground);
|
|
56
|
+
outline-offset: 2px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.pagination__link[data-active] {
|
|
60
|
+
background: var(--foreground);
|
|
61
|
+
color: var(--background);
|
|
62
|
+
font-weight: var(--weight-medium);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.pagination__link[data-active]:hover {
|
|
66
|
+
background: var(--foreground);
|
|
67
|
+
opacity: 0.9;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.pagination__link[aria-disabled="true"],
|
|
71
|
+
.pagination__link[data-disabled] {
|
|
72
|
+
pointer-events: none;
|
|
73
|
+
opacity: 0.45;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.pagination__nav {
|
|
77
|
+
padding: 0 0.625rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.pagination__ellipsis {
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
justify-content: center;
|
|
84
|
+
width: 2.25rem;
|
|
85
|
+
height: 2.25rem;
|
|
86
|
+
color: var(--foreground-muted);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.pagination__sr {
|
|
90
|
+
position: absolute;
|
|
91
|
+
width: 1px;
|
|
92
|
+
height: 1px;
|
|
93
|
+
padding: 0;
|
|
94
|
+
margin: -1px;
|
|
95
|
+
overflow: hidden;
|
|
96
|
+
clip: rect(0, 0, 0, 0);
|
|
97
|
+
white-space: nowrap;
|
|
98
|
+
border: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@media (prefers-reduced-motion: reduce) {
|
|
102
|
+
.pagination__link {
|
|
103
|
+
transition: none;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
3
|
+
import styles from "./styles.module.css";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@SH_UI_UTILS@";
|
|
6
|
+
type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 트리거 요소에 떠다니는 가벼운 패널을 띄우는 비모달 컨테이너. 포커스를 가두지 않으므로
|
|
11
|
+
* 짧은 폼·정보 표시에 적합하고, 강제 응답이 필요하면 Dialog를 사용할 것.
|
|
12
|
+
*/
|
|
13
|
+
export const Popover = BasePopover.Root;
|
|
14
|
+
|
|
15
|
+
/** Popover를 여는 트리거. */
|
|
16
|
+
export const PopoverTrigger = BasePopover.Trigger;
|
|
17
|
+
|
|
18
|
+
/** Popover를 닫는 요소. */
|
|
19
|
+
export const PopoverClose = BasePopover.Close;
|
|
20
|
+
|
|
21
|
+
export interface PopoverContentProps
|
|
22
|
+
extends WithStringClassName<React.ComponentPropsWithoutRef<typeof BasePopover.Popup>> {
|
|
23
|
+
/**
|
|
24
|
+
* Trigger 기준 배치 방향. 공간 부족 시 자동으로 반대편으로 뒤집힌다.
|
|
25
|
+
* @default "bottom"
|
|
26
|
+
*/
|
|
27
|
+
side?: "top" | "right" | "bottom" | "left";
|
|
28
|
+
/**
|
|
29
|
+
* 트리거 축에서의 정렬.
|
|
30
|
+
* - `start` — 트리거 시작 가장자리 정렬
|
|
31
|
+
* - `center` — 가운데 (기본)
|
|
32
|
+
* - `end` — 트리거 끝 가장자리 정렬
|
|
33
|
+
*
|
|
34
|
+
* @default "center"
|
|
35
|
+
*/
|
|
36
|
+
align?: "start" | "center" | "end";
|
|
37
|
+
/**
|
|
38
|
+
* Trigger와 Popup 사이 간격(px).
|
|
39
|
+
* @default 8
|
|
40
|
+
*/
|
|
41
|
+
sideOffset?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Portal이 마운트될 DOM 노드.
|
|
44
|
+
* @default document.body
|
|
45
|
+
*/
|
|
46
|
+
container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
|
|
47
|
+
/**
|
|
48
|
+
* Trigger를 가리키는 화살표 표시 여부.
|
|
49
|
+
* @default false
|
|
50
|
+
*/
|
|
51
|
+
showArrow?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Popover의 콘텐츠. 트리거에 자동 위치 조정되어 portal로 마운트된다.
|
|
56
|
+
* `side`/`align`/`sideOffset`로 배치를 미세조정하고, `showArrow`로 화살표를 노출한다.
|
|
57
|
+
*/
|
|
58
|
+
export const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
|
|
59
|
+
function PopoverContent(
|
|
60
|
+
{ className, children, side, align, sideOffset = 8, container, showArrow, ...props },
|
|
61
|
+
ref,
|
|
62
|
+
) {
|
|
63
|
+
return (
|
|
64
|
+
<BasePopover.Portal container={container}>
|
|
65
|
+
<BasePopover.Positioner
|
|
66
|
+
className={styles.popover__positioner}
|
|
67
|
+
side={side}
|
|
68
|
+
align={align}
|
|
69
|
+
sideOffset={sideOffset}
|
|
70
|
+
>
|
|
71
|
+
<BasePopover.Popup
|
|
72
|
+
ref={ref}
|
|
73
|
+
className={cn(styles.popover__content, className)}
|
|
74
|
+
{...props}
|
|
75
|
+
>
|
|
76
|
+
{showArrow && (
|
|
77
|
+
<BasePopover.Arrow className={styles.popover__arrow} />
|
|
78
|
+
)}
|
|
79
|
+
{children}
|
|
80
|
+
</BasePopover.Popup>
|
|
81
|
+
</BasePopover.Positioner>
|
|
82
|
+
</BasePopover.Portal>
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/** Popover 콘텐츠의 제목. 접근성을 위해 짧은 제목이라도 함께 두는 것을 권장. */
|
|
88
|
+
export const PopoverTitle = React.forwardRef<
|
|
89
|
+
HTMLHeadingElement,
|
|
90
|
+
WithStringClassName<React.ComponentPropsWithoutRef<typeof BasePopover.Title>>
|
|
91
|
+
>(function PopoverTitle({ className, ...props }, ref) {
|
|
92
|
+
return (
|
|
93
|
+
<BasePopover.Title
|
|
94
|
+
ref={ref}
|
|
95
|
+
className={cn(styles.popover__title, className)}
|
|
96
|
+
{...props}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** Popover 콘텐츠의 보조 설명. */
|
|
102
|
+
export const PopoverDescription = React.forwardRef<
|
|
103
|
+
HTMLParagraphElement,
|
|
104
|
+
WithStringClassName<React.ComponentPropsWithoutRef<typeof BasePopover.Description>>
|
|
105
|
+
>(function PopoverDescription({ className, ...props }, ref) {
|
|
106
|
+
return (
|
|
107
|
+
<BasePopover.Description
|
|
108
|
+
ref={ref}
|
|
109
|
+
className={cn(styles.popover__description, className)}
|
|
110
|
+
{...props}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
.popover__positioner {
|
|
2
|
+
z-index: var(--z-popover);
|
|
3
|
+
outline: none;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.popover__content {
|
|
7
|
+
min-width: 12rem;
|
|
8
|
+
padding: var(--space-2);
|
|
9
|
+
background: var(--background);
|
|
10
|
+
color: var(--foreground);
|
|
11
|
+
border: 1px solid var(--border);
|
|
12
|
+
border-radius: var(--radius);
|
|
13
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
14
|
+
outline: none;
|
|
15
|
+
font-size: var(--text-sm);
|
|
16
|
+
line-height: 1.4;
|
|
17
|
+
transform-origin: var(--transform-origin);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.popover__content[data-starting-style],
|
|
21
|
+
.popover__content[data-ending-style] {
|
|
22
|
+
opacity: 0;
|
|
23
|
+
transform: scale(0.96);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.popover__content {
|
|
27
|
+
transition:
|
|
28
|
+
opacity 140ms ease,
|
|
29
|
+
transform 140ms ease;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.popover__content:focus-visible {
|
|
33
|
+
outline: var(--border-width-strong) solid var(--foreground);
|
|
34
|
+
outline-offset: 2px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.popover__arrow {
|
|
38
|
+
color: var(--background);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.popover__arrow svg {
|
|
42
|
+
display: block;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.popover__title {
|
|
46
|
+
margin: 0 0 var(--space-1);
|
|
47
|
+
font-weight: var(--weight-semibold);
|
|
48
|
+
font-size: 0.9375rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.popover__description {
|
|
52
|
+
margin: 0;
|
|
53
|
+
color: var(--foreground-muted);
|
|
54
|
+
font-size: 0.8125rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@media (prefers-reduced-motion: reduce) {
|
|
58
|
+
.popover__content {
|
|
59
|
+
transition: none;
|
|
60
|
+
}
|
|
61
|
+
.popover__content[data-starting-style],
|
|
62
|
+
.popover__content[data-ending-style] {
|
|
63
|
+
transform: none;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import styles from "./styles.module.css";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import { cn } from "@SH_UI_UTILS@";
|
|
6
|
+
function clamp(n: number, min: number, max: number) {
|
|
7
|
+
return Math.min(max, Math.max(min, n));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProgressProps
|
|
11
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> {
|
|
12
|
+
/** 0~100 사이의 현재 값. 생략 시 indeterminate 모드. */
|
|
13
|
+
value?: number;
|
|
14
|
+
/** 최댓값. 기본 100. */
|
|
15
|
+
max?: number;
|
|
16
|
+
/** 접근성: aria-label (시각적 라벨이 없으면 권장). */
|
|
17
|
+
"aria-label"?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 작업 진행도를 가로 바로 표시. value가 없으면 무한 루프 indeterminate.
|
|
22
|
+
*
|
|
23
|
+
* - determinate: `<Progress value={40} />`
|
|
24
|
+
* - indeterminate: `<Progress aria-label="로딩 중" />`
|
|
25
|
+
*/
|
|
26
|
+
export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
|
27
|
+
function Progress(
|
|
28
|
+
{ value, max = 100, className, "aria-label": ariaLabel, ...props },
|
|
29
|
+
ref,
|
|
30
|
+
) {
|
|
31
|
+
const isDeterminate = value !== undefined;
|
|
32
|
+
const normalized = isDeterminate ? clamp((value / max) * 100, 0, 100) : 0;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
ref={ref}
|
|
37
|
+
role="progressbar"
|
|
38
|
+
aria-label={ariaLabel}
|
|
39
|
+
aria-valuemin={isDeterminate ? 0 : undefined}
|
|
40
|
+
aria-valuemax={isDeterminate ? max : undefined}
|
|
41
|
+
aria-valuenow={isDeterminate ? value : undefined}
|
|
42
|
+
data-state={isDeterminate ? "determinate" : "indeterminate"}
|
|
43
|
+
className={cn(styles.progress, className)}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
<div
|
|
47
|
+
className={styles.progress__indicator}
|
|
48
|
+
style={isDeterminate ? { width: `${normalized}%` } : undefined}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
Progress.displayName = "Progress";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
.progress {
|
|
2
|
+
position: relative;
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 0.5rem;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
background: var(--background-muted);
|
|
7
|
+
border-radius: 999px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.progress__indicator {
|
|
11
|
+
height: 100%;
|
|
12
|
+
background: var(--primary);
|
|
13
|
+
border-radius: 999px;
|
|
14
|
+
transition: width var(--duration-base) ease;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* indeterminate 모드: 바가 좌우로 왕복 */
|
|
18
|
+
.progress[data-state="indeterminate"] .progress__indicator {
|
|
19
|
+
width: 40%;
|
|
20
|
+
animation: sh-ui-progress-slide 1.2s ease-in-out infinite;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@keyframes sh-ui-progress-slide {
|
|
24
|
+
0% {
|
|
25
|
+
transform: translateX(-100%);
|
|
26
|
+
}
|
|
27
|
+
100% {
|
|
28
|
+
transform: translateX(250%);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@media (prefers-reduced-motion: reduce) {
|
|
33
|
+
.progress__indicator {
|
|
34
|
+
transition: none;
|
|
35
|
+
}
|
|
36
|
+
.progress[data-state="indeterminate"] .progress__indicator {
|
|
37
|
+
/* 움직임 최소화 — 중앙에 정지된 바 */
|
|
38
|
+
animation: none;
|
|
39
|
+
transform: translateX(75%);
|
|
40
|
+
}
|
|
41
|
+
}
|