sh-ui-cli 0.43.0 → 0.45.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 (46) hide show
  1. package/data/changelog/versions.json +24 -0
  2. package/data/registry/react/components/accordion/index.tailwind.tsx +88 -0
  3. package/data/registry/react/components/avatar/index.tailwind.tsx +74 -0
  4. package/data/registry/react/components/badge/index.tailwind.tsx +47 -0
  5. package/data/registry/react/components/breadcrumb/index.tailwind.tsx +138 -0
  6. package/data/registry/react/components/calendar/index.tailwind.tsx +498 -0
  7. package/data/registry/react/components/carousel/index.tailwind.tsx +309 -0
  8. package/data/registry/react/components/checkbox/index.tailwind.tsx +72 -0
  9. package/data/registry/react/components/code-editor/index.tailwind.tsx +168 -0
  10. package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -0
  11. package/data/registry/react/components/color-picker/index.tailwind.tsx +309 -0
  12. package/data/registry/react/components/combobox/index.tailwind.tsx +160 -0
  13. package/data/registry/react/components/context-menu/index.tailwind.tsx +170 -0
  14. package/data/registry/react/components/date-picker/index.tailwind.tsx +294 -0
  15. package/data/registry/react/components/dialog/index.tailwind.tsx +96 -0
  16. package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +205 -0
  17. package/data/registry/react/components/file-upload/index.tailwind.tsx +290 -0
  18. package/data/registry/react/components/form/field.tailwind.tsx +165 -0
  19. package/data/registry/react/components/form/form.tailwind.tsx +129 -0
  20. package/data/registry/react/components/form/index.tailwind.tsx +49 -0
  21. package/data/registry/react/components/header/index.tailwind.tsx +550 -0
  22. package/data/registry/react/components/label/index.tailwind.tsx +78 -0
  23. package/data/registry/react/components/markdown-editor/index.tailwind.tsx +118 -0
  24. package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
  25. package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
  26. package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
  27. package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
  28. package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
  29. package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
  30. package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
  31. package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +211 -0
  32. package/data/registry/react/components/select/index.tailwind.tsx +199 -0
  33. package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
  34. package/data/registry/react/components/sidebar/index.tailwind.tsx +635 -0
  35. package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
  36. package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
  37. package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
  38. package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
  39. package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
  40. package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
  41. package/data/registry/react/components/toast/index.tailwind.tsx +215 -0
  42. package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
  43. package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
  44. package/data/registry/react/registry.json +696 -98
  45. package/package.json +1 -1
  46. package/src/mcp.mjs +1 -1
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import { CodeEditor } from "../code-editor";
7
+
8
+ export interface MarkdownEditorProps {
9
+ value?: string;
10
+ defaultValue?: string;
11
+ onChange?: (value: string) => void;
12
+ placeholder?: string;
13
+ readOnly?: boolean;
14
+ preview?: boolean;
15
+ previewPosition?: "right" | "bottom";
16
+ minHeight?: string;
17
+ maxHeight?: string;
18
+ className?: string;
19
+ "aria-label"?: string;
20
+ }
21
+
22
+ function cx(...args: (string | undefined | false | null)[]) {
23
+ return args.filter(Boolean).join(" ");
24
+ }
25
+
26
+ /**
27
+ * 마크다운 에디터 (Tailwind 변종) — react-markdown 의 출력 HTML 트리에 대한
28
+ * descendant 스타일링은 utility 만으로 깔끔하게 표현이 어려워 <style> 태그로 inject.
29
+ * outer wrapper grid 레이아웃만 utility class.
30
+ */
31
+ export function MarkdownEditor({
32
+ value: valueProp, defaultValue, onChange, placeholder, readOnly,
33
+ preview = true, previewPosition = "right", minHeight, maxHeight, className,
34
+ "aria-label": ariaLabel = "Markdown editor",
35
+ }: MarkdownEditorProps) {
36
+ const isControlled = valueProp !== undefined;
37
+ const [internalValue, setInternalValue] = useState(valueProp ?? defaultValue ?? "");
38
+ const value = isControlled ? valueProp : internalValue;
39
+
40
+ const handleChange = (next: string) => {
41
+ if (!isControlled) setInternalValue(next);
42
+ onChange?.(next);
43
+ };
44
+
45
+ const layoutClass = !preview
46
+ ? "grid-cols-1"
47
+ : previewPosition === "bottom"
48
+ ? "grid-cols-1"
49
+ : "grid-cols-2 max-md:grid-cols-1";
50
+
51
+ return (
52
+ <div
53
+ className={cx("grid gap-[var(--space-3)]", layoutClass, className)}
54
+ data-readonly={readOnly || undefined}
55
+ >
56
+ <div className="min-w-0">
57
+ <CodeEditor
58
+ value={value}
59
+ onChange={handleChange}
60
+ language="markdown"
61
+ placeholder={placeholder}
62
+ readOnly={readOnly}
63
+ minHeight={minHeight}
64
+ maxHeight={maxHeight}
65
+ aria-label={ariaLabel}
66
+ />
67
+ </div>
68
+ {preview && (
69
+ <div
70
+ className="sh-ui-md-editor__preview min-w-0 border border-border rounded-[var(--radius)] bg-background overflow-hidden"
71
+ role="region"
72
+ aria-label="Preview"
73
+ style={{
74
+ "--sh-ui-md-editor-min-height": minHeight,
75
+ "--sh-ui-md-editor-max-height": maxHeight,
76
+ } as React.CSSProperties}
77
+ >
78
+ <div className="sh-ui-md-editor__preview-inner">
79
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
80
+ </div>
81
+ </div>
82
+ )}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-md-editor]")) {
88
+ const style = document.createElement("style");
89
+ style.setAttribute("data-sh-ui-md-editor", "");
90
+ style.textContent = `
91
+ .sh-ui-md-editor__preview-inner { padding: var(--space-3) var(--space-4); min-height: var(--sh-ui-md-editor-min-height, 7.5rem); max-height: var(--sh-ui-md-editor-max-height, 25rem); overflow-y: auto; font-size: 0.875rem; line-height: 1.65; color: var(--foreground); }
92
+ .sh-ui-md-editor__preview-inner > :first-child { margin-top: 0; }
93
+ .sh-ui-md-editor__preview-inner > :last-child { margin-bottom: 0; }
94
+ .sh-ui-md-editor__preview-inner h1, .sh-ui-md-editor__preview-inner h2, .sh-ui-md-editor__preview-inner h3, .sh-ui-md-editor__preview-inner h4, .sh-ui-md-editor__preview-inner h5, .sh-ui-md-editor__preview-inner h6 { margin-top: var(--space-4); margin-bottom: var(--space-2); font-weight: 600; line-height: 1.3; color: var(--foreground); }
95
+ .sh-ui-md-editor__preview-inner h1 { font-size: 1.5rem; }
96
+ .sh-ui-md-editor__preview-inner h2 { font-size: 1.25rem; }
97
+ .sh-ui-md-editor__preview-inner h3 { font-size: 1.125rem; }
98
+ .sh-ui-md-editor__preview-inner h4, .sh-ui-md-editor__preview-inner h5, .sh-ui-md-editor__preview-inner h6 { font-size: 1rem; }
99
+ .sh-ui-md-editor__preview-inner p, .sh-ui-md-editor__preview-inner ul, .sh-ui-md-editor__preview-inner ol, .sh-ui-md-editor__preview-inner blockquote, .sh-ui-md-editor__preview-inner pre, .sh-ui-md-editor__preview-inner table { margin-top: 0; margin-bottom: var(--space-3); }
100
+ .sh-ui-md-editor__preview-inner ul, .sh-ui-md-editor__preview-inner ol { padding-left: var(--space-5); }
101
+ .sh-ui-md-editor__preview-inner li { margin-bottom: var(--space-1); }
102
+ .sh-ui-md-editor__preview-inner li > input[type="checkbox"] { margin-right: var(--space-2); }
103
+ .sh-ui-md-editor__preview-inner a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
104
+ .sh-ui-md-editor__preview-inner a:hover { text-decoration-thickness: 2px; }
105
+ .sh-ui-md-editor__preview-inner blockquote { padding: var(--space-2) var(--space-3); border-left: 3px solid var(--border-strong); background: var(--background-subtle); color: var(--foreground-muted); border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0; }
106
+ .sh-ui-md-editor__preview-inner blockquote > :last-child { margin-bottom: 0; }
107
+ .sh-ui-md-editor__preview-inner code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.875em; padding: 0.125rem 0.375rem; border-radius: calc(var(--radius) - 4px); background: var(--background-muted); color: var(--foreground); }
108
+ .sh-ui-md-editor__preview-inner pre { padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--background-subtle); overflow-x: auto; font-size: 0.8125rem; line-height: 1.6; }
109
+ .sh-ui-md-editor__preview-inner pre > code { padding: 0; background: transparent; font-size: inherit; }
110
+ .sh-ui-md-editor__preview-inner hr { border: 0; border-top: 1px solid var(--border); margin: var(--space-4) 0; }
111
+ .sh-ui-md-editor__preview-inner table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
112
+ .sh-ui-md-editor__preview-inner th, .sh-ui-md-editor__preview-inner td { padding: var(--space-2) var(--space-3); border: 1px solid var(--border); text-align: left; }
113
+ .sh-ui-md-editor__preview-inner thead { background: var(--background-subtle); }
114
+ .sh-ui-md-editor__preview-inner img { max-width: 100%; height: auto; border-radius: calc(var(--radius) - 2px); }
115
+ .sh-ui-md-editor__preview-inner del { color: var(--foreground-muted); }
116
+ `;
117
+ document.head.appendChild(style);
118
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from "react";
2
+ import { Menubar as BaseMenubar } from "@base-ui/react/menubar";
3
+
4
+ type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
5
+
6
+ function cx(...args: (string | undefined | false | null)[]) {
7
+ return args.filter(Boolean).join(" ");
8
+ }
9
+
10
+ /**
11
+ * 상단 앱 메뉴바 (Tailwind 변종). DropdownMenu 와 함께 사용 — DropdownMenu 의
12
+ * Tailwind 변종이 plain 으로 fallback 된 경우 트리거 스타일은 plain CSS 로 적용됨.
13
+ *
14
+ * Tailwind 변종에서는 메뉴바 안의 DropdownMenu 트리거 재지정을 utility 로 표현하기
15
+ * 어려워 (자식 컴포넌트 클래스 의존), 메뉴바 자체의 외형만 utility 로 변환.
16
+ * 트리거 스타일링이 필요하면 사용자가 trigger 에 직접 className 부여.
17
+ */
18
+ export const Menubar = React.forwardRef<
19
+ HTMLDivElement,
20
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenubar>>
21
+ >(function Menubar({ className, ...props }, ref) {
22
+ return (
23
+ <BaseMenubar
24
+ ref={ref}
25
+ className={cx(
26
+ "inline-flex items-center gap-[var(--space-1)] p-[var(--space-1)] bg-background border border-border rounded-[var(--radius)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]",
27
+ className,
28
+ )}
29
+ {...props}
30
+ />
31
+ );
32
+ });
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ function cx(...args: (string | undefined | null | false)[]) {
6
+ return args.filter(Boolean).join(" ");
7
+ }
8
+
9
+ export interface NumericInputProps
10
+ extends Omit<
11
+ React.InputHTMLAttributes<HTMLInputElement>,
12
+ "value" | "defaultValue" | "onChange" | "type" | "min" | "max" | "step"
13
+ > {
14
+ value?: number;
15
+ defaultValue?: number;
16
+ onValueChange?: (value: number) => void;
17
+ min?: number;
18
+ max?: number;
19
+ step?: number;
20
+ unit?: React.ReactNode;
21
+ }
22
+
23
+ const inputClasses =
24
+ "w-10 px-1 py-0.5 font-mono text-[length:var(--text-xs)] leading-tight text-right border border-transparent rounded-[calc(var(--radius)-4px)] bg-transparent text-foreground appearance-none [-moz-appearance:textfield] transition-[border-color,background-color] duration-[var(--duration-fast)] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0 hover:not-disabled:not-focus:border-border focus:outline-none focus:border-foreground focus:bg-background focus-visible:outline-none focus-visible:border-foreground disabled:cursor-not-allowed disabled:opacity-[var(--opacity-disabled)]";
25
+
26
+ export const NumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
27
+ (
28
+ { value, defaultValue, onValueChange, min, max, step = 1, unit, className, onFocus, onBlur, onKeyDown, ...props },
29
+ ref,
30
+ ) => {
31
+ const isControlled = value !== undefined;
32
+ const [internal, setInternal] = React.useState<number>(defaultValue ?? 0);
33
+ const current = isControlled ? value! : internal;
34
+
35
+ const [buffer, setBuffer] = React.useState<string>(() => String(current));
36
+ const focusedRef = React.useRef(false);
37
+
38
+ React.useEffect(() => {
39
+ if (!focusedRef.current) setBuffer(String(current));
40
+ }, [current]);
41
+
42
+ const clamp = (n: number) => {
43
+ let v = n;
44
+ if (min !== undefined && v < min) v = min;
45
+ if (max !== undefined && v > max) v = max;
46
+ return v;
47
+ };
48
+
49
+ const commit = (n: number): number => {
50
+ const c = clamp(n);
51
+ if (!isControlled) setInternal(c);
52
+ onValueChange?.(c);
53
+ return c;
54
+ };
55
+
56
+ return (
57
+ <span className="inline-flex items-baseline gap-[2px] min-w-[3rem] justify-end">
58
+ <input
59
+ ref={ref}
60
+ type="text"
61
+ inputMode="decimal"
62
+ className={cx(inputClasses, className)}
63
+ value={buffer}
64
+ onChange={(e) => {
65
+ const raw = e.target.value;
66
+ setBuffer(raw);
67
+ if (raw === "" || raw === "-" || raw === "." || raw === "-.") return;
68
+ const n = Number(raw);
69
+ if (Number.isFinite(n)) commit(n);
70
+ }}
71
+ onFocus={(e) => {
72
+ focusedRef.current = true;
73
+ const t = e.currentTarget;
74
+ setTimeout(() => t.select(), 0);
75
+ onFocus?.(e);
76
+ }}
77
+ onBlur={(e) => {
78
+ focusedRef.current = false;
79
+ const n = Number(buffer);
80
+ if (buffer !== "" && Number.isFinite(n)) {
81
+ const c = commit(n);
82
+ setBuffer(String(c));
83
+ } else {
84
+ setBuffer(String(current));
85
+ }
86
+ onBlur?.(e);
87
+ }}
88
+ onKeyDown={(e) => {
89
+ if (e.key === "ArrowUp") {
90
+ e.preventDefault();
91
+ const next = commit(current + step);
92
+ setBuffer(String(next));
93
+ } else if (e.key === "ArrowDown") {
94
+ e.preventDefault();
95
+ const next = commit(current - step);
96
+ setBuffer(String(next));
97
+ } else if (e.key === "Enter") {
98
+ e.currentTarget.blur();
99
+ }
100
+ onKeyDown?.(e);
101
+ }}
102
+ {...props}
103
+ />
104
+ {unit !== undefined && unit !== "" && (
105
+ <span className="font-mono text-[length:var(--text-xs)] text-foreground-muted" aria-hidden>
106
+ {unit}
107
+ </span>
108
+ )}
109
+ </span>
110
+ );
111
+ },
112
+ );
113
+ NumericInput.displayName = "NumericInput";
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
6
+
7
+ export interface PageTOCProps {
8
+ containerSelector?: string;
9
+ routeKey?: string;
10
+ headerOffsetRem?: number;
11
+ label?: React.ReactNode;
12
+ levels?: HeadingLevel[];
13
+ excludeSelector?: string;
14
+ className?: string;
15
+ }
16
+
17
+ const slugify = (text: string): string =>
18
+ text
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/[^\w\s가-힣-]/g, "")
22
+ .replace(/\s+/g, "-");
23
+
24
+ interface TocItem {
25
+ id: string;
26
+ text: string;
27
+ level: HeadingLevel;
28
+ }
29
+
30
+ const cx = (...args: (string | undefined | false | null)[]) =>
31
+ args.filter(Boolean).join(" ");
32
+
33
+ const linkBase =
34
+ "block px-2 py-1 rounded-[calc(var(--radius)-4px)] text-foreground-muted no-underline leading-snug transition-[color,background-color] duration-[var(--duration-fast)] hover:text-foreground hover:bg-background-subtle focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 data-[active=true]:text-foreground data-[active=true]:font-semibold data-[active=true]:bg-background-subtle motion-reduce:transition-none";
35
+
36
+ export function PageTOC({
37
+ containerSelector = "main",
38
+ routeKey,
39
+ headerOffsetRem = 5,
40
+ label = "On this page",
41
+ levels = ["h2", "h3"],
42
+ excludeSelector,
43
+ className,
44
+ }: PageTOCProps) {
45
+ const [items, setItems] = React.useState<TocItem[]>([]);
46
+ const [activeId, setActiveId] = React.useState<string | null>(null);
47
+
48
+ const levelsKey = levels.join(",");
49
+ const levelsRef = React.useRef(levels);
50
+ levelsRef.current = levels;
51
+
52
+ React.useEffect(() => {
53
+ const container = document.querySelector(containerSelector);
54
+ if (!container) {
55
+ setItems([]);
56
+ return;
57
+ }
58
+
59
+ const headingSelector = levelsRef.current.join(", ");
60
+ let headings = Array.from(
61
+ container.querySelectorAll<HTMLHeadingElement>(headingSelector),
62
+ );
63
+ if (excludeSelector) {
64
+ headings = headings.filter((h) => !h.closest(excludeSelector));
65
+ }
66
+
67
+ const usedIds = new Set<string>();
68
+ const collected: TocItem[] = headings.map((h) => {
69
+ const text = h.textContent?.trim() ?? "";
70
+ let id = h.id || slugify(text);
71
+ let suffix = 2;
72
+ const base = id;
73
+ while (!id || usedIds.has(id)) {
74
+ id = `${base}-${suffix++}`;
75
+ }
76
+ usedIds.add(id);
77
+ if (!h.id) h.id = id;
78
+ h.style.scrollMarginTop = `${headerOffsetRem}rem`;
79
+ const level = h.tagName.toLowerCase() as HeadingLevel;
80
+ return { id, text, level };
81
+ });
82
+
83
+ setItems(collected);
84
+ if (collected.length === 0) return;
85
+
86
+ const remInPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
87
+ const topOffsetPx = Math.round(headerOffsetRem * remInPx);
88
+
89
+ const observer = new IntersectionObserver(
90
+ (entries) => {
91
+ const visible = entries
92
+ .filter((e) => e.isIntersecting)
93
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
94
+ if (visible.length > 0) setActiveId(visible[0].target.id);
95
+ },
96
+ { rootMargin: `-${topOffsetPx}px 0px -70% 0px`, threshold: 0 },
97
+ );
98
+
99
+ headings.forEach((h) => observer.observe(h));
100
+ return () => observer.disconnect();
101
+ }, [containerSelector, headerOffsetRem, levelsKey, excludeSelector, routeKey]);
102
+
103
+ const handleClick = (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {
104
+ event.preventDefault();
105
+ const el = document.getElementById(id);
106
+ if (!el) return;
107
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
108
+ history.replaceState(null, "", `#${id}`);
109
+ setActiveId(id);
110
+ };
111
+
112
+ if (items.length === 0) return null;
113
+
114
+ const linkClassesForLevel = (level: HeadingLevel) => {
115
+ const num = parseInt(level.replace("h", ""), 10);
116
+ if (num === 3 || num === 4) return "pl-5 text-[0.8125em] text-[var(--foreground-subtle,var(--foreground-muted))]";
117
+ if (num >= 5) return "pl-8 text-[0.75em] text-[var(--foreground-subtle,var(--foreground-muted))]";
118
+ return "";
119
+ };
120
+
121
+ return (
122
+ <nav
123
+ className={cx(
124
+ "fixed top-20 right-6 w-56 max-h-[calc(100vh-7rem)] overflow-y-auto pl-4 pr-2 py-3 border-l border-border text-[0.8125rem] z-[5] max-[80rem]:hidden",
125
+ className,
126
+ )}
127
+ aria-label={typeof label === "string" ? label : "목차"}
128
+ >
129
+ <div className="font-semibold text-[length:var(--text-xs)] text-foreground-muted uppercase tracking-[0.04em] mb-2">
130
+ {label}
131
+ </div>
132
+ <ul className="list-none m-0 p-0 flex flex-col gap-0.5">
133
+ {items.map((item) => (
134
+ <li key={item.id} data-level={item.level.replace("h", "")}>
135
+ <a
136
+ href={`#${item.id}`}
137
+ onClick={(e) => handleClick(e, item.id)}
138
+ className={cx(linkBase, linkClassesForLevel(item.level))}
139
+ data-active={activeId === item.id ? "true" : undefined}
140
+ aria-current={activeId === item.id ? "true" : undefined}
141
+ >
142
+ {item.text}
143
+ </a>
144
+ </li>
145
+ ))}
146
+ </ul>
147
+ </nav>
148
+ );
149
+ }
@@ -0,0 +1,148 @@
1
+ import * as React from "react";
2
+
3
+ function cx(...args: (string | undefined | false | null)[]) {
4
+ return args.filter(Boolean).join(" ");
5
+ }
6
+
7
+ export const Pagination = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
8
+ function Pagination({ className, ...props }, ref) {
9
+ return (
10
+ <nav
11
+ ref={ref}
12
+ aria-label="Pagination"
13
+ className={cx("flex justify-center text-[length:var(--text-sm)] text-foreground", className)}
14
+ {...props}
15
+ />
16
+ );
17
+ },
18
+ );
19
+
20
+ export const PaginationContent = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(
21
+ function PaginationContent({ className, ...props }, ref) {
22
+ return (
23
+ <ul
24
+ ref={ref}
25
+ className={cx("flex flex-wrap items-center gap-1 m-0 p-0 list-none", className)}
26
+ {...props}
27
+ />
28
+ );
29
+ },
30
+ );
31
+
32
+ export const PaginationItem = React.forwardRef<HTMLLIElement, React.LiHTMLAttributes<HTMLLIElement>>(
33
+ function PaginationItem({ className, ...props }, ref) {
34
+ return <li ref={ref} className={cx("inline-flex items-center", className)} {...props} />;
35
+ },
36
+ );
37
+
38
+ const linkBase =
39
+ "inline-flex items-center justify-center gap-1.5 min-w-9 h-9 px-3 rounded-[calc(var(--radius)-2px)] border border-transparent bg-transparent text-foreground no-underline transition-[background-color,border-color,color] duration-[var(--duration-fast)] cursor-pointer select-none hover:bg-background-muted focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 data-[active]:bg-foreground data-[active]:text-background data-[active]:font-medium data-[active]:hover:opacity-90 aria-disabled:pointer-events-none aria-disabled:opacity-45 data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[size=sm]:min-w-8 data-[size=sm]:h-8 data-[size=sm]:px-2 motion-reduce:transition-none";
40
+
41
+ export interface PaginationLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
42
+ isActive?: boolean;
43
+ size?: "sm" | "md";
44
+ }
45
+
46
+ export const PaginationLink = React.forwardRef<HTMLAnchorElement, PaginationLinkProps>(
47
+ function PaginationLink({ className, isActive, size = "md", ...props }, ref) {
48
+ return (
49
+ <a
50
+ ref={ref}
51
+ aria-current={isActive ? "page" : undefined}
52
+ data-active={isActive ? "" : undefined}
53
+ data-size={size}
54
+ className={cx(linkBase, className)}
55
+ {...props}
56
+ />
57
+ );
58
+ },
59
+ );
60
+
61
+ export const PaginationPrevious = React.forwardRef<HTMLAnchorElement, PaginationLinkProps>(
62
+ function PaginationPrevious({ className, children, ...props }, ref) {
63
+ return (
64
+ <PaginationLink ref={ref} aria-label="이전 페이지" className={cx("px-2.5", className)} {...props}>
65
+ <ChevronLeftIcon />
66
+ {children ?? <span>이전</span>}
67
+ </PaginationLink>
68
+ );
69
+ },
70
+ );
71
+
72
+ export const PaginationNext = React.forwardRef<HTMLAnchorElement, PaginationLinkProps>(
73
+ function PaginationNext({ className, children, ...props }, ref) {
74
+ return (
75
+ <PaginationLink ref={ref} aria-label="다음 페이지" className={cx("px-2.5", className)} {...props}>
76
+ {children ?? <span>다음</span>}
77
+ <ChevronRightIcon />
78
+ </PaginationLink>
79
+ );
80
+ },
81
+ );
82
+
83
+ export const PaginationEllipsis = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
84
+ function PaginationEllipsis({ className, ...props }, ref) {
85
+ return (
86
+ <span
87
+ ref={ref}
88
+ role="presentation"
89
+ aria-hidden="true"
90
+ className={cx("inline-flex items-center justify-center w-9 h-9 text-foreground-muted", className)}
91
+ {...props}
92
+ >
93
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
94
+ <circle cx="3" cy="8" r="1.25" />
95
+ <circle cx="8" cy="8" r="1.25" />
96
+ <circle cx="13" cy="8" r="1.25" />
97
+ </svg>
98
+ <span className="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0 [clip:rect(0,0,0,0)]">
99
+ 더 많은 페이지
100
+ </span>
101
+ </span>
102
+ );
103
+ },
104
+ );
105
+
106
+ export type PaginationToken = number | "dots";
107
+
108
+ export function getPaginationRange({
109
+ page, totalPages, siblings = 1,
110
+ }: { page: number; totalPages: number; siblings?: number }): PaginationToken[] {
111
+ if (totalPages <= 0) return [];
112
+ const totalSlots = siblings * 2 + 5;
113
+ if (totalPages <= totalSlots) return range(1, totalPages);
114
+ const leftSibling = Math.max(page - siblings, 1);
115
+ const rightSibling = Math.min(page + siblings, totalPages);
116
+ const showLeftDots = leftSibling > 2;
117
+ const showRightDots = rightSibling < totalPages - 1;
118
+ if (!showLeftDots && showRightDots) {
119
+ const leftCount = 3 + 2 * siblings;
120
+ return [...range(1, leftCount), "dots", totalPages];
121
+ }
122
+ if (showLeftDots && !showRightDots) {
123
+ const rightCount = 3 + 2 * siblings;
124
+ return [1, "dots", ...range(totalPages - rightCount + 1, totalPages)];
125
+ }
126
+ return [1, "dots", ...range(leftSibling, rightSibling), "dots", totalPages];
127
+ }
128
+
129
+ function range(start: number, end: number): number[] {
130
+ const length = end - start + 1;
131
+ return Array.from({ length }, (_, i) => start + i);
132
+ }
133
+
134
+ function ChevronLeftIcon() {
135
+ return (
136
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
137
+ <path d="M10 4l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
138
+ </svg>
139
+ );
140
+ }
141
+
142
+ function ChevronRightIcon() {
143
+ return (
144
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
145
+ <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
146
+ </svg>
147
+ );
148
+ }
@@ -0,0 +1,77 @@
1
+ import * as React from "react";
2
+ import { Popover as BasePopover } from "@base-ui/react/popover";
3
+
4
+ type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
5
+
6
+ function cx(...args: (string | undefined | false)[]) {
7
+ return args.filter(Boolean).join(" ");
8
+ }
9
+
10
+ export const Popover = BasePopover.Root;
11
+ export const PopoverTrigger = BasePopover.Trigger;
12
+ export const PopoverClose = BasePopover.Close;
13
+
14
+ export interface PopoverContentProps
15
+ extends WithStringClassName<React.ComponentPropsWithoutRef<typeof BasePopover.Popup>> {
16
+ side?: "top" | "right" | "bottom" | "left";
17
+ align?: "start" | "center" | "end";
18
+ sideOffset?: number;
19
+ container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
20
+ showArrow?: boolean;
21
+ }
22
+
23
+ export const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
24
+ function PopoverContent(
25
+ { className, children, side, align, sideOffset = 8, container, showArrow, ...props },
26
+ ref,
27
+ ) {
28
+ return (
29
+ <BasePopover.Portal container={container}>
30
+ <BasePopover.Positioner
31
+ className="z-[var(--z-popover)] outline-none"
32
+ side={side}
33
+ align={align}
34
+ sideOffset={sideOffset}
35
+ >
36
+ <BasePopover.Popup
37
+ ref={ref}
38
+ className={cx(
39
+ "min-w-48 p-[var(--space-2)] bg-background text-foreground border border-border rounded-[var(--radius)] shadow-[0_8px_24px_rgba(0,0,0,0.12)] outline-none text-[length:var(--text-sm)] leading-snug origin-[var(--transform-origin)] transition-[opacity,transform] duration-[140ms] ease-out motion-reduce:transition-none data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 motion-reduce:data-[starting-style]:scale-100 motion-reduce:data-[ending-style]:scale-100 focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2",
40
+ className,
41
+ )}
42
+ {...props}
43
+ >
44
+ {showArrow && <BasePopover.Arrow className="text-background [&_svg]:block" />}
45
+ {children}
46
+ </BasePopover.Popup>
47
+ </BasePopover.Positioner>
48
+ </BasePopover.Portal>
49
+ );
50
+ },
51
+ );
52
+
53
+ export const PopoverTitle = React.forwardRef<
54
+ HTMLHeadingElement,
55
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BasePopover.Title>>
56
+ >(function PopoverTitle({ className, ...props }, ref) {
57
+ return (
58
+ <BasePopover.Title
59
+ ref={ref}
60
+ className={cx("m-0 mb-[var(--space-1)] font-semibold text-[0.9375rem]", className)}
61
+ {...props}
62
+ />
63
+ );
64
+ });
65
+
66
+ export const PopoverDescription = React.forwardRef<
67
+ HTMLParagraphElement,
68
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BasePopover.Description>>
69
+ >(function PopoverDescription({ className, ...props }, ref) {
70
+ return (
71
+ <BasePopover.Description
72
+ ref={ref}
73
+ className={cx("m-0 text-foreground-muted text-[0.8125rem]", className)}
74
+ {...props}
75
+ />
76
+ );
77
+ });