@vetc-miniapp/ui-react 0.0.22 → 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.
- package/README.md +375 -56
- package/package.json +17 -5
- package/src/dist/ui-react/index.js +1 -1
- package/src/ui-react/components/avatar/Avatar.tsx +88 -0
- package/src/ui-react/components/avatar/index.ts +2 -0
- package/src/ui-react/components/bottom-sheet/BottomSheet.tsx +149 -0
- package/src/ui-react/components/bottom-sheet/index.ts +2 -0
- package/src/ui-react/components/button/Button.tsx +246 -0
- package/src/ui-react/components/button/index.ts +2 -0
- package/src/ui-react/components/button-group/ButtonGroup.tsx +108 -0
- package/src/ui-react/components/button-group/index.ts +2 -0
- package/src/ui-react/components/card/Card.tsx +77 -0
- package/src/ui-react/components/card/index.ts +2 -0
- package/src/ui-react/components/checkbox/Checkbox.tsx +232 -0
- package/src/ui-react/components/checkbox/index.ts +2 -0
- package/src/ui-react/components/chip/Chip.tsx +137 -0
- package/src/ui-react/components/chip/index.ts +2 -0
- package/src/ui-react/components/dialog/Dialog.tsx +135 -0
- package/src/ui-react/components/dialog/index.ts +2 -0
- package/src/ui-react/components/divider/Divider.tsx +54 -0
- package/src/ui-react/components/divider/index.ts +2 -0
- package/src/ui-react/components/input/Input.tsx +195 -0
- package/src/ui-react/components/input/index.ts +2 -0
- package/src/ui-react/components/list/List.tsx +180 -0
- package/src/ui-react/components/list/index.ts +2 -0
- package/src/ui-react/components/loading/Loading.tsx +121 -0
- package/src/ui-react/components/loading/index.ts +2 -0
- package/src/ui-react/components/modal/Modal.tsx +116 -0
- package/src/ui-react/components/modal/index.ts +2 -0
- package/src/ui-react/components/navigation-bar/NavigationBar.tsx +188 -0
- package/src/ui-react/components/navigation-bar/index.ts +2 -0
- package/src/ui-react/components/radio/Radio.tsx +216 -0
- package/src/ui-react/components/radio/index.ts +2 -0
- package/src/ui-react/components/select/Select.tsx +109 -0
- package/src/ui-react/components/select/index.ts +2 -0
- package/src/ui-react/components/switch/Switch.tsx +164 -0
- package/src/ui-react/components/switch/index.ts +2 -0
- package/src/ui-react/components/tab-bar/TabBar.tsx +137 -0
- package/src/ui-react/components/tab-bar/index.ts +2 -0
- package/src/ui-react/components/textarea/Textarea.tsx +109 -0
- package/src/ui-react/components/textarea/index.ts +2 -0
- package/src/ui-react/components/toast/Toast.ts +98 -0
- package/src/ui-react/components/toast/index.ts +2 -0
- package/src/ui-react/components/typography/Typography.tsx +201 -0
- package/src/ui-react/components/typography/index.ts +2 -0
- package/src/ui-react/hooks/use-did-show.js +1 -0
- package/src/ui-react/hooks/use-tap-app-bar.js +26 -0
- package/src/ui-react/hooks/use-tap-app-bar.ts +34 -0
- package/src/ui-react/index.js +1 -0
- package/src/ui-react/index.ts +79 -3
- package/src/ui-react/styles/VETCProvider.tsx +152 -0
- package/src/ui-react/styles/tokens.css +427 -0
- package/src/ui-react/tokens/colors.ts +91 -0
- package/src/ui-react/tokens/index.ts +3 -0
- package/src/ui-react/tokens/spacing.ts +59 -0
- package/src/ui-react/tokens/typography.ts +63 -0
- package/src/ui-react/tokens_vetc.json +1517 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VETC Input / TextField & PasswordInput Components
|
|
3
|
+
* All style values use CSS custom properties from tokens.css
|
|
4
|
+
* Figma: Text field page — height 48px, radius 8px, padding-x 12px
|
|
5
|
+
*/
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { Input as AntInput } from 'antd';
|
|
8
|
+
|
|
9
|
+
export type InputStatus = 'default' | 'error' | 'success';
|
|
10
|
+
|
|
11
|
+
// ── Shared label/helper sub-components ───────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function FieldLabel({ htmlFor, required, disabled, children }: {
|
|
14
|
+
htmlFor?: string;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<label
|
|
21
|
+
htmlFor={htmlFor}
|
|
22
|
+
style={{
|
|
23
|
+
fontFamily: 'var(--vetc-font-family)',
|
|
24
|
+
fontSize: 'var(--vetc-input-label-font-size)',
|
|
25
|
+
fontWeight: 'var(--vetc-input-label-font-weight)' as any,
|
|
26
|
+
lineHeight: 'var(--vetc-line-height-relaxed)',
|
|
27
|
+
color: disabled
|
|
28
|
+
? 'var(--vetc-color-text-disabled)'
|
|
29
|
+
: 'var(--vetc-input-label-color)',
|
|
30
|
+
display: 'block',
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
{required && (
|
|
35
|
+
<span style={{ color: 'var(--vetc-color-negative)', marginLeft: 'var(--vetc-space-2)' }}>*</span>
|
|
36
|
+
)}
|
|
37
|
+
</label>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function HelperText({ color, children }: { color: string; children: React.ReactNode }) {
|
|
42
|
+
return (
|
|
43
|
+
<span
|
|
44
|
+
style={{
|
|
45
|
+
fontFamily: 'var(--vetc-font-family)',
|
|
46
|
+
fontSize: 'var(--vetc-input-helper-font-size)',
|
|
47
|
+
lineHeight: 'var(--vetc-line-height-relaxed)',
|
|
48
|
+
color,
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const inputFieldStyle: React.CSSProperties = {
|
|
57
|
+
height: 'var(--vetc-input-height)',
|
|
58
|
+
borderRadius: 'var(--vetc-input-radius)',
|
|
59
|
+
paddingInline:'var(--vetc-input-padding-x)',
|
|
60
|
+
fontSize: 'var(--vetc-input-font-size)',
|
|
61
|
+
fontFamily: 'var(--vetc-font-family)',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── Input ─────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export interface InputProps {
|
|
67
|
+
label?: string;
|
|
68
|
+
placeholder?: string;
|
|
69
|
+
value?: string;
|
|
70
|
+
defaultValue?: string;
|
|
71
|
+
helperText?: string;
|
|
72
|
+
/** Status affecting border color */
|
|
73
|
+
status?: InputStatus;
|
|
74
|
+
/** Error message — sets status=error automatically */
|
|
75
|
+
error?: string;
|
|
76
|
+
disabled?: boolean;
|
|
77
|
+
readOnly?: boolean;
|
|
78
|
+
maxLength?: number;
|
|
79
|
+
showCount?: boolean;
|
|
80
|
+
prefix?: React.ReactNode;
|
|
81
|
+
suffix?: React.ReactNode;
|
|
82
|
+
allowClear?: boolean;
|
|
83
|
+
onChange?: (value: string, event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
84
|
+
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
|
85
|
+
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
|
86
|
+
onPressEnter?: React.KeyboardEventHandler<HTMLInputElement>;
|
|
87
|
+
type?: string;
|
|
88
|
+
id?: string;
|
|
89
|
+
name?: string;
|
|
90
|
+
className?: string;
|
|
91
|
+
style?: React.CSSProperties;
|
|
92
|
+
required?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function Input({
|
|
96
|
+
label, placeholder, value, defaultValue,
|
|
97
|
+
helperText, status = 'default', error,
|
|
98
|
+
disabled = false, readOnly = false,
|
|
99
|
+
maxLength, showCount = false,
|
|
100
|
+
prefix, suffix, allowClear = false,
|
|
101
|
+
onChange, onFocus, onBlur, onPressEnter,
|
|
102
|
+
type = 'text', id, name,
|
|
103
|
+
className = '', style, required = false,
|
|
104
|
+
}: InputProps) {
|
|
105
|
+
const hasError = !!error;
|
|
106
|
+
const resolvedStatus: InputStatus = hasError ? 'error' : status;
|
|
107
|
+
const antStatus = resolvedStatus === 'error' ? 'error' : undefined;
|
|
108
|
+
|
|
109
|
+
const helperMsg = error ?? helperText;
|
|
110
|
+
const helperColor = resolvedStatus === 'error'
|
|
111
|
+
? 'var(--vetc-input-helper-color-error)'
|
|
112
|
+
: 'var(--vetc-input-helper-color)';
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
className={`vetc-input-wrapper ${className}`}
|
|
117
|
+
style={{ display: 'flex', flexDirection: 'column', gap: 'var(--vetc-input-gap)', ...style }}
|
|
118
|
+
>
|
|
119
|
+
{label && <FieldLabel htmlFor={id} required={required} disabled={disabled}>{label}</FieldLabel>}
|
|
120
|
+
|
|
121
|
+
<AntInput
|
|
122
|
+
id={id}
|
|
123
|
+
name={name}
|
|
124
|
+
type={type}
|
|
125
|
+
value={value}
|
|
126
|
+
defaultValue={defaultValue}
|
|
127
|
+
placeholder={placeholder}
|
|
128
|
+
disabled={disabled}
|
|
129
|
+
readOnly={readOnly}
|
|
130
|
+
maxLength={maxLength}
|
|
131
|
+
showCount={showCount}
|
|
132
|
+
prefix={prefix}
|
|
133
|
+
suffix={suffix}
|
|
134
|
+
allowClear={allowClear}
|
|
135
|
+
status={antStatus}
|
|
136
|
+
onChange={(e) => onChange?.(e.target.value, e)}
|
|
137
|
+
onFocus={onFocus}
|
|
138
|
+
onBlur={onBlur}
|
|
139
|
+
onPressEnter={onPressEnter}
|
|
140
|
+
style={inputFieldStyle}
|
|
141
|
+
/>
|
|
142
|
+
|
|
143
|
+
{helperMsg && <HelperText color={helperColor}>{helperMsg}</HelperText>}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Password Input ────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export interface PasswordInputProps extends Omit<InputProps, 'type' | 'suffix' | 'allowClear'> {}
|
|
151
|
+
|
|
152
|
+
export function PasswordInput({
|
|
153
|
+
label, placeholder, value, defaultValue,
|
|
154
|
+
helperText, status = 'default', error,
|
|
155
|
+
disabled = false, readOnly = false, maxLength,
|
|
156
|
+
onChange, onFocus, onBlur,
|
|
157
|
+
id, name, className = '', style, required = false,
|
|
158
|
+
}: PasswordInputProps) {
|
|
159
|
+
const hasError = !!error;
|
|
160
|
+
const resolvedStatus: InputStatus = hasError ? 'error' : status;
|
|
161
|
+
const antStatus = resolvedStatus === 'error' ? 'error' : undefined;
|
|
162
|
+
const helperMsg = error ?? helperText;
|
|
163
|
+
const helperColor = hasError
|
|
164
|
+
? 'var(--vetc-input-helper-color-error)'
|
|
165
|
+
: 'var(--vetc-input-helper-color)';
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
className={`vetc-input-wrapper ${className ?? ''}`}
|
|
170
|
+
style={{ display: 'flex', flexDirection: 'column', gap: 'var(--vetc-input-gap)', ...style }}
|
|
171
|
+
>
|
|
172
|
+
{label && <FieldLabel htmlFor={id} required={required} disabled={disabled}>{label}</FieldLabel>}
|
|
173
|
+
|
|
174
|
+
<AntInput.Password
|
|
175
|
+
id={id}
|
|
176
|
+
name={name}
|
|
177
|
+
value={value}
|
|
178
|
+
defaultValue={defaultValue}
|
|
179
|
+
placeholder={placeholder}
|
|
180
|
+
disabled={disabled}
|
|
181
|
+
readOnly={readOnly}
|
|
182
|
+
maxLength={maxLength}
|
|
183
|
+
status={antStatus}
|
|
184
|
+
onChange={(e) => onChange?.(e.target.value, e as any)}
|
|
185
|
+
onFocus={onFocus as any}
|
|
186
|
+
onBlur={onBlur as any}
|
|
187
|
+
style={inputFieldStyle}
|
|
188
|
+
/>
|
|
189
|
+
|
|
190
|
+
{helperMsg && <HelperText color={helperColor}>{helperMsg}</HelperText>}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default Input;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VETC List & ListItem — tokenized
|
|
3
|
+
* Figma: List item page — height 56px, padding-x 16px
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
// ── ChevronRight Icon ─────────────────────────────────────────────────────────
|
|
8
|
+
function ChevronRightIcon({ disabled }: { disabled?: boolean }) {
|
|
9
|
+
return (
|
|
10
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
11
|
+
<path
|
|
12
|
+
d="M7.5 5L12.5 10L7.5 15"
|
|
13
|
+
stroke={disabled ? 'var(--vetc-color-icon-disabled)' : 'var(--vetc-color-icon-muted)'}
|
|
14
|
+
strokeWidth="1.5"
|
|
15
|
+
strokeLinecap="round"
|
|
16
|
+
strokeLinejoin="round"
|
|
17
|
+
/>
|
|
18
|
+
</svg>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── ListItem ──────────────────────────────────────────────────────────────────
|
|
23
|
+
export interface ListItemProps {
|
|
24
|
+
leading?: React.ReactNode;
|
|
25
|
+
title: React.ReactNode;
|
|
26
|
+
description?: React.ReactNode;
|
|
27
|
+
trailing?: React.ReactNode;
|
|
28
|
+
/** Show right chevron */
|
|
29
|
+
arrow?: boolean;
|
|
30
|
+
/** Bottom divider line */
|
|
31
|
+
divider?: boolean;
|
|
32
|
+
onClick?: () => void;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
className?: string;
|
|
35
|
+
style?: React.CSSProperties;
|
|
36
|
+
id?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ListItem({
|
|
40
|
+
leading,
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
trailing,
|
|
44
|
+
arrow = false,
|
|
45
|
+
divider = true,
|
|
46
|
+
onClick,
|
|
47
|
+
disabled = false,
|
|
48
|
+
className = '',
|
|
49
|
+
style,
|
|
50
|
+
id,
|
|
51
|
+
}: ListItemProps) {
|
|
52
|
+
const isClickable = !!onClick && !disabled;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
id={id}
|
|
57
|
+
role={isClickable ? 'button' : undefined}
|
|
58
|
+
tabIndex={isClickable ? 0 : undefined}
|
|
59
|
+
className={`vetc-list-item ${className}`}
|
|
60
|
+
onClick={!disabled ? onClick : undefined}
|
|
61
|
+
onKeyDown={isClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick?.(); } : undefined}
|
|
62
|
+
style={{
|
|
63
|
+
display: 'flex',
|
|
64
|
+
alignItems: 'center',
|
|
65
|
+
minHeight: 'var(--vetc-list-item-height)',
|
|
66
|
+
padding: `0 var(--vetc-list-item-padding-x)`,
|
|
67
|
+
gap: 'var(--vetc-list-item-gap)',
|
|
68
|
+
cursor: isClickable ? 'pointer' : 'default',
|
|
69
|
+
borderBottom: divider ? `1px solid var(--vetc-list-item-border)` : 'none',
|
|
70
|
+
backgroundColor: 'var(--vetc-list-item-bg)',
|
|
71
|
+
opacity: disabled ? 0.5 : 1,
|
|
72
|
+
fontFamily: 'var(--vetc-font-family)',
|
|
73
|
+
transition: `background-color var(--vetc-transition-fast)`,
|
|
74
|
+
outline: 'none',
|
|
75
|
+
...style,
|
|
76
|
+
}}
|
|
77
|
+
onMouseEnter={(e) => {
|
|
78
|
+
if (isClickable) e.currentTarget.style.backgroundColor = 'var(--vetc-list-item-bg-hover)';
|
|
79
|
+
}}
|
|
80
|
+
onMouseLeave={(e) => {
|
|
81
|
+
if (isClickable) e.currentTarget.style.backgroundColor = 'var(--vetc-list-item-bg)';
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{/* Leading slot */}
|
|
85
|
+
{leading && (
|
|
86
|
+
<div style={{
|
|
87
|
+
flexShrink: 0,
|
|
88
|
+
display: 'flex',
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
color: 'var(--vetc-list-item-icon-color)',
|
|
91
|
+
}}>
|
|
92
|
+
{leading}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{/* Content */}
|
|
97
|
+
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 'var(--vetc-space-2)' }}>
|
|
98
|
+
<span style={{
|
|
99
|
+
fontSize: 'var(--vetc-list-item-title-size)',
|
|
100
|
+
fontWeight: 'var(--vetc-list-item-title-weight)' as any,
|
|
101
|
+
color: disabled ? 'var(--vetc-color-text-disabled)' : 'var(--vetc-list-item-title-color)',
|
|
102
|
+
lineHeight: 'var(--vetc-line-height-relaxed)',
|
|
103
|
+
overflow: 'hidden',
|
|
104
|
+
textOverflow: 'ellipsis',
|
|
105
|
+
whiteSpace: 'nowrap',
|
|
106
|
+
}}>
|
|
107
|
+
{title}
|
|
108
|
+
</span>
|
|
109
|
+
{description && (
|
|
110
|
+
<span style={{
|
|
111
|
+
fontSize: 'var(--vetc-list-item-desc-size)',
|
|
112
|
+
fontWeight: 'var(--vetc-font-weight-regular)' as any,
|
|
113
|
+
color: disabled ? 'var(--vetc-color-text-disabled)' : 'var(--vetc-list-item-desc-color)',
|
|
114
|
+
lineHeight: 'var(--vetc-line-height-relaxed)',
|
|
115
|
+
overflow: 'hidden',
|
|
116
|
+
textOverflow: 'ellipsis',
|
|
117
|
+
whiteSpace: 'nowrap',
|
|
118
|
+
}}>
|
|
119
|
+
{description}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Trailing slot */}
|
|
125
|
+
{(trailing || arrow) && (
|
|
126
|
+
<div style={{
|
|
127
|
+
flexShrink: 0,
|
|
128
|
+
display: 'flex',
|
|
129
|
+
alignItems: 'center',
|
|
130
|
+
gap: 'var(--vetc-space-8)',
|
|
131
|
+
color: 'var(--vetc-list-item-icon-color)',
|
|
132
|
+
}}>
|
|
133
|
+
{trailing}
|
|
134
|
+
{arrow && <ChevronRightIcon disabled={disabled} />}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── List ──────────────────────────────────────────────────────────────────────
|
|
142
|
+
export interface ListProps<T = any> {
|
|
143
|
+
items: T[];
|
|
144
|
+
renderItem: (item: T, index: number) => React.ReactNode;
|
|
145
|
+
bordered?: boolean;
|
|
146
|
+
className?: string;
|
|
147
|
+
style?: React.CSSProperties;
|
|
148
|
+
id?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function List<T = any>({
|
|
152
|
+
items,
|
|
153
|
+
renderItem,
|
|
154
|
+
bordered = false,
|
|
155
|
+
className = '',
|
|
156
|
+
style,
|
|
157
|
+
id,
|
|
158
|
+
}: ListProps<T>) {
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
id={id}
|
|
162
|
+
className={`vetc-list ${className}`}
|
|
163
|
+
style={{
|
|
164
|
+
border: bordered ? `1px solid var(--vetc-color-border-variant)` : 'none',
|
|
165
|
+
borderRadius: bordered ? 'var(--vetc-card-radius)' : 0,
|
|
166
|
+
overflow: 'hidden',
|
|
167
|
+
backgroundColor: 'var(--vetc-color-bg)',
|
|
168
|
+
...style,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{items.map((item, index) => (
|
|
172
|
+
<React.Fragment key={index}>
|
|
173
|
+
{renderItem(item, index)}
|
|
174
|
+
</React.Fragment>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default ListItem;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VETC Loading — tokenized
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Spin, Skeleton } from 'antd';
|
|
6
|
+
import { LoadingOutlined } from '@ant-design/icons';
|
|
7
|
+
|
|
8
|
+
export type SpinnerSize = 'sm' | 'md' | 'lg';
|
|
9
|
+
|
|
10
|
+
const spinnerSizeVarMap: Record<SpinnerSize, string> = {
|
|
11
|
+
sm: 'var(--vetc-spinner-size-sm)',
|
|
12
|
+
md: 'var(--vetc-spinner-size-md)',
|
|
13
|
+
lg: 'var(--vetc-spinner-size-lg)',
|
|
14
|
+
};
|
|
15
|
+
// antd icon needs numeric fontSize — CSS var won't work directly
|
|
16
|
+
const spinnerPxMap: Record<SpinnerSize, number> = { sm: 16, md: 24, lg: 40 };
|
|
17
|
+
|
|
18
|
+
export interface SpinnerProps {
|
|
19
|
+
size?: SpinnerSize;
|
|
20
|
+
color?: string;
|
|
21
|
+
fullscreen?: boolean;
|
|
22
|
+
tip?: string;
|
|
23
|
+
children?: React.ReactNode;
|
|
24
|
+
spinning?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
style?: React.CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Spinner({
|
|
30
|
+
size = 'md',
|
|
31
|
+
color,
|
|
32
|
+
fullscreen = false,
|
|
33
|
+
tip,
|
|
34
|
+
children,
|
|
35
|
+
spinning = true,
|
|
36
|
+
className = '',
|
|
37
|
+
style,
|
|
38
|
+
}: SpinnerProps) {
|
|
39
|
+
const icon = (
|
|
40
|
+
<LoadingOutlined
|
|
41
|
+
style={{ fontSize: spinnerPxMap[size], color: color ?? 'var(--vetc-spinner-color)' }}
|
|
42
|
+
spin
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (children) {
|
|
47
|
+
return (
|
|
48
|
+
<Spin spinning={spinning} indicator={icon} tip={tip} className={`vetc-spinner ${className}`} style={style}>
|
|
49
|
+
{children}
|
|
50
|
+
</Spin>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (fullscreen) {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
className={`vetc-spinner-overlay ${className}`}
|
|
58
|
+
style={{
|
|
59
|
+
position: 'fixed',
|
|
60
|
+
inset: 0,
|
|
61
|
+
display: 'flex',
|
|
62
|
+
alignItems: 'center',
|
|
63
|
+
justifyContent: 'center',
|
|
64
|
+
backgroundColor: 'var(--vetc-color-overlay)',
|
|
65
|
+
zIndex: 9999,
|
|
66
|
+
...style,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<Spin spinning={spinning} indicator={icon} tip={tip} />
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
className={`vetc-spinner ${className}`}
|
|
77
|
+
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', ...style }}
|
|
78
|
+
>
|
|
79
|
+
<Spin spinning={spinning} indicator={icon} tip={tip} />
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Skeleton ──────────────────────────────────────────────────────────────────
|
|
85
|
+
export interface SkeletonProps {
|
|
86
|
+
rows?: number;
|
|
87
|
+
avatar?: boolean;
|
|
88
|
+
avatarSize?: number;
|
|
89
|
+
title?: boolean;
|
|
90
|
+
loading?: boolean;
|
|
91
|
+
className?: string;
|
|
92
|
+
style?: React.CSSProperties;
|
|
93
|
+
children?: React.ReactNode;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function SkeletonLoader({
|
|
97
|
+
rows = 3,
|
|
98
|
+
avatar = false,
|
|
99
|
+
avatarSize = 40,
|
|
100
|
+
title = true,
|
|
101
|
+
loading = true,
|
|
102
|
+
className = '',
|
|
103
|
+
style,
|
|
104
|
+
children,
|
|
105
|
+
}: SkeletonProps) {
|
|
106
|
+
return (
|
|
107
|
+
<Skeleton
|
|
108
|
+
active
|
|
109
|
+
loading={loading}
|
|
110
|
+
avatar={avatar ? { size: avatarSize, shape: 'circle' } : false}
|
|
111
|
+
title={title}
|
|
112
|
+
paragraph={{ rows }}
|
|
113
|
+
className={`vetc-skeleton ${className}`}
|
|
114
|
+
style={style}
|
|
115
|
+
>
|
|
116
|
+
{children}
|
|
117
|
+
</Skeleton>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default Spinner;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VETC Modal / Dialog — tokenized
|
|
3
|
+
* Figma: Dialog page — width 344px, radius 16px, overlay rgba(0,0,0,0.4)
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { Modal as AntModal } from 'antd';
|
|
7
|
+
|
|
8
|
+
export interface ModalProps {
|
|
9
|
+
open: boolean;
|
|
10
|
+
title?: React.ReactNode;
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
/** null = hide footer, undefined = default buttons, ReactNode = custom */
|
|
13
|
+
footer?: React.ReactNode | null;
|
|
14
|
+
okText?: string;
|
|
15
|
+
cancelText?: string;
|
|
16
|
+
okDisabled?: boolean;
|
|
17
|
+
okLoading?: boolean;
|
|
18
|
+
okDanger?: boolean;
|
|
19
|
+
onOk?: () => void;
|
|
20
|
+
onCancel?: () => void;
|
|
21
|
+
maskClosable?: boolean;
|
|
22
|
+
closable?: boolean;
|
|
23
|
+
width?: number | string;
|
|
24
|
+
className?: string;
|
|
25
|
+
style?: React.CSSProperties;
|
|
26
|
+
id?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Shared button style for modal actions */
|
|
30
|
+
const modalBtnStyle: React.CSSProperties = {
|
|
31
|
+
height: 'var(--vetc-btn-height-lg)',
|
|
32
|
+
borderRadius: 'var(--vetc-btn-radius-lg)',
|
|
33
|
+
fontWeight: 'var(--vetc-btn-font-weight)' as any,
|
|
34
|
+
fontSize: 'var(--vetc-btn-font-size-lg)',
|
|
35
|
+
fontFamily: 'var(--vetc-font-family)',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function Modal({
|
|
39
|
+
open, title, children, footer,
|
|
40
|
+
okText = 'Xác nhận', cancelText = 'Hủy',
|
|
41
|
+
okDisabled = false, okLoading = false, okDanger = false,
|
|
42
|
+
onOk, onCancel,
|
|
43
|
+
maskClosable = true, closable = true,
|
|
44
|
+
width,
|
|
45
|
+
className = '', style, id,
|
|
46
|
+
}: ModalProps) {
|
|
47
|
+
return (
|
|
48
|
+
<AntModal
|
|
49
|
+
open={open}
|
|
50
|
+
title={title}
|
|
51
|
+
okText={okText}
|
|
52
|
+
cancelText={cancelText}
|
|
53
|
+
okButtonProps={{
|
|
54
|
+
disabled: okDisabled,
|
|
55
|
+
loading: okLoading,
|
|
56
|
+
danger: okDanger,
|
|
57
|
+
style: modalBtnStyle,
|
|
58
|
+
}}
|
|
59
|
+
cancelButtonProps={{ style: modalBtnStyle }}
|
|
60
|
+
footer={footer === null ? null : footer}
|
|
61
|
+
onOk={onOk}
|
|
62
|
+
onCancel={onCancel}
|
|
63
|
+
maskClosable={maskClosable}
|
|
64
|
+
closable={closable}
|
|
65
|
+
width={width ?? 'var(--vetc-modal-width)'}
|
|
66
|
+
centered
|
|
67
|
+
className={`vetc-modal ${className}`}
|
|
68
|
+
style={{ fontFamily: 'var(--vetc-font-family)', ...style }}
|
|
69
|
+
styles={{
|
|
70
|
+
header: {
|
|
71
|
+
padding: `var(--vetc-modal-padding) var(--vetc-modal-padding) 0`,
|
|
72
|
+
borderBottom: 'none',
|
|
73
|
+
},
|
|
74
|
+
body: {
|
|
75
|
+
padding: `var(--vetc-space-8) var(--vetc-modal-padding) var(--vetc-modal-padding)`,
|
|
76
|
+
},
|
|
77
|
+
footer: {
|
|
78
|
+
padding: `0 var(--vetc-modal-padding) var(--vetc-modal-padding)`,
|
|
79
|
+
borderTop: 'none',
|
|
80
|
+
},
|
|
81
|
+
mask: { backgroundColor: 'var(--vetc-modal-overlay)' },
|
|
82
|
+
} as any}
|
|
83
|
+
>
|
|
84
|
+
{children}
|
|
85
|
+
</AntModal>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Confirm hook ──────────────────────────────────────────────────────────────
|
|
90
|
+
export function useConfirm() {
|
|
91
|
+
const confirm = (opts: {
|
|
92
|
+
title: string;
|
|
93
|
+
content?: React.ReactNode;
|
|
94
|
+
okText?: string;
|
|
95
|
+
cancelText?: string;
|
|
96
|
+
okDanger?: boolean;
|
|
97
|
+
onOk?: () => void | Promise<void>;
|
|
98
|
+
onCancel?: () => void;
|
|
99
|
+
}) => {
|
|
100
|
+
AntModal.confirm({
|
|
101
|
+
title: opts.title,
|
|
102
|
+
content: opts.content,
|
|
103
|
+
okText: opts.okText ?? 'Xác nhận',
|
|
104
|
+
cancelText: opts.cancelText ?? 'Hủy',
|
|
105
|
+
okButtonProps: { danger: opts.okDanger, style: { ...modalBtnStyle, height: 40 } },
|
|
106
|
+
cancelButtonProps: { style: { ...modalBtnStyle, height: 40 } },
|
|
107
|
+
centered: true,
|
|
108
|
+
onOk: opts.onOk,
|
|
109
|
+
onCancel: opts.onCancel,
|
|
110
|
+
styles: { mask: { backgroundColor: 'var(--vetc-modal-overlay)' } } as any,
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
return { confirm };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default Modal;
|