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.
- package/data/changelog/versions.json +12 -0
- package/data/registry/react/components/accordion/index.tailwind.tsx +88 -0
- package/data/registry/react/components/avatar/index.tailwind.tsx +74 -0
- package/data/registry/react/components/badge/index.tailwind.tsx +47 -0
- package/data/registry/react/components/breadcrumb/index.tailwind.tsx +138 -0
- package/data/registry/react/components/checkbox/index.tailwind.tsx +72 -0
- package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -0
- package/data/registry/react/components/combobox/index.tailwind.tsx +160 -0
- package/data/registry/react/components/context-menu/index.tailwind.tsx +170 -0
- package/data/registry/react/components/date-picker/index.tailwind.tsx +294 -0
- package/data/registry/react/components/dialog/index.tailwind.tsx +96 -0
- package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +205 -0
- package/data/registry/react/components/label/index.tailwind.tsx +78 -0
- package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
- package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
- package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
- package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
- package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
- package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
- package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
- package/data/registry/react/components/select/index.tailwind.tsx +199 -0
- package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
- package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
- package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
- package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
- package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
- package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
- package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
- package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
- package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
- package/data/registry/react/registry.json +509 -74
- package/package.json +1 -1
- 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
|
+
}
|