sh-ui-cli 0.118.0 → 0.120.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.
@@ -2,6 +2,30 @@
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.120.0",
7
+ "date": "2026-06-22",
8
+ "title": "Flutter ShUiTable — 정렬 데이터 테이블 위젯",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "신규 Flutter ShUiTable<T> — ShUiTableColumn(id/header/cell/sortKey/align) 정의 + 행 데이터로 렌더하는 데이터 테이블",
12
+ "헤더 탭 정렬(▲/▼, asc/desc) 내장, 밀도 sm/md, zebra, caption, Semantics. sortKey 있는 컬럼만 정렬 가능",
13
+ "sh-ui 토큰 테마. 설치: sh-ui add table (flutter)"
14
+ ],
15
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.120.0"
16
+ },
17
+ {
18
+ "version": "0.119.0",
19
+ "date": "2026-06-19",
20
+ "title": "Command 컴포넌트 — Cmd+K 명령 팔레트",
21
+ "type": "minor",
22
+ "highlights": [
23
+ "신규 Command — cmdk 위 sh-ui 명령 팔레트(9 컴포넌트). CommandDialog 로 Cmd+K 팝업(sh-ui Dialog 기반)",
24
+ "검색 필터·키보드 네비·접근성은 cmdk 에 위임, sh-ui 토큰 스타일. CommandItem value/keywords/onSelect 로 액션 실행",
25
+ "shadcn command 모델. docs dogfooding·액션 자동화는 후속 phase. Flutter 별도"
26
+ ],
27
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.119.0"
28
+ },
5
29
  {
6
30
  "version": "0.118.0",
7
31
  "date": "2026-06-18",
@@ -340,6 +340,15 @@
340
340
  ],
341
341
  "dependencies": [],
342
342
  "registryDependencies": ["tokens"]
343
+ },
344
+ "table": {
345
+ "name": "table",
346
+ "type": "widget",
347
+ "files": [
348
+ { "src": "widgets/sh_ui_table.dart", "dest": "{widgets}/sh_ui_table.dart" }
349
+ ],
350
+ "dependencies": [],
351
+ "registryDependencies": ["tokens"]
343
352
  }
344
353
  }
345
354
  }
