sh-ui-cli 0.14.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 +13 -4
  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,167 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox";
5
+ import "./styles.css";
6
+
7
+ type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
8
+
9
+ function cx(...args: (string | undefined | false)[]) {
10
+ return args.filter(Boolean).join(" ");
11
+ }
12
+
13
+ /**
14
+ * Select + Input의 결합 — 타이핑으로 목록이 자동 필터링된다.
15
+ * Base UI Combobox를 래핑해 `items` 배열을 받으면 기본 필터가 `input` 값 기준으로 동작.
16
+ *
17
+ * <Combobox items={fruits}>
18
+ * <ComboboxInput placeholder="과일 검색" />
19
+ * <ComboboxContent>
20
+ * <ComboboxList>
21
+ * {(item) => <ComboboxItem key={item} value={item}>{item}</ComboboxItem>}
22
+ * </ComboboxList>
23
+ * <ComboboxEmpty>일치하는 항목 없음</ComboboxEmpty>
24
+ * </ComboboxContent>
25
+ * </Combobox>
26
+ */
27
+ export const Combobox = BaseCombobox.Root;
28
+
29
+ export const ComboboxIcon = BaseCombobox.Icon;
30
+ export const ComboboxTrigger = BaseCombobox.Trigger;
31
+ export const ComboboxClear = BaseCombobox.Clear;
32
+ export const ComboboxValue = BaseCombobox.Value;
33
+ export const ComboboxGroup = BaseCombobox.Group;
34
+ export const ComboboxChips = BaseCombobox.Chips;
35
+
36
+ export const ComboboxInput = React.forwardRef<
37
+ HTMLInputElement,
38
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseCombobox.Input>>
39
+ >(function ComboboxInput({ className, ...props }, ref) {
40
+ return (
41
+ <BaseCombobox.Input
42
+ ref={ref}
43
+ className={cx("sh-ui-combobox__input", className)}
44
+ {...props}
45
+ />
46
+ );
47
+ });
48
+
49
+ /** Portal + Positioner + Popup 래퍼. */
50
+ export const ComboboxContent = React.forwardRef<
51
+ HTMLDivElement,
52
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseCombobox.Popup>> & {
53
+ container?: React.ComponentPropsWithoutRef<typeof BaseCombobox.Portal>["container"];
54
+ sideOffset?: number;
55
+ }
56
+ >(function ComboboxContent(
57
+ { className, children, container, sideOffset = 4, ...props },
58
+ ref,
59
+ ) {
60
+ return (
61
+ <BaseCombobox.Portal container={container}>
62
+ <BaseCombobox.Positioner
63
+ className="sh-ui-combobox__positioner"
64
+ sideOffset={sideOffset}
65
+ align="start"
66
+ >
67
+ <BaseCombobox.Popup
68
+ ref={ref}
69
+ className={cx("sh-ui-combobox__content", className)}
70
+ {...props}
71
+ >
72
+ {children}
73
+ </BaseCombobox.Popup>
74
+ </BaseCombobox.Positioner>
75
+ </BaseCombobox.Portal>
76
+ );
77
+ });
78
+
79
+ export const ComboboxList = BaseCombobox.List;
80
+
81
+ export const ComboboxItem = React.forwardRef<
82
+ HTMLDivElement,
83
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseCombobox.Item>>
84
+ >(function ComboboxItem({ className, children, ...props }, ref) {
85
+ return (
86
+ <BaseCombobox.Item
87
+ ref={ref}
88
+ className={cx("sh-ui-combobox__item", className)}
89
+ {...props}
90
+ >
91
+ <BaseCombobox.ItemIndicator className="sh-ui-combobox__item-indicator">
92
+ <CheckIcon />
93
+ </BaseCombobox.ItemIndicator>
94
+ <span className="sh-ui-combobox__item-text">{children}</span>
95
+ </BaseCombobox.Item>
96
+ );
97
+ });
98
+
99
+ export const ComboboxEmpty = React.forwardRef<
100
+ HTMLDivElement,
101
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseCombobox.Empty>>
102
+ >(function ComboboxEmpty({ className, ...props }, ref) {
103
+ return (
104
+ <BaseCombobox.Empty
105
+ ref={ref}
106
+ className={cx("sh-ui-combobox__empty", className)}
107
+ {...props}
108
+ />
109
+ );
110
+ });
111
+
112
+ export const ComboboxGroupLabel = React.forwardRef<
113
+ HTMLDivElement,
114
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseCombobox.GroupLabel>>
115
+ >(function ComboboxGroupLabel({ className, ...props }, ref) {
116
+ return (
117
+ <BaseCombobox.GroupLabel
118
+ ref={ref}
119
+ className={cx("sh-ui-combobox__group-label", className)}
120
+ {...props}
121
+ />
122
+ );
123
+ });
124
+
125
+ /** 다중 선택 칩. */
126
+ export const ComboboxChip = React.forwardRef<
127
+ HTMLDivElement,
128
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseCombobox.Chip>>
129
+ >(function ComboboxChip({ className, ...props }, ref) {
130
+ return (
131
+ <BaseCombobox.Chip
132
+ ref={ref}
133
+ className={cx("sh-ui-combobox__chip", className)}
134
+ {...props}
135
+ />
136
+ );
137
+ });
138
+
139
+ /** 칩 × 제거 버튼. */
140
+ export const ComboboxChipRemove = React.forwardRef<
141
+ HTMLButtonElement,
142
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseCombobox.ChipRemove>>
143
+ >(function ComboboxChipRemove({ className, children, ...props }, ref) {
144
+ return (
145
+ <BaseCombobox.ChipRemove
146
+ ref={ref}
147
+ className={cx("sh-ui-combobox__chip-remove", className)}
148
+ {...props}
149
+ >
150
+ {children ?? "×"}
151
+ </BaseCombobox.ChipRemove>
152
+ );
153
+ });
154
+
155
+ function CheckIcon() {
156
+ return (
157
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden>
158
+ <path
159
+ d="M3 8.5l3 3 7-7"
160
+ stroke="currentColor"
161
+ strokeWidth="2"
162
+ strokeLinecap="round"
163
+ strokeLinejoin="round"
164
+ />
165
+ </svg>
166
+ );
167
+ }
@@ -0,0 +1,151 @@
1
+ /* ───── Input (트리거 겸용) ───── */
2
+ .sh-ui-combobox__input {
3
+ display: inline-flex;
4
+ width: 100%;
5
+ min-width: 10rem;
6
+ height: var(--control-md);
7
+ padding: 0 var(--space-3);
8
+ background: var(--background);
9
+ color: var(--foreground);
10
+ border: 1px solid var(--border);
11
+ border-radius: var(--radius);
12
+ font-size: var(--text-sm);
13
+ line-height: 1;
14
+ outline: none;
15
+ transition: border-color var(--duration-fast);
16
+ }
17
+ .sh-ui-combobox__input::placeholder {
18
+ color: var(--foreground-subtle);
19
+ }
20
+ .sh-ui-combobox__input:hover:not(:disabled) {
21
+ border-color: var(--border-strong);
22
+ }
23
+ .sh-ui-combobox__input:focus-visible {
24
+ outline: var(--border-width-strong) solid var(--foreground);
25
+ outline-offset: 2px;
26
+ }
27
+ .sh-ui-combobox__input:disabled {
28
+ opacity: var(--opacity-disabled);
29
+ pointer-events: none;
30
+ }
31
+
32
+ /* ───── Popup ───── */
33
+ .sh-ui-combobox__positioner {
34
+ z-index: var(--z-dropdown);
35
+ outline: none;
36
+ width: var(--anchor-width);
37
+ }
38
+
39
+ .sh-ui-combobox__content {
40
+ max-height: min(20rem, var(--available-height));
41
+ overflow-y: auto;
42
+ padding: var(--space-1);
43
+ background: var(--background);
44
+ color: var(--foreground);
45
+ border: 1px solid var(--border);
46
+ border-radius: var(--radius);
47
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
48
+ outline: none;
49
+ transform-origin: var(--transform-origin);
50
+ transition: opacity 140ms ease, transform 140ms ease;
51
+ }
52
+ .sh-ui-combobox__content[data-starting-style],
53
+ .sh-ui-combobox__content[data-ending-style] {
54
+ opacity: 0;
55
+ transform: scale(0.97);
56
+ }
57
+
58
+ /* ───── Item ───── */
59
+ .sh-ui-combobox__item {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: var(--space-2);
63
+ padding: 0.375rem 0.75rem;
64
+ font-size: var(--text-sm);
65
+ line-height: 1.4;
66
+ border-radius: calc(var(--radius) - 2px);
67
+ cursor: pointer;
68
+ user-select: none;
69
+ outline: none;
70
+ }
71
+ .sh-ui-combobox__item[data-highlighted],
72
+ .sh-ui-combobox__item:hover {
73
+ background: var(--background-muted);
74
+ }
75
+ .sh-ui-combobox__item[data-selected] {
76
+ color: var(--foreground);
77
+ font-weight: var(--weight-medium);
78
+ }
79
+ .sh-ui-combobox__item[data-disabled] {
80
+ opacity: var(--opacity-disabled);
81
+ pointer-events: none;
82
+ }
83
+
84
+ .sh-ui-combobox__item-indicator {
85
+ order: 1;
86
+ margin-left: auto;
87
+ display: inline-flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ color: var(--foreground);
91
+ }
92
+
93
+ .sh-ui-combobox__item-text {
94
+ flex: 1;
95
+ overflow: hidden;
96
+ text-overflow: ellipsis;
97
+ white-space: nowrap;
98
+ }
99
+
100
+ /* ───── Empty ───── */
101
+ .sh-ui-combobox__empty {
102
+ padding: var(--space-3) var(--space-2);
103
+ text-align: center;
104
+ font-size: 0.8125rem;
105
+ color: var(--foreground-muted);
106
+ }
107
+
108
+ /* ───── Group label ───── */
109
+ .sh-ui-combobox__group-label {
110
+ padding: 0.375rem var(--space-2) var(--space-1);
111
+ font-size: var(--text-xs);
112
+ font-weight: var(--weight-semibold);
113
+ color: var(--foreground-muted);
114
+ text-transform: uppercase;
115
+ letter-spacing: 0.04em;
116
+ }
117
+
118
+ /* ───── Chips (multi) ───── */
119
+ .sh-ui-combobox__chip {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: var(--space-1);
123
+ padding: 0.125rem 0.375rem 0.125rem var(--space-2);
124
+ margin-right: var(--space-1);
125
+ font-size: var(--text-xs);
126
+ line-height: 1.25rem;
127
+ background: var(--background-muted);
128
+ border-radius: calc(var(--radius) - 2px);
129
+ white-space: nowrap;
130
+ }
131
+
132
+ .sh-ui-combobox__chip-remove {
133
+ display: inline-flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ width: 1rem;
137
+ height: 1rem;
138
+ padding: 0;
139
+ border: 0;
140
+ border-radius: 999px;
141
+ background: transparent;
142
+ color: var(--foreground-muted);
143
+ font-size: var(--text-sm);
144
+ line-height: 1;
145
+ cursor: pointer;
146
+ transition: background-color var(--duration-fast), color var(--duration-fast);
147
+ }
148
+ .sh-ui-combobox__chip-remove:hover {
149
+ background: var(--background);
150
+ color: var(--foreground);
151
+ }
@@ -0,0 +1,253 @@
1
+ import * as React from "react";
2
+ import { ContextMenu as BaseContextMenu } from "@base-ui-components/react/context-menu";
3
+ import "./styles.css";
4
+
5
+ type WithStringClassName<T> = Omit<T, "className"> & { className?: string };
6
+
7
+ function cx(...args: (string | undefined | false | null)[]) {
8
+ return args.filter(Boolean).join(" ");
9
+ }
10
+
11
+ /* ───────── Root ───────── */
12
+
13
+ /**
14
+ * 우클릭(또는 long-press)으로 열리는 컨텍스트 메뉴 루트. 자식으로 Trigger와 Content를 둔다.
15
+ * 사용 가능 액션이 명시적으로 보이는 게 유리한 경우엔 일반 버튼 + DropdownMenu를 권장.
16
+ */
17
+ export const ContextMenu = BaseContextMenu.Root;
18
+
19
+ /* ───────── Trigger ─────────
20
+ * 우클릭(또는 long-press)을 감지하는 wrapper. 기본은 투명, 사용자는
21
+ * 자신의 영역(Card, 이미지 등)에 적용하여 감싼다.
22
+ */
23
+
24
+ /** 우클릭/long-press를 감지할 영역. Card나 이미지 등 임의 영역을 자식으로 감싼다. */
25
+ export const ContextMenuTrigger = React.forwardRef<
26
+ HTMLDivElement,
27
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseContextMenu.Trigger>>
28
+ >(function ContextMenuTrigger({ className, ...props }, ref) {
29
+ return (
30
+ <BaseContextMenu.Trigger
31
+ ref={ref}
32
+ className={cx("sh-ui-cm__trigger", className)}
33
+ {...props}
34
+ />
35
+ );
36
+ });
37
+
38
+ /* ───────── Content ───────── */
39
+
40
+ export interface ContextMenuContentProps
41
+ extends WithStringClassName<
42
+ React.ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>
43
+ > {
44
+ /**
45
+ * Portal이 마운트될 DOM 노드.
46
+ * @default document.body
47
+ */
48
+ container?: React.ComponentPropsWithoutRef<
49
+ typeof BaseContextMenu.Portal
50
+ >["container"];
51
+ }
52
+
53
+ /** 메뉴의 실제 콘텐츠. portal로 마운트되며 클릭 위치 기준으로 자동 배치된다. */
54
+ export const ContextMenuContent = React.forwardRef<
55
+ HTMLDivElement,
56
+ ContextMenuContentProps
57
+ >(function ContextMenuContent({ className, children, container, ...props }, ref) {
58
+ return (
59
+ <BaseContextMenu.Portal container={container}>
60
+ <BaseContextMenu.Positioner className="sh-ui-cm__positioner">
61
+ <BaseContextMenu.Popup
62
+ ref={ref}
63
+ className={cx("sh-ui-cm__content", className)}
64
+ {...props}
65
+ >
66
+ {children}
67
+ </BaseContextMenu.Popup>
68
+ </BaseContextMenu.Positioner>
69
+ </BaseContextMenu.Portal>
70
+ );
71
+ });
72
+
73
+ /* ───────── Item ───────── */
74
+
75
+ /** 일반 메뉴 항목. 클릭 시 메뉴가 닫히고 onClick이 발생한다. */
76
+ export const ContextMenuItem = React.forwardRef<
77
+ HTMLDivElement,
78
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseContextMenu.Item>>
79
+ >(function ContextMenuItem({ className, ...props }, ref) {
80
+ return (
81
+ <BaseContextMenu.Item
82
+ ref={ref}
83
+ className={cx("sh-ui-cm__item", className)}
84
+ {...props}
85
+ />
86
+ );
87
+ });
88
+
89
+ /* ───────── CheckboxItem / RadioItem ───────── */
90
+
91
+ /** 체크 표시를 토글하는 메뉴 항목. 메뉴는 닫지 않고 상태만 바꾸는 옵션 ON/OFF 용도. */
92
+ export const ContextMenuCheckboxItem = React.forwardRef<
93
+ HTMLDivElement,
94
+ WithStringClassName<
95
+ React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>
96
+ >
97
+ >(function ContextMenuCheckboxItem({ className, children, ...props }, ref) {
98
+ return (
99
+ <BaseContextMenu.CheckboxItem
100
+ ref={ref}
101
+ className={cx("sh-ui-cm__item", "sh-ui-cm__item--check", className)}
102
+ {...props}
103
+ >
104
+ <span className="sh-ui-cm__item-indicator" aria-hidden>
105
+ <BaseContextMenu.CheckboxItemIndicator>
106
+ <CheckIcon />
107
+ </BaseContextMenu.CheckboxItemIndicator>
108
+ </span>
109
+ <span className="sh-ui-cm__item-text">{children}</span>
110
+ </BaseContextMenu.CheckboxItem>
111
+ );
112
+ });
113
+
114
+ /** RadioItem들을 단일 선택 그룹으로 묶는 컨테이너. `value`로 선택값을 제어. */
115
+ export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup;
116
+
117
+ /** ContextMenuRadioGroup 내부의 단일 선택 항목. */
118
+ export const ContextMenuRadioItem = React.forwardRef<
119
+ HTMLDivElement,
120
+ WithStringClassName<
121
+ React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>
122
+ >
123
+ >(function ContextMenuRadioItem({ className, children, ...props }, ref) {
124
+ return (
125
+ <BaseContextMenu.RadioItem
126
+ ref={ref}
127
+ className={cx("sh-ui-cm__item", "sh-ui-cm__item--check", className)}
128
+ {...props}
129
+ >
130
+ <span className="sh-ui-cm__item-indicator" aria-hidden>
131
+ <BaseContextMenu.RadioItemIndicator>
132
+ <DotIcon />
133
+ </BaseContextMenu.RadioItemIndicator>
134
+ </span>
135
+ <span className="sh-ui-cm__item-text">{children}</span>
136
+ </BaseContextMenu.RadioItem>
137
+ );
138
+ });
139
+
140
+ /* ───────── Group / Label / Separator ───────── */
141
+
142
+ /** 의미적으로 묶이는 항목 그룹. ContextMenuLabel과 함께 사용해 카테고리 헤더를 붙인다. */
143
+ export const ContextMenuGroup = React.forwardRef<
144
+ HTMLDivElement,
145
+ WithStringClassName<React.ComponentPropsWithoutRef<typeof BaseContextMenu.Group>>
146
+ >(function ContextMenuGroup({ className, ...props }, ref) {
147
+ return (
148
+ <BaseContextMenu.Group
149
+ ref={ref}
150
+ className={cx("sh-ui-cm__group", className)}
151
+ {...props}
152
+ />
153
+ );
154
+ });
155
+
156
+ /** 그룹의 카테고리 헤더 라벨. 클릭 불가, 시각·접근성 컨텍스트 제공용. */
157
+ export const ContextMenuLabel = React.forwardRef<
158
+ HTMLDivElement,
159
+ WithStringClassName<
160
+ React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>
161
+ >
162
+ >(function ContextMenuLabel({ className, ...props }, ref) {
163
+ return (
164
+ <BaseContextMenu.GroupLabel
165
+ ref={ref}
166
+ className={cx("sh-ui-cm__label", className)}
167
+ {...props}
168
+ />
169
+ );
170
+ });
171
+
172
+ /** 항목 사이의 시각적 구분선. */
173
+ export const ContextMenuSeparator = React.forwardRef<
174
+ HTMLDivElement,
175
+ React.HTMLAttributes<HTMLDivElement>
176
+ >(function ContextMenuSeparator({ className, ...props }, ref) {
177
+ return (
178
+ <div
179
+ ref={ref}
180
+ role="separator"
181
+ aria-orientation="horizontal"
182
+ className={cx("sh-ui-cm__separator", className)}
183
+ {...props}
184
+ />
185
+ );
186
+ });
187
+
188
+ /* ───────── Submenu ───────── */
189
+
190
+ /** 서브메뉴 루트. 호버/포커스 시 우측으로 자식 메뉴를 펼친다. */
191
+ export const ContextMenuSub = BaseContextMenu.SubmenuRoot;
192
+
193
+ /** 서브메뉴를 여는 항목. 우측 화살표 아이콘이 자동 부착된다. */
194
+ export const ContextMenuSubTrigger = React.forwardRef<
195
+ HTMLDivElement,
196
+ WithStringClassName<
197
+ React.ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger>
198
+ >
199
+ >(function ContextMenuSubTrigger({ className, children, ...props }, ref) {
200
+ return (
201
+ <BaseContextMenu.SubmenuTrigger
202
+ ref={ref}
203
+ className={cx("sh-ui-cm__item", "sh-ui-cm__sub-trigger", className)}
204
+ {...props}
205
+ >
206
+ <span className="sh-ui-cm__item-text">{children}</span>
207
+ <span className="sh-ui-cm__sub-arrow" aria-hidden>
208
+ <ChevronRightIcon />
209
+ </span>
210
+ </BaseContextMenu.SubmenuTrigger>
211
+ );
212
+ });
213
+
214
+ /** 서브메뉴의 콘텐츠. ContextMenuContent의 별칭이다. */
215
+ export const ContextMenuSubContent = ContextMenuContent;
216
+
217
+ /* ───────── 기본 아이콘 ───────── */
218
+
219
+ function CheckIcon() {
220
+ return (
221
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
222
+ <path
223
+ d="M3.5 8.5l3 3 6-7"
224
+ stroke="currentColor"
225
+ strokeWidth="1.75"
226
+ strokeLinecap="round"
227
+ strokeLinejoin="round"
228
+ />
229
+ </svg>
230
+ );
231
+ }
232
+
233
+ function DotIcon() {
234
+ return (
235
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" aria-hidden>
236
+ <circle cx="4" cy="4" r="3" />
237
+ </svg>
238
+ );
239
+ }
240
+
241
+ function ChevronRightIcon() {
242
+ return (
243
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
244
+ <path
245
+ d="M6 4l4 4-4 4"
246
+ stroke="currentColor"
247
+ strokeWidth="1.5"
248
+ strokeLinecap="round"
249
+ strokeLinejoin="round"
250
+ />
251
+ </svg>
252
+ );
253
+ }
@@ -0,0 +1,140 @@
1
+ /* ContextMenu는 DropdownMenu와 동일한 시각 언어를 유지한다.
2
+ 트리거만 우클릭 감지용 투명 wrapper. */
3
+
4
+ .sh-ui-cm__trigger {
5
+ /* 기본은 투명 — 사용자가 자신의 요소를 그대로 사용 */
6
+ display: contents;
7
+ }
8
+
9
+ /* ───────── Positioner / Content ───────── */
10
+
11
+ .sh-ui-cm__positioner {
12
+ outline: none;
13
+ z-index: var(--z-dropdown);
14
+ }
15
+
16
+ .sh-ui-cm__content {
17
+ min-width: 10rem;
18
+ max-height: min(24rem, var(--available-height, 24rem));
19
+ overflow-y: auto;
20
+ padding: var(--space-1);
21
+ background: var(--background);
22
+ color: var(--foreground);
23
+ border: 1px solid var(--border);
24
+ border-radius: var(--radius);
25
+ box-shadow:
26
+ 0 4px 6px -1px rgba(0, 0, 0, 0.08),
27
+ 0 2px 4px -2px rgba(0, 0, 0, 0.05);
28
+ font-size: var(--text-sm);
29
+ transform-origin: var(--transform-origin);
30
+ animation: sh-ui-cm-in 140ms ease-out;
31
+ outline: none;
32
+ }
33
+
34
+ .sh-ui-cm__content[data-ending-style] {
35
+ animation: sh-ui-cm-out 100ms ease-in forwards;
36
+ }
37
+
38
+ @keyframes sh-ui-cm-in {
39
+ from { opacity: 0; transform: scale(0.96); }
40
+ to { opacity: 1; transform: scale(1); }
41
+ }
42
+ @keyframes sh-ui-cm-out {
43
+ from { opacity: 1; transform: scale(1); }
44
+ to { opacity: 0; transform: scale(0.96); }
45
+ }
46
+
47
+ /* ───────── Item ───────── */
48
+
49
+ .sh-ui-cm__item {
50
+ position: relative;
51
+ display: flex;
52
+ align-items: center;
53
+ gap: var(--space-2);
54
+ padding: 0.5rem 0.75rem;
55
+ border-radius: calc(var(--radius) - 2px);
56
+ cursor: pointer;
57
+ outline: none;
58
+ user-select: none;
59
+ transition: background-color 80ms;
60
+ }
61
+
62
+ .sh-ui-cm__item[data-highlighted],
63
+ .sh-ui-cm__item:hover {
64
+ background: var(--background-muted);
65
+ }
66
+
67
+ .sh-ui-cm__item[data-disabled] {
68
+ opacity: var(--opacity-disabled);
69
+ pointer-events: none;
70
+ }
71
+
72
+ .sh-ui-cm__item-text {
73
+ flex: 1;
74
+ min-width: 0;
75
+ overflow: hidden;
76
+ text-overflow: ellipsis;
77
+ white-space: nowrap;
78
+ }
79
+
80
+ .sh-ui-cm__item--check {
81
+ padding-left: 1.75rem;
82
+ }
83
+
84
+ .sh-ui-cm__item-indicator {
85
+ position: absolute;
86
+ left: 0.5rem;
87
+ display: inline-flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ width: 1rem;
91
+ height: 1rem;
92
+ color: var(--foreground);
93
+ }
94
+
95
+ /* ───────── Group / Label / Separator ───────── */
96
+
97
+ .sh-ui-cm__group {
98
+ padding: 0;
99
+ }
100
+
101
+ .sh-ui-cm__label {
102
+ padding: var(--space-2) var(--space-2) var(--space-1);
103
+ font-size: var(--text-xs);
104
+ font-weight: var(--weight-semibold);
105
+ color: var(--foreground-muted);
106
+ text-transform: uppercase;
107
+ letter-spacing: 0.04em;
108
+ }
109
+
110
+ .sh-ui-cm__separator {
111
+ height: 1px;
112
+ background: var(--border);
113
+ margin: var(--space-1) 0;
114
+ }
115
+
116
+ /* ───────── Submenu trigger ───────── */
117
+
118
+ .sh-ui-cm__sub-arrow {
119
+ display: inline-flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ margin-left: auto;
123
+ color: var(--foreground-muted);
124
+ }
125
+
126
+ .sh-ui-cm__sub-trigger[data-popup-open] {
127
+ background: var(--background-muted);
128
+ }
129
+
130
+ /* ───────── Reduced motion ───────── */
131
+
132
+ @media (prefers-reduced-motion: reduce) {
133
+ .sh-ui-cm__content,
134
+ .sh-ui-cm__content[data-ending-style] {
135
+ animation: none;
136
+ }
137
+ .sh-ui-cm__item {
138
+ transition: none;
139
+ }
140
+ }