@vetc-miniapp/ui-react 0.0.23 → 0.0.24

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 (56) hide show
  1. package/README.md +375 -56
  2. package/package.json +17 -5
  3. package/src/dist/ui-react/index.js +1 -1
  4. package/src/ui-react/components/avatar/Avatar.tsx +88 -0
  5. package/src/ui-react/components/avatar/index.ts +2 -0
  6. package/src/ui-react/components/bottom-sheet/BottomSheet.tsx +149 -0
  7. package/src/ui-react/components/bottom-sheet/index.ts +2 -0
  8. package/src/ui-react/components/button/Button.tsx +246 -0
  9. package/src/ui-react/components/button/index.ts +2 -0
  10. package/src/ui-react/components/button-group/ButtonGroup.tsx +108 -0
  11. package/src/ui-react/components/button-group/index.ts +2 -0
  12. package/src/ui-react/components/card/Card.tsx +77 -0
  13. package/src/ui-react/components/card/index.ts +2 -0
  14. package/src/ui-react/components/checkbox/Checkbox.tsx +232 -0
  15. package/src/ui-react/components/checkbox/index.ts +2 -0
  16. package/src/ui-react/components/chip/Chip.tsx +137 -0
  17. package/src/ui-react/components/chip/index.ts +2 -0
  18. package/src/ui-react/components/dialog/Dialog.tsx +135 -0
  19. package/src/ui-react/components/dialog/index.ts +2 -0
  20. package/src/ui-react/components/divider/Divider.tsx +54 -0
  21. package/src/ui-react/components/divider/index.ts +2 -0
  22. package/src/ui-react/components/input/Input.tsx +195 -0
  23. package/src/ui-react/components/input/index.ts +2 -0
  24. package/src/ui-react/components/list/List.tsx +180 -0
  25. package/src/ui-react/components/list/index.ts +2 -0
  26. package/src/ui-react/components/loading/Loading.tsx +121 -0
  27. package/src/ui-react/components/loading/index.ts +2 -0
  28. package/src/ui-react/components/modal/Modal.tsx +116 -0
  29. package/src/ui-react/components/modal/index.ts +2 -0
  30. package/src/ui-react/components/navigation-bar/NavigationBar.tsx +188 -0
  31. package/src/ui-react/components/navigation-bar/index.ts +2 -0
  32. package/src/ui-react/components/radio/Radio.tsx +216 -0
  33. package/src/ui-react/components/radio/index.ts +2 -0
  34. package/src/ui-react/components/select/Select.tsx +109 -0
  35. package/src/ui-react/components/select/index.ts +2 -0
  36. package/src/ui-react/components/switch/Switch.tsx +164 -0
  37. package/src/ui-react/components/switch/index.ts +2 -0
  38. package/src/ui-react/components/tab-bar/TabBar.tsx +137 -0
  39. package/src/ui-react/components/tab-bar/index.ts +2 -0
  40. package/src/ui-react/components/textarea/Textarea.tsx +109 -0
  41. package/src/ui-react/components/textarea/index.ts +2 -0
  42. package/src/ui-react/components/toast/Toast.ts +98 -0
  43. package/src/ui-react/components/toast/index.ts +2 -0
  44. package/src/ui-react/components/typography/Typography.tsx +201 -0
  45. package/src/ui-react/components/typography/index.ts +2 -0
  46. package/src/ui-react/hooks/use-tap-app-bar.js +26 -0
  47. package/src/ui-react/hooks/use-tap-app-bar.ts +34 -0
  48. package/src/ui-react/index.js +1 -0
  49. package/src/ui-react/index.ts +79 -3
  50. package/src/ui-react/styles/VETCProvider.tsx +152 -0
  51. package/src/ui-react/styles/tokens.css +427 -0
  52. package/src/ui-react/tokens/colors.ts +91 -0
  53. package/src/ui-react/tokens/index.ts +3 -0
  54. package/src/ui-react/tokens/spacing.ts +59 -0
  55. package/src/ui-react/tokens/typography.ts +63 -0
  56. package/src/ui-react/tokens_vetc.json +1517 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * VETC NavigationBar — Top navigation bar