@@ -0,0 +1,278 @@
1
+ import 'package:flutter/material.dart';
2
+ import '../foundation/sh_ui_tokens.dart';
3
+
4
+ /// 테이블 밀도. [sm]은 행 높이·폰트가 더 컴팩트한 변형.
5
+ enum ShUiTableSize { sm, md }
6
+
7
+ /// 정렬 방향.
8
+ enum ShUiSortDirection { ascending, descending }
9
+
10
+ /// 테이블 컬럼 정의.
11
+ class ShUiTableColumn<T> {
12
+ /// 정렬 상태 추적 키.
13
+ final String id;
14
+
15
+ /// 헤더에 표시될 라벨.
16
+ final String header;
17
+
18
+ /// 행에서 셀 표시 텍스트를 뽑는 접근자.
19
+ final String Function(T row) cell;
20
+
21
+ /// 정렬 키 접근자. 제공되면 이 컬럼은 헤더 탭으로 정렬 가능해진다.
22
+ final Comparable<Object?> Function(T row)? sortKey;
23
+
24
+ /// 셀/헤더 텍스트 정렬. 기본 [TextAlign.start].
25
+ final TextAlign align;
26
+
27
+ const ShUiTableColumn({
28
+ required this.id,
29
+ required this.header,
30
+ required this.cell,
31
+ this.sortKey,
32
+ this.align = TextAlign.start,
33
+ });
34
+
35
+ bool get sortable => sortKey != null;
36
+ }
37
+
38
+ /// shUi Table — 컬럼 정의 + 행 데이터로 렌더하는 themed 데이터 테이블.
39
+ ///
40
+ /// 정렬 가능 컬럼(헤더 탭 → ▲/▼, 단일 컬럼 asc/desc 토글)을 내장한다.
41
+ /// sh-ui 토큰으로 테마링하며 Semantics(스크린 리더)를 제공한다.
42
+ class ShUiTable<T> extends StatefulWidget {
43
+ final List<ShUiTableColumn<T>> columns;
44
+ final List<T> rows;
45
+ final ShUiTableSize size;
46
+ final bool zebra;
47
+ final String? caption;
48
+ final String? initialSortColumnId;
49
+ final ShUiSortDirection initialSortDirection;
50
+
51
+ const ShUiTable({
52
+ super.key,
53
+ required this.columns,
54
+ required this.rows,
55
+ this.size = ShUiTableSize.md,
56
+ this.zebra = false,
57
+ this.caption,
58
+ this.initialSortColumnId,
59
+ this.initialSortDirection = ShUiSortDirection.ascending,
60
+ });
61
+
62
+ @override
63
+ State<ShUiTable<T>> createState() => _ShUiTableState<T>();
64
+ }
65
+
66
+ class _ShUiTableState<T> extends State<ShUiTable<T>> {
67
+ String? _sortId;
68
+ late ShUiSortDirection _sortDir;
69
+
70
+ @override
71
+ void initState() {
72
+ super.initState();
73
+ _sortId = widget.initialSortColumnId;
74
+ _sortDir = widget.initialSortDirection;
75
+ }
76
+
77
+ void _onHeaderTap(ShUiTableColumn<T> col) {
78
+ if (!col.sortable) return;
79
+ setState(() {
80
+ if (_sortId == col.id) {
81
+ _sortDir = _sortDir == ShUiSortDirection.ascending
82
+ ? ShUiSortDirection.descending
83
+ : ShUiSortDirection.ascending;
84
+ } else {
85
+ _sortId = col.id;
86
+ _sortDir = ShUiSortDirection.ascending;
87
+ }
88
+ });
89
+ }
90
+
91
+ List<T> _sortedRows() {
92
+ final id = _sortId;
93
+ if (id == null) return widget.rows;
94
+ ShUiTableColumn<T>? col;
95
+ for (final c in widget.columns) {
96
+ if (c.id == id) {
97
+ col = c;
98
+ break;
99
+ }
100
+ }
101
+ final key = col?.sortKey;
102
+ if (key == null) return widget.rows;
103
+ final copy = List<T>.from(widget.rows);
104
+ copy.sort((a, b) {
105
+ final cmp = key(a).compareTo(key(b));
106
+ return _sortDir == ShUiSortDirection.ascending ? cmp : -cmp;
107
+ });
108
+ return copy;
109
+ }
110
+
111
+ @override
112
+ Widget build(BuildContext context) {
113
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
114
+ final colors = shUi.colors;
115
+ final sm = widget.size == ShUiTableSize.sm;
116
+ final fontSize = sm ? shUi.text.xs : shUi.text.sm;
117
+ final vPad = sm ? shUi.spacing.s2 : shUi.spacing.s3;
118
+ final hPad = shUi.spacing.s3;
119
+ final rows = _sortedRows();
120
+
121
+ return Column(
122
+ crossAxisAlignment: CrossAxisAlignment.start,
123
+ mainAxisSize: MainAxisSize.min,
124
+ children: [
125
+ DecoratedBox(
126
+ decoration: BoxDecoration(
127
+ border: Border.all(color: colors.border, width: shUi.borderWidth.normal),
128
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
129
+ ),
130
+ child: ClipRRect(
131
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
132
+ child: Column(
133
+ mainAxisSize: MainAxisSize.min,
134
+ children: [
135
+ _buildHeader(shUi, colors, fontSize, vPad, hPad),
136
+ for (var i = 0; i < rows.length; i++)
137
+ _buildBodyRow(
138
+ shUi,
139
+ colors,
140
+ fontSize,
141
+ vPad,
142
+ hPad,
143
+ rows[i],
144
+ i,
145
+ isLast: i == rows.length - 1,
146
+ ),
147
+ ],
148
+ ),
149
+ ),
150
+ ),
151
+ if (widget.caption != null) ...[
152
+ SizedBox(height: shUi.spacing.s2),
153
+ Text(
154
+ widget.caption!,
155
+ style: TextStyle(color: colors.foregroundMuted, fontSize: shUi.text.xs),
156
+ ),
157
+ ],
158
+ ],
159
+ );
160
+ }
161
+
162
+ Widget _buildHeader(
163
+ ShUiTheme shUi,
164
+ ShUiColorTokens colors,
165
+ double fontSize,
166
+ double vPad,
167
+ double hPad,
168
+ ) {
169
+ return Container(
170
+ color: colors.backgroundMuted,
171
+ child: Row(
172
+ children: [
173
+ for (final col in widget.columns)
174
+ Expanded(child: _headerCell(shUi, colors, fontSize, vPad, hPad, col)),
175
+ ],
176
+ ),
177
+ );
178
+ }
179
+
180
+ Widget _headerCell(
181
+ ShUiTheme shUi,
182
+ ShUiColorTokens colors,
183
+ double fontSize,
184
+ double vPad,
185
+ double hPad,
186
+ ShUiTableColumn<T> col,
187
+ ) {
188
+ final isSorted = _sortId == col.id;
189
+ final arrow = !isSorted
190
+ ? null
191
+ : (_sortDir == ShUiSortDirection.ascending ? '▲' : '▼');
192
+ final label = Row(
193
+ mainAxisAlignment: col.align == TextAlign.end
194
+ ? MainAxisAlignment.end
195
+ : MainAxisAlignment.start,
196
+ children: [
197
+ Flexible(
198
+ child: Text(
199
+ col.header,
200
+ textAlign: col.align,
201
+ overflow: TextOverflow.ellipsis,
202
+ style: TextStyle(
203
+ color: colors.foregroundMuted,
204
+ fontSize: fontSize,
205
+ fontWeight: shUi.weight.medium,
206
+ ),
207
+ ),
208
+ ),
209
+ if (arrow != null) ...[
210
+ const SizedBox(width: 4),
211
+ Text(
212
+ arrow,
213
+ style: TextStyle(color: colors.foregroundMuted, fontSize: fontSize),
214
+ ),
215
+ ],
216
+ ],
217
+ );
218
+ final padded = Padding(
219
+ padding: EdgeInsets.symmetric(vertical: vPad, horizontal: hPad),
220
+ child: label,
221
+ );
222
+ if (!col.sortable) {
223
+ return Semantics(header: true, child: padded);
224
+ }
225
+ return Semantics(
226
+ header: true,
227
+ button: true,
228
+ label: '${col.header}, 정렬'
229
+ '${isSorted ? (_sortDir == ShUiSortDirection.ascending ? ', 오름차순' : ', 내림차순') : ''}',
230
+ child: InkWell(onTap: () => _onHeaderTap(col), child: padded),
231
+ );
232
+ }
233
+
234
+ Widget _buildBodyRow(
235
+ ShUiTheme shUi,
236
+ ShUiColorTokens colors,
237
+ double fontSize,
238
+ double vPad,
239
+ double hPad,
240
+ T row,
241
+ int index, {
242
+ required bool isLast,
243
+ }) {
244
+ final bg = widget.zebra && index.isOdd
245
+ ? colors.backgroundSubtle
246
+ : colors.background;
247
+ return DecoratedBox(
248
+ decoration: BoxDecoration(
249
+ color: bg,
250
+ border: isLast
251
+ ? null
252
+ : Border(
253
+ bottom: BorderSide(color: colors.border, width: shUi.borderWidth.normal),
254
+ ),
255
+ ),
256
+ child: Row(
257
+ children: [
258
+ for (final col in widget.columns)
259
+ Expanded(
260
+ child: Padding(
261
+ padding: EdgeInsets.symmetric(vertical: vPad, horizontal: hPad),
262
+ child: Text(
263
+ col.cell(row),
264
+ textAlign: col.align,
265
+ overflow: TextOverflow.ellipsis,
266
+ style: TextStyle(
267
+ color: colors.foreground,
268
+ fontSize: fontSize,
269
+ fontWeight: shUi.weight.regular,
270
+ ),
271
+ ),
272
+ ),
273
+ ),
274
+ ],
275
+ ),
276
+ );
277
+ }
278
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import * as React from "react";
4
+ import {
5
+ Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator, CommandShortcut,
6
+ } from "./index";
7
+
8
+ function Sample({ onSelect }: { onSelect?: (v: string) => void }) {
9
+ return (
10
+ <Command>
11
+ <CommandInput placeholder="검색" />
12
+ <CommandList>
13
+ <CommandEmpty>결과 없음</CommandEmpty>
14
+ <CommandGroup heading="페이지">
15
+ <CommandItem value="components" keywords={["컴포넌트"]} onSelect={() => onSelect?.("components")}>
16
+ 컴포넌트 <CommandShortcut>⌘C</CommandShortcut>
17
+ </CommandItem>
18
+ <CommandItem value="tokens" keywords={["토큰"]} onSelect={() => onSelect?.("tokens")}>토큰</CommandItem>
19
+ </CommandGroup>
20
+ <CommandSeparator />
21
+ <CommandGroup heading="액션">
22
+ <CommandItem value="theme" keywords={["테마 전환"]} onSelect={() => onSelect?.("theme")}>테마 전환</CommandItem>
23
+ </CommandGroup>
24
+ </CommandList>
25
+ </Command>
26
+ );
27
+ }
28
+
29
+ describe("Command", () => {
30
+ it("그룹/아이템을 렌더", () => {
31
+ render(<Sample />);
32
+ expect(screen.getByText("컴포넌트")).toBeTruthy();
33
+ expect(screen.getByText("페이지")).toBeTruthy();
34
+ expect(screen.getByRole("option", { name: /토큰/ })).toBeTruthy();
35
+ });
36
+
37
+ it("입력으로 아이템을 필터", () => {
38
+ render(<Sample />);
39
+ fireEvent.change(screen.getByPlaceholderText("검색"), { target: { value: "테마" } });
40
+ expect(screen.getByText("테마 전환")).toBeTruthy();
41
+ expect(screen.queryByText("토큰")).toBeNull();
42
+ });
43
+
44
+ it("매칭 없으면 CommandEmpty 표시", () => {
45
+ render(<Sample />);
46
+ fireEvent.change(screen.getByPlaceholderText("검색"), { target: { value: "zzzzz" } });
47
+ expect(screen.getByText("결과 없음")).toBeTruthy();
48
+ });
49
+
50
+ it("아이템 클릭이 onSelect 호출", () => {
51
+ const onSelect = vi.fn();
52
+ render(<Sample onSelect={onSelect} />);
53
+ fireEvent.click(screen.getByText("토큰"));
54
+ expect(onSelect).toHaveBeenCalledWith("tokens");
55
+ });
56
+ });
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+ import { Command as CommandPrimitive } from "cmdk";
3
+ import styles from "./styles.module.css";
4
+ import { cn } from "@SH_UI_UTILS@";
5
+ import { Dialog, DialogContent, DialogTitle } from "../dialog";
6
+
7
+ export const Command = React.forwardRef<
8
+ React.ElementRef<typeof CommandPrimitive>,
9
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
10
+ >(({ className, ...props }, ref) => (
11
+ <CommandPrimitive ref={ref} className={cn(styles.command, className)} {...props} />
12
+ ));
13
+ Command.displayName = "Command";
14
+
15
+ export interface CommandDialogProps
16
+ extends React.ComponentPropsWithoutRef<typeof CommandPrimitive> {
17
+ open?: boolean;
18
+ onOpenChange?: (open: boolean) => void;
19
+ title?: string;
20
+ }
21
+
22
+ export function CommandDialog({ open, onOpenChange, title = "명령 팔레트", children, ...props }: CommandDialogProps) {
23
+ return (
24
+ <Dialog open={open} onOpenChange={onOpenChange}>
25
+ <DialogContent className={styles["command__dialog"]}>
26
+ <DialogTitle className={styles["command__sr-only"]}>{title}</DialogTitle>
27
+ <Command {...props}>{children}</Command>
28
+ </DialogContent>
29
+ </Dialog>
30
+ );
31
+ }
32
+
33
+ export const CommandInput = React.forwardRef<
34
+ React.ElementRef<typeof CommandPrimitive.Input>,
35
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
36
+ >(({ className, ...props }, ref) => (
37
+ <div className={styles["command__input-wrapper"]}>
38
+ <CommandPrimitive.Input ref={ref} className={cn(styles.command__input, className)} {...props} />
39
+ </div>
40
+ ));
41
+ CommandInput.displayName = "CommandInput";
42
+
43
+ export const CommandList = React.forwardRef<
44
+ React.ElementRef<typeof CommandPrimitive.List>,
45
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
46
+ >(({ className, ...props }, ref) => (
47
+ <CommandPrimitive.List ref={ref} className={cn(styles.command__list, className)} {...props} />
48
+ ));
49
+ CommandList.displayName = "CommandList";
50
+
51
+ export const CommandEmpty = React.forwardRef<
52
+ React.ElementRef<typeof CommandPrimitive.Empty>,
53
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
54
+ >(({ className, ...props }, ref) => (
55
+ <CommandPrimitive.Empty ref={ref} className={cn(styles.command__empty, className)} {...props} />
56
+ ));
57
+ CommandEmpty.displayName = "CommandEmpty";
58
+
59
+ export const CommandGroup = React.forwardRef<
60
+ React.ElementRef<typeof CommandPrimitive.Group>,
61
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
62
+ >(({ className, ...props }, ref) => (
63
+ <CommandPrimitive.Group ref={ref} className={cn(styles.command__group, className)} {...props} />
64
+ ));
65
+ CommandGroup.displayName = "CommandGroup";
66
+
67
+ export const CommandSeparator = React.forwardRef<
68
+ React.ElementRef<typeof CommandPrimitive.Separator>,
69
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
70
+ >(({ className, ...props }, ref) => (
71
+ <CommandPrimitive.Separator ref={ref} className={cn(styles.command__separator, className)} {...props} />
72
+ ));
73
+ CommandSeparator.displayName = "CommandSeparator";
74
+
75
+ export const CommandItem = React.forwardRef<
76
+ React.ElementRef<typeof CommandPrimitive.Item>,
77
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
78
+ >(({ className, ...props }, ref) => (
79
+ <CommandPrimitive.Item ref={ref} className={cn(styles.command__item, className)} {...props} />
80
+ ));
81
+ CommandItem.displayName = "CommandItem";
82
+
83
+ export function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
84
+ return <span className={cn(styles.command__shortcut, className)} {...props} />;
85
+ }
86
+ CommandShortcut.displayName = "CommandShortcut";
@@ -0,0 +1,130 @@
1
+ import * as React from "react";
2
+ import { Command as CommandPrimitive } from "cmdk";
3
+ import { cn } from "@SH_UI_UTILS@";
4
+ import { Dialog, DialogContent, DialogTitle } from "../dialog";
5
+
6
+ export const Command = React.forwardRef<
7
+ React.ElementRef<typeof CommandPrimitive>,
8
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
9
+ >(({ className, ...props }, ref) => (
10
+ <CommandPrimitive
11
+ ref={ref}
12
+ className={cn(
13
+ "flex flex-col w-full bg-[var(--popover,var(--background))] text-foreground rounded-[var(--radius)] overflow-hidden",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ ));
19
+ Command.displayName = "Command";
20
+
21
+ export interface CommandDialogProps
22
+ extends React.ComponentPropsWithoutRef<typeof CommandPrimitive> {
23
+ open?: boolean;
24
+ onOpenChange?: (open: boolean) => void;
25
+ title?: string;
26
+ }
27
+
28
+ export function CommandDialog({ open, onOpenChange, title = "명령 팔레트", children, ...props }: CommandDialogProps) {
29
+ return (
30
+ <Dialog open={open} onOpenChange={onOpenChange}>
31
+ <DialogContent className="p-0 max-w-2xl">
32
+ <DialogTitle className="sr-only">{title}</DialogTitle>
33
+ <Command {...props}>{children}</Command>
34
+ </DialogContent>
35
+ </Dialog>
36
+ );
37
+ }
38
+
39
+ export const CommandInput = React.forwardRef<
40
+ React.ElementRef<typeof CommandPrimitive.Input>,
41
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
42
+ >(({ className, ...props }, ref) => (
43
+ <div className="border-b border-border px-[var(--space-3)]">
44
+ <CommandPrimitive.Input
45
+ ref={ref}
46
+ className={cn(
47
+ "w-full h-[var(--control-md)] bg-transparent border-none outline-none text-foreground text-[length:var(--text-sm)] placeholder:text-foreground-muted",
48
+ className
49
+ )}
50
+ {...props}
51
+ />
52
+ </div>
53
+ ));
54
+ CommandInput.displayName = "CommandInput";
55
+
56
+ export const CommandList = React.forwardRef<
57
+ React.ElementRef<typeof CommandPrimitive.List>,
58
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
59
+ >(({ className, ...props }, ref) => (
60
+ <CommandPrimitive.List
61
+ ref={ref}
62
+ className={cn("max-h-80 overflow-y-auto p-[var(--space-1)]", className)}
63
+ {...props}
64
+ />
65
+ ));
66
+ CommandList.displayName = "CommandList";
67
+
68
+ export const CommandEmpty = React.forwardRef<
69
+ React.ElementRef<typeof CommandPrimitive.Empty>,
70
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
71
+ >(({ className, ...props }, ref) => (
72
+ <CommandPrimitive.Empty
73
+ ref={ref}
74
+ className={cn("p-[var(--space-4)] text-center text-foreground-muted text-[length:var(--text-sm)]", className)}
75
+ {...props}
76
+ />
77
+ ));
78
+ CommandEmpty.displayName = "CommandEmpty";
79
+
80
+ export const CommandGroup = React.forwardRef<
81
+ React.ElementRef<typeof CommandPrimitive.Group>,
82
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
83
+ >(({ className, ...props }, ref) => (
84
+ <CommandPrimitive.Group
85
+ ref={ref}
86
+ className={cn(
87
+ "[&_[cmdk-group-heading]]:px-[var(--space-2)] [&_[cmdk-group-heading]]:py-[var(--space-1)] [&_[cmdk-group-heading]]:text-[length:var(--text-xs)] [&_[cmdk-group-heading]]:text-foreground-muted",
88
+ className
89
+ )}
90
+ {...props}
91
+ />
92
+ ));
93
+ CommandGroup.displayName = "CommandGroup";
94
+
95
+ export const CommandSeparator = React.forwardRef<
96
+ React.ElementRef<typeof CommandPrimitive.Separator>,
97
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
98
+ >(({ className, ...props }, ref) => (
99
+ <CommandPrimitive.Separator
100
+ ref={ref}
101
+ className={cn("h-px bg-border my-[var(--space-1)]", className)}
102
+ {...props}
103
+ />
104
+ ));
105
+ CommandSeparator.displayName = "CommandSeparator";
106
+
107
+ export const CommandItem = React.forwardRef<
108
+ React.ElementRef<typeof CommandPrimitive.Item>,
109
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
110
+ >(({ className, ...props }, ref) => (
111
+ <CommandPrimitive.Item
112
+ ref={ref}
113
+ className={cn(
114
+ "flex items-center gap-[var(--space-2)] px-[var(--space-2)] py-[var(--space-2)] rounded-[calc(var(--radius)-2px)] text-[length:var(--text-sm)] cursor-pointer select-none data-[selected=true]:bg-background-muted aria-selected:bg-background-muted data-[disabled=true]:text-foreground-muted",
115
+ className
116
+ )}
117
+ {...props}
118
+ />
119
+ ));
120
+ CommandItem.displayName = "CommandItem";
121
+
122
+ export function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
123
+ return (
124
+ <span
125
+ className={cn("ms-auto text-[length:var(--text-xs)] text-foreground-muted tracking-wider", className)}
126
+ {...props}
127
+ />
128
+ );
129
+ }
130
+ CommandShortcut.displayName = "CommandShortcut";
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+ import { Command as CommandPrimitive } from "cmdk";
3
+ import "./styles.css";
4
+ import { cn } from "@SH_UI_UTILS@";
5
+ import { Dialog, DialogContent, DialogTitle } from "../dialog";
6
+
7
+ export const Command = React.forwardRef<
8
+ React.ElementRef<typeof CommandPrimitive>,
9
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
10
+ >(({ className, ...props }, ref) => (
11
+ <CommandPrimitive ref={ref} className={cn("sh-ui-command", className)} {...props} />
12
+ ));
13
+ Command.displayName = "Command";
14
+
15
+ export interface CommandDialogProps
16
+ extends React.ComponentPropsWithoutRef<typeof CommandPrimitive> {
17
+ open?: boolean;
18
+ onOpenChange?: (open: boolean) => void;
19
+ title?: string;
20
+ }
21
+
22
+ export function CommandDialog({ open, onOpenChange, title = "명령 팔레트", children, ...props }: CommandDialogProps) {
23
+ return (
24
+ <Dialog open={open} onOpenChange={onOpenChange}>
25
+ <DialogContent className="sh-ui-command__dialog">
26
+ <DialogTitle className="sh-ui-command__sr-only">{title}</DialogTitle>
27
+ <Command {...props}>{children}</Command>
28
+ </DialogContent>
29
+ </Dialog>
30
+ );
31
+ }
32
+
33
+ export const CommandInput = React.forwardRef<
34
+ React.ElementRef<typeof CommandPrimitive.Input>,
35
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
36
+ >(({ className, ...props }, ref) => (
37
+ <div className="sh-ui-command__input-wrapper">
38
+ <CommandPrimitive.Input ref={ref} className={cn("sh-ui-command__input", className)} {...props} />
39
+ </div>
40
+ ));
41
+ CommandInput.displayName = "CommandInput";
42
+
43
+ export const CommandList = React.forwardRef<
44
+ React.ElementRef<typeof CommandPrimitive.List>,
45
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
46
+ >(({ className, ...props }, ref) => (
47
+ <CommandPrimitive.List ref={ref} className={cn("sh-ui-command__list", className)} {...props} />
48
+ ));
49
+ CommandList.displayName = "CommandList";
50
+
51
+ export const CommandEmpty = React.forwardRef<
52
+ React.ElementRef<typeof CommandPrimitive.Empty>,
53
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
54
+ >(({ className, ...props }, ref) => (
55
+ <CommandPrimitive.Empty ref={ref} className={cn("sh-ui-command__empty", className)} {...props} />
56
+ ));
57
+ CommandEmpty.displayName = "CommandEmpty";
58
+
59
+ export const CommandGroup = React.forwardRef<
60
+ React.ElementRef<typeof CommandPrimitive.Group>,
61
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
62
+ >(({ className, ...props }, ref) => (
63
+ <CommandPrimitive.Group ref={ref} className={cn("sh-ui-command__group", className)} {...props} />
64
+ ));
65
+ CommandGroup.displayName = "CommandGroup";
66
+
67
+ export const CommandSeparator = React.forwardRef<
68
+ React.ElementRef<typeof CommandPrimitive.Separator>,
69
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
70
+ >(({ className, ...props }, ref) => (
71
+ <CommandPrimitive.Separator ref={ref} className={cn("sh-ui-command__separator", className)} {...props} />
72
+ ));
73
+ CommandSeparator.displayName = "CommandSeparator";
74
+
75
+ export const CommandItem = React.forwardRef<
76
+ React.ElementRef<typeof CommandPrimitive.Item>,
77
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
78
+ >(({ className, ...props }, ref) => (
79
+ <CommandPrimitive.Item ref={ref} className={cn("sh-ui-command__item", className)} {...props} />
80
+ ));
81
+ CommandItem.displayName = "CommandItem";
82
+
83
+ export function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
84
+ return <span className={cn("sh-ui-command__shortcut", className)} {...props} />;
85
+ }
86
+ CommandShortcut.displayName = "CommandShortcut";
@@ -0,0 +1,33 @@
1
+ .sh-ui-command {
2
+ display: flex; flex-direction: column; width: 100%;
3
+ background: var(--popover, var(--background)); color: var(--foreground);
4
+ border-radius: var(--radius); overflow: hidden;
5
+ }
6
+ .sh-ui-command__dialog { padding: 0; max-width: 40rem; }
7
+ .sh-ui-command__sr-only {
8
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
9
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
10
+ }
11
+ .sh-ui-command__input-wrapper { border-bottom: 1px solid var(--border); padding: 0 var(--space-3); }
12
+ .sh-ui-command__input {
13
+ width: 100%; height: var(--control-md); background: transparent; border: none; outline: none;
14
+ color: var(--foreground); font-size: var(--text-sm);
15
+ }
16
+ .sh-ui-command__input::placeholder { color: var(--foreground-muted); }
17
+ .sh-ui-command__list { max-height: 20rem; overflow-y: auto; padding: var(--space-1); }
18
+ .sh-ui-command__empty { padding: var(--space-4); text-align: center; color: var(--foreground-muted); font-size: var(--text-sm); }
19
+ .sh-ui-command__group [cmdk-group-heading] {
20
+ padding: var(--space-2) var(--space-2) var(--space-1);
21
+ font-size: var(--text-xs); color: var(--foreground-muted);
22
+ }
23
+ .sh-ui-command__item {
24
+ display: flex; align-items: center; gap: var(--space-2);
25
+ padding: var(--space-2) var(--space-2); border-radius: calc(var(--radius) - 2px);
26
+ font-size: var(--text-sm); cursor: pointer; user-select: none;
27
+ }
28
+ .sh-ui-command__item[data-selected="true"],
29
+ .sh-ui-command__item[aria-selected="true"] { background: var(--background-muted); }
30
+ .sh-ui-command__item[data-disabled="true"],
31
+ .sh-ui-command__item[aria-disabled="true"] { color: var(--foreground-muted); cursor: not-allowed; }
32
+ .sh-ui-command__separator { height: 1px; background: var(--border); margin: var(--space-1) 0; }
33
+ .sh-ui-command__shortcut { margin-inline-start: auto; font-size: var(--text-xs); color: var(--foreground-muted); letter-spacing: 0.05em; }
@@ -0,0 +1,33 @@
1
+ .command {
2
+ display: flex; flex-direction: column; width: 100%;
3
+ background: var(--popover, var(--background)); color: var(--foreground);
4
+ border-radius: var(--radius); overflow: hidden;
5
+ }
6
+ .command__dialog { padding: 0; max-width: 40rem; }
7
+ .command__sr-only {
8
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
9
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
10
+ }
11
+ .command__input-wrapper { border-bottom: 1px solid var(--border); padding: 0 var(--space-3); }
12
+ .command__input {
13
+ width: 100%; height: var(--control-md); background: transparent; border: none; outline: none;
14
+ color: var(--foreground); font-size: var(--text-sm);
15
+ }
16
+ .command__input::placeholder { color: var(--foreground-muted); }
17
+ .command__list { max-height: 20rem; overflow-y: auto; padding: var(--space-1); }
18
+ .command__empty { padding: var(--space-4); text-align: center; color: var(--foreground-muted); font-size: var(--text-sm); }
19
+ .command__group [cmdk-group-heading] {
20
+ padding: var(--space-2) var(--space-2) var(--space-1);
21
+ font-size: var(--text-xs); color: var(--foreground-muted);
22
+ }
23
+ .command__item {
24
+ display: flex; align-items: center; gap: var(--space-2);
25
+ padding: var(--space-2) var(--space-2); border-radius: calc(var(--radius) - 2px);
26
+ font-size: var(--text-sm); cursor: pointer; user-select: none;
27
+ }
28
+ .command__item[data-selected="true"],
29
+ .command__item[aria-selected="true"] { background: var(--background-muted); }
30
+ .command__item[data-disabled="true"],
31
+ .command__item[aria-disabled="true"] { color: var(--foreground-muted); cursor: not-allowed; }
32
+ .command__separator { height: 1px; background: var(--border); margin: var(--space-1) 0; }
33
+ .command__shortcut { margin-inline-start: auto; font-size: var(--text-xs); color: var(--foreground-muted); letter-spacing: 0.05em; }
@@ -874,6 +874,39 @@
874
874
  "dependencies": ["@base-ui/react"],
