sh-ui-cli 0.42.1 → 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 (53) hide show
  1. package/README.md +6 -1
  2. package/data/changelog/versions.json +25 -0
  3. package/data/registry/flutter/registry.json +1 -1
  4. package/data/registry/react/components/accordion/index.tailwind.tsx +88 -0
  5. package/data/registry/react/components/avatar/index.tailwind.tsx +74 -0
  6. package/data/registry/react/components/badge/index.tailwind.tsx +47 -0
  7. package/data/registry/react/components/breadcrumb/index.tailwind.tsx +138 -0
  8. package/data/registry/react/components/button/index.tailwind.tsx +70 -0
  9. package/data/registry/react/components/card/index.tailwind.tsx +111 -0
  10. package/data/registry/react/components/checkbox/index.tailwind.tsx +72 -0
  11. package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -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/input/index.tailwind.tsx +405 -0
  18. package/data/registry/react/components/label/index.tailwind.tsx +78 -0
  19. package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
  20. package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
  21. package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
  22. package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
  23. package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
  24. package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
  25. package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
  26. package/data/registry/react/components/select/index.tailwind.tsx +199 -0
  27. package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
  28. package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
  29. package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
  30. package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
  31. package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
  32. package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
  33. package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
  34. package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
  35. package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
  36. package/data/registry/react/peer-versions.json +1 -0
  37. package/data/registry/react/registry.json +530 -72
  38. package/data/tokens/build.mjs +66 -0
  39. package/package.json +1 -1
  40. package/src/add.mjs +54 -6
  41. package/src/api.d.ts +14 -0
  42. package/src/api.js +4 -0
  43. package/src/constants.js +19 -0
  44. package/src/create/cli-args.js +18 -2
  45. package/src/create/generator.js +55 -6
  46. package/src/create/index.mjs +3 -1
  47. package/src/init.mjs +25 -7
  48. package/src/mcp.mjs +13 -2
  49. package/templates/flutter-standalone/sh-ui.config.json +1 -1
  50. package/templates/nextjs-standalone/app/globals.css +1 -21
  51. package/templates/nextjs-standalone/sh-ui.config.json +1 -1
  52. package/templates/ui-app-template/sh-ui.config.json +1 -1
  53. package/templates/ui-app-template/src/styles/globals.css +1 -21
