sh-ui-cli 0.45.3 → 0.47.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 (93) hide show
  1. package/data/changelog/versions.json +26 -0
  2. package/data/registry/react/components/accordion/index.module.tsx +97 -0
  3. package/data/registry/react/components/accordion/styles.module.css +111 -0
  4. package/data/registry/react/components/avatar/index.module.tsx +73 -0
  5. package/data/registry/react/components/avatar/styles.module.css +36 -0
  6. package/data/registry/react/components/badge/index.module.tsx +40 -0
  7. package/data/registry/react/components/badge/styles.module.css +57 -0
  8. package/data/registry/react/components/breadcrumb/index.module.tsx +152 -0
  9. package/data/registry/react/components/breadcrumb/styles.module.css +82 -0
  10. package/data/registry/react/components/button/index.module.tsx +45 -0
  11. package/data/registry/react/components/button/styles.module.css +92 -0
  12. package/data/registry/react/components/calendar/index.module.tsx +806 -0
  13. package/data/registry/react/components/calendar/styles.module.css +213 -0
  14. package/data/registry/react/components/card/index.module.tsx +63 -0
  15. package/data/registry/react/components/card/styles.module.css +73 -0
  16. package/data/registry/react/components/carousel/index.module.tsx +430 -0
  17. package/data/registry/react/components/carousel/styles.module.css +155 -0
  18. package/data/registry/react/components/checkbox/index.module.tsx +96 -0
  19. package/data/registry/react/components/checkbox/styles.module.css +75 -0
  20. package/data/registry/react/components/code-editor/index.module.tsx +230 -0
  21. package/data/registry/react/components/code-editor/styles.module.css +76 -0
  22. package/data/registry/react/components/code-panel/index.module.tsx +191 -0
  23. package/data/registry/react/components/code-panel/styles.module.css +124 -0
  24. package/data/registry/react/components/color-picker/index.module.tsx +467 -0
  25. package/data/registry/react/components/color-picker/styles.module.css +166 -0
  26. package/data/registry/react/components/combobox/index.module.tsx +165 -0
  27. package/data/registry/react/components/combobox/styles.module.css +151 -0
  28. package/data/registry/react/components/context-menu/index.module.tsx +251 -0
  29. package/data/registry/react/components/context-menu/styles.module.css +140 -0
  30. package/data/registry/react/components/date-picker/index.module.tsx +520 -0
  31. package/data/registry/react/components/date-picker/styles.module.css +103 -0
  32. package/data/registry/react/components/dialog/index.module.tsx +95 -0
  33. package/data/registry/react/components/dialog/styles.module.css +127 -0
  34. package/data/registry/react/components/dropdown-menu/index.module.tsx +255 -0
  35. package/data/registry/react/components/dropdown-menu/styles.module.css +150 -0
  36. package/data/registry/react/components/file-upload/index.module.tsx +487 -0
  37. package/data/registry/react/components/file-upload/styles.module.css +170 -0
  38. package/data/registry/react/components/form/index.module.tsx +61 -0
  39. package/data/registry/react/components/form/styles.module.css +47 -0
  40. package/data/registry/react/components/header/index.module.tsx +805 -0
  41. package/data/registry/react/components/header/styles.module.css +350 -0
  42. package/data/registry/react/components/input/index.module.tsx +486 -0
  43. package/data/registry/react/components/input/styles.module.css +200 -0
  44. package/data/registry/react/components/label/index.module.tsx +52 -0
  45. package/data/registry/react/components/label/styles.module.css +90 -0
  46. package/data/registry/react/components/markdown-editor/index.module.tsx +119 -0
  47. package/data/registry/react/components/markdown-editor/styles.module.css +160 -0
  48. package/data/registry/react/components/menubar/index.module.tsx +32 -0
  49. package/data/registry/react/components/menubar/styles.module.css +45 -0
  50. package/data/registry/react/components/numeric-input/index.module.tsx +148 -0
  51. package/data/registry/react/components/numeric-input/styles.module.css +56 -0
  52. package/data/registry/react/components/page-toc/index.module.tsx +174 -0
  53. package/data/registry/react/components/page-toc/styles.module.css +82 -0
  54. package/data/registry/react/components/pagination/index.module.tsx +269 -0
  55. package/data/registry/react/components/pagination/styles.module.css +105 -0
  56. package/data/registry/react/components/popover/index.module.tsx +113 -0
  57. package/data/registry/react/components/popover/styles.module.css +65 -0
  58. package/data/registry/react/components/progress/index.module.tsx +54 -0
  59. package/data/registry/react/components/progress/styles.module.css +41 -0
  60. package/data/registry/react/components/radio/index.module.tsx +65 -0
  61. package/data/registry/react/components/radio/styles.module.css +80 -0
  62. package/data/registry/react/components/rich-text-editor/index.module.tsx +348 -0
  63. package/data/registry/react/components/rich-text-editor/styles.module.css +196 -0
  64. package/data/registry/react/components/select/index.module.tsx +234 -0
  65. package/data/registry/react/components/select/styles.module.css +193 -0
  66. package/data/registry/react/components/separator/index.module.tsx +46 -0
  67. package/data/registry/react/components/separator/styles.module.css +15 -0
  68. package/data/registry/react/components/sidebar/index.module.tsx +1067 -0
  69. package/data/registry/react/components/sidebar/styles.module.css +502 -0
  70. package/data/registry/react/components/skeleton/index.module.tsx +22 -0
  71. package/data/registry/react/components/skeleton/styles.module.css +24 -0
  72. package/data/registry/react/components/slider/index.module.tsx +298 -0
  73. package/data/registry/react/components/slider/styles.module.css +64 -0
  74. package/data/registry/react/components/spinner/index.module.tsx +38 -0
  75. package/data/registry/react/components/spinner/styles.module.css +37 -0
  76. package/data/registry/react/components/switch/index.module.tsx +39 -0
  77. package/data/registry/react/components/switch/styles.module.css +83 -0
  78. package/data/registry/react/components/tabs/index.module.tsx +91 -0
  79. package/data/registry/react/components/tabs/styles.module.css +148 -0
  80. package/data/registry/react/components/textarea/index.module.tsx +23 -0
  81. package/data/registry/react/components/textarea/styles.module.css +54 -0
  82. package/data/registry/react/components/toast/index.module.tsx +258 -0
  83. package/data/registry/react/components/toast/styles.module.css +290 -0
  84. package/data/registry/react/components/toggle/index.module.tsx +131 -0
  85. package/data/registry/react/components/toggle/styles.module.css +85 -0
  86. package/data/registry/react/components/tooltip/index.module.tsx +83 -0
  87. package/data/registry/react/components/tooltip/styles.module.css +44 -0
  88. package/data/registry/react/registry.json +604 -1
  89. package/data/tokens/build.mjs +4 -0
  90. package/package.json +1 -1
  91. package/src/add.mjs +12 -12
  92. package/src/api.d.ts +4 -3
  93. package/src/constants.js +4 -3
