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,567 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:flutter/services.dart';
|
|
3
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
4
|
+
|
|
5
|
+
/// sh-ui Dialog — 모달 대화상자.
|
|
6
|
+
///
|
|
7
|
+
/// 두 가지 방식 지원:
|
|
8
|
+
///
|
|
9
|
+
/// 1) 명령형 (imperative, 기존 방식):
|
|
10
|
+
/// ShUiDialog.show(
|
|
11
|
+
/// context: context,
|
|
12
|
+
/// builder: (context) => ShUiDialogContent(
|
|
13
|
+
/// title: const ShUiDialogTitle('제목'),
|
|
14
|
+
/// description: const ShUiDialogDescription('설명 텍스트'),
|
|
15
|
+
/// footer: ShUiDialogFooter(children: [...]),
|
|
16
|
+
/// ),
|
|
17
|
+
/// );
|
|
18
|
+
///
|
|
19
|
+
/// 2) 선언형 (declarative, controlled):
|
|
20
|
+
/// ShUiDialog(
|
|
21
|
+
/// open: _open,
|
|
22
|
+
/// onOpenChange: (v) => setState(() => _open = v),
|
|
23
|
+
/// child: ShUiDialogContent(
|
|
24
|
+
/// header: const ShUiDialogHeader(
|
|
25
|
+
/// title: '제목',
|
|
26
|
+
/// description: '설명',
|
|
27
|
+
/// ),
|
|
28
|
+
/// body: const Text('본문'),
|
|
29
|
+
/// footer: ShUiDialogFooter(children: [...]),
|
|
30
|
+
/// ),
|
|
31
|
+
/// )
|
|
32
|
+
class ShUiDialog extends StatefulWidget {
|
|
33
|
+
/// controlled open 상태.
|
|
34
|
+
final bool open;
|
|
35
|
+
|
|
36
|
+
/// open 변경 콜백 (백드롭 탭 / Esc / 하드웨어 백 시 false).
|
|
37
|
+
final ValueChanged<bool>? onOpenChange;
|
|
38
|
+
|
|
39
|
+
/// 대화상자 내용 (보통 [ShUiDialogContent]).
|
|
40
|
+
final Widget child;
|
|
41
|
+
|
|
42
|
+
/// 배경(백드롭)을 탭하면 닫히는지 여부.
|
|
43
|
+
final bool barrierDismissible;
|
|
44
|
+
|
|
45
|
+
const ShUiDialog({
|
|
46
|
+
super.key,
|
|
47
|
+
required this.open,
|
|
48
|
+
required this.child,
|
|
49
|
+
this.onOpenChange,
|
|
50
|
+
this.barrierDismissible = true,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/// 명령형 API — 기존 호환. Navigator 스택에 push.
|
|
54
|
+
static Future<T?> show<T>({
|
|
55
|
+
required BuildContext context,
|
|
56
|
+
required WidgetBuilder builder,
|
|
57
|
+
bool barrierDismissible = true,
|
|
58
|
+
}) {
|
|
59
|
+
return showGeneralDialog<T>(
|
|
60
|
+
context: context,
|
|
61
|
+
barrierDismissible: barrierDismissible,
|
|
62
|
+
barrierLabel: '닫기',
|
|
63
|
+
barrierColor: Colors.black.withValues(alpha: 0.45),
|
|
64
|
+
transitionDuration: const Duration(milliseconds: 150),
|
|
65
|
+
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
|
66
|
+
return FadeTransition(
|
|
67
|
+
opacity: animation,
|
|
68
|
+
child: ScaleTransition(
|
|
69
|
+
scale: Tween<double>(begin: 0.96, end: 1.0).animate(
|
|
70
|
+
CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
|
71
|
+
),
|
|
72
|
+
child: child,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
pageBuilder: (context, animation, secondaryAnimation) {
|
|
77
|
+
return Center(child: builder(context));
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@override
|
|
83
|
+
State<ShUiDialog> createState() => _ShUiDialogState();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class _ShUiDialogState extends State<ShUiDialog>
|
|
87
|
+
with SingleTickerProviderStateMixin {
|
|
88
|
+
OverlayEntry? _entry;
|
|
89
|
+
late final AnimationController _ctrl;
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
void initState() {
|
|
93
|
+
super.initState();
|
|
94
|
+
final shUi = _resolveTheme(context);
|
|
95
|
+
_ctrl = AnimationController(
|
|
96
|
+
vsync: this,
|
|
97
|
+
duration: shUi.duration.base,
|
|
98
|
+
reverseDuration: shUi.duration.base,
|
|
99
|
+
);
|
|
100
|
+
if (widget.open) {
|
|
101
|
+
// 첫 빌드 후 삽입 (Overlay.of가 안전하게 해소되도록).
|
|
102
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
103
|
+
if (!mounted) return;
|
|
104
|
+
_insertOverlay();
|
|
105
|
+
_ctrl.forward();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
void didUpdateWidget(covariant ShUiDialog oldWidget) {
|
|
112
|
+
super.didUpdateWidget(oldWidget);
|
|
113
|
+
if (widget.open == oldWidget.open) {
|
|
114
|
+
// 상태가 동일해도 child가 바뀌었을 수 있으므로 entry를 rebuild.
|
|
115
|
+
_entry?.markNeedsBuild();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (widget.open) {
|
|
119
|
+
_insertOverlay();
|
|
120
|
+
_ctrl.forward();
|
|
121
|
+
} else {
|
|
122
|
+
_ctrl.reverse().whenComplete(_removeOverlay);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@override
|
|
127
|
+
void dispose() {
|
|
128
|
+
_removeOverlay();
|
|
129
|
+
_ctrl.dispose();
|
|
130
|
+
super.dispose();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ShUiTheme _resolveTheme(BuildContext context) {
|
|
134
|
+
return Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
void _insertOverlay() {
|
|
138
|
+
if (_entry != null) return;
|
|
139
|
+
final overlay = Overlay.maybeOf(context, rootOverlay: true);
|
|
140
|
+
if (overlay == null) return;
|
|
141
|
+
_entry = OverlayEntry(
|
|
142
|
+
builder: (overlayContext) => _ShUiDialogOverlay(
|
|
143
|
+
animation: _ctrl,
|
|
144
|
+
barrierDismissible: widget.barrierDismissible,
|
|
145
|
+
onBarrierTap: () => widget.onOpenChange?.call(false),
|
|
146
|
+
onEscape: () => widget.onOpenChange?.call(false),
|
|
147
|
+
child: widget.child,
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
overlay.insert(_entry!);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
void _removeOverlay() {
|
|
154
|
+
_entry?.remove();
|
|
155
|
+
_entry = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@override
|
|
159
|
+
Widget build(BuildContext context) {
|
|
160
|
+
// Declarative wrapper는 자체 렌더 공간을 차지하지 않는다.
|
|
161
|
+
// 닫기 제스처(하드웨어 백)는 Overlay 내부 PopScope가 처리.
|
|
162
|
+
return const SizedBox.shrink();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
class _ShUiDialogOverlay extends StatelessWidget {
|
|
167
|
+
final Animation<double> animation;
|
|
168
|
+
final bool barrierDismissible;
|
|
169
|
+
final VoidCallback onBarrierTap;
|
|
170
|
+
final VoidCallback onEscape;
|
|
171
|
+
final Widget child;
|
|
172
|
+
|
|
173
|
+
const _ShUiDialogOverlay({
|
|
174
|
+
required this.animation,
|
|
175
|
+
required this.barrierDismissible,
|
|
176
|
+
required this.onBarrierTap,
|
|
177
|
+
required this.onEscape,
|
|
178
|
+
required this.child,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
@override
|
|
182
|
+
Widget build(BuildContext context) {
|
|
183
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
184
|
+
final curved = CurvedAnimation(parent: animation, curve: shUi.ease.standard);
|
|
185
|
+
|
|
186
|
+
return PopScope(
|
|
187
|
+
canPop: false,
|
|
188
|
+
onPopInvokedWithResult: (didPop, _) {
|
|
189
|
+
if (!didPop) onEscape();
|
|
190
|
+
},
|
|
191
|
+
child: FocusScope(
|
|
192
|
+
autofocus: true,
|
|
193
|
+
child: Shortcuts(
|
|
194
|
+
shortcuts: const <ShortcutActivator, Intent>{
|
|
195
|
+
SingleActivator(LogicalKeyboardKey.escape): _DismissIntent(),
|
|
196
|
+
},
|
|
197
|
+
child: Actions(
|
|
198
|
+
actions: <Type, Action<Intent>>{
|
|
199
|
+
_DismissIntent: CallbackAction<_DismissIntent>(
|
|
200
|
+
onInvoke: (_) {
|
|
201
|
+
onEscape();
|
|
202
|
+
return null;
|
|
203
|
+
},
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
child: Stack(
|
|
207
|
+
children: [
|
|
208
|
+
// Backdrop
|
|
209
|
+
Positioned.fill(
|
|
210
|
+
child: FadeTransition(
|
|
211
|
+
opacity: curved,
|
|
212
|
+
child: GestureDetector(
|
|
213
|
+
behavior: HitTestBehavior.opaque,
|
|
214
|
+
onTap: barrierDismissible ? onBarrierTap : null,
|
|
215
|
+
child: Container(
|
|
216
|
+
color: Colors.black.withValues(alpha: 0.45),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
),
|
|
221
|
+
// Popup
|
|
222
|
+
Positioned.fill(
|
|
223
|
+
child: Center(
|
|
224
|
+
child: FadeTransition(
|
|
225
|
+
opacity: curved,
|
|
226
|
+
child: ScaleTransition(
|
|
227
|
+
scale: Tween<double>(begin: 0.96, end: 1.0)
|
|
228
|
+
.animate(curved),
|
|
229
|
+
// popup 영역은 별도 GestureDetector로 감싸 backdrop 탭 전파 차단.
|
|
230
|
+
child: GestureDetector(
|
|
231
|
+
behavior: HitTestBehavior.opaque,
|
|
232
|
+
onTap: () {},
|
|
233
|
+
child: _ShUiDialogCloseScope(
|
|
234
|
+
onClose: onEscape,
|
|
235
|
+
child: child,
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
],
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
class _DismissIntent extends Intent {
|
|
252
|
+
const _DismissIntent();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// 선언형 편의 위젯 — child를 감싸 tap 시 콜백 실행.
|
|
256
|
+
/// 내부 child에 onPressed:null을 주어도 이 위젯의 onTap이 동작한다.
|
|
257
|
+
class ShUiDialogTrigger extends StatelessWidget {
|
|
258
|
+
final VoidCallback onTap;
|
|
259
|
+
final Widget child;
|
|
260
|
+
|
|
261
|
+
const ShUiDialogTrigger({
|
|
262
|
+
super.key,
|
|
263
|
+
required this.onTap,
|
|
264
|
+
required this.child,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
@override
|
|
268
|
+
Widget build(BuildContext context) {
|
|
269
|
+
return GestureDetector(
|
|
270
|
+
behavior: HitTestBehavior.opaque,
|
|
271
|
+
onTap: onTap,
|
|
272
|
+
child: child,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// 대화상자 컨텐츠 래퍼.
|
|
278
|
+
///
|
|
279
|
+
/// 두 가지 사용법 모두 지원 (하위 호환):
|
|
280
|
+
/// - 기존: title / description / child / footer
|
|
281
|
+
/// - 신규: header / body / footer
|
|
282
|
+
class ShUiDialogContent extends StatelessWidget {
|
|
283
|
+
final Widget? title;
|
|
284
|
+
final Widget? description;
|
|
285
|
+
final Widget? footer;
|
|
286
|
+
final Widget? child;
|
|
287
|
+
|
|
288
|
+
/// 신규: header (보통 [ShUiDialogHeader]).
|
|
289
|
+
/// header가 제공되면 title/description은 무시된다.
|
|
290
|
+
final Widget? header;
|
|
291
|
+
|
|
292
|
+
/// 신규: body (child의 별칭).
|
|
293
|
+
/// body가 제공되면 child 대신 사용된다.
|
|
294
|
+
final Widget? body;
|
|
295
|
+
|
|
296
|
+
final bool showCloseButton;
|
|
297
|
+
final double maxWidth;
|
|
298
|
+
|
|
299
|
+
const ShUiDialogContent({
|
|
300
|
+
super.key,
|
|
301
|
+
this.title,
|
|
302
|
+
this.description,
|
|
303
|
+
this.footer,
|
|
304
|
+
this.child,
|
|
305
|
+
this.header,
|
|
306
|
+
this.body,
|
|
307
|
+
this.showCloseButton = true,
|
|
308
|
+
this.maxWidth = 480,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
@override
|
|
312
|
+
Widget build(BuildContext context) {
|
|
313
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
314
|
+
final colors = shUi.colors;
|
|
315
|
+
|
|
316
|
+
// header 우선, 없으면 title/description으로 조합.
|
|
317
|
+
final Widget? resolvedHeader = header ??
|
|
318
|
+
((title != null || description != null)
|
|
319
|
+
? _LegacyHeader(title: title, description: description)
|
|
320
|
+
: null);
|
|
321
|
+
final Widget? resolvedBody = body ?? child;
|
|
322
|
+
|
|
323
|
+
return Material(
|
|
324
|
+
color: Colors.transparent,
|
|
325
|
+
child: Container(
|
|
326
|
+
constraints: BoxConstraints(maxWidth: maxWidth),
|
|
327
|
+
margin: EdgeInsets.all(shUi.spacing.s6),
|
|
328
|
+
padding: EdgeInsets.all(shUi.spacing.s6),
|
|
329
|
+
decoration: BoxDecoration(
|
|
330
|
+
color: colors.background,
|
|
331
|
+
border: Border.all(color: colors.border),
|
|
332
|
+
borderRadius: BorderRadius.circular(shUi.radius.defaultRadius + 4),
|
|
333
|
+
boxShadow: shUi.shadow.xl,
|
|
334
|
+
),
|
|
335
|
+
child: Stack(
|
|
336
|
+
children: [
|
|
337
|
+
Column(
|
|
338
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
339
|
+
mainAxisSize: MainAxisSize.min,
|
|
340
|
+
children: [
|
|
341
|
+
if (resolvedHeader != null) resolvedHeader,
|
|
342
|
+
if (resolvedHeader != null && resolvedBody != null)
|
|
343
|
+
SizedBox(height: shUi.spacing.s4),
|
|
344
|
+
if (resolvedBody != null) resolvedBody,
|
|
345
|
+
if (footer != null) ...[
|
|
346
|
+
SizedBox(height: shUi.spacing.s6),
|
|
347
|
+
footer!,
|
|
348
|
+
],
|
|
349
|
+
],
|
|
350
|
+
),
|
|
351
|
+
if (showCloseButton)
|
|
352
|
+
Positioned(
|
|
353
|
+
top: 0,
|
|
354
|
+
right: 0,
|
|
355
|
+
child: _ShUiDialogCloseButton(colors: colors),
|
|
356
|
+
),
|
|
357
|
+
],
|
|
358
|
+
),
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// 신규 선언형 헤더 — title/description을 문자열로 받는 축약 API.
|
|
365
|
+
class ShUiDialogHeader extends StatelessWidget {
|
|
366
|
+
final String? title;
|
|
367
|
+
final String? description;
|
|
368
|
+
|
|
369
|
+
/// 커스텀 위젯이 필요한 경우 title/description 대신 사용.
|
|
370
|
+
final Widget? titleWidget;
|
|
371
|
+
final Widget? descriptionWidget;
|
|
372
|
+
|
|
373
|
+
const ShUiDialogHeader({
|
|
374
|
+
super.key,
|
|
375
|
+
this.title,
|
|
376
|
+
this.description,
|
|
377
|
+
this.titleWidget,
|
|
378
|
+
this.descriptionWidget,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
@override
|
|
382
|
+
Widget build(BuildContext context) {
|
|
383
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
384
|
+
final t = titleWidget ?? (title != null ? ShUiDialogTitle(title!) : null);
|
|
385
|
+
final d = descriptionWidget ??
|
|
386
|
+
(description != null ? ShUiDialogDescription(description!) : null);
|
|
387
|
+
|
|
388
|
+
return Column(
|
|
389
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
390
|
+
mainAxisSize: MainAxisSize.min,
|
|
391
|
+
children: [
|
|
392
|
+
if (t != null) t,
|
|
393
|
+
if (t != null && d != null) SizedBox(height: shUi.spacing.s2),
|
|
394
|
+
if (d != null) d,
|
|
395
|
+
],
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
class _LegacyHeader extends StatelessWidget {
|
|
401
|
+
final Widget? title;
|
|
402
|
+
final Widget? description;
|
|
403
|
+
|
|
404
|
+
const _LegacyHeader({this.title, this.description});
|
|
405
|
+
|
|
406
|
+
@override
|
|
407
|
+
Widget build(BuildContext context) {
|
|
408
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
409
|
+
return Column(
|
|
410
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
411
|
+
mainAxisSize: MainAxisSize.min,
|
|
412
|
+
children: [
|
|
413
|
+
if (title != null) title!,
|
|
414
|
+
if (title != null && description != null)
|
|
415
|
+
SizedBox(height: shUi.spacing.s2),
|
|
416
|
+
if (description != null) description!,
|
|
417
|
+
],
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
class _ShUiDialogCloseButton extends StatefulWidget {
|
|
423
|
+
final ShUiColorTokens colors;
|
|
424
|
+
|
|
425
|
+
const _ShUiDialogCloseButton({required this.colors});
|
|
426
|
+
|
|
427
|
+
@override
|
|
428
|
+
State<_ShUiDialogCloseButton> createState() => _ShUiDialogCloseButtonState();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
class _ShUiDialogCloseButtonState extends State<_ShUiDialogCloseButton> {
|
|
432
|
+
bool _hover = false;
|
|
433
|
+
|
|
434
|
+
@override
|
|
435
|
+
Widget build(BuildContext context) {
|
|
436
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
437
|
+
return MouseRegion(
|
|
438
|
+
cursor: SystemMouseCursors.click,
|
|
439
|
+
onEnter: (_) => setState(() => _hover = true),
|
|
440
|
+
onExit: (_) => setState(() => _hover = false),
|
|
441
|
+
child: GestureDetector(
|
|
442
|
+
onTap: () {
|
|
443
|
+
// 명령형(Navigator.pop)과 선언형(Overlay+onOpenChange) 모두에서 닫힘.
|
|
444
|
+
// 선언형 상위에서는 _ShUiDialogCloseScope가 있으면 그걸 호출.
|
|
445
|
+
final scope = _ShUiDialogCloseScope.maybeOf(context);
|
|
446
|
+
if (scope != null) {
|
|
447
|
+
scope.onClose();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// Navigator route(명령형 show) 에서만 pop.
|
|
451
|
+
final route = ModalRoute.of(context);
|
|
452
|
+
if (route != null) {
|
|
453
|
+
Navigator.of(context).pop();
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
child: Container(
|
|
457
|
+
width: 28,
|
|
458
|
+
height: 28,
|
|
459
|
+
decoration: BoxDecoration(
|
|
460
|
+
color: _hover
|
|
461
|
+
? widget.colors.backgroundMuted
|
|
462
|
+
: Colors.transparent,
|
|
463
|
+
borderRadius: BorderRadius.circular(4),
|
|
464
|
+
),
|
|
465
|
+
child: Center(
|
|
466
|
+
child: Text(
|
|
467
|
+
'×',
|
|
468
|
+
style: TextStyle(
|
|
469
|
+
color: widget.colors.foregroundMuted,
|
|
470
|
+
fontSize: shUi.text.lg,
|
|
471
|
+
height: 1,
|
|
472
|
+
),
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
),
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// 선언형 모드에서 × 버튼 등이 `onOpenChange(false)`를 호출할 수 있도록 제공하는 InheritedWidget.
|
|
482
|
+
/// 선언형 모드가 아닐 땐 제공되지 않으며, × 버튼은 Navigator.pop 경로를 사용한다.
|
|
483
|
+
class _ShUiDialogCloseScope extends InheritedWidget {
|
|
484
|
+
final VoidCallback onClose;
|
|
485
|
+
|
|
486
|
+
const _ShUiDialogCloseScope({
|
|
487
|
+
required this.onClose,
|
|
488
|
+
required super.child,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
static _ShUiDialogCloseScope? maybeOf(BuildContext context) {
|
|
492
|
+
return context.dependOnInheritedWidgetOfExactType<_ShUiDialogCloseScope>();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
@override
|
|
496
|
+
bool updateShouldNotify(_ShUiDialogCloseScope oldWidget) =>
|
|
497
|
+
oldWidget.onClose != onClose;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
class ShUiDialogTitle extends StatelessWidget {
|
|
501
|
+
final String text;
|
|
502
|
+
|
|
503
|
+
const ShUiDialogTitle(this.text, {super.key});
|
|
504
|
+
|
|
505
|
+
@override
|
|
506
|
+
Widget build(BuildContext context) {
|
|
507
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
508
|
+
return Text(
|
|
509
|
+
text,
|
|
510
|
+
style: TextStyle(
|
|
511
|
+
color: shUi.colors.foreground,
|
|
512
|
+
fontSize: shUi.text.lg,
|
|
513
|
+
fontWeight: shUi.weight.semibold,
|
|
514
|
+
height: 1.3,
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
class ShUiDialogDescription extends StatelessWidget {
|
|
521
|
+
final String text;
|
|
522
|
+
|
|
523
|
+
const ShUiDialogDescription(this.text, {super.key});
|
|
524
|
+
|
|
525
|
+
@override
|
|
526
|
+
Widget build(BuildContext context) {
|
|
527
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
528
|
+
return Text(
|
|
529
|
+
text,
|
|
530
|
+
style: TextStyle(
|
|
531
|
+
color: shUi.colors.foregroundMuted,
|
|
532
|
+
fontSize: shUi.text.sm,
|
|
533
|
+
height: 1.5,
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
class ShUiDialogFooter extends StatelessWidget {
|
|
540
|
+
final List<Widget> children;
|
|
541
|
+
final MainAxisAlignment alignment;
|
|
542
|
+
|
|
543
|
+
const ShUiDialogFooter({
|
|
544
|
+
super.key,
|
|
545
|
+
required this.children,
|
|
546
|
+
this.alignment = MainAxisAlignment.end,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
@override
|
|
550
|
+
Widget build(BuildContext context) {
|
|
551
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
552
|
+
return Row(
|
|
553
|
+
mainAxisAlignment: alignment,
|
|
554
|
+
children: _withGaps(children, shUi.spacing.s2),
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
List<Widget> _withGaps(List<Widget> children, double gap) {
|
|
560
|
+
if (children.length <= 1) return children;
|
|
561
|
+
final out = <Widget>[];
|
|
562
|
+
for (var i = 0; i < children.length; i++) {
|
|
563
|
+
out.add(children[i]);
|
|
564
|
+
if (i != children.length - 1) out.add(SizedBox(width: gap));
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|