sh-ui-cli 0.15.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/sh-ui.mjs +6 -0
- package/data/changelog/versions.json +354 -0
- package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
- package/data/registry/flutter/registry.json +336 -0
- package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
- package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
- package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
- package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
- package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
- package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
- package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
- package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
- package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
- package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
- package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
- package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
- package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
- package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
- package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
- package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
- package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
- package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
- package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
- package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
- package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
- package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
- package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
- package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
- package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
- package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
- package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
- package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
- package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
- package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
- package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
- package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
- package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
- package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
- package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
- package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
- package/data/registry/react/components/accordion/index.tsx +85 -0
- package/data/registry/react/components/accordion/styles.css +94 -0
- package/data/registry/react/components/animations/animations.css +51 -0
- package/data/registry/react/components/avatar/index.tsx +75 -0
- package/data/registry/react/components/avatar/styles.css +36 -0
- package/data/registry/react/components/badge/index.tsx +42 -0
- package/data/registry/react/components/badge/styles.css +57 -0
- package/data/registry/react/components/base/base.css +102 -0
- package/data/registry/react/components/breadcrumb/index.tsx +154 -0
- package/data/registry/react/components/breadcrumb/styles.css +82 -0
- package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
- package/data/registry/react/components/button/index.tsx +47 -0
- package/data/registry/react/components/button/styles.css +93 -0
- package/data/registry/react/components/card/index.tsx +86 -0
- package/data/registry/react/components/card/styles.css +73 -0
- package/data/registry/react/components/carousel/index.tsx +432 -0
- package/data/registry/react/components/carousel/styles.css +155 -0
- package/data/registry/react/components/checkbox/index.tsx +98 -0
- package/data/registry/react/components/checkbox/styles.css +75 -0
- package/data/registry/react/components/code-panel/copy.tsx +56 -0
- package/data/registry/react/components/code-panel/index.tsx +193 -0
- package/data/registry/react/components/code-panel/styles.css +124 -0
- package/data/registry/react/components/color-picker/index.tsx +466 -0
- package/data/registry/react/components/color-picker/styles.css +166 -0
- package/data/registry/react/components/combobox/index.tsx +167 -0
- package/data/registry/react/components/combobox/styles.css +151 -0
- package/data/registry/react/components/context-menu/index.tsx +253 -0
- package/data/registry/react/components/context-menu/styles.css +140 -0
- package/data/registry/react/components/date-picker/index.tsx +757 -0
- package/data/registry/react/components/date-picker/styles.css +279 -0
- package/data/registry/react/components/dialog/index.tsx +97 -0
- package/data/registry/react/components/dialog/styles.css +127 -0
- package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
- package/data/registry/react/components/dropdown-menu/styles.css +150 -0
- package/data/registry/react/components/file-upload/index.tsx +489 -0
- package/data/registry/react/components/file-upload/styles.css +170 -0
- package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
- package/data/registry/react/components/form/context.ts +92 -0
- package/data/registry/react/components/form/field.test.tsx +230 -0
- package/data/registry/react/components/form/field.tsx +236 -0
- package/data/registry/react/components/form/focus-first-error.ts +54 -0
- package/data/registry/react/components/form/form.section.test.tsx +58 -0
- package/data/registry/react/components/form/form.test.tsx +146 -0
- package/data/registry/react/components/form/form.tsx +180 -0
- package/data/registry/react/components/form/index.tsx +61 -0
- package/data/registry/react/components/form/steps.test.tsx +106 -0
- package/data/registry/react/components/form/steps.tsx +193 -0
- package/data/registry/react/components/form/store.test.ts +206 -0
- package/data/registry/react/components/form/store.ts +318 -0
- package/data/registry/react/components/form/styles.css +47 -0
- package/data/registry/react/components/form/types.ts +104 -0
- package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
- package/data/registry/react/components/form/utils.test.ts +44 -0
- package/data/registry/react/components/form/utils.ts +49 -0
- package/data/registry/react/components/form/validation.test.ts +67 -0
- package/data/registry/react/components/form/validation.ts +64 -0
- package/data/registry/react/components/form-rhf/README.md +27 -0
- package/data/registry/react/components/form-rhf/index.tsx +289 -0
- package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
- package/data/registry/react/components/form-tanstack/README.md +27 -0
- package/data/registry/react/components/form-tanstack/index.tsx +352 -0
- package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
- package/data/registry/react/components/form-yup/README.md +22 -0
- package/data/registry/react/components/form-yup/index.tsx +50 -0
- package/data/registry/react/components/form-yup/yup.test.ts +27 -0
- package/data/registry/react/components/header/index.tsx +257 -0
- package/data/registry/react/components/header/styles.css +190 -0
- package/data/registry/react/components/input/index.tsx +517 -0
- package/data/registry/react/components/input/styles.css +203 -0
- package/data/registry/react/components/label/index.tsx +54 -0
- package/data/registry/react/components/label/styles.css +90 -0
- package/data/registry/react/components/menubar/index.tsx +34 -0
- package/data/registry/react/components/menubar/styles.css +45 -0
- package/data/registry/react/components/pagination/index.tsx +271 -0
- package/data/registry/react/components/pagination/styles.css +105 -0
- package/data/registry/react/components/popover/index.tsx +115 -0
- package/data/registry/react/components/popover/styles.css +65 -0
- package/data/registry/react/components/progress/index.tsx +56 -0
- package/data/registry/react/components/progress/styles.css +41 -0
- package/data/registry/react/components/radio/index.tsx +67 -0
- package/data/registry/react/components/radio/styles.css +80 -0
- package/data/registry/react/components/select/index.tsx +236 -0
- package/data/registry/react/components/select/styles.css +193 -0
- package/data/registry/react/components/separator/index.tsx +48 -0
- package/data/registry/react/components/separator/styles.css +15 -0
- package/data/registry/react/components/sidebar/index.tsx +1084 -0
- package/data/registry/react/components/sidebar/styles.css +502 -0
- package/data/registry/react/components/skeleton/index.tsx +24 -0
- package/data/registry/react/components/skeleton/styles.css +24 -0
- package/data/registry/react/components/slider/index.tsx +300 -0
- package/data/registry/react/components/slider/styles.css +64 -0
- package/data/registry/react/components/spinner/index.tsx +40 -0
- package/data/registry/react/components/spinner/styles.css +37 -0
- package/data/registry/react/components/switch/index.tsx +41 -0
- package/data/registry/react/components/switch/styles.css +83 -0
- package/data/registry/react/components/tabs/index.tsx +93 -0
- package/data/registry/react/components/tabs/styles.css +148 -0
- package/data/registry/react/components/textarea/index.tsx +25 -0
- package/data/registry/react/components/textarea/styles.css +54 -0
- package/data/registry/react/components/theme/index.tsx +91 -0
- package/data/registry/react/components/toast/index.tsx +257 -0
- package/data/registry/react/components/toast/styles.css +290 -0
- package/data/registry/react/components/toggle/index.tsx +133 -0
- package/data/registry/react/components/toggle/styles.css +85 -0
- package/data/registry/react/components/tooltip/index.tsx +85 -0
- package/data/registry/react/components/tooltip/styles.css +44 -0
- package/data/registry/react/components/z-index/z-index.css +16 -0
- package/data/registry/react/hooks/use-active-section.ts +104 -0
- package/data/registry/react/hooks/use-media-query.ts +27 -0
- package/data/registry/react/lib/cn.ts +39 -0
- package/data/registry/react/registry.json +835 -0
- package/data/summaries/flutter.json +42 -0
- package/data/summaries/react.json +50 -0
- package/data/tokens/build.mjs +553 -0
- package/data/tokens/src/primitives.json +146 -0
- package/data/tokens/src/semantic.json +146 -0
- package/package.json +9 -2
- package/src/add.mjs +13 -12
- package/src/list.mjs +3 -11
- package/src/mcp.mjs +308 -0
- package/src/paths.mjs +52 -0
- package/src/remove.mjs +4 -11
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
3
|
+
import 'sh_ui_sidebar.dart';
|
|
4
|
+
|
|
5
|
+
/// sh-ui App Shell — 사이드바 기반 앱 레이아웃을 한 번에 선언한다.
|
|
6
|
+
///
|
|
7
|
+
/// Scaffold + AppBar + ShUiSidebarProvider + ShUiSidebar + 메인 콘텐츠 교체를
|
|
8
|
+
/// 내장하고, 선택된 아이템에 따라 본문과 AppBar 타이틀을 자동으로 바꾼다.
|
|
9
|
+
/// 모바일에서는 drawer, 데스크탑에서는 inline 사이드바로 자동 전환.
|
|
10
|
+
///
|
|
11
|
+
/// 직접 Navigator push로 라우트를 쌓는 패턴이 필요한 경우에는 [ShUiSidebar]와
|
|
12
|
+
/// [Scaffold]를 직접 조합한다. 이 위젯은 "한 화면에서 콘텐츠를 스위칭하는
|
|
13
|
+
/// 단일 쉘 레이아웃"에 특화되어 있다.
|
|
14
|
+
///
|
|
15
|
+
/// ```dart
|
|
16
|
+
/// ShUiAppShell(
|
|
17
|
+
/// title: 'My App',
|
|
18
|
+
/// groups: [
|
|
19
|
+
/// ShUiAppShellGroup(label: '메뉴', items: [
|
|
20
|
+
/// ShUiAppShellItem(
|
|
21
|
+
/// icon: Icons.home,
|
|
22
|
+
/// label: '홈',
|
|
23
|
+
/// builder: (_) => HomeContent(),
|
|
24
|
+
/// ),
|
|
25
|
+
/// ShUiAppShellItem(
|
|
26
|
+
/// icon: Icons.settings,
|
|
27
|
+
/// label: '설정',
|
|
28
|
+
/// builder: (_) => SettingsContent(),
|
|
29
|
+
/// ),
|
|
30
|
+
/// ]),
|
|
31
|
+
/// ],
|
|
32
|
+
/// )
|
|
33
|
+
/// ```
|
|
34
|
+
|
|
35
|
+
/// 사이드바 그룹 선언.
|
|
36
|
+
@immutable
|
|
37
|
+
class ShUiAppShellGroup {
|
|
38
|
+
/// 그룹 카테고리 라벨. 미지정 시 라벨 없이 항목만 노출.
|
|
39
|
+
final String? label;
|
|
40
|
+
|
|
41
|
+
/// 그룹에 속한 메뉴 항목 목록.
|
|
42
|
+
final List<ShUiAppShellItem> items;
|
|
43
|
+
|
|
44
|
+
/// 그룹 라벨 탭으로 접기/펼치기 가능.
|
|
45
|
+
final bool collapsible;
|
|
46
|
+
|
|
47
|
+
/// [collapsible] 활성 시 초기 확장 여부.
|
|
48
|
+
final bool initiallyExpanded;
|
|
49
|
+
|
|
50
|
+
const ShUiAppShellGroup({
|
|
51
|
+
this.label,
|
|
52
|
+
required this.items,
|
|
53
|
+
this.collapsible = true,
|
|
54
|
+
this.initiallyExpanded = true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// 사이드바 아이템 선언. 탭하면 [builder]가 메인 영역에 렌더된다.
|
|
59
|
+
@immutable
|
|
60
|
+
class ShUiAppShellItem {
|
|
61
|
+
/// 라벨 좌측에 붙는 아이콘 (선택).
|
|
62
|
+
final IconData? icon;
|
|
63
|
+
|
|
64
|
+
/// 메뉴 라벨. AppBar 타이틀로도 사용된다(item 선택 시 자동 갱신).
|
|
65
|
+
final String label;
|
|
66
|
+
|
|
67
|
+
/// 선택 시 메인 영역에 표시할 위젯을 반환. [Scaffold] 없이 body-only 위젯을 반환해야 한다.
|
|
68
|
+
final WidgetBuilder builder;
|
|
69
|
+
|
|
70
|
+
const ShUiAppShellItem({
|
|
71
|
+
this.icon,
|
|
72
|
+
required this.label,
|
|
73
|
+
required this.builder,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// shUi App Shell 위젯. 위 파일 헤더 dartdoc 참고.
|
|
78
|
+
///
|
|
79
|
+
/// 사이드바 그룹/아이템과 AppBar 상수 옵션만 선언하면 한 줄로 앱 셸이 완성된다.
|
|
80
|
+
/// 항상 한 화면에서 콘텐츠를 스위칭하는 단일 쉘 패턴에 사용한다.
|
|
81
|
+
class ShUiAppShell extends StatefulWidget {
|
|
82
|
+
/// AppBar 좌측 로고/아이콘. 사이드바 헤더에도 동일하게 노출된다.
|
|
83
|
+
final Widget? logo;
|
|
84
|
+
|
|
85
|
+
/// 앱 이름. 사이드바 헤더 텍스트.
|
|
86
|
+
final String? title;
|
|
87
|
+
|
|
88
|
+
/// AppBar 우측 actions.
|
|
89
|
+
final List<Widget>? actions;
|
|
90
|
+
|
|
91
|
+
/// 사이드바 그룹들.
|
|
92
|
+
final List<ShUiAppShellGroup> groups;
|
|
93
|
+
|
|
94
|
+
/// 초기 선택 아이템 인덱스 (group 무관하게 flat 인덱스). 기본 0.
|
|
95
|
+
final int initialIndex;
|
|
96
|
+
|
|
97
|
+
/// 선택 변경 콜백 (flat 인덱스).
|
|
98
|
+
final ValueChanged<int>? onIndexChanged;
|
|
99
|
+
|
|
100
|
+
/// 메인이 비어있을 때(initialIndex가 범위 밖 등) 표시할 placeholder.
|
|
101
|
+
final WidgetBuilder? emptyBuilder;
|
|
102
|
+
|
|
103
|
+
/// 사이드바 variant.
|
|
104
|
+
final ShUiSidebarVariant sidebarVariant;
|
|
105
|
+
|
|
106
|
+
const ShUiAppShell({
|
|
107
|
+
super.key,
|
|
108
|
+
this.logo,
|
|
109
|
+
this.title,
|
|
110
|
+
this.actions,
|
|
111
|
+
required this.groups,
|
|
112
|
+
this.initialIndex = 0,
|
|
113
|
+
this.onIndexChanged,
|
|
114
|
+
this.emptyBuilder,
|
|
115
|
+
this.sidebarVariant = ShUiSidebarVariant.sidebar,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
@override
|
|
119
|
+
State<ShUiAppShell> createState() => _ShUiAppShellState();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class _ShUiAppShellState extends State<ShUiAppShell> {
|
|
123
|
+
late int _selectedIndex;
|
|
124
|
+
|
|
125
|
+
@override
|
|
126
|
+
void initState() {
|
|
127
|
+
super.initState();
|
|
128
|
+
_selectedIndex = widget.initialIndex;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// 그룹을 평탄화해 flat 아이템 리스트로 반환.
|
|
132
|
+
List<ShUiAppShellItem> get _flatItems =>
|
|
133
|
+
widget.groups.expand((g) => g.items).toList();
|
|
134
|
+
|
|
135
|
+
void _select(int flatIndex, BuildContext itemCtx, ShUiTheme shUi) {
|
|
136
|
+
if (flatIndex == _selectedIndex) {
|
|
137
|
+
// drawer 모드에서 같은 아이템을 다시 탭해도 drawer는 닫히도록.
|
|
138
|
+
_maybeCloseDrawer(itemCtx, shUi);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
setState(() => _selectedIndex = flatIndex);
|
|
142
|
+
widget.onIndexChanged?.call(flatIndex);
|
|
143
|
+
_maybeCloseDrawer(itemCtx, shUi);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
void _maybeCloseDrawer(BuildContext itemCtx, ShUiTheme shUi) {
|
|
147
|
+
final scope = useSidebar(itemCtx);
|
|
148
|
+
final isNarrow =
|
|
149
|
+
MediaQuery.of(itemCtx).size.width < shUi.breakpoint.md;
|
|
150
|
+
if (scope != null && scope.open && isNarrow) {
|
|
151
|
+
scope.toggle();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@override
|
|
156
|
+
Widget build(BuildContext context) {
|
|
157
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
158
|
+
final colors = shUi.colors;
|
|
159
|
+
final items = _flatItems;
|
|
160
|
+
final selected = (items.isNotEmpty && _selectedIndex < items.length)
|
|
161
|
+
? items[_selectedIndex]
|
|
162
|
+
: null;
|
|
163
|
+
|
|
164
|
+
return ShUiSidebarProvider(
|
|
165
|
+
defaultOpen: true,
|
|
166
|
+
child: Scaffold(
|
|
167
|
+
backgroundColor: colors.background,
|
|
168
|
+
appBar: AppBar(
|
|
169
|
+
leading: const Padding(
|
|
170
|
+
padding: EdgeInsets.symmetric(horizontal: 8),
|
|
171
|
+
child: ShUiSidebarTrigger(),
|
|
172
|
+
),
|
|
173
|
+
title: Text(
|
|
174
|
+
selected?.label ?? widget.title ?? '',
|
|
175
|
+
style: TextStyle(
|
|
176
|
+
color: colors.foreground,
|
|
177
|
+
fontWeight: shUi.weight.semibold,
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
backgroundColor: colors.background,
|
|
181
|
+
elevation: 0,
|
|
182
|
+
scrolledUnderElevation: 0.5,
|
|
183
|
+
actions: widget.actions,
|
|
184
|
+
bottom: PreferredSize(
|
|
185
|
+
preferredSize: const Size.fromHeight(1),
|
|
186
|
+
child: Container(height: 1, color: colors.border),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
body: Row(
|
|
190
|
+
children: [
|
|
191
|
+
ShUiSidebar(
|
|
192
|
+
variant: widget.sidebarVariant,
|
|
193
|
+
header: (widget.logo != null || widget.title != null)
|
|
194
|
+
? ShUiSidebarHeader(child: _buildHeader(shUi, colors))
|
|
195
|
+
: null,
|
|
196
|
+
children: [
|
|
197
|
+
for (final group in widget.groups)
|
|
198
|
+
ShUiSidebarGroup(
|
|
199
|
+
label: group.label,
|
|
200
|
+
collapsible: group.collapsible,
|
|
201
|
+
initiallyExpanded: group.initiallyExpanded,
|
|
202
|
+
children: [
|
|
203
|
+
for (final item in group.items)
|
|
204
|
+
_buildItem(item, items.indexOf(item), shUi),
|
|
205
|
+
],
|
|
206
|
+
),
|
|
207
|
+
],
|
|
208
|
+
),
|
|
209
|
+
Expanded(
|
|
210
|
+
child: selected != null
|
|
211
|
+
? Builder(builder: selected.builder)
|
|
212
|
+
: (widget.emptyBuilder != null
|
|
213
|
+
? Builder(builder: widget.emptyBuilder!)
|
|
214
|
+
: const SizedBox.shrink()),
|
|
215
|
+
),
|
|
216
|
+
],
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
Widget _buildHeader(ShUiTheme shUi, ShUiColorTokens colors) {
|
|
223
|
+
return Builder(
|
|
224
|
+
builder: (ctx) {
|
|
225
|
+
final isOpen = useSidebar(ctx)?.open ?? true;
|
|
226
|
+
if (!isOpen) {
|
|
227
|
+
return Center(
|
|
228
|
+
child: widget.logo ??
|
|
229
|
+
Icon(Icons.apps, size: 20, color: colors.foreground),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return Row(
|
|
233
|
+
children: [
|
|
234
|
+
if (widget.logo != null) ...[
|
|
235
|
+
widget.logo!,
|
|
236
|
+
SizedBox(width: shUi.spacing.s2),
|
|
237
|
+
],
|
|
238
|
+
if (widget.title != null)
|
|
239
|
+
Expanded(
|
|
240
|
+
child: Text(
|
|
241
|
+
widget.title!,
|
|
242
|
+
style: TextStyle(
|
|
243
|
+
color: colors.foreground,
|
|
244
|
+
fontSize: shUi.text.lg,
|
|
245
|
+
fontWeight: shUi.weight.bold,
|
|
246
|
+
letterSpacing: -0.5,
|
|
247
|
+
),
|
|
248
|
+
overflow: TextOverflow.ellipsis,
|
|
249
|
+
),
|
|
250
|
+
),
|
|
251
|
+
],
|
|
252
|
+
);
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
Widget _buildItem(ShUiAppShellItem item, int flatIndex, ShUiTheme shUi) {
|
|
258
|
+
return Builder(
|
|
259
|
+
builder: (itemCtx) => ShUiSidebarItem(
|
|
260
|
+
icon: item.icon,
|
|
261
|
+
label: item.label,
|
|
262
|
+
isActive: flatIndex == _selectedIndex,
|
|
263
|
+
onTap: () => _select(flatIndex, itemCtx, shUi),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
3
|
+
|
|
4
|
+
enum ShUiAvatarSize { sm, md, lg, xl }
|
|
5
|
+
|
|
6
|
+
/// sh-ui Avatar — 프로필 이미지 + 이니셜/아이콘 fallback.
|
|
7
|
+
///
|
|
8
|
+
/// [imageUrl]이 주어지면 이미지를 로드하고, 실패 시 [fallback]을 렌더한다.
|
|
9
|
+
/// 이미지 없이 이니셜만 보여주려면 [imageUrl]을 생략하고 [fallback]만 제공.
|
|
10
|
+
class ShUiAvatar extends StatelessWidget {
|
|
11
|
+
/// 로드할 이미지 URL. 네트워크 또는 asset. null이면 바로 fallback 렌더.
|
|
12
|
+
final String? imageUrl;
|
|
13
|
+
|
|
14
|
+
/// 이미지 로드 실패/미제공 시 표시할 위젯(보통 이니셜 Text 또는 Icon).
|
|
15
|
+
final Widget? fallback;
|
|
16
|
+
|
|
17
|
+
final ShUiAvatarSize size;
|
|
18
|
+
final String? semanticLabel;
|
|
19
|
+
|
|
20
|
+
const ShUiAvatar({
|
|
21
|
+
super.key,
|
|
22
|
+
this.imageUrl,
|
|
23
|
+
this.fallback,
|
|
24
|
+
this.size = ShUiAvatarSize.md,
|
|
25
|
+
this.semanticLabel,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
double _diameter() => switch (size) {
|
|
29
|
+
ShUiAvatarSize.sm => 28.0,
|
|
30
|
+
ShUiAvatarSize.md => 40.0,
|
|
31
|
+
ShUiAvatarSize.lg => 48.0,
|
|
32
|
+
ShUiAvatarSize.xl => 64.0,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
double _fontSize() => switch (size) {
|
|
36
|
+
ShUiAvatarSize.sm => 11.0,
|
|
37
|
+
ShUiAvatarSize.md => 13.0,
|
|
38
|
+
ShUiAvatarSize.lg => 14.0,
|
|
39
|
+
ShUiAvatarSize.xl => 16.0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
@override
|
|
43
|
+
Widget build(BuildContext context) {
|
|
44
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
45
|
+
final colors = shUi.colors;
|
|
46
|
+
final diameter = _diameter();
|
|
47
|
+
|
|
48
|
+
final fallbackWidget = DefaultTextStyle(
|
|
49
|
+
style: TextStyle(
|
|
50
|
+
color: colors.foregroundMuted,
|
|
51
|
+
fontSize: _fontSize(),
|
|
52
|
+
fontWeight: shUi.weight.medium,
|
|
53
|
+
letterSpacing: 0.3,
|
|
54
|
+
),
|
|
55
|
+
textAlign: TextAlign.center,
|
|
56
|
+
child: IconTheme(
|
|
57
|
+
data: IconThemeData(
|
|
58
|
+
color: colors.foregroundMuted,
|
|
59
|
+
size: _fontSize() + 4,
|
|
60
|
+
),
|
|
61
|
+
child: fallback ?? const SizedBox.shrink(),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
final content = imageUrl != null
|
|
66
|
+
? Image.network(
|
|
67
|
+
imageUrl!,
|
|
68
|
+
fit: BoxFit.cover,
|
|
69
|
+
width: diameter,
|
|
70
|
+
height: diameter,
|
|
71
|
+
errorBuilder: (_, __, ___) => fallbackWidget,
|
|
72
|
+
loadingBuilder: (context, child, progress) {
|
|
73
|
+
if (progress == null) return child;
|
|
74
|
+
return fallbackWidget;
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
: fallbackWidget;
|
|
78
|
+
|
|
79
|
+
return Semantics(
|
|
80
|
+
label: semanticLabel,
|
|
81
|
+
image: imageUrl != null,
|
|
82
|
+
child: Container(
|
|
83
|
+
width: diameter,
|
|
84
|
+
height: diameter,
|
|
85
|
+
alignment: Alignment.center,
|
|
86
|
+
decoration: BoxDecoration(
|
|
87
|
+
color: colors.backgroundMuted,
|
|
88
|
+
shape: BoxShape.circle,
|
|
89
|
+
),
|
|
90
|
+
clipBehavior: Clip.antiAlias,
|
|
91
|
+
child: content,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
3
|
+
|
|
4
|
+
enum ShUiBadgeVariant { primary, secondary, success, warning, danger, outline }
|
|
5
|
+
|
|
6
|
+
enum ShUiBadgeSize { sm, md }
|
|
7
|
+
|
|
8
|
+
/// sh-ui Badge — 상태·카테고리·수량을 짧게 표기하는 인라인 라벨.
|
|
9
|
+
///
|
|
10
|
+
/// 색만으로 의미를 전달하지 말고 텍스트·아이콘과 함께 사용한다.
|
|
11
|
+
class ShUiBadge extends StatelessWidget {
|
|
12
|
+
final Widget child;
|
|
13
|
+
final ShUiBadgeVariant variant;
|
|
14
|
+
final ShUiBadgeSize size;
|
|
15
|
+
|
|
16
|
+
const ShUiBadge({
|
|
17
|
+
super.key,
|
|
18
|
+
required this.child,
|
|
19
|
+
this.variant = ShUiBadgeVariant.primary,
|
|
20
|
+
this.size = ShUiBadgeSize.md,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/// 텍스트만 넣는 편의 생성자.
|
|
24
|
+
factory ShUiBadge.text(
|
|
25
|
+
String text, {
|
|
26
|
+
Key? key,
|
|
27
|
+
ShUiBadgeVariant variant = ShUiBadgeVariant.primary,
|
|
28
|
+
ShUiBadgeSize size = ShUiBadgeSize.md,
|
|
29
|
+
}) {
|
|
30
|
+
return ShUiBadge(
|
|
31
|
+
key: key,
|
|
32
|
+
variant: variant,
|
|
33
|
+
size: size,
|
|
34
|
+
child: Text(text),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
(Color bg, Color fg, Color border) _colors(ShUiColorTokens c) {
|
|
39
|
+
return switch (variant) {
|
|
40
|
+
ShUiBadgeVariant.primary => (c.primary, c.primaryForeground, Colors.transparent),
|
|
41
|
+
ShUiBadgeVariant.secondary => (c.backgroundMuted, c.foreground, c.border),
|
|
42
|
+
ShUiBadgeVariant.success => (const Color(0xFF16A34A), Colors.white, Colors.transparent),
|
|
43
|
+
ShUiBadgeVariant.warning => (const Color(0xFFD97706), Colors.white, Colors.transparent),
|
|
44
|
+
ShUiBadgeVariant.danger => (c.danger, c.dangerForeground, Colors.transparent),
|
|
45
|
+
ShUiBadgeVariant.outline => (Colors.transparent, c.foreground, c.borderStrong),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@override
|
|
50
|
+
Widget build(BuildContext context) {
|
|
51
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
52
|
+
final (bg, fg, border) = _colors(shUi.colors);
|
|
53
|
+
|
|
54
|
+
final (double height, double fontSize, EdgeInsets padding) = switch (size) {
|
|
55
|
+
ShUiBadgeSize.sm => (20.0, 11.0, const EdgeInsets.symmetric(horizontal: 6)),
|
|
56
|
+
ShUiBadgeSize.md => (24.0, shUi.text.xs, const EdgeInsets.symmetric(horizontal: 8)),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return Container(
|
|
60
|
+
height: height,
|
|
61
|
+
padding: padding,
|
|
62
|
+
decoration: BoxDecoration(
|
|
63
|
+
color: bg,
|
|
64
|
+
border: Border.all(color: border),
|
|
65
|
+
borderRadius: BorderRadius.circular(999),
|
|
66
|
+
),
|
|
67
|
+
alignment: Alignment.center,
|
|
68
|
+
child: DefaultTextStyle(
|
|
69
|
+
style: TextStyle(
|
|
70
|
+
color: fg,
|
|
71
|
+
fontSize: fontSize,
|
|
72
|
+
fontWeight: shUi.weight.medium,
|
|
73
|
+
height: 1,
|
|
74
|
+
),
|
|
75
|
+
child: IconTheme(
|
|
76
|
+
data: IconThemeData(color: fg, size: fontSize + 2),
|
|
77
|
+
child: child,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
3
|
+
|
|
4
|
+
/// sh-ui Breadcrumb — 현재 페이지의 계층 위치를 나타내는 내비게이션.
|
|
5
|
+
///
|
|
6
|
+
/// ShUiBreadcrumb(
|
|
7
|
+
/// items: [
|
|
8
|
+
/// ShUiBreadcrumbItem(label: '홈', onTap: () => ...),
|
|
9
|
+
/// ShUiBreadcrumbItem(label: '컴포넌트', onTap: () => ...),
|
|
10
|
+
/// ShUiBreadcrumbItem(label: 'Breadcrumb', isCurrent: true),
|
|
11
|
+
/// ],
|
|
12
|
+
/// )
|
|
13
|
+
class ShUiBreadcrumb extends StatelessWidget {
|
|
14
|
+
final List<ShUiBreadcrumbItem> items;
|
|
15
|
+
|
|
16
|
+
/// 구분자 위젯. null이면 ▶ 아이콘.
|
|
17
|
+
final Widget? separator;
|
|
18
|
+
|
|
19
|
+
const ShUiBreadcrumb({
|
|
20
|
+
super.key,
|
|
21
|
+
required this.items,
|
|
22
|
+
this.separator,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
Widget build(BuildContext context) {
|
|
27
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
28
|
+
final colors = shUi.colors;
|
|
29
|
+
|
|
30
|
+
final effectiveSeparator = separator ??
|
|
31
|
+
Icon(
|
|
32
|
+
Icons.chevron_right,
|
|
33
|
+
size: 16,
|
|
34
|
+
color: colors.foregroundMuted.withValues(alpha: 0.6),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
final children = <Widget>[];
|
|
38
|
+
for (var i = 0; i < items.length; i++) {
|
|
39
|
+
children.add(items[i]);
|
|
40
|
+
if (i < items.length - 1) {
|
|
41
|
+
children.add(
|
|
42
|
+
Padding(
|
|
43
|
+
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
44
|
+
child: effectiveSeparator,
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Semantics(
|
|
51
|
+
label: 'Breadcrumb',
|
|
52
|
+
container: true,
|
|
53
|
+
child: Wrap(
|
|
54
|
+
crossAxisAlignment: WrapCrossAlignment.center,
|
|
55
|
+
children: children,
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Breadcrumb 내 단일 항목.
|
|
62
|
+
///
|
|
63
|
+
/// - [onTap]이 주어지면 링크처럼 동작.
|
|
64
|
+
/// - [isCurrent] = true면 현재 페이지(탭 불가, 강조).
|
|
65
|
+
class ShUiBreadcrumbItem extends StatelessWidget {
|
|
66
|
+
final String label;
|
|
67
|
+
final VoidCallback? onTap;
|
|
68
|
+
final bool isCurrent;
|
|
69
|
+
|
|
70
|
+
const ShUiBreadcrumbItem({
|
|
71
|
+
super.key,
|
|
72
|
+
required this.label,
|
|
73
|
+
this.onTap,
|
|
74
|
+
this.isCurrent = false,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
@override
|
|
78
|
+
Widget build(BuildContext context) {
|
|
79
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
80
|
+
final colors = shUi.colors;
|
|
81
|
+
|
|
82
|
+
final text = Text(
|
|
83
|
+
label,
|
|
84
|
+
style: TextStyle(
|
|
85
|
+
color: isCurrent ? colors.foreground : colors.foregroundMuted,
|
|
86
|
+
fontSize: shUi.text.sm,
|
|
87
|
+
fontWeight: isCurrent ? shUi.weight.medium : FontWeight.normal,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (isCurrent || onTap == null) {
|
|
92
|
+
return Semantics(
|
|
93
|
+
header: isCurrent,
|
|
94
|
+
child: text,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return InkWell(
|
|
99
|
+
onTap: onTap,
|
|
100
|
+
borderRadius: BorderRadius.circular(4),
|
|
101
|
+
child: Padding(
|
|
102
|
+
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
103
|
+
child: text,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|