@@ -0,0 +1,298 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import styles from "./styles.module.css";
5
+
6
+
7
+ import { cn } from "@SH_UI_UTILS@";
8
+ function clamp(n: number, min: number, max: number) {
9
+ return Math.min(max, Math.max(min, n));
10
+ }
11
+
12
+ function snap(value: number, step: number, min: number) {
13
+ if (step <= 0) return value;
14
+ return min + Math.round((value - min) / step) * step;
15
+ }
16
+
17
+ interface SliderContextValue {
18
+ value: number;
19
+ setValue: (next: number) => void;
20
+ min: number;
21
+ max: number;
22
+ step: number;
23
+ disabled: boolean;
24
+ ariaLabel?: string;
25
+ trackRef: React.RefObject<HTMLDivElement | null>;
26
+ setTrackEl: (el: HTMLDivElement | null) => void;
27
+ percent: string;
28
+ }
29
+
30
+ const SliderContext = React.createContext<SliderContextValue | null>(null);
31
+
32
+ function useSliderContext(): SliderContextValue {
33
+ const ctx = React.useContext(SliderContext);
34
+ if (!ctx) {
35
+ throw new Error("Slider 하위 컴포넌트는 <Slider> 안에서만 사용할 수 있습니다.");
36
+ }
37
+ return ctx;
38
+ }
39
+
40
+ /** 현재 Slider 상태를 읽을 수 있는 hook. 외부 라벨·커스텀 컨트롤에 사용. */
41
+ export function useSliderState(): Pick<
42
+ SliderContextValue,
43
+ "value" | "min" | "max" | "step" | "disabled"
44
+ > {
45
+ const { value, min, max, step, disabled } = useSliderContext();
46
+ return { value, min, max, step, disabled };
47
+ }
48
+
49
+ export interface SliderProps
50
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> {
51
+ value?: number;
52
+ defaultValue?: number;
53
+ onValueChange?: (value: number) => void;
54
+ min?: number;
55
+ max?: number;
56
+ step?: number;
57
+ disabled?: boolean;
58
+ /** 접근성: aria-label. Thumb으로 전달됨. */
59
+ "aria-label"?: string;
60
+ }
61
+
62
+ /**
63
+ * 값 범위 안에서 단일 값을 선택. 마우스/터치 드래그 + 키보드(화살표/Home/End/PageUp·Down) 지원.
64
+ *
65
+ * 기본 렌더(자식 생략 시) — `<SliderTrack><SliderRange /><SliderThumb /></SliderTrack>`.
66
+ * 커스텀 레이아웃이 필요하면 하위 컴포넌트를 직접 조합한다.
67
+ */
68
+ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
69
+ function Slider(
70
+ {
71
+ value: valueProp,
72
+ defaultValue = 0,
73
+ onValueChange,
74
+ min = 0,
75
+ max = 100,
76
+ step = 1,
77
+ disabled = false,
78
+ className,
79
+ children,
80
+ "aria-label": ariaLabel,
81
+ ...rest
82
+ },
83
+ ref,
84
+ ) {
85
+ const isControlled = valueProp !== undefined;
86
+ const [internal, setInternal] = React.useState(defaultValue);
87
+ const rawValue = isControlled ? (valueProp as number) : internal;
88
+ const value = clamp(rawValue, min, max);
89
+
90
+ const trackRef = React.useRef<HTMLDivElement | null>(null);
91
+ const setTrackEl = React.useCallback((el: HTMLDivElement | null) => {
92
+ trackRef.current = el;
93
+ }, []);
94
+
95
+ const setValue = React.useCallback(
96
+ (next: number) => {
97
+ const snapped = clamp(snap(next, step, min), min, max);
98
+ if (snapped === value) return;
99
+ if (!isControlled) setInternal(snapped);
100
+ onValueChange?.(snapped);
101
+ },
102
+ [isControlled, max, min, onValueChange, step, value],
103
+ );
104
+
105
+ const ratio = max === min ? 0 : (value - min) / (max - min);
106
+ const percent = `${ratio * 100}%`;
107
+
108
+ const ctxValue = React.useMemo<SliderContextValue>(
109
+ () => ({
110
+ value,
111
+ setValue,
112
+ min,
113
+ max,
114
+ step,
115
+ disabled,
116
+ ariaLabel,
117
+ trackRef,
118
+ setTrackEl,
119
+ percent,
120
+ }),
121
+ [ariaLabel, disabled, max, min, percent, setTrackEl, setValue, step, value],
122
+ );
123
+
124
+ return (
125
+ <SliderContext.Provider value={ctxValue}>
126
+ <div
127
+ ref={ref}
128
+ {...rest}
129
+ className={cn(
130
+ styles.slider,
131
+ disabled && styles["slider--disabled"],
132
+ className,
133
+ )}
134
+ data-disabled={disabled || undefined}
135
+ >
136
+ {children ?? (
137
+ <SliderTrack>
138
+ <SliderRange />
139
+ <SliderThumb />
140
+ </SliderTrack>
141
+ )}
142
+ </div>
143
+ </SliderContext.Provider>
144
+ );
145
+ },
146
+ );
147
+ Slider.displayName = "Slider";
148
+
149
+ /* ───────── SliderTrack ─────────
150
+ * 클릭/드래그 수신을 담당. 내부에 SliderRange + SliderThumb을 자식으로 조합.
151
+ */
152
+ export const SliderTrack = React.forwardRef<
153
+ HTMLDivElement,
154
+ React.HTMLAttributes<HTMLDivElement>
155
+ >(function SliderTrack(
156
+ { className, onPointerDown: userOnPointerDown, children, ...props },
157
+ ref,
158
+ ) {
159
+ const { disabled, setValue, min, max, setTrackEl, trackRef } = useSliderContext();
160
+
161
+ const mergedRef = React.useCallback(
162
+ (el: HTMLDivElement | null) => {
163
+ setTrackEl(el);
164
+ if (typeof ref === "function") ref(el);
165
+ else if (ref)
166
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
167
+ },
168
+ [ref, setTrackEl],
169
+ );
170
+
171
+ const moveToClient = (clientX: number) => {
172
+ const el = trackRef.current;
173
+ if (!el) return;
174
+ const r = el.getBoundingClientRect();
175
+ const ratio = clamp((clientX - r.left) / r.width, 0, 1);
176
+ setValue(min + ratio * (max - min));
177
+ };
178
+
179
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
180
+ userOnPointerDown?.(e);
181
+ if (e.defaultPrevented || disabled) return;
182
+ const el = trackRef.current;
183
+ if (!el) return;
184
+ el.setPointerCapture(e.pointerId);
185
+ moveToClient(e.clientX);
186
+
187
+ const onMove = (ev: PointerEvent) => moveToClient(ev.clientX);
188
+ const onUp = (ev: PointerEvent) => {
189
+ el.releasePointerCapture(ev.pointerId);
190
+ el.removeEventListener("pointermove", onMove);
191
+ el.removeEventListener("pointerup", onUp);
192
+ };
193
+ el.addEventListener("pointermove", onMove);
194
+ el.addEventListener("pointerup", onUp);
195
+ };
196
+
197
+ return (
198
+ <div
199
+ ref={mergedRef}
200
+ className={cn(styles.slider__track, className)}
201
+ onPointerDown={onPointerDown}
202
+ {...props}
203
+ >
204
+ {children ?? (
205
+ <>
206
+ <SliderRange />
207
+ <SliderThumb />
208
+ </>
209
+ )}
210
+ </div>
211
+ );
212
+ });
213
+ SliderTrack.displayName = "SliderTrack";
214
+
215
+ /* ───────── SliderRange ─────────
216
+ * 선택된 값까지의 진행 바. 넓이는 Context의 percent로 계산.
217
+ */
218
+ export const SliderRange = React.forwardRef<
219
+ HTMLDivElement,
220
+ React.HTMLAttributes<HTMLDivElement>
221
+ >(function SliderRange({ className, style, ...props }, ref) {
222
+ const { percent } = useSliderContext();
223
+ return (
224
+ <div
225
+ ref={ref}
226
+ className={cn(styles.slider__range, className)}
227
+ style={{ width: percent, ...style }}
228
+ {...props}
229
+ />
230
+ );
231
+ });
232
+ SliderRange.displayName = "SliderRange";
233
+
234
+ /* ───────── SliderThumb ─────────
235
+ * 키보드 포커스 및 조작 수신. aria-valuenow 등 접근성 속성을 담는다.
236
+ */
237
+ export const SliderThumb = React.forwardRef<
238
+ HTMLDivElement,
239
+ Omit<React.HTMLAttributes<HTMLDivElement>, "role" | "tabIndex">
240
+ >(function SliderThumb(
241
+ { className, style, onKeyDown: userOnKeyDown, ...props },
242
+ ref,
243
+ ) {
244
+ const { value, setValue, min, max, step, disabled, ariaLabel, percent } =
245
+ useSliderContext();
246
+
247
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
248
+ userOnKeyDown?.(e);
249
+ if (e.defaultPrevented || disabled) return;
250
+ const big = e.shiftKey ? step * 10 : step;
251
+ switch (e.key) {
252
+ case "ArrowRight":
253
+ case "ArrowUp":
254
+ e.preventDefault();
255
+ setValue(value + big);
256
+ break;
257
+ case "ArrowLeft":
258
+ case "ArrowDown":
259
+ e.preventDefault();
260
+ setValue(value - big);
261
+ break;
262
+ case "Home":
263
+ e.preventDefault();
264
+ setValue(min);
265
+ break;
266
+ case "End":
267
+ e.preventDefault();
268
+ setValue(max);
269
+ break;
270
+ case "PageUp":
271
+ e.preventDefault();
272
+ setValue(value + step * 10);
273
+ break;
274
+ case "PageDown":
275
+ e.preventDefault();
276
+ setValue(value - step * 10);
277
+ break;
278
+ }
279
+ };
280
+
281
+ return (
282
+ <div
283
+ ref={ref}
284
+ role="slider"
285
+ tabIndex={disabled ? -1 : 0}
286
+ aria-label={ariaLabel}
287
+ aria-valuemin={min}
288
+ aria-valuemax={max}
289
+ aria-valuenow={value}
290
+ aria-disabled={disabled || undefined}
291
+ onKeyDown={onKeyDown}
292
+ className={cn(styles.slider__thumb, className)}
293
+ style={{ left: percent, ...style }}
294
+ {...props}
295
+ />
296
+ );
297
+ });
298
+ SliderThumb.displayName = "SliderThumb";
@@ -0,0 +1,64 @@
1
+ .slider {
2
+ position: relative;
3
+ width: 100%;
4
+ padding: var(--space-2) 0;
5
+ user-select: none;
6
+ -webkit-user-select: none;
7
+ }
8
+
9
+ .slider--disabled {
10
+ opacity: var(--opacity-disabled);
11
+ pointer-events: none;
12
+ }
13
+
14
+ .slider__track {
15
+ position: relative;
16
+ width: 100%;
17
+ height: 0.375rem;
18
+ background: var(--background-muted);
19
+ border-radius: 999px;
20
+ cursor: pointer;
21
+ touch-action: none;
22
+ }
23
+
24
+ .slider__range {
25
+ position: absolute;
26
+ top: 0;
27
+ left: 0;
28
+ height: 100%;
29
+ background: var(--primary);
30
+ border-radius: 999px;
31
+ pointer-events: none;
32
+ }
33
+
34
+ .slider__thumb {
35
+ position: absolute;
36
+ top: 50%;
37
+ width: 1rem;
38
+ height: 1rem;
39
+ margin-left: -0.5rem;
40
+ transform: translateY(-50%);
41
+ background: var(--background);
42
+ border: 2px solid var(--primary);
43
+ border-radius: 50%;
44
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
45
+ cursor: grab;
46
+ transition: transform 80ms;
47
+ }
48
+ .slider__thumb:active {
49
+ cursor: grabbing;
50
+ transform: translateY(-50%) scale(1.1);
51
+ }
52
+ .slider__thumb:focus-visible {
53
+ outline: var(--border-width-strong) solid var(--foreground);
54
+ outline-offset: 2px;
55
+ }
56
+
57
+ /* 모바일/터치: thumb 확대 */
58
+ @media (hover: none) and (pointer: coarse) {
59
+ .slider__thumb {
60
+ width: 1.25rem;
61
+ height: 1.25rem;
62
+ margin-left: -0.625rem;
63
+ }
64
+ }
@@ -0,0 +1,38 @@
1
+ import * as React from "react";
2
+ import styles from "./styles.module.css";
3
+
4
+
5
+ import { cn } from "@SH_UI_UTILS@";
6
+ export type SpinnerSize = "sm" | "md" | "lg";
7
+
8
+ export interface SpinnerProps
9
+ extends Omit<React.HTMLAttributes<HTMLSpanElement>, "role"> {
10
+ size?: SpinnerSize;
11
+ /** 접근성: 로딩 중임을 알리는 라벨. 기본 "로딩 중". */
12
+ "aria-label"?: string;
13
+ }
14
+
15
+ /**
16
+ * 짧은 비동기 작업의 로딩 표시. 200ms 이상 걸리는 요청에는 즉시 피드백을 준다는
17
+ * 원칙(ui-states.md)에 맞춰 버튼·입력 등에 인라인으로 사용.
18
+ */
19
+ export const Spinner = React.forwardRef<HTMLSpanElement, SpinnerProps>(
20
+ function Spinner(
21
+ { size = "md", className, "aria-label": ariaLabel = "로딩 중", ...props },
22
+ ref,
23
+ ) {
24
+ return (
25
+ <span
26
+ ref={ref}
27
+ role="status"
28
+ aria-live="polite"
29
+ aria-label={ariaLabel}
30
+ className={cn(styles.spinner, styles[`spinner--${size}`], className)}
31
+ {...props}
32
+ >
33
+ <span className={styles.spinner__ring} aria-hidden />
34
+ </span>
35
+ );
36
+ },
37
+ );
38
+ Spinner.displayName = "Spinner";
@@ -0,0 +1,37 @@
1
+ .spinner {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ vertical-align: middle;
6
+ color: currentColor;
7
+ }
8
+
9
+ .spinner--sm { width: 0.875rem; height: 0.875rem; }
10
+ .spinner--md { width: 1.125rem; height: 1.125rem; }
11
+ .spinner--lg { width: 1.5rem; height: 1.5rem; }
12
+
13
+ .spinner__ring {
14
+ display: inline-block;
15
+ width: 100%;
16
+ height: 100%;
17
+ border: 2px solid currentColor;
18
+ border-radius: 999px;
19
+ border-top-color: transparent;
20
+ opacity: 0.8;
21
+ animation: sh-ui-spinner-rotate 0.8s linear infinite;
22
+ }
23
+
24
+ .spinner--sm .spinner__ring {
25
+ border-width: 1.5px;
26
+ }
27
+
28
+ @keyframes sh-ui-spinner-rotate {
29
+ to { transform: rotate(360deg); }
30
+ }
31
+
32
+ @media (prefers-reduced-motion: reduce) {
33
+ /* 움직임을 거의 없애되 "현재 진행 중"은 알아볼 수 있게 점선 느낌으로 유지 */
34
+ .spinner__ring {
35
+ animation-duration: 3s;
36
+ }
37
+ }
@@ -0,0 +1,39 @@
1
+ import * as React from "react";
2
+ import { Switch as BaseSwitch } from "@base-ui/react/switch";
3
+ import styles from "./styles.module.css";
4
+
5
+
6
+ import { cn } from "@SH_UI_UTILS@";
7
+ /* ───────────── Switch ───────────── */
8
+
9
+ export type SwitchProps = Omit<
10
+ React.ComponentPropsWithoutRef<typeof BaseSwitch.Root>,
11
+ "className"
12
+ > & {
13
+ className?: string;
14
+ /**
15
+ * 크기.
16
+ * - `sm` — 조밀한 폼이나 툴바
17
+ * - `md` — 일반 (기본)
18
+ *
19
+ * @default "md"
20
+ */
21
+ size?: "sm" | "md";
22
+ };
23
+
24
+ /**
25
+ * 즉시 반영되는 on/off 토글. 변경이 즉시 적용되는 설정에 사용하고, 폼 제출과
26
+ * 함께 적용되는 선택에는 Checkbox를 권장. label과 연결해 접근성 텍스트를 함께 제공할 것.
27
+ */
28
+ export const Switch = React.forwardRef<HTMLElement, SwitchProps>(
29
+ ({ className, size = "md", ...props }, ref) => (
30
+ <BaseSwitch.Root
31
+ ref={ref}
32
+ className={cn(styles.switch, styles[`switch--${size}`], className)}
33
+ {...props}
34
+ >
35
+ <BaseSwitch.Thumb className={styles.switch__thumb} />
36
+ </BaseSwitch.Root>
37
+ ),
38
+ );
39
+ Switch.displayName = "Switch";
@@ -0,0 +1,83 @@
1
+ /* ───────────── Switch ───────────── */
2
+
3
+ .switch {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ border: none;
7
+ border-radius: 999px;
8
+ background: var(--background-muted);
9
+ cursor: pointer;
10
+ flex-shrink: 0;
11
+ padding: 0.125rem;
12
+ transition: background-color 150ms;
13
+ -webkit-tap-highlight-color: transparent;
14
+ }
15
+
16
+ /* sizes */
17
+ .switch--sm {
18
+ width: 2rem;
19
+ height: 1.125rem;
20
+ }
21
+
22
+ .switch--md {
23
+ width: 2.5rem;
24
+ height: 1.375rem;
25
+ }
26
+
27
+ .switch:hover:not([data-disabled]) {
28
+ background: var(--border-strong);
29
+ }
30
+
31
+ .switch:focus-visible {
32
+ outline: var(--border-width-strong) solid var(--foreground);
33
+ outline-offset: 2px;
34
+ }
35
+
36
+ .switch[data-checked] {
37
+ background: var(--primary);
38
+ }
39
+
40
+ .switch[data-checked]:hover:not([data-disabled]) {
41
+ background: var(--primary-hover);
42
+ }
43
+
44
+ .switch[data-disabled] {
45
+ opacity: var(--opacity-disabled);
46
+ cursor: not-allowed;
47
+ }
48
+
49
+ /* ───────────── Thumb ───────────── */
50
+
51
+ .switch__thumb {
52
+ display: block;
53
+ border-radius: 999px;
54
+ background: white;
55
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
56
+ transition: transform 150ms ease-out;
57
+ }
58
+
59
+ .switch--sm .switch__thumb {
60
+ width: 0.875rem;
61
+ height: 0.875rem;
62
+ }
63
+
64
+ .switch--md .switch__thumb {
65
+ width: 1.125rem;
66
+ height: 1.125rem;
67
+ }
68
+
69
+ .switch--sm[data-checked] .switch__thumb {
70
+ transform: translateX(0.875rem);
71
+ }
72
+
73
+ .switch--md[data-checked] .switch__thumb {
74
+ transform: translateX(1.125rem);
75
+ }
76
+
77
+ /* reduced motion */
78
+ @media (prefers-reduced-motion: reduce) {
79
+ .switch,
80
+ .switch__thumb {
81
+ transition-duration: 0.01ms !important;
82
+ }
83
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Tabs as BaseTabs } from "@base-ui/react/tabs";
5
+ import styles from "./styles.module.css";
6
+
7
+
8
+ import { cn } from "@SH_UI_UTILS@";
9
+ type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
10
+
11
+ export type TabsVariant = "underline" | "pill" | "plain";
12
+
13
+ interface TabsContextValue {
14
+ variant: TabsVariant;
15
+ }
16
+ const TabsContext = React.createContext<TabsContextValue>({ variant: "underline" });
17
+
18
+ export type TabsProps = WithStringClassName<
19
+ React.ComponentPropsWithoutRef<typeof BaseTabs.Root>
20
+ > & {
21
+ /**
22
+ * 외형 변형.
23
+ * - `underline` — 활성 탭 하단 underline (기본). 일반 탭 UI
24
+ * - `pill` — 활성 탭 둥근 배경. 세그먼트 컨트롤 스타일
25
+ * - `plain` — 시각 강조 없음. 직접 스타일링용
26
+ *
27
+ * @default "underline"
28
+ */
29
+ variant?: TabsVariant;
30
+ };
31
+
32
+ /**
33
+ * 한 영역에 여러 패널을 배치하고 탭으로 전환하는 컴파운드 컴포넌트. 같은 페이지의 동일 평면
34
+ * 정보를 분류할 때 사용한다(라우트 분기는 라우팅으로). 자식 구조: TabsList > TabsTrigger × n, TabsContent × n.
35
+ */
36
+ export const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(
37
+ ({ className, variant = "underline", ...props }, ref) => (
38
+ <TabsContext.Provider value={{ variant }}>
39
+ <BaseTabs.Root
40
+ ref={ref}
41
+ data-variant={variant}
42
+ className={cn(styles.tabs, className)}
43
+ {...props}
44
+ />
45
+ </TabsContext.Provider>
46
+ ),
47
+ );
48
+ Tabs.displayName = "Tabs";
49
+
50
+ /** 탭 트리거들을 묶는 컨테이너. 키보드 화살표·Home·End로 트리거 간 이동이 가능하다. */
51
+ export const TabsList = React.forwardRef<
52
+ HTMLDivElement,
53
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseTabs.List>>
54
+ >(({ className, ...props }, ref) => (
55
+ <BaseTabs.List ref={ref} className={cn(styles.tabs__list, className)} {...props} />
56
+ ));
57
+ TabsList.displayName = "TabsList";
58
+
59
+ /** 한 탭의 트리거 버튼. `value` prop으로 매칭되는 TabsContent와 연결된다. */
60
+ export const TabsTrigger = React.forwardRef<
61
+ HTMLButtonElement,
62
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseTabs.Tab>>
63
+ >(({ className, ...props }, ref) => (
64
+ <BaseTabs.Tab ref={ref} className={cn(styles.tabs__trigger, className)} {...props} />
65
+ ));
66
+ TabsTrigger.displayName = "TabsTrigger";
67
+
68
+ /** 활성 탭 위치를 시각적으로 강조하는 인디케이터(보통 underline). TabsList 안에 둔다. */
69
+ export const TabsIndicator = React.forwardRef<
70
+ HTMLSpanElement,
71
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseTabs.Indicator>>
72
+ >(({ className, ...props }, ref) => (
73
+ <BaseTabs.Indicator
74
+ ref={ref}
75
+ className={cn(styles.tabs__indicator, className)}
76
+ {...props}
77
+ />
78
+ ));
79
+ TabsIndicator.displayName = "TabsIndicator";
80
+
81
+ /** 한 탭의 패널. 같은 `value`의 TabsTrigger가 활성일 때 노출된다. */
82
+ export const TabsContent = React.forwardRef<
83
+ HTMLDivElement,
84
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseTabs.Panel>>
85
+ >(({ className, ...props }, ref) => (
86
+ <BaseTabs.Panel ref={ref} className={cn(styles.tabs__content, className)} {...props} />
87
+ ));
88
+ TabsContent.displayName = "TabsContent";
89
+
90
+ /** 현재 Tabs의 variant를 자식에서 읽기 위한 훅. 커스텀 트리거를 만들 때 유용. */
91
+ export const useTabsVariant = () => React.useContext(TabsContext).variant;