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.
Files changed (162) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +354 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/registry.json +835 -0
  152. package/data/summaries/flutter.json +42 -0
  153. package/data/summaries/react.json +50 -0
  154. package/data/tokens/build.mjs +553 -0
  155. package/data/tokens/src/primitives.json +146 -0
  156. package/data/tokens/src/semantic.json +146 -0
  157. package/package.json +9 -2
  158. package/src/add.mjs +13 -12
  159. package/src/list.mjs +3 -11
  160. package/src/mcp.mjs +308 -0
  161. package/src/paths.mjs +52 -0
  162. package/src/remove.mjs +4 -11
@@ -0,0 +1,664 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter/services.dart';
3
+ import '../foundation/sh_ui_tokens.dart';
4
+
5
+ /// shUi 텍스트 입력 필드.
6
+ /// Material TextField를 기반으로 토큰 스타일을 입힌다.
7
+ class ShUiInput extends StatefulWidget {
8
+ final TextEditingController? controller;
9
+ final String? placeholder;
10
+ final String? initialValue;
11
+ final bool enabled;
12
+ final bool readOnly;
13
+ final bool obscureText;
14
+ final bool invalid;
15
+ final TextInputType? keyboardType;
16
+ final List<TextInputFormatter>? inputFormatters;
17
+ final int? maxLength;
18
+ final ValueChanged<String>? onChanged;
19
+ final ValueChanged<String>? onSubmitted;
20
+ final FocusNode? focusNode;
21
+ final Widget? prefix;
22
+ final Widget? suffix;
23
+
24
+ const ShUiInput({
25
+ super.key,
26
+ this.controller,
27
+ this.placeholder,
28
+ this.initialValue,
29
+ this.enabled = true,
30
+ this.readOnly = false,
31
+ this.obscureText = false,
32
+ this.invalid = false,
33
+ this.keyboardType,
34
+ this.inputFormatters,
35
+ this.maxLength,
36
+ this.onChanged,
37
+ this.onSubmitted,
38
+ this.focusNode,
39
+ this.prefix,
40
+ this.suffix,
41
+ });
42
+
43
+ @override
44
+ State<ShUiInput> createState() => _ShUiInputState();
45
+ }
46
+
47
+ class _ShUiInputState extends State<ShUiInput> {
48
+ late final FocusNode _focusNode;
49
+ bool _ownsFocusNode = false;
50
+ bool _hover = false;
51
+
52
+ @override
53
+ void initState() {
54
+ super.initState();
55
+ _focusNode = widget.focusNode ?? FocusNode();
56
+ _ownsFocusNode = widget.focusNode == null;
57
+ _focusNode.addListener(_onFocusChange);
58
+ }
59
+
60
+ @override
61
+ void dispose() {
62
+ _focusNode.removeListener(_onFocusChange);
63
+ if (_ownsFocusNode) _focusNode.dispose();
64
+ super.dispose();
65
+ }
66
+
67
+ void _onFocusChange() => setState(() {});
68
+
69
+ Color _borderColor(ShUiColorTokens colors) {
70
+ if (widget.invalid) return colors.danger;
71
+ if (_focusNode.hasFocus) return colors.foreground;
72
+ if (_hover && widget.enabled && !widget.readOnly) return colors.borderStrong;
73
+ return colors.border;
74
+ }
75
+
76
+ @override
77
+ Widget build(BuildContext context) {
78
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
79
+ final colors = shUi.colors;
80
+ final focused = _focusNode.hasFocus;
81
+
82
+ final bg = (!widget.enabled || widget.readOnly)
83
+ ? colors.backgroundSubtle
84
+ : colors.background;
85
+
86
+ final textField = TextField(
87
+ controller: widget.controller,
88
+ focusNode: _focusNode,
89
+ enabled: widget.enabled,
90
+ readOnly: widget.readOnly,
91
+ obscureText: widget.obscureText,
92
+ keyboardType: widget.keyboardType,
93
+ inputFormatters: widget.inputFormatters,
94
+ maxLength: widget.maxLength,
95
+ onChanged: widget.onChanged,
96
+ onSubmitted: widget.onSubmitted,
97
+ style: TextStyle(
98
+ color: colors.foreground,
99
+ fontSize: shUi.text.sm,
100
+ height: 1.2,
101
+ ),
102
+ cursorColor: colors.foreground,
103
+ decoration: InputDecoration(
104
+ isCollapsed: true,
105
+ contentPadding: EdgeInsets.zero,
106
+ border: InputBorder.none,
107
+ counterText: "",
108
+ hintText: widget.placeholder,
109
+ hintStyle: TextStyle(
110
+ color: colors.foregroundMuted,
111
+ fontSize: shUi.text.sm,
112
+ ),
113
+ ),
114
+ );
115
+
116
+ return MouseRegion(
117
+ onEnter: (_) => setState(() => _hover = true),
118
+ onExit: (_) => setState(() => _hover = false),
119
+ child: AnimatedContainer(
120
+ duration: shUi.duration.fast,
121
+ height: shUi.control.md,
122
+ padding: EdgeInsets.only(
123
+ left: widget.prefix == null ? shUi.spacing.s3 : shUi.spacing.s2,
124
+ right: widget.suffix == null ? shUi.spacing.s3 : shUi.spacing.s1,
125
+ ),
126
+ decoration: BoxDecoration(
127
+ color: bg,
128
+ border: Border.all(
129
+ color: _borderColor(colors),
130
+ width: focused ? shUi.borderWidth.strong : shUi.borderWidth.normal,
131
+ ),
132
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
133
+ ),
134
+ child: Opacity(
135
+ opacity: widget.enabled ? 1 : shUi.opacity.disabled,
136
+ child: Row(
137
+ children: [
138
+ if (widget.prefix != null) ...[
139
+ IconTheme(
140
+ data: IconThemeData(color: colors.foregroundMuted, size: 16),
141
+ child: widget.prefix!,
142
+ ),
143
+ SizedBox(width: shUi.spacing.s2),
144
+ ],
145
+ Expanded(child: textField),
146
+ if (widget.suffix != null) ...[
147
+ SizedBox(width: shUi.spacing.s1),
148
+ IconTheme(
149
+ data: IconThemeData(color: colors.foregroundMuted, size: 16),
150
+ child: widget.suffix!,
151
+ ),
152
+ ],
153
+ ],
154
+ ),
155
+ ),
156
+ ),
157
+ );
158
+ }
159
+ }
160
+
161
+ /* ───────── ShUiPasswordInput ───────── */
162
+
163
+ class ShUiPasswordInput extends StatefulWidget {
164
+ final TextEditingController? controller;
165
+ final String? placeholder;
166
+ final bool enabled;
167
+ final bool invalid;
168
+ final bool hideToggle;
169
+ final ValueChanged<String>? onChanged;
170
+ final ValueChanged<String>? onSubmitted;
171
+ final FocusNode? focusNode;
172
+
173
+ const ShUiPasswordInput({
174
+ super.key,
175
+ this.controller,
176
+ this.placeholder,
177
+ this.enabled = true,
178
+ this.invalid = false,
179
+ this.hideToggle = false,
180
+ this.onChanged,
181
+ this.onSubmitted,
182
+ this.focusNode,
183
+ });
184
+
185
+ @override
186
+ State<ShUiPasswordInput> createState() => _ShUiPasswordInputState();
187
+ }
188
+
189
+ class _ShUiPasswordInputState extends State<ShUiPasswordInput> {
190
+ bool _visible = false;
191
+
192
+ @override
193
+ Widget build(BuildContext context) {
194
+ return ShUiInput(
195
+ controller: widget.controller,
196
+ placeholder: widget.placeholder,
197
+ enabled: widget.enabled,
198
+ invalid: widget.invalid,
199
+ obscureText: !_visible,
200
+ onChanged: widget.onChanged,
201
+ onSubmitted: widget.onSubmitted,
202
+ focusNode: widget.focusNode,
203
+ suffix: widget.hideToggle
204
+ ? null
205
+ : _ToggleButton(
206
+ visible: _visible,
207
+ onToggle: () => setState(() => _visible = !_visible),
208
+ ),
209
+ );
210
+ }
211
+ }
212
+
213
+ class _ToggleButton extends StatelessWidget {
214
+ final bool visible;
215
+ final VoidCallback onToggle;
216
+ const _ToggleButton({required this.visible, required this.onToggle});
217
+
218
+ @override
219
+ Widget build(BuildContext context) {
220
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
221
+ return Semantics(
222
+ button: true,
223
+ label: visible ? '비밀번호 숨기기' : '비밀번호 표시',
224
+ child: InkWell(
225
+ onTap: onToggle,
226
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius - 2),
227
+ child: Padding(
228
+ padding: EdgeInsets.all(shUi.spacing.s2),
229
+ child: Icon(
230
+ visible ? Icons.visibility_off_outlined : Icons.visibility_outlined,
231
+ size: 18,
232
+ color: shUi.colors.foregroundMuted,
233
+ ),
234
+ ),
235
+ ),
236
+ );
237
+ }
238
+ }
239
+
240
+ /* ───────── ShUiNumberInput ─────────
241
+ * 정수 입력 + 천 단위 콤마(옵션). onChanged는 num? 콜백.
242
+ * blur 시 min/max로 clamp.
243
+ */
244
+
245
+ String _shUiFormatNumber(String digits, bool thousandsSeparator) {
246
+ if (digits.isEmpty || digits == '-') return digits;
247
+ final negative = digits.startsWith('-');
248
+ final body = negative ? digits.substring(1) : digits;
249
+ if (body.isEmpty) return negative ? '-' : '';
250
+ String formatted = body;
251
+ if (thousandsSeparator) {
252
+ final re = RegExp(r'\B(?=(\d{3})+(?!\d))');
253
+ formatted = body.replaceAllMapped(re, (_) => ',');
254
+ }
255
+ return negative ? '-$formatted' : formatted;
256
+ }
257
+
258
+ num? _shUiParseNumber(String s) {
259
+ final cleaned = s.replaceAll(RegExp(r'[^\d-]'), '');
260
+ if (cleaned.isEmpty || cleaned == '-') return null;
261
+ return num.tryParse(cleaned);
262
+ }
263
+
264
+ class ShUiNumberInput extends StatefulWidget {
265
+ final num? value;
266
+ final num? defaultValue;
267
+ final ValueChanged<num?>? onChanged;
268
+ final String? placeholder;
269
+ final bool enabled;
270
+ final bool invalid;
271
+ final bool thousandsSeparator;
272
+ final bool allowNegative;
273
+ final num? min;
274
+ final num? max;
275
+ final FocusNode? focusNode;
276
+
277
+ const ShUiNumberInput({
278
+ super.key,
279
+ this.value,
280
+ this.defaultValue,
281
+ this.onChanged,
282
+ this.placeholder,
283
+ this.enabled = true,
284
+ this.invalid = false,
285
+ this.thousandsSeparator = true,
286
+ this.allowNegative = true,
287
+ this.min,
288
+ this.max,
289
+ this.focusNode,
290
+ });
291
+
292
+ @override
293
+ State<ShUiNumberInput> createState() => _ShUiNumberInputState();
294
+ }
295
+
296
+ class _ShUiNumberInputState extends State<ShUiNumberInput> {
297
+ late final TextEditingController _controller;
298
+ late final FocusNode _focusNode;
299
+ bool _ownsFocusNode = false;
300
+
301
+ bool get _isControlled => widget.value != null;
302
+
303
+ String _displayFor(num? v) => v == null
304
+ ? ''
305
+ : _shUiFormatNumber(v.toString(), widget.thousandsSeparator);
306
+
307
+ @override
308
+ void initState() {
309
+ super.initState();
310
+ final initial = widget.value ?? widget.defaultValue;
311
+ _controller = TextEditingController(text: _displayFor(initial));
312
+ _focusNode = widget.focusNode ?? FocusNode();
313
+ _ownsFocusNode = widget.focusNode == null;
314
+ _focusNode.addListener(_onFocusChange);
315
+ }
316
+
317
+ @override
318
+ void didUpdateWidget(covariant ShUiNumberInput oldWidget) {
319
+ super.didUpdateWidget(oldWidget);
320
+ if (_isControlled) {
321
+ final next = _displayFor(widget.value);
322
+ if (next != _controller.text) {
323
+ _controller.value = TextEditingValue(
324
+ text: next,
325
+ selection: TextSelection.collapsed(offset: next.length),
326
+ );
327
+ }
328
+ }
329
+ }
330
+
331
+ @override
332
+ void dispose() {
333
+ _focusNode.removeListener(_onFocusChange);
334
+ if (_ownsFocusNode) _focusNode.dispose();
335
+ _controller.dispose();
336
+ super.dispose();
337
+ }
338
+
339
+ void _onFocusChange() {
340
+ if (!_focusNode.hasFocus) _clamp();
341
+ }
342
+
343
+ void _handleChanged(String raw) {
344
+ final re = widget.allowNegative
345
+ ? RegExp(r'[^\d-]')
346
+ : RegExp(r'[^\d]');
347
+ String cleaned = raw.replaceAll(re, '');
348
+ if (widget.allowNegative) {
349
+ // "-"는 맨 앞에만
350
+ if (cleaned.contains('-')) {
351
+ final first = cleaned.startsWith('-');
352
+ cleaned = cleaned.replaceAll('-', '');
353
+ if (first) cleaned = '-$cleaned';
354
+ }
355
+ }
356
+ final formatted = _shUiFormatNumber(cleaned, widget.thousandsSeparator);
357
+ if (!_isControlled && formatted != _controller.text) {
358
+ _controller.value = TextEditingValue(
359
+ text: formatted,
360
+ selection: TextSelection.collapsed(offset: formatted.length),
361
+ );
362
+ }
363
+ widget.onChanged?.call(_shUiParseNumber(cleaned));
364
+ }
365
+
366
+ void _clamp() {
367
+ final n = _shUiParseNumber(_controller.text);
368
+ if (n == null) return;
369
+ num clamped = n;
370
+ if (widget.min != null && clamped < widget.min!) clamped = widget.min!;
371
+ if (widget.max != null && clamped > widget.max!) clamped = widget.max!;
372
+ if (clamped != n) {
373
+ final f = _shUiFormatNumber(clamped.toString(), widget.thousandsSeparator);
374
+ if (!_isControlled) {
375
+ _controller.value = TextEditingValue(
376
+ text: f,
377
+ selection: TextSelection.collapsed(offset: f.length),
378
+ );
379
+ }
380
+ widget.onChanged?.call(clamped);
381
+ }
382
+ }
383
+
384
+ @override
385
+ Widget build(BuildContext context) {
386
+ return ShUiInput(
387
+ controller: _controller,
388
+ focusNode: _focusNode,
389
+ placeholder: widget.placeholder,
390
+ enabled: widget.enabled,
391
+ invalid: widget.invalid,
392
+ keyboardType: const TextInputType.numberWithOptions(signed: true),
393
+ onChanged: _handleChanged,
394
+ );
395
+ }
396
+ }
397
+
398
+ /* ───────── _DigitsMaskFormatter ─────────
399
+ * 숫자만 남긴 뒤 지정된 포맷 함수로 재포맷. 커서 위치는
400
+ * "커서 앞 숫자 개수"를 기준으로 재계산해 하이픈 삭제/붙여넣기에 대응.
401
+ */
402
+ class _DigitsMaskFormatter extends TextInputFormatter {
403
+ final String Function(String digits) format;
404
+ final int maxDigits;
405
+ const _DigitsMaskFormatter({required this.format, required this.maxDigits});
406
+
407
+ @override
408
+ TextEditingValue formatEditUpdate(
409
+ TextEditingValue oldValue,
410
+ TextEditingValue newValue,
411
+ ) {
412
+ final rawText = newValue.text;
413
+ final selectionEnd = newValue.selection.end.clamp(0, rawText.length);
414
+
415
+ // 커서 앞 숫자 개수
416
+ int digitsBeforeCursor = 0;
417
+ for (int i = 0; i < selectionEnd; i++) {
418
+ if (RegExp(r'\d').hasMatch(rawText[i])) digitsBeforeCursor++;
419
+ }
420
+
421
+ final allDigits = rawText.replaceAll(RegExp(r'\D'), '');
422
+ final clipped = allDigits.length > maxDigits
423
+ ? allDigits.substring(0, maxDigits)
424
+ : allDigits;
425
+ if (digitsBeforeCursor > clipped.length) {
426
+ digitsBeforeCursor = clipped.length;
427
+ }
428
+
429
+ final formatted = format(clipped);
430
+
431
+ // 재배치: 포맷된 문자열에서 digitsBeforeCursor 개의 숫자가 지나간 위치
432
+ int newOffset = formatted.length;
433
+ int seen = 0;
434
+ for (int i = 0; i < formatted.length; i++) {
435
+ if (seen == digitsBeforeCursor) {
436
+ newOffset = i;
437
+ break;
438
+ }
439
+ if (RegExp(r'\d').hasMatch(formatted[i])) seen++;
440
+ }
441
+
442
+ return TextEditingValue(
443
+ text: formatted,
444
+ selection: TextSelection.collapsed(offset: newOffset),
445
+ );
446
+ }
447
+ }
448
+
449
+ /* ───────── ShUiPhoneInput (KR) ───────── */
450
+
451
+ String _shUiFormatPhoneKR(String digits) {
452
+ final d = digits.length > 11 ? digits.substring(0, 11) : digits;
453
+ if (d.isEmpty) return '';
454
+ if (d.startsWith('02')) {
455
+ if (d.length <= 2) return d;
456
+ if (d.length <= 5) return '${d.substring(0, 2)}-${d.substring(2)}';
457
+ if (d.length <= 9) {
458
+ return '${d.substring(0, 2)}-${d.substring(2, 5)}-${d.substring(5)}';
459
+ }
460
+ return '${d.substring(0, 2)}-${d.substring(2, 6)}-${d.substring(6, d.length > 10 ? 10 : d.length)}';
461
+ }
462
+ if (d.length <= 3) return d;
463
+ if (d.length <= 6) return '${d.substring(0, 3)}-${d.substring(3)}';
464
+ if (d.length <= 10) {
465
+ return '${d.substring(0, 3)}-${d.substring(3, 6)}-${d.substring(6)}';
466
+ }
467
+ return '${d.substring(0, 3)}-${d.substring(3, 7)}-${d.substring(7)}';
468
+ }
469
+
470
+ class ShUiPhoneInput extends StatefulWidget {
471
+ final String? value;
472
+ final String? defaultValue;
473
+ final ValueChanged<String>? onChanged;
474
+ final String? placeholder;
475
+ final bool enabled;
476
+ final bool invalid;
477
+ final FocusNode? focusNode;
478
+
479
+ const ShUiPhoneInput({
480
+ super.key,
481
+ this.value,
482
+ this.defaultValue,
483
+ this.onChanged,
484
+ this.placeholder,
485
+ this.enabled = true,
486
+ this.invalid = false,
487
+ this.focusNode,
488
+ });
489
+
490
+ @override
491
+ State<ShUiPhoneInput> createState() => _ShUiPhoneInputState();
492
+ }
493
+
494
+ class _ShUiPhoneInputState extends State<ShUiPhoneInput> {
495
+ late final TextEditingController _controller;
496
+
497
+ bool get _isControlled => widget.value != null;
498
+
499
+ @override
500
+ void initState() {
501
+ super.initState();
502
+ final initialDigits = (widget.value ?? widget.defaultValue ?? '')
503
+ .replaceAll(RegExp(r'\D'), '');
504
+ _controller = TextEditingController(text: _shUiFormatPhoneKR(initialDigits));
505
+ }
506
+
507
+ @override
508
+ void didUpdateWidget(covariant ShUiPhoneInput oldWidget) {
509
+ super.didUpdateWidget(oldWidget);
510
+ if (_isControlled) {
511
+ final d = (widget.value ?? '').replaceAll(RegExp(r'\D'), '');
512
+ final f = _shUiFormatPhoneKR(d);
513
+ if (f != _controller.text) {
514
+ _controller.value = TextEditingValue(
515
+ text: f,
516
+ selection: TextSelection.collapsed(offset: f.length),
517
+ );
518
+ }
519
+ }
520
+ }
521
+
522
+ @override
523
+ void dispose() {
524
+ _controller.dispose();
525
+ super.dispose();
526
+ }
527
+
528
+ void _handleChanged(String formatted) {
529
+ final digits = formatted.replaceAll(RegExp(r'\D'), '');
530
+ widget.onChanged?.call(digits);
531
+ }
532
+
533
+ @override
534
+ Widget build(BuildContext context) {
535
+ return ShUiInput(
536
+ controller: _controller,
537
+ focusNode: widget.focusNode,
538
+ placeholder: widget.placeholder,
539
+ enabled: widget.enabled,
540
+ invalid: widget.invalid,
541
+ keyboardType: TextInputType.phone,
542
+ inputFormatters: [
543
+ _DigitsMaskFormatter(format: _shUiFormatPhoneKR, maxDigits: 11),
544
+ ],
545
+ onChanged: _handleChanged,
546
+ );
547
+ }
548
+ }
549
+
550
+ /* ───────── ShUiBusinessNumberInput (KR 사업자등록번호) ───────── */
551
+
552
+ String _shUiFormatBRN(String digits) {
553
+ final d = digits.length > 10 ? digits.substring(0, 10) : digits;
554
+ if (d.length <= 3) return d;
555
+ if (d.length <= 5) return '${d.substring(0, 3)}-${d.substring(3)}';
556
+ return '${d.substring(0, 3)}-${d.substring(3, 5)}-${d.substring(5)}';
557
+ }
558
+
559
+ /// 국세청 사업자등록번호 체크섬 알고리즘.
560
+ /// 9자리에 가중치 [1,3,7,1,3,7,1,3,5]를 곱해 합산,
561
+ /// 9번째 자리의 5배를 10으로 나눈 몫을 더한 후
562
+ /// (10 - (합 mod 10)) mod 10 이 마지막 자리와 일치하면 유효.
563
+ bool isValidBRN(String digits) {
564
+ final d = digits.replaceAll(RegExp(r'\D'), '');
565
+ if (d.length != 10) return false;
566
+ const w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
567
+ int sum = 0;
568
+ for (int i = 0; i < 9; i++) {
569
+ sum += int.parse(d[i]) * w[i];
570
+ }
571
+ sum += ((int.parse(d[8]) * 5) / 10).floor();
572
+ final check = (10 - (sum % 10)) % 10;
573
+ return check == int.parse(d[9]);
574
+ }
575
+
576
+ class ShUiBusinessNumberInput extends StatefulWidget {
577
+ final String? value;
578
+ final String? defaultValue;
579
+ final ValueChanged<String>? onChanged;
580
+ final String? placeholder;
581
+ final bool enabled;
582
+ final bool invalid;
583
+ final bool validateChecksum;
584
+ final FocusNode? focusNode;
585
+
586
+ const ShUiBusinessNumberInput({
587
+ super.key,
588
+ this.value,
589
+ this.defaultValue,
590
+ this.onChanged,
591
+ this.placeholder,
592
+ this.enabled = true,
593
+ this.invalid = false,
594
+ this.validateChecksum = false,
595
+ this.focusNode,
596
+ });
597
+
598
+ @override
599
+ State<ShUiBusinessNumberInput> createState() =>
600
+ _ShUiBusinessNumberInputState();
601
+ }
602
+
603
+ class _ShUiBusinessNumberInputState extends State<ShUiBusinessNumberInput> {
604
+ late final TextEditingController _controller;
605
+
606
+ bool get _isControlled => widget.value != null;
607
+
608
+ @override
609
+ void initState() {
610
+ super.initState();
611
+ final initialDigits = (widget.value ?? widget.defaultValue ?? '')
612
+ .replaceAll(RegExp(r'\D'), '');
613
+ _controller = TextEditingController(text: _shUiFormatBRN(initialDigits));
614
+ }
615
+
616
+ @override
617
+ void didUpdateWidget(covariant ShUiBusinessNumberInput oldWidget) {
618
+ super.didUpdateWidget(oldWidget);
619
+ if (_isControlled) {
620
+ final d = (widget.value ?? '').replaceAll(RegExp(r'\D'), '');
621
+ final f = _shUiFormatBRN(d);
622
+ if (f != _controller.text) {
623
+ _controller.value = TextEditingValue(
624
+ text: f,
625
+ selection: TextSelection.collapsed(offset: f.length),
626
+ );
627
+ }
628
+ }
629
+ }
630
+
631
+ @override
632
+ void dispose() {
633
+ _controller.dispose();
634
+ super.dispose();
635
+ }
636
+
637
+ void _handleChanged(String formatted) {
638
+ final digits = formatted.replaceAll(RegExp(r'\D'), '');
639
+ widget.onChanged?.call(digits);
640
+ }
641
+
642
+ bool get _computedInvalid {
643
+ if (widget.invalid) return true;
644
+ if (!widget.validateChecksum) return false;
645
+ final d = _controller.text.replaceAll(RegExp(r'\D'), '');
646
+ return d.length == 10 && !isValidBRN(d);
647
+ }
648
+
649
+ @override
650
+ Widget build(BuildContext context) {
651
+ return ShUiInput(
652
+ controller: _controller,
653
+ focusNode: widget.focusNode,
654
+ placeholder: widget.placeholder,
655
+ enabled: widget.enabled,
656
+ invalid: _computedInvalid,
657
+ keyboardType: TextInputType.number,
658
+ inputFormatters: [
659
+ _DigitsMaskFormatter(format: _shUiFormatBRN, maxDigits: 10),
660
+ ],
661
+ onChanged: _handleChanged,
662
+ );
663
+ }
664
+ }