sh-ui-cli 0.119.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,18 @@
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
+ },
5
17
  {
6
18
  "version": "0.119.0",
7
19
  "date": "2026-06-19",
@@ -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
+ }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.119.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"