@@ -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
+ });
@@ -0,0 +1,60 @@
1
+ import * as React from "react";
2
+
3
+ function cx(...args: (string | undefined | false | null)[]) {
4
+ return args.filter(Boolean).join(" ");
5
+ }
6
+
7
+ function clamp(n: number, min: number, max: number) {
8
+ return Math.min(max, Math.max(min, n));
9
+ }
10
+
11
+ export interface ProgressProps
12
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> {
13
+ value?: number;
14
+ max?: number;
15
+ "aria-label"?: string;
16
+ }
17
+
18
+ export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
19
+ function Progress(
20
+ { value, max = 100, className, "aria-label": ariaLabel, ...props },
21
+ ref,
22
+ ) {
23
+ const isDeterminate = value !== undefined;
24
+ const normalized = isDeterminate ? clamp((value / max) * 100, 0, 100) : 0;
25
+
26
+ return (
27
+ <div
28
+ ref={ref}
29
+ role="progressbar"
30
+ aria-label={ariaLabel}
31
+ aria-valuemin={isDeterminate ? 0 : undefined}
32
+ aria-valuemax={isDeterminate ? max : undefined}
33
+ aria-valuenow={isDeterminate ? value : undefined}
34
+ data-state={isDeterminate ? "determinate" : "indeterminate"}
35
+ className={cx(
36
+ "relative w-full h-2 overflow-hidden bg-background-muted rounded-full",
37
+ className,
38
+ )}
39
+ {...props}
40
+ >
41
+ <div
42
+ className={cx(
43
+ "h-full bg-primary rounded-full transition-[width] duration-[var(--duration-base)] ease-out motion-reduce:transition-none",
44
+ !isDeterminate &&
45
+ "w-2/5 animate-[sh-ui-progress-slide_1.2s_ease-in-out_infinite] motion-reduce:animate-none motion-reduce:translate-x-3/4",
46
+ )}
47
+ style={isDeterminate ? { width: `${normalized}%` } : undefined}
48
+ />
49
+ </div>
50
+ );
51
+ },
52
+ );
53
+ Progress.displayName = "Progress";
54
+
55
+ if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-progress]")) {
56
+ const style = document.createElement("style");
57
+ style.setAttribute("data-sh-ui-progress", "");
58
+ style.textContent = `@keyframes sh-ui-progress-slide { 0% { transform: translateX(-100%) } 100% { transform: translateX(250%) } }`;
59
+ document.head.appendChild(style);
60
+ }
@@ -0,0 +1,54 @@
1
+ import * as React from "react";
2
+ import { Radio as BaseRadio } from "@base-ui/react/radio";
3
+ import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
4
+
5
+ function cx(...args: (string | undefined | false)[]) {
6
+ return args.filter(Boolean).join(" ");
7
+ }
8
+
9
+ export type RadioProps = Omit<
10
+ React.ComponentPropsWithoutRef<typeof BaseRadio.Root>,
11
+ "className"
12
+ > & {
13
+ className?: string;
14
+ };
15
+
16
+ export const Radio = React.forwardRef<HTMLElement, RadioProps>(
17
+ ({ className, ...props }, ref) => (
18
+ <BaseRadio.Root
19
+ ref={ref}
20
+ className={cx(
21
+ "inline-flex items-center justify-center w-[1.125rem] h-[1.125rem] border border-border-strong rounded-full bg-background cursor-pointer shrink-0 transition-[border-color] duration-[var(--duration-fast)] hover:not-data-[disabled]:border-foreground focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 data-[checked]:border-primary data-[disabled]:opacity-[var(--opacity-disabled)] data-[disabled]:cursor-not-allowed motion-reduce:transition-none [@media(hover:none)_and_(pointer:coarse)]:w-5 [@media(hover:none)_and_(pointer:coarse)]:h-5",
22
+ className,
23
+ )}
24
+ {...props}
25
+ >
26
+ <BaseRadio.Indicator className="w-2 h-2 rounded-full bg-primary scale-0 transition-transform duration-[var(--duration-fast)] ease-out data-[checked]:scale-100 motion-reduce:transition-none" />
27
+ </BaseRadio.Root>
28
+ ),
29
+ );
30
+ Radio.displayName = "Radio";
31
+
32
+ export type RadioGroupProps = Omit<
33
+ React.ComponentPropsWithoutRef<typeof BaseRadioGroup>,
34
+ "className"
35
+ > & {
36
+ className?: string;
37
+ orientation?: "horizontal" | "vertical";
38
+ };
39
+
40
+ export const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
41
+ ({ className, orientation = "vertical", ...props }, ref) => (
42
+ <BaseRadioGroup
43
+ ref={ref}
44
+ className={cx(
45
+ "flex gap-2.5",
46
+ orientation === "vertical" ? "flex-col" : "flex-row flex-wrap",
47
+ className,
48
+ )}
49
+ data-orientation={orientation}
50
+ {...props}
51
+ />
52
+ ),
53
+ );
54
+ RadioGroup.displayName = "RadioGroup";
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Select as BaseSelect } from "@base-ui/react/select";
5
+
6
+ function cx(...args: (string | undefined | false)[]) {
7
+ return args.filter(Boolean).join(" ");
8
+ }
9
+
10
+ export const Select = BaseSelect.Root;
11
+
12
+ export function SelectValue({ placeholder, className, ...props }: { placeholder?: string; className?: string } & Omit<
13
+ React.ComponentPropsWithoutRef<typeof BaseSelect.Value>, "children"
14
+ >) {
15
+ return (
16
+ <BaseSelect.Value className={cx("flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap", className)} {...props}>
17
+ {(value) =>
18
+ value !== null && value !== undefined && value !== "" ? (
19
+ (value as React.ReactNode)
20
+ ) : (
21
+ <span className="text-foreground-subtle">{placeholder}</span>
22
+ )
23
+ }
24
+ </BaseSelect.Value>
25
+ );
26
+ }
27
+
28
+ export const SelectTrigger = React.forwardRef<
29
+ HTMLButtonElement,
30
+ Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger>, "className"> & { className?: string }
31
+ >(({ className, children, ...props }, ref) => (
32
+ <BaseSelect.Trigger
33
+ ref={ref}
34
+ className={cx(
35
+ "inline-flex items-center justify-between gap-[var(--space-2)] min-w-40 h-[var(--control-md)] px-[var(--space-3)] bg-background text-foreground border border-border rounded-[var(--radius)] text-[length:var(--text-sm)] leading-none cursor-pointer transition-[border-color,background-color] duration-[var(--duration-fast)] select-none hover:not-disabled:border-border-strong focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 data-[popup-open]:border-border-strong disabled:opacity-[var(--opacity-disabled)] disabled:pointer-events-none",
36
+ className,
37
+ )}
38
+ {...props}
39
+ >
40
+ {children}
41
+ <BaseSelect.Icon
42
+ className="inline-flex items-center justify-center text-foreground-muted shrink-0 transition-transform duration-[var(--duration-fast)] [[data-popup-open]_&]:rotate-180"
43
+ aria-hidden
44
+ >
45
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none">
46
+ <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
47
+ </svg>
48
+ </BaseSelect.Icon>
49
+ </BaseSelect.Trigger>
50
+ ));
51
+ SelectTrigger.displayName = "SelectTrigger";
52
+
53
+ export const SelectContent = React.forwardRef<
54
+ HTMLDivElement,
55
+ Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Popup>, "className"> & {
56
+ className?: string;
57
+ container?: React.ComponentPropsWithoutRef<typeof BaseSelect.Portal>["container"];
58
+ }
59
+ >(({ className, children, container, ...props }, ref) => (
60
+ <BaseSelect.Portal container={container}>
61
+ <BaseSelect.Positioner className="outline-none z-[var(--z-dropdown)]" sideOffset={4} align="start">
62
+ <BaseSelect.Popup
63
+ ref={ref}
64
+ className={cx(
65
+ "min-w-[var(--anchor-width,10rem)] 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-select-in_140ms_ease-out] data-[ending-style]:animate-[sh-ui-select-out_100ms_ease-in_forwards] motion-reduce:animate-none motion-reduce:data-[ending-style]:animate-none",
66
+ className,
67
+ )}
68
+ {...props}
69
+ >
70
+ {children}
71
+ </BaseSelect.Popup>
72
+ </BaseSelect.Positioner>
73
+ </BaseSelect.Portal>
74
+ ));
75
+ SelectContent.displayName = "SelectContent";
76
+
77
+ export const SelectGroup = BaseSelect.Group;
78
+
79
+ export const SelectLabel = React.forwardRef<
80
+ HTMLDivElement,
81
+ Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.GroupLabel>, "className"> & { className?: string }
82
+ >(({ className, ...props }, ref) => (
83
+ <BaseSelect.GroupLabel
84
+ ref={ref}
85
+ className={cx(
86
+ "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]",
87
+ className,
88
+ )}
89
+ {...props}
90
+ />
91
+ ));
92
+ SelectLabel.displayName = "SelectLabel";
93
+
94
+ export const SelectItem = React.forwardRef<
95
+ HTMLDivElement,
96
+ Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Item>, "className"> & { className?: string }
97
+ >(({ className, children, ...props }, ref) => (
98
+ <BaseSelect.Item
99
+ ref={ref}
100
+ className={cx(
101
+ "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",
102
+ className,
103
+ )}
104
+ {...props}
105
+ >
106
+ <BaseSelect.ItemIndicator
107
+ className="order-1 ml-auto inline-flex items-center justify-center text-foreground"
108
+ aria-hidden
109
+ >
110
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none">
111
+ <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
112
+ </svg>
113
+ </BaseSelect.ItemIndicator>
114
+ <BaseSelect.ItemText className="flex-1">{children}</BaseSelect.ItemText>
115
+ </BaseSelect.Item>
116
+ ));
117
+ SelectItem.displayName = "SelectItem";
118
+
119
+ export const SelectSeparator = React.forwardRef<
120
+ HTMLDivElement,
121
+ Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Separator>, "className"> & { className?: string }
122
+ >(({ className, ...props }, ref) => (
123
+ <BaseSelect.Separator
124
+ ref={ref}
125
+ className={cx("h-px bg-border my-[var(--space-1)]", className)}
126
+ {...props}
127
+ />
128
+ ));
129
+ SelectSeparator.displayName = "SelectSeparator";
130
+
131
+ /* MultiSelect — 동일 로직, 위 컴포넌트 재사용 */
132
+
133
+ type BaseRootProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Root>;
134
+
135
+ type MultiSelectCtx = { values: string[]; remove: (value: string) => void; clear: () => void; };
136
+ const MultiSelectContext = React.createContext<MultiSelectCtx | null>(null);
137
+ export const useMultiSelect = () => {
138
+ const ctx = React.useContext(MultiSelectContext);
139
+ if (!ctx) throw new Error("useMultiSelect는 MultiSelect 하위에서만 사용할 수 있습니다.");
140
+ return ctx;
141
+ };
142
+
143
+ export const MultiSelect = React.forwardRef<
144
+ HTMLDivElement,
145
+ Omit<BaseRootProps, "multiple" | "value" | "defaultValue" | "onValueChange"> & {
146
+ value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void;
147
+ }
148
+ >(({ value: valueProp, defaultValue, onValueChange, children, ...props }, _ref) => {
149
+ const isControlled = valueProp !== undefined;
150
+ const [internal, setInternal] = React.useState<string[]>(defaultValue ?? []);
151
+ const values = isControlled ? valueProp! : internal;
152
+
153
+ const commit = React.useCallback((next: string[]) => {
154
+ if (!isControlled) setInternal(next);
155
+ onValueChange?.(next);
156
+ }, [isControlled, onValueChange]);
157
+
158
+ const ctx = React.useMemo<MultiSelectCtx>(() => ({
159
+ values,
160
+ remove: (v) => commit(values.filter((x) => x !== v)),
161
+ clear: () => commit([]),
162
+ }), [values, commit]);
163
+
164
+ return (
165
+ <MultiSelectContext.Provider value={ctx}>
166
+ <BaseSelect.Root multiple value={values} onValueChange={commit} {...props}>{children}</BaseSelect.Root>
167
+ </MultiSelectContext.Provider>
168
+ );
169
+ });
170
+ MultiSelect.displayName = "MultiSelect";
171
+
172
+ export function MultiSelectValue({
173
+ placeholder, render, separator = ", ", className, ...props
174
+ }: {
175
+ placeholder?: string;
176
+ render?: (values: string[], handlers: { remove: (value: string) => void; clear: () => void }) => React.ReactNode;
177
+ separator?: string; className?: string;
178
+ } & Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Value>, "children" | "render">) {
179
+ const { remove, clear } = useMultiSelect();
180
+ return (
181
+ <BaseSelect.Value className={cx("flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap", className)} {...props}>
182
+ {(value) => {
183
+ const arr = Array.isArray(value) ? (value as string[]) : [];
184
+ if (arr.length === 0) return <span className="text-foreground-subtle">{placeholder}</span>;
185
+ return render ? render(arr, { remove, clear }) : arr.join(separator);
186
+ }}
187
+ </BaseSelect.Value>
188
+ );
189
+ }
190
+
191
+ if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-select]")) {
192
+ const style = document.createElement("style");
193
+ style.setAttribute("data-sh-ui-select", "");
194
+ style.textContent = `
195
+ @keyframes sh-ui-select-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }
196
+ @keyframes sh-ui-select-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.96); } }
197
+ `;
198
+ document.head.appendChild(style);
199
+ }