3
+ * Figma: h=56px, title=20px/semibold, icon-btn=32px, padding-x=8px
4
+ * Variants: back button, close button, action buttons (max 2 right)
5
+ */
6
+ import React from 'react';
7
+
8
+ export interface NavigationBarAction {
9
+ icon: React.ReactNode;
10
+ label?: string;
11
+ onClick?: () => void;
12
+ disabled?: boolean;
13
+ id?: string;
14
+ }
15
+
16
+ export type NavigationBarLeadingType = 'back' | 'close' | 'none';
17
+
18
+ export interface NavigationBarProps {
19
+ title?: React.ReactNode;
20
+ /** Leading left button type */
21
+ leading?: NavigationBarLeadingType;
22
+ /** Custom back/close icon (overrides default) */
23
+ leadingIcon?: React.ReactNode;
24
+ onLeadingPress?: () => void;
25
+ /** Max 2 action buttons on right */
26
+ actions?: NavigationBarAction[];
27
+ /** Fully custom left slot (overrides leading) */
28
+ leftSlot?: React.ReactNode;
29
+ /** Fully custom right slot (overrides actions) */
30
+ rightSlot?: React.ReactNode;
31
+ backgroundColor?: string;
32
+ divider?: boolean;
33
+ transparent?: boolean;
34
+ className?: string;
35
+ style?: React.CSSProperties;
36
+ id?: string;
37
+ /** @deprecated use leading="back" + onLeadingPress */
38
+ showBack?: boolean;
39
+ /** @deprecated use leadingIcon */
40
+ backIcon?: React.ReactNode;
41
+ /** @deprecated use onLeadingPress */
42
+ onBack?: () => void;
43
+ left?: React.ReactNode;
44
+ right?: React.ReactNode;
45
+ }
46
+
47
+ function BackIcon() {
48
+ return (
49
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
50
+ <path d="M15 19L8 12L15 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
51
+ </svg>
52
+ );
53
+ }
54
+
55
+ function CloseIcon() {
56
+ return (
57
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
58
+ <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
59
+ </svg>
60
+ );
61
+ }
62
+
63
+ const iconBtnStyle: React.CSSProperties = {
64
+ background: 'none',
65
+ border: 'none',
66
+ cursor: 'pointer',
67
+ width: 'var(--vetc-nav-icon-btn-size)',
68
+ height: 'var(--vetc-nav-icon-btn-size)',
69
+ display: 'flex',
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ borderRadius: 'var(--vetc-radius-pill)',
73
+ color: 'var(--vetc-nav-icon-color)',
74
+ flexShrink: 0,
75
+ padding: 0,
76
+ transition: 'background-color var(--vetc-transition-fast)',
77
+ };
78
+
79
+ export function NavigationBar({
80
+ title,
81
+ leading = 'none',
82
+ leadingIcon,
83
+ onLeadingPress,
84
+ actions = [],
85
+ leftSlot,
86
+ rightSlot,
87
+ backgroundColor,
88
+ divider = true,
89
+ transparent = false,
90
+ className = '',
91
+ style,
92
+ id,
93
+ // deprecated props
94
+ showBack,
95
+ backIcon,
96
+ onBack,
97
+ left,
98
+ right,
99
+ }: NavigationBarProps) {
100
+ // Backward compat
101
+ const resolvedLeading: NavigationBarLeadingType = showBack ? 'back' : leading;
102
+ const resolvedLeadingIcon = leadingIcon ?? backIcon;
103
+ const resolvedOnLeading = onLeadingPress ?? onBack;
104
+
105
+ const renderLeadingButton = () => {
106
+ if (leftSlot !== undefined || left !== undefined) return leftSlot ?? left;
107
+ if (resolvedLeading === 'none') return null;
108
+
109
+ const icon = resolvedLeadingIcon ?? (resolvedLeading === 'close' ? <CloseIcon /> : <BackIcon />);
110
+ const label = resolvedLeading === 'close' ? 'Đóng' : 'Quay lại';
111
+
112
+ return (
113
+ <button onClick={resolvedOnLeading} style={iconBtnStyle} aria-label={label}>
114
+ {icon}
115
+ </button>
116
+ );
117
+ };
118
+
119
+ return (
120
+ <header
121
+ id={id}
122
+ className={`vetc-nav-bar ${className}`}
123
+ style={{
124
+ display: 'flex',
125
+ alignItems: 'center',
126
+ height: 'var(--vetc-nav-height)',
127
+ padding: `0 var(--vetc-nav-padding-x)`,
128
+ backgroundColor: transparent ? 'transparent' : (backgroundColor ?? 'var(--vetc-nav-bg)'),
129
+ borderBottom: divider && !transparent ? `1px solid var(--vetc-nav-border)` : 'none',
130
+ fontFamily: 'var(--vetc-font-family)',
131
+ position: 'relative',
132
+ ...style,
133
+ }}
134
+ >
135
+ {/* Left zone — always occupies space to keep title centered */}
136
+ <div style={{ display: 'flex', alignItems: 'center', minWidth: 'var(--vetc-nav-icon-btn-size)', zIndex: 1 }}>
137
+ {renderLeadingButton()}
138
+ </div>
139
+
140
+ {/* Title — absolutely centered in the full bar */}
141
+ {title && (
142
+ <div style={{
143
+ position: 'absolute',
144
+ left: 0,
145
+ right: 0,
146
+ display: 'flex',
147
+ alignItems: 'center',
148
+ justifyContent: 'center',
149
+ pointerEvents: 'none',
150
+ }}>
151
+ <span style={{
152
+ fontSize: 'var(--vetc-nav-title-size)',
153
+ fontWeight: 'var(--vetc-nav-title-weight)' as any,
154
+ color: 'var(--vetc-nav-title-color)',
155
+ lineHeight: 'var(--vetc-line-height-normal)',
156
+ maxWidth: '60%',
157
+ overflow: 'hidden',
158
+ textOverflow: 'ellipsis',
159
+ whiteSpace: 'nowrap',
160
+ }}>
161
+ {title}
162
+ </span>
163
+ </div>
164
+ )}
165
+
166
+ {/* Right zone */}
167
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--vetc-space-4)', marginLeft: 'auto', zIndex: 1 }}>
168
+ {rightSlot !== undefined || right !== undefined
169
+ ? (rightSlot ?? right)
170
+ : actions.slice(0, 2).map((action, i) => (
171
+ <button
172
+ key={i}
173
+ id={action.id}
174
+ onClick={action.onClick}
175
+ disabled={action.disabled}
176
+ aria-label={action.label}
177
+ style={{ ...iconBtnStyle, opacity: action.disabled ? 0.4 : 1 }}
178
+ >
179
+ {action.icon}
180
+ </button>
181
+ ))
182
+ }
183
+ </div>
184
+ </header>
185
+ );
186
+ }
187
+
188
+ export default NavigationBar;
@@ -0,0 +1,2 @@
1
+ export { NavigationBar } from './NavigationBar';
2
+ export type { NavigationBarProps, NavigationBarAction } from './NavigationBar';
@@ -0,0 +1,216 @@
1
+ /**
2
+ * VETC Radio & RadioGroup
3
+ * Figma: size=20px, states: default/hover/selected/disabled
4
+ * variant: neutral (gray-90) | brand (green-40)
5
+ */
6
+ import React, { useState } from 'react';
7
+
8
+ export type RadioVariant = 'neutral' | 'brand';
9
+
10
+ export interface RadioProps {
11
+ label?: React.ReactNode;
12
+ description?: React.ReactNode;
13
+ checked?: boolean;
14
+ defaultChecked?: boolean;
15
+ disabled?: boolean;
16
+ variant?: RadioVariant;
17
+ onChange?: (checked: boolean) => void;
18
+ value?: any;
19
+ id?: string;
20
+ className?: string;
21
+ style?: React.CSSProperties;
22
+ }
23
+
24
+ export function Radio({
25
+ label,
26
+ description,
27
+ checked,
28
+ defaultChecked = false,
29
+ disabled = false,
30
+ variant = 'brand',
31
+ onChange,
32
+ value,
33
+ id,
34
+ className = '',
35
+ style,
36
+ }: RadioProps) {
37
+ const [internalChecked, setInternalChecked] = useState(defaultChecked);
38
+ const [hovered, setHovered] = useState(false);
39
+
40
+ const isControlled = checked !== undefined;
41
+ const isChecked = isControlled ? checked : internalChecked;
42
+
43
+ const handleChange = () => {
44
+ if (disabled || isChecked) return;
45
+ if (!isControlled) setInternalChecked(true);
46
+ onChange?.(true);
47
+ };
48
+
49
+ const accentColor = variant === 'brand'
50
+ ? 'var(--vetc-color-brand)'
51
+ : 'var(--vetc-gray-90)';
52
+ const accentHover = variant === 'brand'
53
+ ? 'var(--vetc-color-brand-hover)'
54
+ : 'var(--vetc-gray-70)';
55
+
56
+ const ringColor = disabled
57
+ ? 'var(--vetc-color-border-disabled)'
58
+ : isChecked
59
+ ? (hovered ? accentHover : accentColor)
60
+ : (hovered ? accentColor : 'var(--vetc-color-border)');
61
+
62
+ const dotColor = disabled ? 'var(--vetc-color-text-disabled)' : accentColor;
63
+
64
+ return (
65
+ <label
66
+ htmlFor={id}
67
+ className={`vetc-radio ${className}`}
68
+ onMouseEnter={() => !disabled && setHovered(true)}
69
+ onMouseLeave={() => setHovered(false)}
70
+ style={{
71
+ display: 'inline-flex',
72
+ alignItems: description ? 'flex-start' : 'center',
73
+ gap: 'var(--vetc-space-12)',
74
+ cursor: disabled ? 'not-allowed' : 'pointer',
75
+ userSelect: 'none',
76
+ fontFamily: 'var(--vetc-font-family)',
77
+ ...style,
78
+ }}
79
+ >
80
+ <input
81
+ id={id}
82
+ type="radio"
83
+ checked={isChecked}
84
+ disabled={disabled}
85
+ value={value}
86
+ onChange={handleChange}
87
+ style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }}
88
+ />
89
+
90
+ {/* Visual radio ring */}
91
+ <span
92
+ aria-hidden="true"
93
+ style={{
94
+ width: '20px',
95
+ height: '20px',
96
+ flexShrink: 0,
97
+ borderRadius: '50%',
98
+ border: `1.5px solid ${ringColor}`,
99
+ backgroundColor: 'transparent',
100
+ display: 'flex',
101
+ alignItems: 'center',
102
+ justifyContent: 'center',
103
+ transition: 'border-color var(--vetc-transition-fast)',
104
+ marginTop: description ? '2px' : 0,
105
+ }}
106
+ >
107
+ {isChecked && (
108
+ <span style={{
109
+ width: '8px',
110
+ height: '8px',
111
+ borderRadius: '50%',
112
+ backgroundColor: dotColor,
113
+ flexShrink: 0,
114
+ }} />
115
+ )}
116
+ </span>
117
+
118
+ {/* Label + description */}
119
+ {(label || description) && (
120
+ <span style={{ display: 'flex', flexDirection: 'column', gap: 'var(--vetc-space-2)' }}>
121
+ {label && (
122
+ <span style={{
123
+ fontSize: 'var(--vetc-font-size-base)',
124
+ fontWeight: 'var(--vetc-font-weight-regular)' as any,
125
+ lineHeight: 'var(--vetc-line-height-relaxed)',
126
+ color: disabled ? 'var(--vetc-color-text-disabled)' : 'var(--vetc-color-text-primary)',
127
+ }}>
128
+ {label}
129
+ </span>
130
+ )}
131
+ {description && (
132
+ <span style={{
133
+ fontSize: 'var(--vetc-font-size-sm)',
134
+ fontWeight: 'var(--vetc-font-weight-regular)' as any,
135
+ lineHeight: 'var(--vetc-line-height-relaxed)',
136
+ color: disabled ? 'var(--vetc-color-text-disabled)' : 'var(--vetc-color-text-secondary)',
137
+ }}>
138
+ {description}
139
+ </span>
140
+ )}
141
+ </span>
142
+ )}
143
+ </label>
144
+ );
145
+ }
146
+
147
+ // ── RadioGroup ────────────────────────────────────────────────────────────────
148
+ export interface RadioOption {
149
+ label: React.ReactNode;
150
+ description?: React.ReactNode;
151
+ value: any;
152
+ disabled?: boolean;
153
+ }
154
+
155
+ export interface RadioGroupProps {
156
+ options: RadioOption[];
157
+ value?: any;
158
+ defaultValue?: any;
159
+ direction?: 'horizontal' | 'vertical';
160
+ disabled?: boolean;
161
+ variant?: RadioVariant;
162
+ onChange?: (value: any) => void;
163
+ className?: string;
164
+ style?: React.CSSProperties;
165
+ }
166
+
167
+ export function RadioGroup({
168
+ options,
169
+ value,
170
+ defaultValue,
171
+ direction = 'vertical',
172
+ disabled = false,
173
+ variant = 'brand',
174
+ onChange,
175
+ className = '',
176
+ style,
177
+ }: RadioGroupProps) {
178
+ const [internalValue, setInternalValue] = useState(defaultValue);
179
+ const isControlled = value !== undefined;
180
+ const currentValue = isControlled ? value : internalValue;
181
+
182
+ const handleChange = (optValue: any) => {
183
+ if (!isControlled) setInternalValue(optValue);
184
+ onChange?.(optValue);
185
+ };
186
+
187
+ return (
188
+ <div
189
+ role="radiogroup"
190
+ className={`vetc-radio-group ${className}`}
191
+ style={{
192
+ display: 'flex',
193
+ flexDirection: direction === 'vertical' ? 'column' : 'row',
194
+ flexWrap: direction === 'horizontal' ? 'wrap' : undefined,
195
+ gap: direction === 'vertical' ? 'var(--vetc-space-12)' : 'var(--vetc-space-16)',
196
+ fontFamily: 'var(--vetc-font-family)',
197
+ ...style,
198
+ }}
199
+ >
200
+ {options.map((opt) => (
201
+ <Radio
202
+ key={String(opt.value)}
203
+ label={opt.label}
204
+ description={opt.description}
205
+ checked={currentValue === opt.value}
206
+ disabled={opt.disabled ?? disabled}
207
+ variant={variant}
208
+ value={opt.value}
209
+ onChange={() => handleChange(opt.value)}
210
+ />
211
+ ))}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ export default Radio;
@@ -0,0 +1,2 @@
1
+ export { Radio, RadioGroup } from './Radio';
2
+ export type { RadioProps, RadioGroupProps, RadioOption, RadioVariant } from './Radio';
@@ -0,0 +1,109 @@
1
+ /**
2
+ * VETC Select — tokenized
3
+ */
4
+ import React from 'react';
5
+ import { Select as AntSelect } from 'antd';
6
+ import type { DefaultOptionType } from 'antd/es/select';
7
+
8
+ export type SelectOption = DefaultOptionType;
9
+
10
+ export interface SelectProps {
11
+ label?: string;
12
+ placeholder?: string;
13
+ value?: string | string[] | number | number[];
14
+ defaultValue?: string | string[] | number | number[];
15
+ options?: SelectOption[];
16
+ helperText?: string;
17
+ error?: string;
18
+ disabled?: boolean;
19
+ multiple?: boolean;
20
+ searchable?: boolean;
21
+ allowClear?: boolean;
22
+ loading?: boolean;
23
+ onChange?: (value: any, option: any) => void;
24
+ onSearch?: (value: string) => void;
25
+ id?: string;
26
+ className?: string;
27
+ style?: React.CSSProperties;
28
+ required?: boolean;
29
+ popupMatchSelectWidth?: boolean;
30
+ }
31
+
32
+ export function Select({
33
+ label, placeholder, value, defaultValue,
34
+ options = [], helperText, error,
35
+ disabled = false, multiple = false, searchable = false,
36
+ allowClear = false, loading = false,
37
+ onChange, onSearch, id, className = '', style, required = false,
38
+ popupMatchSelectWidth = true,
39
+ }: SelectProps) {
40
+ const hasError = !!error;
41
+ const helperMsg = error ?? helperText;
42
+ const helperColor = hasError
43
+ ? 'var(--vetc-input-helper-color-error)'
44
+ : 'var(--vetc-input-helper-color)';
45
+
46
+ return (
47
+ <div
48
+ className={`vetc-select-wrapper ${className}`}
49
+ style={{ display: 'flex', flexDirection: 'column', gap: 'var(--vetc-input-gap)', ...style }}
50
+ >
51
+ {label && (
52
+ <label
53
+ htmlFor={id}
54
+ style={{
55
+ fontFamily: 'var(--vetc-font-family)',
56
+ fontSize: 'var(--vetc-input-label-font-size)',
57
+ fontWeight: 'var(--vetc-input-label-font-weight)' as any,
58
+ lineHeight: 'var(--vetc-line-height-relaxed)',
59
+ color: disabled
60
+ ? 'var(--vetc-color-text-disabled)'
61
+ : 'var(--vetc-input-label-color)',
62
+ display: 'block',
63
+ }}
64
+ >
65
+ {label}
66
+ {required && (
67
+ <span style={{ color: 'var(--vetc-color-negative)', marginLeft: 'var(--vetc-space-2)' }}>*</span>
68
+ )}
69
+ </label>
70
+ )}
71
+
72
+ <AntSelect
73
+ id={id}
74
+ value={value}
75
+ defaultValue={defaultValue}
76
+ placeholder={placeholder}
77
+ options={options}
78
+ disabled={disabled}
79
+ mode={multiple ? 'multiple' : undefined}
80
+ showSearch={searchable}
81
+ allowClear={allowClear}
82
+ loading={loading}
83
+ status={hasError ? 'error' : undefined}
84
+ onChange={onChange}
85
+ onSearch={onSearch}
86
+ popupMatchSelectWidth={popupMatchSelectWidth}
87
+ style={{
88
+ height: 'var(--vetc-input-height)',
89
+ width: '100%',
90
+ fontFamily: 'var(--vetc-font-family)',
91
+ fontSize: 'var(--vetc-input-font-size)',
92
+ }}
93
+ />
94
+
95
+ {helperMsg && (
96
+ <span style={{
97
+ fontFamily: 'var(--vetc-font-family)',
98
+ fontSize: 'var(--vetc-input-helper-font-size)',
99
+ lineHeight: 'var(--vetc-line-height-relaxed)',
100
+ color: helperColor,
101
+ }}>
102
+ {helperMsg}
103
+ </span>
104
+ )}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ export default Select;
@@ -0,0 +1,2 @@
1
+ export { Select } from './Select';
2
+ export type { SelectProps, SelectOption } from './Select';
@@ -0,0 +1,164 @@
1
+ /**
2
+ * VETC Switch
3
+ * Figma: w=52px h=32px, thumb=28px, ON=brand-green, OFF=gray-30
4
+ * variant: neutral (gray-90 on) | brand (green-40 on)
5
+ */
6
+ import React, { useState } from 'react';
7
+
8
+ export type SwitchVariant = 'neutral' | 'brand';
9
+
10
+ export interface SwitchProps {
11
+ label?: React.ReactNode;
12
+ description?: React.ReactNode;
13
+ labelPosition?: 'left' | 'right';
14
+ checked?: boolean;
15
+ defaultChecked?: boolean;
16
+ disabled?: boolean;
17
+ loading?: boolean;
18
+ variant?: SwitchVariant;
19
+ onChange?: (checked: boolean) => void;
20
+ id?: string;
21
+ className?: string;
22
+ style?: React.CSSProperties;
23
+ }
24
+
25
+ export function Switch({
26
+ label,
27
+ description,
28
+ labelPosition = 'right',
29
+ checked,
30
+ defaultChecked = false,
31
+ disabled = false,
32
+ loading = false,
33
+ variant = 'brand',
34
+ onChange,
35
+ id,
36
+ className = '',
37
+ style,
38
+ }: SwitchProps) {
39
+ const [internalChecked, setInternalChecked] = useState(defaultChecked);
40
+
41
+ const isControlled = checked !== undefined;
42
+ const isChecked = isControlled ? checked : internalChecked;
43
+ const isDisabled = disabled || loading;
44
+
45
+ const handleToggle = () => {
46
+ if (isDisabled) return;
47
+ const next = !isChecked;
48
+ if (!isControlled) setInternalChecked(next);
49
+ onChange?.(next);
50
+ };
51
+
52
+ const onColor = variant === 'brand'
53
+ ? 'var(--vetc-switch-on-bg)'
54
+ : 'var(--vetc-gray-90)';
55
+
56
+ const trackBg = isDisabled
57
+ ? 'var(--vetc-color-bg-disabled)'
58
+ : isChecked
59
+ ? onColor
60
+ : 'var(--vetc-switch-off-bg)';
61
+
62
+ const thumbLeft = isChecked ? 'calc(100% - 30px)' : '2px';
63
+
64
+ const switchEl = (
65
+ <button
66
+ id={id}
67
+ type="button"
68
+ role="switch"
69
+ aria-checked={isChecked}
70
+ disabled={isDisabled}
71
+ onClick={handleToggle}
72
+ className={`vetc-switch ${className}`}
73
+ style={{
74
+ position: 'relative',
75
+ display: 'inline-flex',
76
+ alignItems: 'center',
77
+ width: 'var(--vetc-switch-width)',
78
+ height: 'var(--vetc-switch-height)',
79
+ borderRadius: 'var(--vetc-radius-pill)',
80
+ backgroundColor: trackBg,
81
+ border: 'none',
82
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
83
+ padding: 0,
84
+ flexShrink: 0,
85
+ transition: 'background-color var(--vetc-transition-fast)',
86
+ outline: 'none',
87
+ ...(label || description ? {} : style),
88
+ }}
89
+ >
90
+ {/* Thumb */}
91
+ <span style={{
92
+ position: 'absolute',
93
+ left: thumbLeft,
94
+ width: 'var(--vetc-switch-thumb-size)',
95
+ height: 'var(--vetc-switch-thumb-size)',
96
+ borderRadius: '50%',
97
+ backgroundColor: isDisabled ? 'var(--vetc-gray-20)' : 'var(--vetc-white)',
98
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
99
+ transition: 'left var(--vetc-transition-fast)',
100
+ display: 'flex',
101
+ alignItems: 'center',
102
+ justifyContent: 'center',
103
+ }}>
104
+ {loading && (
105
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ animation: 'vetc-btn-spin 0.6s linear infinite' }} aria-hidden="true">
106
+ <circle cx="6" cy="6" r="4" stroke="var(--vetc-gray-30)" strokeWidth="1.5" />
107
+ <path d="M6 2a4 4 0 014 4" stroke="var(--vetc-color-brand)" strokeWidth="1.5" strokeLinecap="round" />
108
+ </svg>
109
+ )}
110
+ </span>
111
+ <style>{`@keyframes vetc-btn-spin { to { transform: rotate(360deg); } }`}</style>
112
+ </button>
113
+ );
114
+
115
+ if (!label && !description) return switchEl;
116
+
117
+ const labelBlock = (
118
+ <span style={{ display: 'flex', flexDirection: 'column', gap: 'var(--vetc-space-2)', flex: 1 }}>
119
+ {label && (
120
+ <span style={{
121
+ fontSize: 'var(--vetc-font-size-base)',
122
+ fontWeight: 'var(--vetc-font-weight-regular)' as any,
123
+ lineHeight: 'var(--vetc-line-height-relaxed)',
124
+ color: isDisabled ? 'var(--vetc-color-text-disabled)' : 'var(--vetc-color-text-primary)',
125
+ }}>
126
+ {label}
127
+ </span>
128
+ )}
129
+ {description && (
130
+ <span style={{
131
+ fontSize: 'var(--vetc-font-size-sm)',
132
+ fontWeight: 'var(--vetc-font-weight-regular)' as any,
133
+ lineHeight: 'var(--vetc-line-height-relaxed)',
134
+ color: isDisabled ? 'var(--vetc-color-text-disabled)' : 'var(--vetc-color-text-secondary)',
135
+ }}>
136
+ {description}
137
+ </span>
138
+ )}
139
+ </span>
140
+ );
141
+
142
+ return (
143
+ <div
144
+ style={{
145
+ display: 'flex',
146
+ alignItems: 'center',
147
+ gap: 'var(--vetc-space-12)',
148
+ flexDirection: labelPosition === 'left' ? 'row-reverse' : 'row',
149
+ fontFamily: 'var(--vetc-font-family)',
150
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
151
+ ...style,
152
+ }}
153
+ onClick={handleToggle}
154
+ >
155
+ {labelPosition === 'right' ? (
156
+ <>{switchEl}{labelBlock}</>
157
+ ) : (
158
+ <>{labelBlock}{switchEl}</>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ export default Switch;
@@ -0,0 +1,2 @@
1
+ export { Switch } from './Switch';
2
+ export type { SwitchProps, SwitchVariant } from './Switch';