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