sh-ui-cli 0.45.2 → 0.46.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 (98) hide show
  1. package/data/changelog/versions.json +26 -0
  2. package/data/registry/react/components/accordion/index.tailwind.tsx +5 -7
  3. package/data/registry/react/components/accordion/index.tsx +5 -7
  4. package/data/registry/react/components/avatar/index.tailwind.tsx +4 -6
  5. package/data/registry/react/components/avatar/index.tsx +4 -6
  6. package/data/registry/react/components/badge/index.tailwind.tsx +2 -4
  7. package/data/registry/react/components/badge/index.tsx +2 -4
  8. package/data/registry/react/components/breadcrumb/index.tailwind.tsx +8 -10
  9. package/data/registry/react/components/breadcrumb/index.tsx +8 -10
  10. package/data/registry/react/components/button/index.module.tsx +45 -0
  11. package/data/registry/react/components/button/index.tailwind.tsx +2 -1
  12. package/data/registry/react/components/button/index.tsx +3 -4
  13. package/data/registry/react/components/button/styles.module.css +92 -0
  14. package/data/registry/react/components/calendar/index.tailwind.tsx +10 -12
  15. package/data/registry/react/components/calendar/index.tsx +9 -11
  16. package/data/registry/react/components/card/index.module.tsx +63 -0
  17. package/data/registry/react/components/card/index.tailwind.tsx +8 -10
  18. package/data/registry/react/components/card/index.tsx +8 -10
  19. package/data/registry/react/components/card/styles.module.css +73 -0
  20. package/data/registry/react/components/carousel/index.tailwind.tsx +7 -9
  21. package/data/registry/react/components/carousel/index.tsx +7 -9
  22. package/data/registry/react/components/checkbox/index.tailwind.tsx +3 -5
  23. package/data/registry/react/components/checkbox/index.tsx +3 -5
  24. package/data/registry/react/components/code-editor/index.tailwind.tsx +2 -4
  25. package/data/registry/react/components/code-editor/index.tsx +2 -4
  26. package/data/registry/react/components/code-panel/index.tailwind.tsx +5 -7
  27. package/data/registry/react/components/code-panel/index.tsx +5 -7
  28. package/data/registry/react/components/color-picker/index.tailwind.tsx +7 -6
  29. package/data/registry/react/components/color-picker/index.tsx +7 -6
  30. package/data/registry/react/components/combobox/index.tailwind.tsx +8 -10
  31. package/data/registry/react/components/combobox/index.tsx +8 -10
  32. package/data/registry/react/components/context-menu/index.tailwind.tsx +10 -12
  33. package/data/registry/react/components/context-menu/index.tsx +10 -12
  34. package/data/registry/react/components/date-picker/index.tailwind.tsx +7 -9
  35. package/data/registry/react/components/date-picker/index.tsx +7 -9
  36. package/data/registry/react/components/dialog/index.tailwind.tsx +6 -8
  37. package/data/registry/react/components/dialog/index.tsx +6 -8
  38. package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +10 -12
  39. package/data/registry/react/components/dropdown-menu/index.tsx +10 -12
  40. package/data/registry/react/components/file-upload/index.tailwind.tsx +6 -8
  41. package/data/registry/react/components/file-upload/index.tsx +6 -8
  42. package/data/registry/react/components/form/field.tailwind.tsx +2 -1
  43. package/data/registry/react/components/form/field.tsx +2 -3
  44. package/data/registry/react/components/header/index.tailwind.tsx +17 -19
  45. package/data/registry/react/components/header/index.tsx +17 -19
  46. package/data/registry/react/components/input/index.module.tsx +486 -0
  47. package/data/registry/react/components/input/index.tailwind.tsx +4 -6
  48. package/data/registry/react/components/input/index.tsx +4 -6
  49. package/data/registry/react/components/input/styles.module.css +200 -0
  50. package/data/registry/react/components/label/index.tailwind.tsx +6 -8
  51. package/data/registry/react/components/label/index.tsx +6 -8
  52. package/data/registry/react/components/markdown-editor/index.tailwind.tsx +2 -4
  53. package/data/registry/react/components/markdown-editor/index.tsx +2 -4
  54. package/data/registry/react/components/menubar/index.tailwind.tsx +2 -4
  55. package/data/registry/react/components/menubar/index.tsx +2 -4
  56. package/data/registry/react/components/numeric-input/index.tailwind.tsx +2 -4
  57. package/data/registry/react/components/numeric-input/index.tsx +2 -4
  58. package/data/registry/react/components/page-toc/index.tailwind.tsx +3 -2
  59. package/data/registry/react/components/page-toc/index.tsx +2 -3
  60. package/data/registry/react/components/pagination/index.tailwind.tsx +8 -10
  61. package/data/registry/react/components/pagination/index.tsx +8 -10
  62. package/data/registry/react/components/popover/index.tailwind.tsx +4 -6
  63. package/data/registry/react/components/popover/index.tsx +4 -6
  64. package/data/registry/react/components/progress/index.tailwind.tsx +3 -5
  65. package/data/registry/react/components/progress/index.tsx +2 -4
  66. package/data/registry/react/components/radio/index.tailwind.tsx +3 -5
  67. package/data/registry/react/components/radio/index.tsx +3 -5
  68. package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +3 -5
  69. package/data/registry/react/components/rich-text-editor/index.tsx +3 -5
  70. package/data/registry/react/components/select/index.tailwind.tsx +8 -10
  71. package/data/registry/react/components/select/index.tsx +8 -10
  72. package/data/registry/react/components/separator/index.tailwind.tsx +2 -4
  73. package/data/registry/react/components/separator/index.tsx +2 -4
  74. package/data/registry/react/components/sidebar/index.tailwind.tsx +32 -43
  75. package/data/registry/react/components/sidebar/index.tsx +29 -46
  76. package/data/registry/react/components/skeleton/index.tailwind.tsx +2 -4
  77. package/data/registry/react/components/skeleton/index.tsx +2 -4
  78. package/data/registry/react/components/slider/index.tailwind.tsx +5 -7
  79. package/data/registry/react/components/slider/index.tsx +5 -7
  80. package/data/registry/react/components/spinner/index.tailwind.tsx +3 -5
  81. package/data/registry/react/components/spinner/index.tsx +2 -4
  82. package/data/registry/react/components/switch/index.tailwind.tsx +3 -5
  83. package/data/registry/react/components/switch/index.tsx +2 -4
  84. package/data/registry/react/components/tabs/index.tailwind.tsx +6 -8
  85. package/data/registry/react/components/tabs/index.tsx +6 -8
  86. package/data/registry/react/components/textarea/index.tailwind.tsx +2 -4
  87. package/data/registry/react/components/textarea/index.tsx +2 -4
  88. package/data/registry/react/components/toggle/index.tailwind.tsx +4 -6
  89. package/data/registry/react/components/toggle/index.tsx +4 -6
  90. package/data/registry/react/components/tooltip/index.tailwind.tsx +2 -4
  91. package/data/registry/react/components/tooltip/index.tsx +2 -4
  92. package/data/registry/react/lib/cn.tailwind.ts +17 -0
  93. package/data/registry/react/peer-versions.json +3 -1
  94. package/data/registry/react/registry.json +202 -43
  95. package/data/tokens/build.mjs +4 -0
  96. package/package.json +1 -1
  97. package/src/add.mjs +37 -13
  98. package/templates/ui-app-template/sh-ui.config.json +5 -0