875
875
  "registryDependencies": ["utils"]
876
876
  },
877
+ "command": {
878
+ "name": "command",
879
+ "type": "component",
880
+ "files": [
881
+ {
882
+ "src": "components/command/index.tsx",
883
+ "dest": "{components}/command/index.tsx",
884
+ "frameworks": ["plain"]
885
+ },
886
+ {
887
+ "src": "components/command/styles.css",
888
+ "dest": "{components}/command/styles.css",
889
+ "frameworks": ["plain"]
890
+ },
891
+ {
892
+ "src": "components/command/index.tailwind.tsx",
893
+ "dest": "{components}/command/index.tsx",
894
+ "frameworks": ["tailwind"]
895
+ },
896
+ {
897
+ "src": "components/command/index.module.tsx",
898
+ "dest": "{components}/command/index.tsx",
899
+ "frameworks": ["css-modules"]
900
+ },
901
+ {
902
+ "src": "components/command/styles.module.css",
903
+ "dest": "{components}/command/styles.module.css",
904
+ "frameworks": ["css-modules"]
905
+ }
906
+ ],
907
+ "dependencies": ["cmdk"],
908
+ "registryDependencies": ["utils", "dialog"]
909
+ },
877
910
  "popover": {
878
911
  "name": "popover",
879
912
  "type": "component",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$description": "컴포넌트별 토큰 의존성 (var(--*) 추출). build-registry-tokens.mjs 가 자동 생성.",
3
- "$generated": "2026-06-19T00:56:42.605Z",
3
+ "$generated": "2026-06-19T05:19:57.653Z",
4
4
  "components": {
5
5
  "button": {
6
6
  "plain": [
@@ -1081,6 +1081,50 @@
1081
1081
  ],
1082
1082
  "vanilla-extract": []
1083
1083
  },
1084
+ "command": {
1085
+ "plain": [
1086
+ "--background",
1087
+ "--background-muted",
1088
+ "--border",
1089
+ "--control-md",
1090
+ "--foreground",
1091
+ "--foreground-muted",
1092
+ "--radius",
1093
+ "--space-1",
1094
+ "--space-2",
1095
+ "--space-3",
1096
+ "--space-4",
1097
+ "--text-sm",
1098
+ "--text-xs"
1099
+ ],
1100
+ "tailwind": [
1101
+ "--background",
1102
+ "--control-md",
1103
+ "--radius",
1104
+ "--space-1",
1105
+ "--space-2",
1106
+ "--space-3",
1107
+ "--space-4",
1108
+ "--text-sm",
1109
+ "--text-xs"
1110
+ ],
1111
+ "css-modules": [
1112
+ "--background",
1113
+ "--background-muted",
1114
+ "--border",
1115
+ "--control-md",
1116
+ "--foreground",
1117
+ "--foreground-muted",
1118
+ "--radius",
1119
+ "--space-1",
1120
+ "--space-2",
1121
+ "--space-3",
1122
+ "--space-4",
1123
+ "--text-sm",
1124
+ "--text-xs"
1125
+ ],
1126
+ "vanilla-extract": []
1127
+ },
1084
1128
  "popover": {
1085
1129
  "plain": [
1086
1130
  "--background",
@@ -38,6 +38,7 @@
38
38
  "spinner": "ShUiSpinner — 원형 로딩.",
39
39
  "separator": "ShUiSeparator — 구분선 (horizontal/vertical).",
40
40
  "skeleton": "ShUiSkeleton — 로딩 플레이스홀더.",
41
- "tree": "ShUiTree — 계층 데이터 트리. 확장/축소 + 단일 선택(제어·비제어). ShUiTreeNode(id/label/children/icon/disabled) 재귀. 키보드(화살표·Home/End·Enter/Space)·Semantics. ShUiTreeSize sm/md."
41
+ "tree": "ShUiTree — 계층 데이터 트리. 확장/축소 + 단일 선택(제어·비제어). ShUiTreeNode(id/label/children/icon/disabled) 재귀. 키보드(화살표·Home/End·Enter/Space)·Semantics. ShUiTreeSize sm/md.",
42
+ "table": "ShUiTable<T> — 정렬 내장 데이터 테이블. ShUiTableColumn(id/header/cell/sortKey/align) 정의 + rows. 헤더 탭 정렬(▲/▼, asc/desc), 밀도 sm/md, zebra, caption, Semantics. sortKey 있는 컬럼만 정렬 가능."
42
43
  }
43
44
  }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "$description": "React 컴포넌트 summary — llms.txt 생성용. key는 registry.json의 name과 동일.",
3
3
  "summaries": {
4
+ "command": "Cmd+K 명령 팔레트 — cmdk 위 sh-ui. Command/CommandDialog/CommandInput/CommandList/CommandEmpty/CommandGroup/CommandItem/CommandSeparator/CommandShortcut. CommandDialog=sh-ui Dialog+Command(open/onOpenChange), 검색 필터·키보드 네비는 cmdk. CommandItem value/keywords/onSelect — 한국어 라벨엔 keywords 필요.",
4
5
  "button": "기본 버튼 — variant(primary/secondary/ghost/danger/link) + size(sm/md/lg).",
5
6
  "card": "카드 컨테이너 — separate exports: Card / CardHeader / CardTitle / CardDescription / CardAction / CardContent / CardFooter. dot syntax(`Card.Header`) 아님.",
6
7
  "input": "단일 행 텍스트 입력 — hasError 지원. 같은 모듈에 NumberInput / PhoneInput / BusinessNumberInput 변형 포함. 비밀번호 토글은 InputGroup + InputAdornment 레시피로 조립.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.118.0",
3
+ "version": "0.120.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "devDependencies": {
33
33
  "fs-extra": "^11.3.5",
34
- "vitest": "^4.1.8"
34
+ "vitest": "^4.1.9"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"