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,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
+ }
@@ -0,0 +1,42 @@
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 type SeparatorOrientation = "horizontal" | "vertical";
8
+
9
+ export interface SeparatorProps
10
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> {
11
+ orientation?: SeparatorOrientation;
12
+ /**
13
+ * 의미 없는 시각적 구분선인지 여부. 기본 true(aria-hidden).
14
+ * 스크린리더에도 섹션 구분을 알려야 하면 false.
15
+ */
16
+ decorative?: boolean;
17
+ }
18
+
19
+ /**
20
+ * 시각적 구분선 (Tailwind utility 변종). 가로(height=1px) / 세로(width=1px).
21
+ */
22
+ export const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
23
+ function Separator(
24
+ { className, orientation = "horizontal", decorative = true, ...props },
25
+ ref,
26
+ ) {
27
+ const sizing =
28
+ orientation === "horizontal" ? "w-full h-px" : "w-px h-full self-stretch";
29
+ return (
30
+ <div
31
+ ref={ref}
32
+ role={decorative ? undefined : "separator"}
33
+ aria-orientation={decorative ? undefined : orientation}
34
+ aria-hidden={decorative || undefined}
35
+ data-orientation={orientation}
36
+ className={cx("bg-border shrink-0", sizing, className)}
37
+ {...props}
38
+ />
39
+ );
40
+ },
41
+ );
42
+ Separator.displayName = "Separator";
@@ -0,0 +1,39 @@
1
+ import * as React from "react";
2
+
3
+ function cx(...args: (string | undefined | false | null)[]) {
4
+ return args.filter(Boolean).join(" ");
5
+ }
6
+
7
+ /**
8
+ * 로딩 중 콘텐츠 자리를 채우는 placeholder 박스 (Tailwind utility 변종).
9
+ * `aria-hidden`이 기본 적용되므로 스크린리더에 노출되지 않는다.
10
+ */
11
+ export const Skeleton = React.forwardRef<
12
+ HTMLDivElement,
13
+ React.HTMLAttributes<HTMLDivElement>
14
+ >(({ className, ...props }, ref) => (
15
+ <div
16
+ ref={ref}
17
+ aria-hidden="true"
18
+ className={cx(
19
+ "block w-full h-4 bg-background-muted rounded-[calc(var(--radius)-2px)] animate-[sh-ui-skeleton-pulse_1.6s_ease-in-out_infinite] motion-reduce:animate-none",
20
+ className,
21
+ )}
22
+ style={
23
+ {
24
+ ...(props.style as React.CSSProperties),
25
+ }
26
+ }
27
+ {...props}
28
+ />
29
+ ));
30
+ Skeleton.displayName = "Skeleton";
31
+
32
+ /* keyframes — Tailwind 4 의 @theme 가 keyframe 도 가져가지만,
33
+ * 사용자 토큰에는 없으므로 컴포넌트 옆에 한 번만 inject. */
34
+ if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-skeleton]")) {
35
+ const style = document.createElement("style");
36
+ style.setAttribute("data-sh-ui-skeleton", "");
37
+ style.textContent = `@keyframes sh-ui-skeleton-pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.55 } }`;
38
+ document.head.appendChild(style);
39
+ }