sh-ui-cli 0.43.0 → 0.44.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 (33) hide show
  1. package/data/changelog/versions.json +12 -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/checkbox/index.tailwind.tsx +72 -0
  7. package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -0
  8. package/data/registry/react/components/combobox/index.tailwind.tsx +160 -0
  9. package/data/registry/react/components/context-menu/index.tailwind.tsx +170 -0
  10. package/data/registry/react/components/date-picker/index.tailwind.tsx +294 -0
  11. package/data/registry/react/components/dialog/index.tailwind.tsx +96 -0
  12. package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +205 -0
  13. package/data/registry/react/components/label/index.tailwind.tsx +78 -0
  14. package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
  15. package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
  16. package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
  17. package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
  18. package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
  19. package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
  20. package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
  21. package/data/registry/react/components/select/index.tailwind.tsx +199 -0
  22. package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
  23. package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
  24. package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
  25. package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
  26. package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
  27. package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
  28. package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
  29. package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
  30. package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
  31. package/data/registry/react/registry.json +509 -74
  32. package/package.json +1 -1
  33. package/src/mcp.mjs +1 -1
@@ -0,0 +1,96 @@
1
+ import * as React from "react";
2
+ import { Dialog as BaseDialog } from "@base-ui/react/dialog";
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 Dialog = BaseDialog.Root;
11
+ export const DialogTrigger = BaseDialog.Trigger;
12
+ export const DialogClose = BaseDialog.Close;
13
+
14
+ export function DialogCloseX({ className, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
15
+ return (
16
+ <BaseDialog.Close
17
+ className={cx(
18
+ "absolute top-[var(--space-3)] right-[var(--space-3)] inline-flex items-center justify-center w-8 h-8 border-0 rounded-[calc(var(--radius)-2px)] bg-transparent text-foreground-muted text-[length:var(--text-lg)] leading-none cursor-pointer transition-[background-color,color] duration-[var(--duration-fast)] hover:bg-background-muted hover:text-foreground focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 motion-reduce:transition-none",
19
+ className,
20
+ )}
21
+ aria-label="닫기"
22
+ {...props}
23
+ >
24
+ {children ?? "×"}
25
+ </BaseDialog.Close>
26
+ );
27
+ }
28
+
29
+ export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
30
+ return (
31
+ <div
32
+ className={cx(
33
+ "flex items-center justify-end gap-[var(--space-2)] pt-[var(--space-4)] border-t border-border mt-auto",
34
+ className,
35
+ )}
36
+ {...props}
37
+ />
38
+ );
39
+ }
40
+
41
+ export interface DialogContentProps
42
+ extends WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>> {
43
+ container?: React.ComponentPropsWithoutRef<typeof BaseDialog.Portal>["container"];
44
+ }
45
+
46
+ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
47
+ function DialogContent({ className, children, container, ...props }, ref) {
48
+ return (
49
+ <BaseDialog.Portal container={container}>
50
+ <BaseDialog.Backdrop className="fixed inset-0 z-[var(--z-overlay)] bg-black/25 backdrop-blur-md transition-opacity duration-[var(--duration-slow)] motion-reduce:transition-none data-[starting-style]:opacity-0 data-[ending-style]:opacity-0" />
51
+ <BaseDialog.Popup
52
+ ref={ref}
53
+ className={cx(
54
+ "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[var(--z-modal)] flex flex-col w-[calc(100%-2rem)] max-w-md max-h-[calc(100dvh-4rem)] p-[var(--space-6)] bg-background text-foreground border border-border rounded-[var(--radius)] shadow-[var(--shadow-xl)] outline-none overflow-y-auto transition-[opacity,transform] duration-[var(--duration-slow)] motion-reduce:transition-none data-[starting-style]:opacity-0 data-[starting-style]:translate-y-[calc(-50%+0.5rem)] data-[starting-style]:scale-[0.97] data-[ending-style]:opacity-0 data-[ending-style]:translate-y-[calc(-50%+0.25rem)] data-[ending-style]:scale-[0.98] motion-reduce:data-[starting-style]:translate-y-[-50%] motion-reduce:data-[starting-style]:scale-100 motion-reduce:data-[ending-style]:translate-y-[-50%] motion-reduce:data-[ending-style]:scale-100 focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2",
55
+ className,
56
+ )}
57
+ {...props}
58
+ >
59
+ {children}
60
+ </BaseDialog.Popup>
61
+ </BaseDialog.Portal>
62
+ );
63
+ },
64
+ );
65
+
66
+ export const DialogTitle = React.forwardRef<
67
+ HTMLHeadingElement,
68
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>>
69
+ >(function DialogTitle({ className, ...props }, ref) {
70
+ return (
71
+ <BaseDialog.Title
72
+ ref={ref}
73
+ className={cx(
74
+ "m-0 mb-[var(--space-1)] font-semibold text-[length:var(--text-lg)] leading-snug",
75
+ className,
76
+ )}
77
+ {...props}
78
+ />
79
+ );
80
+ });
81
+
82
+ export const DialogDescription = React.forwardRef<
83
+ HTMLParagraphElement,
84
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>>
85
+ >(function DialogDescription({ className, ...props }, ref) {
86
+ return (
87
+ <BaseDialog.Description
88
+ ref={ref}
89
+ className={cx(
90
+ "m-0 mb-[var(--space-5)] text-foreground-muted text-[length:var(--text-sm)] leading-normal",
91
+ className,
92
+ )}
93
+ {...props}
94
+ />
95
+ );
96
+ });
@@ -0,0 +1,205 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Menu as BaseMenu } from "@base-ui/react/menu";
5
+
6
+ type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
7
+
8
+ function cx(...args: (string | undefined | false | null)[]) {
9
+ return args.filter(Boolean).join(" ");
10
+ }
11
+
12
+ const itemBase =
13
+ "relative flex items-center gap-[var(--space-2)] py-2 px-3 rounded-[calc(var(--radius)-2px)] cursor-pointer outline-none select-none transition-colors duration-[80ms] data-[highlighted]:bg-background-muted hover:bg-background-muted data-[disabled]:opacity-[var(--opacity-disabled)] data-[disabled]:pointer-events-none motion-reduce:transition-none";
14
+ const itemCheck = "pl-7";
15
+
16
+ export const DropdownMenu = BaseMenu.Root;
17
+
18
+ export const DropdownMenuTrigger = React.forwardRef<
19
+ HTMLButtonElement,
20
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>>
21
+ >(function DropdownMenuTrigger({ className, ...props }, ref) {
22
+ return (
23
+ <BaseMenu.Trigger
24
+ ref={ref}
25
+ className={cx(
26
+ "font-[inherit] cursor-pointer focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2",
27
+ className,
28
+ )}
29
+ {...props}
30
+ />
31
+ );
32
+ });
33
+
34
+ export interface DropdownMenuContentProps
35
+ extends WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>> {
36
+ side?: "top" | "right" | "bottom" | "left";
37
+ align?: "start" | "center" | "end";
38
+ sideOffset?: number;
39
+ container?: React.ComponentPropsWithoutRef<typeof BaseMenu.Portal>["container"];
40
+ }
41
+
42
+ export const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
43
+ function DropdownMenuContent(
44
+ { className, children, side, align, sideOffset = 6, container, ...props },
45
+ ref,
46
+ ) {
47
+ return (
48
+ <BaseMenu.Portal container={container}>
49
+ <BaseMenu.Positioner
50
+ className="outline-none z-[var(--z-dropdown)]"
51
+ side={side}
52
+ align={align}
53
+ sideOffset={sideOffset}
54
+ >
55
+ <BaseMenu.Popup
56
+ ref={ref}
57
+ className={cx(
58
+ "min-w-40 max-h-[min(24rem,var(--available-height,24rem))] overflow-y-auto p-[var(--space-1)] bg-background text-foreground border border-border rounded-[var(--radius)] shadow-[0_4px_6px_-1px_rgba(0,0,0,0.08),0_2px_4px_-2px_rgba(0,0,0,0.05)] text-[length:var(--text-sm)] origin-[var(--transform-origin)] animate-[sh-ui-dm-in_140ms_ease-out] data-[ending-style]:animate-[sh-ui-dm-out_100ms_ease-in_forwards] outline-none motion-reduce:animate-none motion-reduce:data-[ending-style]:animate-none",
59
+ className,
60
+ )}
61
+ {...props}
62
+ >
63
+ {children}
64
+ </BaseMenu.Popup>
65
+ </BaseMenu.Positioner>
66
+ </BaseMenu.Portal>
67
+ );
68
+ },
69
+ );
70
+
71
+ export const DropdownMenuItem = React.forwardRef<
72
+ HTMLDivElement,
73
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>>
74
+ >(function DropdownMenuItem({ className, ...props }, ref) {
75
+ return <BaseMenu.Item ref={ref} className={cx(itemBase, className)} {...props} />;
76
+ });
77
+
78
+ export interface DropdownMenuCheckboxItemProps
79
+ extends WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenu.CheckboxItem>> {}
80
+
81
+ export const DropdownMenuCheckboxItem = React.forwardRef<
82
+ HTMLDivElement,
83
+ DropdownMenuCheckboxItemProps
84
+ >(function DropdownMenuCheckboxItem({ className, children, ...props }, ref) {
85
+ return (
86
+ <BaseMenu.CheckboxItem ref={ref} className={cx(itemBase, itemCheck, className)} {...props}>
87
+ <span className="absolute left-2 inline-flex items-center justify-center w-4 h-4 text-foreground" aria-hidden>
88
+ <BaseMenu.CheckboxItemIndicator>
89
+ <CheckIcon />
90
+ </BaseMenu.CheckboxItemIndicator>
91
+ </span>
92
+ <span className="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{children}</span>
93
+ </BaseMenu.CheckboxItem>
94
+ );
95
+ });
96
+
97
+ export const DropdownMenuRadioGroup = BaseMenu.RadioGroup;
98
+
99
+ export interface DropdownMenuRadioItemProps
100
+ extends WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenu.RadioItem>> {}
101
+
102
+ export const DropdownMenuRadioItem = React.forwardRef<
103
+ HTMLDivElement,
104
+ DropdownMenuRadioItemProps
105
+ >(function DropdownMenuRadioItem({ className, children, ...props }, ref) {
106
+ return (
107
+ <BaseMenu.RadioItem ref={ref} className={cx(itemBase, itemCheck, className)} {...props}>
108
+ <span className="absolute left-2 inline-flex items-center justify-center w-4 h-4 text-foreground" aria-hidden>
109
+ <BaseMenu.RadioItemIndicator>
110
+ <DotIcon />
111
+ </BaseMenu.RadioItemIndicator>
112
+ </span>
113
+ <span className="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{children}</span>
114
+ </BaseMenu.RadioItem>
115
+ );
116
+ });
117
+
118
+ export const DropdownMenuGroup = React.forwardRef<
119
+ HTMLDivElement,
120
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenu.Group>>
121
+ >(function DropdownMenuGroup({ className, ...props }, ref) {
122
+ return <BaseMenu.Group ref={ref} className={cx("p-0", className)} {...props} />;
123
+ });
124
+
125
+ export const DropdownMenuLabel = React.forwardRef<
126
+ HTMLDivElement,
127
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenu.GroupLabel>>
128
+ >(function DropdownMenuLabel({ className, ...props }, ref) {
129
+ return (
130
+ <BaseMenu.GroupLabel
131
+ ref={ref}
132
+ className={cx(
133
+ "py-[var(--space-2)] px-[var(--space-2)] pb-[var(--space-1)] text-[length:var(--text-xs)] font-semibold text-foreground-muted uppercase tracking-[0.04em]",
134
+ className,
135
+ )}
136
+ {...props}
137
+ />
138
+ );
139
+ });
140
+
141
+ export const DropdownMenuSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
142
+ function DropdownMenuSeparator({ className, ...props }, ref) {
143
+ return (
144
+ <div
145
+ ref={ref}
146
+ role="separator"
147
+ aria-orientation="horizontal"
148
+ className={cx("h-px bg-border my-[var(--space-1)]", className)}
149
+ {...props}
150
+ />
151
+ );
152
+ },
153
+ );
154
+
155
+ export const DropdownMenuSub = BaseMenu.SubmenuRoot;
156
+
157
+ export const DropdownMenuSubTrigger = React.forwardRef<
158
+ HTMLDivElement,
159
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>>
160
+ >(function DropdownMenuSubTrigger({ className, children, ...props }, ref) {
161
+ return (
162
+ <BaseMenu.SubmenuTrigger
163
+ ref={ref}
164
+ className={cx(itemBase, "data-[popup-open]:bg-background-muted", className)}
165
+ {...props}
166
+ >
167
+ <span className="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{children}</span>
168
+ <span className="inline-flex items-center justify-center ml-auto text-foreground-muted" aria-hidden>
169
+ <ChevronRightIcon />
170
+ </span>
171
+ </BaseMenu.SubmenuTrigger>
172
+ );
173
+ });
174
+
175
+ export const DropdownMenuSubContent = DropdownMenuContent;
176
+
177
+ function CheckIcon() {
178
+ return (
179
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
180
+ <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
181
+ </svg>
182
+ );
183
+ }
184
+
185
+ function DotIcon() {
186
+ return <svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" aria-hidden><circle cx="4" cy="4" r="3" /></svg>;
187
+ }
188
+
189
+ function ChevronRightIcon() {
190
+ return (
191
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
192
+ <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
193
+ </svg>
194
+ );
195
+ }
196
+
197
+ if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-dm]")) {
198
+ const style = document.createElement("style");
199
+ style.setAttribute("data-sh-ui-dm", "");
200
+ style.textContent = `
201
+ @keyframes sh-ui-dm-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }
202
+ @keyframes sh-ui-dm-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.96); } }
203
+ `;
204
+ document.head.appendChild(style);
205
+ }
@@ -0,0 +1,78 @@
1
+ import * as React from "react";
2
+
3
+ export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
4
+ /**
5
+ * 필수 필드 표시. `true`면 LabelTitle 뒤에 `*` 표시.
6
+ * (Tailwind 변종은 plain 변종의 `:has()` 인접 셀렉터 자동 감지를 지원하지 않음 — 명시적으로 prop 사용.)
7
+ *
8
+ * @default false
9
+ */
10
+ isRequired?: boolean;
11
+ }
12
+
13
+ function cx(...args: (string | undefined | false)[]) {
14
+ return args.filter(Boolean).join(" ");
15
+ }
16
+
17
+ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
18
+ ({ className, children, isRequired, ...props }, ref) => (
19
+ <label
20
+ ref={ref}
21
+ className={cx(
22
+ "flex flex-col gap-0.5 text-[length:var(--text-sm)] font-medium leading-snug text-foreground cursor-pointer select-none not-has-[[data-sh-ui-label-part]]:block",
23
+ // 필수 표시 — title 이 있으면 title 뒤, 없으면 label 뒤에 * 부착
24
+ isRequired &&
25
+ "has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:content-['_*'] has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:text-danger has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:font-semibold not-has-[[data-sh-ui-label-part='title']]:after:content-['_*'] not-has-[[data-sh-ui-label-part='title']]:after:text-danger not-has-[[data-sh-ui-label-part='title']]:after:font-semibold",
26
+ className,
27
+ )}
28
+ data-required={isRequired || undefined}
29
+ {...props}
30
+ >
31
+ {children}
32
+ </label>
33
+ ),
34
+ );
35
+ Label.displayName = "Label";
36
+
37
+ export function LabelTitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
38
+ return (
39
+ <span
40
+ data-sh-ui-label-part="title"
41
+ className={cx("font-semibold text-[length:var(--text-sm)] text-foreground", className)}
42
+ {...props}
43
+ />
44
+ );
45
+ }
46
+
47
+ export function LabelSubtitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
48
+ return (
49
+ <span
50
+ data-sh-ui-label-part="subtitle"
51
+ className={cx("font-normal text-[0.8125rem] text-foreground", className)}
52
+ {...props}
53
+ />
54
+ );
55
+ }
56
+
57
+ export function LabelDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
58
+ return (
59
+ <p
60
+ data-sh-ui-label-part="description"
61
+ className={cx("m-0 font-normal text-[0.8125rem] leading-snug text-foreground-muted", className)}
62
+ {...props}
63
+ />
64
+ );
65
+ }
66
+
67
+ export function LabelCaption({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
68
+ return (
69
+ <p
70
+ data-sh-ui-label-part="caption"
71
+ className={cx(
72
+ "m-0 font-normal text-[length:var(--text-xs)] leading-tight text-[var(--foreground-subtle,var(--foreground-muted))] opacity-75",
73
+ className,
74
+ )}
75
+ {...props}
76
+ />
77
+ );
78
+ }
@@ -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
+ }