@@ -2,6 +2,32 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
4
4
  "versions": [
5
+ {
6
+ "version": "0.46.0",
7
+ "date": "2026-05-04",
8
+ "title": "CSS Modules 변종 파일럿 — button/card/input",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**CSS Modules 변종 파일럿** — `button`, `card`, `input` 에 `index.module.tsx` + `styles.module.css` 변종 추가. `sh-ui.config.json` 의 `cssFramework` 를 `\"css-modules\"` 로 두면 CLI 가 `import styles from \"./styles.module.css\"` 형태로 설치하고, 클래스 이름은 모듈 해시로 격리됨. 토큰은 plain/tailwind 와 동일한 `tokens.css` (`:root` CSS custom properties) 를 공유.",
12
+ "**Fallback 일반화** — 기존엔 tailwind 한정이던 `effectiveFramework` 가 모든 변종 공통으로 동작. `cssFramework` 에 변종이 없는 컴포넌트는 plain 으로 자동 fallback 되며 `ℹ <name> — <fw> 변종 미제공, plain 변종으로 설치` 한 줄 안내 출력. 점진적 rollout 패턴 그대로 — 컴포넌트마다 변종을 갖출 필요 없이 가능한 것부터 제공.",
13
+ "**`utils` 의 frameworks 분기** — `lib/cn.ts` 가 `[\"plain\", \"css-modules\"]` 로 매칭. CSS Modules 환경도 zero-dep `cn` 을 그대로 공유 (clsx/tailwind-merge 가 필요한 건 tailwind 변종뿐).",
14
+ "css-modules 는 여전히 `CSS_FRAMEWORKS_PLANNED` 유지 — 파일럿 3개만 변종이 있어 UI 노출은 다음 라운드에서 전체 컴포넌트 롤아웃 후 SUPPORTED 로 승격. 지금도 사용자가 직접 `sh-ui.config.json` 을 손대면 동작."
15
+ ],
16
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.46.0"
17
+ },
18
+ {
19
+ "version": "0.45.3",
20
+ "date": "2026-04-30",
21
+ "title": "공유 `cn` 유틸 + CSS 프레임워크 가이드 docs",
22
+ "type": "patch",
23
+ "highlights": [
24
+ "**공유 `cn` 유틸** — 컴포넌트 74 곳에 흩어져 있던 로컬 `cx`/`mergeClass` 함수 제거. 모두 `lib/utils.ts` 의 `cn` 으로 통일. plain 변종은 zero-dep custom 구현, tailwind 변종은 `clsx + tailwind-merge` 로 utility 충돌 머지(예: `cn(\"bg-primary\", \"bg-red-500\")` → `\"bg-red-500\"`). registry.json 에 `utils` 엔트리 신설, styled 컴포넌트 43 개가 `registryDependencies: [\"utils\"]` 로 자동 의존 — `add button` 시 `utils` 도 함께 카피.",
25
+ "**`@SH_UI_UTILS@` placeholder 치환** — 컴포넌트가 `import { cn } from \"@SH_UI_UTILS@\"` 로 import 하면 CLI 가 add 시점에 `aliases.utils` 값(예: `@/lib/utils`)으로 치환. cn 유틸 import 경로가 사용자 프로젝트 alias 와 자동 맞아떨어짐. `aliases.utils` 미설정 시 친절 에러로 안내.",
26
+ "**CSS 프레임워크 docs 페이지 추가** — `apps/docs/(docs)/css-framework/page.tsx` 신설. 변종 시스템 큰 그림 (현재 plain/tailwind + 계획 중 css-modules/vanilla-extract) + 모드별 차이·전환·fallback 설명. 사이드바 nav 에 \"CSS 프레임워크\" 링크 추가, cli 페이지에서 cross-link.",
27
+ "**ui-app-template config 보강** — 누락됐던 `aliases.utils` 추가. 신규 init 도 동일하게 채워짐."
28
+ ],
29
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.45.3"
30
+ },
5
31
  {
6
32
  "version": "0.45.2",
7
33
  "date": "2026-04-30",
@@ -1,10 +1,8 @@
1
1
  import * as React from "react";
2
2
  import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
3
3
 
4
- function cx(...args: (string | undefined | false)[]) {
5
- return args.filter(Boolean).join(" ");
6
- }
7
4
 
5
+ import { cn } from "@SH_UI_UTILS@";
8
6
  type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
9
7
 
10
8
  export type AccordionSize = "sm" | "md";
@@ -19,7 +17,7 @@ export const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
19
17
  ({ className, size = "md", ...props }, ref) => (
20
18
  <BaseAccordion.Root
21
19
  ref={ref}
22
- className={cx("flex flex-col w-full", className)}
20
+ className={cn("flex flex-col w-full", className)}
23
21
  data-size={size}
24
22
  {...props}
25
23
  />
@@ -33,7 +31,7 @@ export const AccordionItem = React.forwardRef<
33
31
  >(({ className, ...props }, ref) => (
34
32
  <BaseAccordion.Item
35
33
  ref={ref}
36
- className={cx("border-b border-border first:border-t", className)}
34
+ className={cn("border-b border-border first:border-t", className)}
37
35
  {...props}
38
36
  />
39
37
  ));
@@ -46,7 +44,7 @@ export const AccordionTrigger = React.forwardRef<
46
44
  <BaseAccordion.Header className="m-0 font-[inherit]">
47
45
  <BaseAccordion.Trigger
48
46
  ref={ref}
49
- className={cx(
47
+ className={cn(
50
48
  "flex items-center justify-between gap-[var(--space-4)] w-full px-[var(--space-1)] py-[var(--space-4)] bg-transparent border-none text-foreground text-[0.9375rem] font-medium leading-snug text-left cursor-pointer transition-[background-color] duration-[var(--duration-fast)] hover:not-disabled:not-data-[disabled]:bg-background-muted focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 focus-visible:rounded-[calc(var(--radius)-2px)] disabled:cursor-not-allowed disabled:text-foreground-muted data-[disabled]:cursor-not-allowed data-[disabled]:text-foreground-muted [[data-size=sm]_&]:py-[var(--space-2)] [[data-size=sm]_&]:text-[length:var(--text-xs)] [[data-size=sm]_&]:leading-[1.2] motion-reduce:transition-none",
51
49
  className,
52
50
  )}
@@ -74,7 +72,7 @@ export const AccordionContent = React.forwardRef<
74
72
  >(({ className, children, ...props }, ref) => (
75
73
  <BaseAccordion.Panel
76
74
  ref={ref}
77
- className={cx(
75
+ className={cn(
78
76
  "overflow-hidden h-[var(--accordion-panel-height)] transition-[height] duration-[var(--duration-slow)] data-[starting-style]:h-0 data-[ending-style]:h-0 motion-reduce:transition-none",
79
77
  className,
80
78
  )}
@@ -2,10 +2,8 @@ import * as React from "react";
2
2
  import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
3
3
  import "./styles.css";
4
4
 
5
- function cx(...args: (string | undefined | false)[]) {
6
- return args.filter(Boolean).join(" ");
7
- }
8
5
 
6
+ import { cn } from "@SH_UI_UTILS@";
9
7
  type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
10
8
 
11
9
  export type AccordionSize = "sm" | "md";
@@ -26,7 +24,7 @@ export const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
26
24
  ({ className, size = "md", ...props }, ref) => (
27
25
  <BaseAccordion.Root
28
26
  ref={ref}
29
- className={cx("sh-ui-accordion", className)}
27
+ className={cn("sh-ui-accordion", className)}
30
28
  data-size={size}
31
29
  {...props}
32
30
  />
@@ -40,7 +38,7 @@ export const AccordionItem = React.forwardRef<
40
38
  >(({ className, ...props }, ref) => (
41
39
  <BaseAccordion.Item
42
40
  ref={ref}
43
- className={cx("sh-ui-accordion__item", className)}
41
+ className={cn("sh-ui-accordion__item", className)}
44
42
  {...props}
45
43
  />
46
44
  ));
@@ -59,7 +57,7 @@ export const AccordionTrigger = React.forwardRef<
59
57
  <BaseAccordion.Header className="sh-ui-accordion__header">
60
58
  <BaseAccordion.Trigger
61
59
  ref={ref}
62
- className={cx("sh-ui-accordion__trigger", className)}
60
+ className={cn("sh-ui-accordion__trigger", className)}
63
61
  {...props}
64
62
  >
65
63
  <span className="sh-ui-accordion__trigger-label">{children}</span>
@@ -90,7 +88,7 @@ export const AccordionContent = React.forwardRef<
90
88
  >(({ className, children, ...props }, ref) => (
91
89
  <BaseAccordion.Panel
92
90
  ref={ref}
93
- className={cx("sh-ui-accordion__panel", className)}
91
+ className={cn("sh-ui-accordion__panel", className)}
94
92
  {...props}
95
93
  >
96
94
  <div className="sh-ui-accordion__content">{children}</div>
@@ -2,11 +2,9 @@ import * as React from "react";
2
2
  import { Avatar as BaseAvatar } from "@base-ui/react/avatar";
3
3
  import { cva, type VariantProps } from "class-variance-authority";
4
4
 
5
+ import { cn } from "@SH_UI_UTILS@";
5
6
  type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
6
7
 
7
- function cx(...args: (string | undefined | false | null)[]) {
8
- return args.filter(Boolean).join(" ");
9
- }
10
8
 
11
9
  const avatarVariants = cva(
12
10
  "relative inline-flex items-center justify-center shrink-0 align-middle overflow-hidden rounded-full bg-background-muted text-foreground-muted font-medium select-none",
@@ -37,7 +35,7 @@ export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
37
35
  return (
38
36
  <BaseAvatar.Root
39
37
  ref={ref}
40
- className={cx(avatarVariants({ size }), className)}
38
+ className={cn(avatarVariants({ size }), className)}
41
39
  {...props}
42
40
  />
43
41
  );
@@ -51,7 +49,7 @@ export const AvatarImage = React.forwardRef<
51
49
  return (
52
50
  <BaseAvatar.Image
53
51
  ref={ref}
54
- className={cx("w-full h-full object-cover block", className)}
52
+ className={cn("w-full h-full object-cover block", className)}
55
53
  {...props}
56
54
  />
57
55
  );
@@ -64,7 +62,7 @@ export const AvatarFallback = React.forwardRef<
64
62
  return (
65
63
  <BaseAvatar.Fallback
66
64
  ref={ref}
67
- className={cx(
65
+ className={cn(
68
66
  "inline-flex items-center justify-center w-full h-full uppercase tracking-[0.02em]",
69
67
  className,
70
68
  )}
@@ -2,11 +2,9 @@ import * as React from "react";
2
2
  import { Avatar as BaseAvatar } from "@base-ui/react/avatar";
3
3
  import "./styles.css";
4
4
 
5
+ import { cn } from "@SH_UI_UTILS@";
5
6
  type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
6
7
 
7
- function cx(...args: (string | undefined | false | null)[]) {
8
- return args.filter(Boolean).join(" ");
9
- }
10
8
 
11
9
  export type AvatarSize = "sm" | "md" | "lg" | "xl";
12
10
 
@@ -35,7 +33,7 @@ export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
35
33
  return (
36
34
  <BaseAvatar.Root
37
35
  ref={ref}
38
- className={cx("sh-ui-avatar", `sh-ui-avatar--${size}`, className)}
36
+ className={cn("sh-ui-avatar", `sh-ui-avatar--${size}`, className)}
39
37
  {...props}
40
38
  />
41
39
  );
@@ -52,7 +50,7 @@ export const AvatarImage = React.forwardRef<
52
50
  return (
53
51
  <BaseAvatar.Image
54
52
  ref={ref}
55
- className={cx("sh-ui-avatar__image", className)}
53
+ className={cn("sh-ui-avatar__image", className)}
56
54
  {...props}
57
55
  />
58
56
  );
@@ -68,7 +66,7 @@ export const AvatarFallback = React.forwardRef<
68
66
  return (
69
67
  <BaseAvatar.Fallback
70
68
  ref={ref}
71
- className={cx("sh-ui-avatar__fallback", className)}
69
+ className={cn("sh-ui-avatar__fallback", className)}
72
70
  {...props}
73
71
  />
74
72
  );
@@ -1,10 +1,8 @@
1
1
  import * as React from "react";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
 
4
- function cx(...args: (string | undefined | false | null)[]) {
5
- return args.filter(Boolean).join(" ");
6
- }
7
4
 
5
+ import { cn } from "@SH_UI_UTILS@";
8
6
  const badgeVariants = cva(
9
7
  "inline-flex items-center gap-1 px-2 border border-transparent rounded-full font-medium leading-none whitespace-nowrap align-middle select-none",
10
8
  {
@@ -39,7 +37,7 @@ export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
39
37
  return (
40
38
  <span
41
39
  ref={ref}
42
- className={cx(badgeVariants({ variant, size }), className)}
40
+ className={cn(badgeVariants({ variant, size }), className)}
43
41
  {...props}
44
42
  />
45
43
  );
@@ -1,10 +1,8 @@
1
1
  import * as React from "react";
2
2
  import "./styles.css";
3
3
 
4
- function cx(...args: (string | undefined | false | null)[]) {
5
- return args.filter(Boolean).join(" ");
6
- }
7
4
 
5
+ import { cn } from "@SH_UI_UTILS@";
8
6
  export type BadgeVariant =
9
7
  | "primary"
10
8
  | "secondary"
@@ -29,7 +27,7 @@ export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
29
27
  return (
30
28
  <span
31
29
  ref={ref}
32
- className={cx(
30
+ className={cn(
33
31
  "sh-ui-badge",
34
32
  `sh-ui-badge--${variant}`,
35
33
  `sh-ui-badge--${size}`,
@@ -1,9 +1,7 @@
1
1
  import * as React from "react";
2
2
 
3
- function cx(...args: (string | undefined | false | null)[]) {
4
- return args.filter(Boolean).join(" ");
5
- }
6
3
 
4
+ import { cn } from "@SH_UI_UTILS@";
7
5
  export const Breadcrumb = React.forwardRef<
8
6
  HTMLElement,
9
7
  React.HTMLAttributes<HTMLElement>
@@ -12,7 +10,7 @@ export const Breadcrumb = React.forwardRef<
12
10
  <nav
13
11
  ref={ref}
14
12
  aria-label="Breadcrumb"
15
- className={cx("text-[length:var(--text-sm)] text-foreground-muted", className)}
13
+ className={cn("text-[length:var(--text-sm)] text-foreground-muted", className)}
16
14
  {...props}
17
15
  />
18
16
  );
@@ -25,7 +23,7 @@ export const BreadcrumbList = React.forwardRef<
25
23
  return (
26
24
  <ol
27
25
  ref={ref}
28
- className={cx(
26
+ className={cn(
29
27
  "flex items-center flex-wrap gap-1.5 m-0 p-0 list-none",
30
28
  className,
31
29
  )}
@@ -41,7 +39,7 @@ export const BreadcrumbItem = React.forwardRef<
41
39
  return (
42
40
  <li
43
41
  ref={ref}
44
- className={cx("inline-flex items-center gap-1.5 min-w-0", className)}
42
+ className={cn("inline-flex items-center gap-1.5 min-w-0", className)}
45
43
  {...props}
46
44
  />
47
45
  );
@@ -54,7 +52,7 @@ export const BreadcrumbLink = React.forwardRef<
54
52
  return (
55
53
  <a
56
54
  ref={ref}
57
- className={cx(
55
+ className={cn(
58
56
  "text-foreground-muted no-underline rounded-[calc(var(--radius)-2px)] px-0.5 transition-colors duration-[var(--duration-fast)] hover:text-foreground hover:underline hover:underline-offset-[3px] focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 motion-reduce:transition-none",
59
57
  className,
60
58
  )}
@@ -73,7 +71,7 @@ export const BreadcrumbPage = React.forwardRef<
73
71
  role="link"
74
72
  aria-current="page"
75
73
  aria-disabled="true"
76
- className={cx(
74
+ className={cn(
77
75
  "text-foreground font-medium overflow-hidden text-ellipsis whitespace-nowrap",
78
76
  className,
79
77
  )}
@@ -91,7 +89,7 @@ export const BreadcrumbSeparator = React.forwardRef<
91
89
  ref={ref}
92
90
  role="presentation"
93
91
  aria-hidden="true"
94
- className={cx(
92
+ className={cn(
95
93
  "inline-flex items-center text-foreground-muted opacity-60",
96
94
  className,
97
95
  )}
@@ -111,7 +109,7 @@ export const BreadcrumbEllipsis = React.forwardRef<
111
109
  ref={ref}
112
110
  role="presentation"
113
111
  aria-hidden="true"
114
- className={cx(
112
+ className={cn(
115
113
  "inline-flex items-center w-6 h-6 justify-center text-foreground-muted",
116
114
  className,
117
115
  )}
@@ -1,10 +1,8 @@
1
1
  import * as React from "react";
2
2
  import "./styles.css";
3
3
 
4
- function cx(...args: (string | undefined | false | null)[]) {
5
- return args.filter(Boolean).join(" ");
6
- }
7
4
 
5
+ import { cn } from "@SH_UI_UTILS@";
8
6
  /* ───────── Breadcrumb (nav) ─────────
9
7
  * 시맨틱: <nav aria-label="Breadcrumb"><ol>...</ol></nav>.
10
8
  */
@@ -21,7 +19,7 @@ export const Breadcrumb = React.forwardRef<
21
19
  <nav
22
20
  ref={ref}
23
21
  aria-label="Breadcrumb"
24
- className={cx("sh-ui-breadcrumb", className)}
22
+ className={cn("sh-ui-breadcrumb", className)}
25
23
  {...props}
26
24
  />
27
25
  );
@@ -37,7 +35,7 @@ export const BreadcrumbList = React.forwardRef<
37
35
  return (
38
36
  <ol
39
37
  ref={ref}
40
- className={cx("sh-ui-breadcrumb__list", className)}
38
+ className={cn("sh-ui-breadcrumb__list", className)}
41
39
  {...props}
42
40
  />
43
41
  );
@@ -53,7 +51,7 @@ export const BreadcrumbItem = React.forwardRef<
53
51
  return (
54
52
  <li
55
53
  ref={ref}
56
- className={cx("sh-ui-breadcrumb__item", className)}
54
+ className={cn("sh-ui-breadcrumb__item", className)}
57
55
  {...props}
58
56
  />
59
57
  );
@@ -69,7 +67,7 @@ export const BreadcrumbLink = React.forwardRef<
69
67
  return (
70
68
  <a
71
69
  ref={ref}
72
- className={cx("sh-ui-breadcrumb__link", className)}
70
+ className={cn("sh-ui-breadcrumb__link", className)}
73
71
  {...props}
74
72
  />
75
73
  );
@@ -88,7 +86,7 @@ export const BreadcrumbPage = React.forwardRef<
88
86
  role="link"
89
87
  aria-current="page"
90
88
  aria-disabled="true"
91
- className={cx("sh-ui-breadcrumb__page", className)}
89
+ className={cn("sh-ui-breadcrumb__page", className)}
92
90
  {...props}
93
91
  />
94
92
  );
@@ -106,7 +104,7 @@ export const BreadcrumbSeparator = React.forwardRef<
106
104
  ref={ref}
107
105
  role="presentation"
108
106
  aria-hidden="true"
109
- className={cx("sh-ui-breadcrumb__separator", className)}
107
+ className={cn("sh-ui-breadcrumb__separator", className)}
110
108
  {...props}
111
109
  >
112
110
  {children ?? <ChevronRightIcon />}
@@ -126,7 +124,7 @@ export const BreadcrumbEllipsis = React.forwardRef<
126
124
  ref={ref}
127
125
  role="presentation"
128
126
  aria-hidden="true"
129
- className={cx("sh-ui-breadcrumb__ellipsis", className)}
127
+ className={cn("sh-ui-breadcrumb__ellipsis", className)}
130
128
  {...props}
131
129
  >
132
130
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import { cn } from "@SH_UI_UTILS@";
3
+ import styles from "./styles.module.css";
4
+
5
+ type Variant = "primary" | "secondary" | "ghost" | "danger" | "link";
6
+ type Size = "sm" | "md" | "lg";
7
+
8
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9
+ /**
10
+ * 시각적 위계.
11
+ * - `primary` — 페이지의 주요 액션. 한 화면에 하나만 권장.
12
+ * - `secondary` — 보조 액션. 약한 배경 + border.
13
+ * - `ghost` — 배경 없는 hover 강조 액션. 툴바/메뉴 항목에 적합.
14
+ * - `danger` — 파괴적 액션(삭제, 취소 등).
15
+ * - `link` — 텍스트 링크처럼 보이는 인라인 버튼.
16
+ *
17
+ * @default "primary"
18
+ */
19
+ variant?: Variant;
20
+ /**
21
+ * 크기.
22
+ * - `sm` — 조밀한 영역(테이블 행, 툴바)
23
+ * - `md` — 일반
24
+ * - `lg` — CTA·랜딩 영역
25
+ *
26
+ * @default "md"
27
+ */
28
+ size?: Size;
29
+ }
30
+
31
+ /**
32
+ * 사용자 액션을 트리거하는 기본 버튼 (CSS Modules 변종).
33
+ * variant로 시각적 위계(primary/secondary/ghost/danger/link)를,
34
+ * size로 크기를 결정한다. 페이지 이동 목적이면 anchor를 감싼 `link` variant를 사용할 것.
35
+ */
36
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
37
+ ({ variant = "primary", size = "md", className, ...props }, ref) => (
38
+ <button
39
+ ref={ref}
40
+ className={cn(styles.button, styles[variant], styles[size], className)}
41
+ {...props}
42
+ />
43
+ ),
44
+ );
45
+ Button.displayName = "Button";
@@ -1,4 +1,5 @@
1
1
  import * as React from "react";
2
+ import { cn } from "@SH_UI_UTILS@";
2
3
  import { cva, type VariantProps } from "class-variance-authority";
3
4
 
4
5
  const buttonVariants = cva(
@@ -62,7 +63,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
62
63
  ({ variant = "primary", size = "md", className, ...props }, ref) => (
63
64
  <button
64
65
  ref={ref}
65
- className={[buttonVariants({ variant, size }), className].filter(Boolean).join(" ")}
66
+ className={cn(buttonVariants({ variant, size }), className)}
66
67
  {...props}
67
68
  />
68
69
  ),
@@ -1,4 +1,5 @@
1
1
  import * as React from "react";
2
+ import { cn } from "@SH_UI_UTILS@";
2
3
  import "./styles.css";
3
4
 
4
5
  type Variant = "primary" | "secondary" | "ghost" | "danger" | "link";
@@ -33,14 +34,12 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
33
34
  */
34
35
  export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
35
36
  ({ variant = "primary", size = "md", className, ...props }, ref) => {
36
- const classes = [
37
+ const classes = cn(
37
38
  "sh-ui-button",
38
39
  `sh-ui-button--${variant}`,
39
40
  `sh-ui-button--${size}`,
40
41
  className,
41
- ]
42
- .filter(Boolean)
43
- .join(" ");
42
+ );
44
43
  return <button ref={ref} className={classes} {...props} />;
45
44
  },
46
45
  );
@@ -0,0 +1,92 @@
1
+ .button {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ gap: var(--space-2);
6
+ border: 1px solid transparent;
7
+ border-radius: var(--radius);
8
+ font-weight: var(--weight-medium);
9
+ line-height: 1;
10
+ cursor: pointer;
11
+ transition: background-color var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast),
12
+ transform 80ms ease-out, filter 80ms;
13
+ user-select: none;
14
+ -webkit-tap-highlight-color: transparent;
15
+ }
16
+
17
+ .button:disabled {
18
+ opacity: var(--opacity-disabled);
19
+ pointer-events: none;
20
+ }
21
+
22
+ .button:focus-visible {
23
+ outline: var(--border-width-strong) solid var(--foreground);
24
+ outline-offset: 2px;
25
+ }
26
+
27
+ .button:active:not(:disabled) {
28
+ transform: scale(0.97);
29
+ filter: brightness(0.92);
30
+ transition-duration: 40ms;
31
+ }
32
+
33
+ /* sizes */
34
+ .sm { height: var(--control-sm); padding: 0 var(--space-3); font-size: var(--text-sm); }
35
+ .md { height: var(--control-md); padding: 0 var(--space-4); font-size: var(--text-sm); }
36
+ .lg { height: var(--control-lg); padding: 0 var(--space-5); font-size: var(--text-base); }
37
+
38
+ /* 모바일/터치 디바이스: 최소 탭 영역 보장 */
39
+ @media (hover: none) and (pointer: coarse) {
40
+ .sm { height: 2.25rem; }
41
+ .md { height: 2.75rem; }
42
+ }
43
+
44
+ /* variants */
45
+ .primary {
46
+ background-color: var(--primary);
47
+ color: var(--primary-foreground);
48
+ }
49
+ .primary:hover { background-color: var(--primary-hover); }
50
+
51
+ .secondary {
52
+ background-color: var(--background-muted);
53
+ color: var(--foreground);
54
+ border-color: var(--border);
55
+ }
56
+ .secondary:hover { background-color: var(--background-subtle); }
57
+
58
+ .ghost {
59
+ background-color: transparent;
60
+ color: var(--foreground);
61
+ }
62
+ .ghost:hover { background-color: var(--background-muted); }
63
+
64
+ .danger {
65
+ background-color: var(--danger);
66
+ color: var(--danger-foreground);
67
+ }
68
+
69
+ .link {
70
+ background-color: transparent;
71
+ color: var(--foreground);
72
+ border-color: transparent;
73
+ height: auto;
74
+ padding: 0;
75
+ text-underline-offset: 3px;
76
+ }
77
+ .link:hover { text-decoration: underline; }
78
+ .link:active:not(:disabled) {
79
+ transform: none;
80
+ filter: none;
81
+ color: var(--foreground-muted);
82
+ }
83
+
84
+ @media (prefers-reduced-motion: reduce) {
85
+ .button {
86
+ transition: none;
87
+ }
88
+ .button:active:not(:disabled) {
89
+ transform: none;
90
+ filter: none;
91
+ }
92
+ }
@@ -3,10 +3,8 @@
3
3
  import * as React from "react";
4
4
  import { Select, SelectContent, SelectItem, SelectTrigger } from "../select";
5
5
 
6
- function cx(...args: (string | undefined | false)[]) {
7
- return args.filter(Boolean).join(" ");
8
- }
9
6
 
7
+ import { cn } from "@SH_UI_UTILS@";
10
8
  const DEFAULT_WEEKDAYS_KO = ["일", "월", "화", "수", "목", "금", "토"] as const;
11
9
 
12
10
  const isSameDay = (a: Date, b: Date) =>
@@ -287,7 +285,7 @@ export function Calendar(props: CalendarProps) {
287
285
 
288
286
  return (
289
287
  <div
290
- className={cx("inline-flex gap-[var(--space-4)] select-none", numberOfMonths > 1 && "flex-wrap", className)}
288
+ className={cn("inline-flex gap-[var(--space-4)] select-none", numberOfMonths > 1 && "flex-wrap", className)}
291
289
  aria-label={ariaLabel}
292
290
  >
293
291
  {children
@@ -316,7 +314,7 @@ export function Calendar(props: CalendarProps) {
316
314
  export interface CalendarHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
317
315
  export const CalendarHeader = React.forwardRef<HTMLDivElement, CalendarHeaderProps>(
318
316
  function CalendarHeader({ className, ...props }, ref) {
319
- return <div ref={ref} className={cx("flex items-center justify-between gap-[var(--space-1)] mb-[var(--space-2)]", className)} {...props} />;
317
+ return <div ref={ref} className={cn("flex items-center justify-between gap-[var(--space-1)] mb-[var(--space-2)]", className)} {...props} />;
320
318
  },
321
319
  );
322
320
 
@@ -324,7 +322,7 @@ const navButtonClasses =
324
322
  "inline-flex items-center justify-center w-7 h-7 p-0 border-none rounded-[calc(var(--radius)-2px)] bg-transparent text-foreground-muted cursor-pointer shrink-0 transition-[background-color,color] duration-[var(--duration-fast)] hover:not-disabled:bg-background-muted hover:not-disabled:text-foreground focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 motion-reduce:transition-none";
325
323
 
326
324
  function CalendarNavPlaceholder() {
327
- return <span className={cx(navButtonClasses, "invisible pointer-events-none")} aria-hidden />;
325
+ return <span className={cn(navButtonClasses, "invisible pointer-events-none")} aria-hidden />;
328
326
  }
329
327
 
330
328
  export interface CalendarNavButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
@@ -344,7 +342,7 @@ function makeNavButton(
344
342
  <button
345
343
  ref={ref}
346
344
  type="button"
347
- className={cx(navButtonClasses, className)}
345
+ className={cn(navButtonClasses, className)}
348
346
  aria-label={ariaLabel ?? defaultLabel}
349
347
  onClick={(e) => { resolveHandler(ctx)(); onClick?.(e); }}
350
348
  {...props}
@@ -377,7 +375,7 @@ export function CalendarYearSelect({ className, formatYear = (y) => `${y}년` }:
377
375
  const items = ctx.yearOptions.includes(year) ? ctx.yearOptions : [...ctx.yearOptions, year].sort((a, b) => a - b);
378
376
  return (
379
377
  <Select value={String(year)} onValueChange={(v) => ctx.setYearForVisible(Number(v))}>
380
- <SelectTrigger className={cx(calendarSelectTriggerClasses, className)} aria-label="연도">
378
+ <SelectTrigger className={cn(calendarSelectTriggerClasses, className)} aria-label="연도">
381
379
  <span>{formatYear(year)}</span>
382
380
  </SelectTrigger>
383
381
  <SelectContent>
@@ -397,7 +395,7 @@ export function CalendarMonthSelect({ className, formatMonth = (m) => `${m + 1}
397
395
  const month = ctx.visibleMonth.getMonth();
398
396
  return (
399
397
  <Select value={String(month)} onValueChange={(v) => ctx.setMonthForVisible(Number(v))}>
400
- <SelectTrigger className={cx(calendarSelectTriggerClasses, className)} aria-label="월">
398
+ <SelectTrigger className={cn(calendarSelectTriggerClasses, className)} aria-label="월">
401
399
  <span>{formatMonth(month)}</span>
402
400
  </SelectTrigger>
403
401
  <SelectContent>
@@ -420,7 +418,7 @@ export const CalendarGrid = React.forwardRef<HTMLDivElement, CalendarGridProps>(
420
418
  const ariaLabel = ctx.ariaLabel ?? monthLabel;
421
419
 
422
420
  return (
423
- <div ref={ref} className={cx("", className)} {...rest}>
421
+ <div ref={ref} className={cn("", className)} {...rest}>
424
422
  <div className="grid grid-cols-7 mb-[var(--space-1)]" role="row">
425
423
  {ctx.weekdayLabels.map((label) => (
426
424
  <span key={label} className="flex items-center justify-center h-8 text-[length:var(--text-xs)] font-medium text-foreground-muted" role="columnheader" aria-label={label}>
@@ -450,10 +448,10 @@ export const CalendarGrid = React.forwardRef<HTMLDivElement, CalendarGridProps>(
450
448
  isStart && isEnd ? "rounded-[calc(var(--radius)-2px)]" : "";
451
449
 
452
450
  return (
453
- <div key={i} className={cx("flex items-center justify-center w-full h-[2.375rem] min-w-0", cellRangeBg, cellRangeRadius)}>
451
+ <div key={i} className={cn("flex items-center justify-center w-full h-[2.375rem] min-w-0", cellRangeBg, cellRangeRadius)}>
454
452
  <button
455
453
  type="button"
456
- className={cx(
454
+ className={cn(
457
455
  "flex items-center justify-center w-9 h-9 p-0 border-none rounded-[calc(var(--radius)-2px)] bg-transparent text-foreground text-[0.8125rem] font-[inherit] cursor-pointer transition-[background-color,color] duration-[var(--duration-fast)] hover:not-disabled:bg-background-muted focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 disabled:opacity-30 disabled:cursor-not-allowed motion-reduce:transition-none",
458
456
  !current && "text-[var(--foreground-subtle,var(--foreground-muted))] opacity-40",
459
457
  isToday && "font-bold underline underline-offset-[0.125rem]",