@arcblock/ux 2.12.20 → 2.12.22

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.
@@ -1,7 +1,7 @@
1
1
  import { SelectProps } from '@mui/material';
2
2
  import type { CountryIso2 } from 'react-international-phone';
3
3
  export interface CountryDisplayOptions {
4
- showFullName?: boolean;
4
+ hideFlag?: boolean;
5
5
  }
6
6
  export interface CountrySelectProps extends Omit<SelectProps, 'value' | 'onChange'> {
7
7
  value: CountryIso2;
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, forwardRef, useState, useCallback } from 'react';
2
+ import { useMemo, forwardRef, useState } from 'react';
3
3
  import { Box, MenuItem, Select, Typography, TextField } from '@mui/material';
4
4
  import { FlagEmoji, defaultCountries, parseCountry } from 'react-international-phone';
5
+ import ArrowDownwardIcon from '@arcblock/icons/lib/ArrowDown';
6
+ import { temp as colors } from '../Colors';
5
7
  const CountrySelect = /*#__PURE__*/forwardRef(({
6
8
  value,
7
9
  onChange,
@@ -10,14 +12,15 @@ const CountrySelect = /*#__PURE__*/forwardRef(({
10
12
  preview = false
11
13
  }, ref) => {
12
14
  const {
13
- showFullName = false
15
+ hideFlag = true
14
16
  } = selectCountryProps || {};
15
17
  const [searchQuery, setSearchQuery] = useState('');
16
18
  const countryDetail = useMemo(() => {
17
19
  const item = defaultCountries.find(v => v[1] === value);
18
20
  return value && item ? parseCountry(item) : {
19
21
  name: '',
20
- iso2: ''
22
+ iso2: '',
23
+ dialCode: ''
21
24
  };
22
25
  }, [value]);
23
26
  const filteredCountries = useMemo(() => {
@@ -28,10 +31,7 @@ const CountrySelect = /*#__PURE__*/forwardRef(({
28
31
  return parsed.name.toLowerCase().includes(query) || parsed.iso2.toLowerCase().includes(query) || parsed.dialCode.includes(query);
29
32
  });
30
33
  }, [searchQuery]);
31
- const onCountryChange = e => {
32
- onChange?.(e.target.value);
33
- };
34
- const renderCountryContent = useCallback(code => /*#__PURE__*/_jsxs(Box, {
34
+ const renderCountryContent = /*#__PURE__*/_jsxs(Box, {
35
35
  display: "flex",
36
36
  alignItems: "center",
37
37
  flexWrap: "nowrap",
@@ -39,26 +39,30 @@ const CountrySelect = /*#__PURE__*/forwardRef(({
39
39
  sx: {
40
40
  cursor: preview ? 'default' : 'pointer'
41
41
  },
42
- children: [/*#__PURE__*/_jsx(FlagEmoji, {
43
- iso2: code,
42
+ children: [hideFlag ? null : /*#__PURE__*/_jsx(FlagEmoji, {
43
+ iso2: value,
44
44
  style: {
45
45
  display: 'flex',
46
- width: '24px',
46
+ width: 24,
47
47
  color: 'inherit'
48
48
  }
49
- }), /*#__PURE__*/_jsx(Typography, {
50
- children: showFullName ? countryDetail?.name : countryDetail?.iso2
49
+ }), /*#__PURE__*/_jsxs(Typography, {
50
+ component: "span",
51
+ sx: {
52
+ lineHeight: 1.5
53
+ },
54
+ children: ["+", countryDetail?.dialCode]
51
55
  })]
52
- }), [preview, showFullName, countryDetail]);
56
+ });
53
57
  if (preview) {
54
- return renderCountryContent(value);
58
+ return renderCountryContent;
55
59
  }
56
60
  return /*#__PURE__*/_jsxs(Select, {
57
61
  ref: ref,
58
62
  MenuProps: {
59
63
  style: {
60
- height: '300px',
61
- top: '10px'
64
+ maxHeight: 400,
65
+ top: 2
62
66
  },
63
67
  anchorOrigin: {
64
68
  vertical: 'bottom',
@@ -70,21 +74,26 @@ const CountrySelect = /*#__PURE__*/forwardRef(({
70
74
  }
71
75
  },
72
76
  sx: {
73
- width: '100%',
74
- fieldset: {
75
- display: 'none'
76
- },
77
77
  '&.Mui-focused:has(div[aria-expanded="false"])': {
78
78
  fieldset: {
79
79
  display: 'block'
80
80
  }
81
81
  },
82
82
  '.MuiSelect-select': {
83
- padding: '8px',
83
+ padding: 1,
84
84
  paddingRight: '24px !important'
85
85
  },
86
86
  svg: {
87
- right: 0
87
+ right: 4,
88
+ top: 10
89
+ },
90
+ '&:hover': {
91
+ 'fieldset.MuiOutlinedInput-notchedOutline': {
92
+ borderColor: colors.dividerColor
93
+ }
94
+ },
95
+ 'fieldset.MuiOutlinedInput-notchedOutline': {
96
+ borderColor: colors.dividerColor
88
97
  },
89
98
  '.MuiMenuItem-root': {
90
99
  justifyContent: 'flex-start'
@@ -92,8 +101,15 @@ const CountrySelect = /*#__PURE__*/forwardRef(({
92
101
  ...sx
93
102
  },
94
103
  value: value,
95
- onChange: onCountryChange,
96
- renderValue: renderCountryContent,
104
+ onChange: e => onChange?.(e.target.value)
105
+ // eslint-disable-next-line react/no-unstable-nested-components
106
+ ,
107
+ IconComponent: props => /*#__PURE__*/_jsx(ArrowDownwardIcon, {
108
+ ...props,
109
+ width: 20,
110
+ height: 20
111
+ }),
112
+ renderValue: () => renderCountryContent,
97
113
  children: [/*#__PURE__*/_jsx(Box, {
98
114
  sx: {
99
115
  p: 1,
@@ -109,31 +125,24 @@ const CountrySelect = /*#__PURE__*/forwardRef(({
109
125
  value: searchQuery,
110
126
  onChange: e => setSearchQuery(e.target.value),
111
127
  onClick: e => e.stopPropagation(),
112
- onKeyDown: e => e.stopPropagation(),
113
- sx: {
114
- '& .MuiOutlinedInput-root': {
115
- '& fieldset': {
116
- borderColor: 'divider'
117
- }
118
- }
119
- }
128
+ onKeyDown: e => e.stopPropagation()
120
129
  })
121
130
  }), filteredCountries.map(c => {
122
131
  const parsed = parseCountry(c);
123
132
  return /*#__PURE__*/_jsxs(MenuItem, {
124
133
  value: parsed.iso2,
125
- children: [/*#__PURE__*/_jsx(FlagEmoji, {
134
+ children: [hideFlag ? null : /*#__PURE__*/_jsx(FlagEmoji, {
126
135
  iso2: parsed.iso2,
127
136
  style: {
128
- marginRight: '8px',
129
- width: '24px'
137
+ marginRight: 8,
138
+ width: 24
130
139
  }
131
140
  }), /*#__PURE__*/_jsx(Typography, {
132
- marginRight: "8px",
141
+ marginRight: 1,
133
142
  children: parsed.name
134
143
  }), /*#__PURE__*/_jsxs(Typography, {
135
144
  color: "gray",
136
- children: ["+", parsed.dialCode]
145
+ children: ["(+", parsed.dialCode, ")"]
137
146
  })]
138
147
  }, parsed.iso2);
139
148
  })]
@@ -12,6 +12,5 @@ export interface PhoneInputProps extends Omit<TextFieldProps, 'value' | 'onChang
12
12
  preview?: boolean;
13
13
  allowDial?: boolean;
14
14
  }
15
- export declare function getCountryCallingCode(country: CountryIso2): string;
16
15
  export declare function validatePhoneNumber(phone: string): boolean;
17
16
  export default function PhoneInput({ value, onChange, placeholder, countryDisplayOptions, preview, allowDial, ...props }: PhoneInputProps): import("react/jsx-runtime").JSX.Element;
@@ -1,21 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from 'react';
3
- import { Box, InputAdornment, TextField, Typography } from '@mui/material';
3
+ import { Box, TextField, Typography } from '@mui/material';
4
4
  import { defaultCountries, usePhoneInput, parseCountry } from 'react-international-phone';
5
5
  import isMobilePhone from 'validator/lib/isMobilePhone';
6
6
  import CountrySelect from './country-select';
7
- export function getCountryCallingCode(country) {
8
- const countryData = defaultCountries.find(c => parseCountry(c).iso2 === country);
9
- if (countryData) {
10
- return parseCountry(countryData).dialCode.replace('+', '');
11
- }
12
- return '';
13
- }
7
+ import { mergeSx } from '../Util/style';
14
8
  export function validatePhoneNumber(phone) {
15
9
  return isMobilePhone(phone.replace(/[\s\-()]+/g, ''), 'any', {
16
10
  strictMode: true
17
11
  });
18
12
  }
13
+
14
+ // 从带区号的电话号码中提取纯号码部分
15
+ function extractPhoneWithoutCode(phone, dialCode) {
16
+ if (!phone) return '';
17
+ // 先去除区号
18
+ const phoneWithoutCode = phone.replace(new RegExp(`^\\+${dialCode}`), '');
19
+ // 去除非数字字符,但保留括号
20
+ return phoneWithoutCode.replace(/[^\d()]/g, '');
21
+ }
22
+
23
+ // 添加区号到纯号码
24
+ function addCountryCodeToPhone(phone, dialCode) {
25
+ if (!phone) return '';
26
+ return phone.startsWith('+') ? phone : `+${dialCode}${phone}`;
27
+ }
28
+
29
+ // 获取不带+号的区号
30
+ function getDialCodeWithoutPlus(dialCode) {
31
+ return dialCode.replace(/^\+/, '');
32
+ }
19
33
  export default function PhoneInput({
20
34
  value = {
21
35
  country: 'us',
@@ -28,39 +42,73 @@ export default function PhoneInput({
28
42
  allowDial = true,
29
43
  ...props
30
44
  }) {
31
- // 在使用 usePhoneInput 之前处理电话号码
32
- const phoneWithPrefix = useMemo(() => {
33
- if (!value.phone) return '';
34
- // 如果电话号码已经包含 + 号,说明已经有区号了
35
- if (value.phone.startsWith('+')) return value.phone;
36
- // 获取国家区号并添加到号码前
37
- const countryCode = getCountryCallingCode(value.country);
38
- return `+${countryCode}${value.phone}`;
39
- }, [value.phone, value.country]);
45
+ // 创建国家代码映射表,避免重复解析
46
+ const countryMap = useMemo(() => {
47
+ const map = new Map();
48
+ defaultCountries.forEach(country => {
49
+ const parsed = parseCountry(country);
50
+ map.set(parsed.iso2, parsed);
51
+ });
52
+ return map;
53
+ }, []);
54
+
55
+ // 使用 react-international-phone 的 hook
40
56
  const {
41
57
  phone,
42
- handlePhoneValueChange,
43
58
  inputRef,
44
- country,
45
- setCountry
59
+ country
46
60
  } = usePhoneInput({
47
61
  defaultCountry: value.country,
48
- value: phoneWithPrefix,
62
+ value: value.phone,
49
63
  countries: defaultCountries,
50
64
  onChange: data => {
51
- // 确保类型匹配 PhoneValue 接口
52
- const phoneValue = {
53
- country: data.country,
54
- phone: data.phone
55
- };
56
- onChange?.(phoneValue);
65
+ if (!preview && onChange) {
66
+ onChange({
67
+ country: data.country,
68
+ phone: data.phone
69
+ });
70
+ }
57
71
  }
58
72
  });
59
- const onCountryChange = v => {
60
- if (!preview) {
61
- setCountry(v);
62
- }
73
+
74
+ // 获取当前国家的区号(不带+号)
75
+ const currentDialCode = useMemo(() => {
76
+ const countryData = countryMap.get(country);
77
+ return countryData ? getDialCodeWithoutPlus(countryData.dialCode) : '';
78
+ }, [country, countryMap]);
79
+
80
+ // 从完整电话号码中提取不带区号的部分用于显示,并去除格式化字符
81
+ const displayPhone = useMemo(() => extractPhoneWithoutCode(phone, currentDialCode), [phone, currentDialCode]);
82
+
83
+ // 创建更新后的 PhoneValue 对象
84
+ const createUpdatedPhoneValue = (countryIso2, phoneWithoutCode) => {
85
+ const countryData = countryMap.get(countryIso2);
86
+ if (!countryData) return {
87
+ country: countryIso2,
88
+ phone: ''
89
+ };
90
+ const dialCode = getDialCodeWithoutPlus(countryData.dialCode);
91
+ return {
92
+ country: countryIso2,
93
+ phone: addCountryCodeToPhone(phoneWithoutCode, dialCode)
94
+ };
95
+ };
96
+
97
+ // 处理电话号码变更
98
+ const onPhoneChange = e => {
99
+ if (preview) return;
100
+ // 去除用户输入中的非数字字符,但保留括号
101
+ const cleanedValue = e.target.value.replace(/[^\d()]/g, '');
102
+ onChange?.(createUpdatedPhoneValue(country, cleanedValue));
103
+ };
104
+
105
+ // 处理国家变更
106
+ const onCountryChange = newCountry => {
107
+ if (preview) return;
108
+ onChange?.(createUpdatedPhoneValue(newCountry, displayPhone));
63
109
  };
110
+
111
+ // 预览模式
64
112
  if (preview) {
65
113
  const isValid = phone && validatePhoneNumber(phone);
66
114
  const canDial = allowDial && isValid;
@@ -90,56 +138,41 @@ export default function PhoneInput({
90
138
  sx: {
91
139
  ml: 0.5
92
140
  },
93
- children: phone
141
+ children: displayPhone
94
142
  })]
95
143
  });
96
144
  }
97
- return /*#__PURE__*/_jsx(TextField, {
98
- ...props,
99
- value: phone,
100
- onChange: preview ? undefined : handlePhoneValueChange,
101
- placeholder: placeholder,
102
- className: "phone-input",
103
- inputRef: inputRef,
104
- disabled: preview,
105
- sx: {
106
- '&>.MuiInputBase-root': {
107
- paddingLeft: '8px',
108
- cursor: preview ? 'default' : undefined
109
- },
110
- ...(props.sx ?? {}),
111
- ...(preview ? {
112
- '& .Mui-disabled': {
113
- '-webkit-text-fill-color': 'inherit',
114
- color: 'inherit'
145
+ // 编辑模式
146
+ return /*#__PURE__*/_jsxs(Box, {
147
+ display: "flex",
148
+ alignItems: "flex-start",
149
+ gap: 0.5,
150
+ children: [/*#__PURE__*/_jsx(CountrySelect, {
151
+ value: country,
152
+ onChange: onCountryChange,
153
+ preview: false,
154
+ selectCountryProps: countryDisplayOptions
155
+ }), /*#__PURE__*/_jsx(TextField, {
156
+ ...props,
157
+ value: displayPhone,
158
+ onChange: onPhoneChange,
159
+ placeholder: placeholder,
160
+ className: "phone-input",
161
+ inputRef: inputRef,
162
+ sx: mergeSx({
163
+ '& .MuiInputBase-input': {
164
+ padding: 1
115
165
  },
116
- fieldset: {
117
- display: 'none'
166
+ '& .MuiFormHelperText-root': {
167
+ position: 'absolute',
168
+ bottom: 0,
169
+ left: 0,
170
+ transform: 'translateY(100%)',
171
+ margin: 0
118
172
  }
119
- } : {})
120
- },
121
- InputProps: {
122
- startAdornment: /*#__PURE__*/_jsx(InputAdornment, {
123
- position: "start",
124
- style: {
125
- marginRight: '2px',
126
- marginLeft: '-8px'
127
- },
128
- children: /*#__PURE__*/_jsx(CountrySelect, {
129
- value: country,
130
- onChange: onCountryChange,
131
- preview: preview,
132
- selectCountryProps: countryDisplayOptions,
133
- sx: {
134
- '&.Mui-focused .MuiOutlinedInput-notchedOutline': {
135
- borderColor: 'transparent'
136
- },
137
- opacity: preview ? 1 : undefined,
138
- cursor: preview ? 'default' : undefined
139
- }
140
- })
141
- }),
142
- ...(props.InputProps ?? {})
143
- }
173
+ }, props.sx // 这里传入的可能是一个数组或一个对象
174
+ ),
175
+ InputProps: props.InputProps ?? {}
176
+ })]
144
177
  });
145
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcblock/ux",
3
- "version": "2.12.20",
3
+ "version": "2.12.22",
4
4
  "description": "Common used react components for arcblock products",
5
5
  "keywords": [
6
6
  "react",
@@ -68,12 +68,12 @@
68
68
  "react": ">=18.2.0",
69
69
  "react-router-dom": ">=6.22.3"
70
70
  },
71
- "gitHead": "c2b00ec84f106657107bb415f255855e22e1de26",
71
+ "gitHead": "f6f7c7949553c8c3e66516f13f768264f480b5d6",
72
72
  "dependencies": {
73
73
  "@arcblock/did-motif": "^1.1.13",
74
- "@arcblock/icons": "^2.12.20",
75
- "@arcblock/nft-display": "^2.12.20",
76
- "@arcblock/react-hooks": "^2.12.20",
74
+ "@arcblock/icons": "^2.12.22",
75
+ "@arcblock/nft-display": "^2.12.22",
76
+ "@arcblock/react-hooks": "^2.12.22",
77
77
  "@babel/plugin-syntax-dynamic-import": "^7.8.3",
78
78
  "@fontsource/inter": "^5.0.16",
79
79
  "@fontsource/ubuntu-mono": "^5.0.18",
@@ -110,7 +110,7 @@
110
110
  "react-error-boundary": "^3.1.4",
111
111
  "react-ga4": "^2.1.0",
112
112
  "react-helmet": "^6.1.0",
113
- "react-international-phone": "^3.1.2",
113
+ "react-international-phone": "3.1.2",
114
114
  "react-intersection-observer": "^8.34.0",
115
115
  "react-lottie-player": "^1.4.3",
116
116
  "react-player": "^1.15.3",
@@ -1,10 +1,12 @@
1
- import { useMemo, forwardRef, useState, useCallback } from 'react';
1
+ import { useMemo, forwardRef, useState } from 'react';
2
2
  import { Box, MenuItem, Select, Typography, SelectProps, TextField } from '@mui/material';
3
3
  import { FlagEmoji, defaultCountries, parseCountry } from 'react-international-phone';
4
4
  import type { CountryIso2 } from 'react-international-phone';
5
+ import ArrowDownwardIcon from '@arcblock/icons/lib/ArrowDown';
6
+ import { temp as colors } from '../Colors';
5
7
 
6
8
  export interface CountryDisplayOptions {
7
- showFullName?: boolean;
9
+ hideFlag?: boolean;
8
10
  }
9
11
 
10
12
  export interface CountrySelectProps extends Omit<SelectProps, 'value' | 'onChange'> {
@@ -16,16 +18,17 @@ export interface CountrySelectProps extends Omit<SelectProps, 'value' | 'onChang
16
18
 
17
19
  const CountrySelect = forwardRef<HTMLDivElement, CountrySelectProps>(
18
20
  ({ value, onChange, sx = {}, selectCountryProps, preview = false }, ref) => {
19
- const { showFullName = false } = selectCountryProps || {};
21
+ const { hideFlag = true } = selectCountryProps || {};
20
22
  const [searchQuery, setSearchQuery] = useState('');
21
23
 
22
24
  const countryDetail = useMemo(() => {
23
25
  const item = defaultCountries.find((v) => v[1] === value);
24
- return value && item ? parseCountry(item) : { name: '', iso2: '' };
26
+ return value && item ? parseCountry(item) : { name: '', iso2: '', dialCode: '' };
25
27
  }, [value]);
26
28
 
27
29
  const filteredCountries = useMemo(() => {
28
30
  if (!searchQuery) return defaultCountries;
31
+
29
32
  const query = searchQuery.toLowerCase();
30
33
  return defaultCountries.filter((country) => {
31
34
  const parsed = parseCountry(country);
@@ -37,71 +40,52 @@ const CountrySelect = forwardRef<HTMLDivElement, CountrySelectProps>(
37
40
  });
38
41
  }, [searchQuery]);
39
42
 
40
- const onCountryChange = (e: any) => {
41
- onChange?.(e.target.value as CountryIso2);
42
- };
43
-
44
- const renderCountryContent = useCallback(
45
- (code: CountryIso2) => (
46
- <Box
47
- display="flex"
48
- alignItems="center"
49
- flexWrap="nowrap"
50
- gap={0.5}
51
- sx={{ cursor: preview ? 'default' : 'pointer' }}>
52
- <FlagEmoji iso2={code} style={{ display: 'flex', width: '24px', color: 'inherit' }} />
53
- <Typography>{showFullName ? countryDetail?.name : countryDetail?.iso2}</Typography>
54
- </Box>
55
- ),
56
- [preview, showFullName, countryDetail]
43
+ const renderCountryContent = (
44
+ <Box
45
+ display="flex"
46
+ alignItems="center"
47
+ flexWrap="nowrap"
48
+ gap={0.5}
49
+ sx={{ cursor: preview ? 'default' : 'pointer' }}>
50
+ {hideFlag ? null : <FlagEmoji iso2={value} style={{ display: 'flex', width: 24, color: 'inherit' }} />}
51
+ <Typography component="span" sx={{ lineHeight: 1.5 }}>
52
+ +{countryDetail?.dialCode}
53
+ </Typography>
54
+ </Box>
57
55
  );
58
56
 
59
57
  if (preview) {
60
- return renderCountryContent(value);
58
+ return renderCountryContent;
61
59
  }
62
60
 
63
61
  return (
64
62
  <Select
65
63
  ref={ref}
66
64
  MenuProps={{
67
- style: {
68
- height: '300px',
69
- top: '10px',
70
- },
71
- anchorOrigin: {
72
- vertical: 'bottom',
73
- horizontal: 'left',
74
- },
75
- transformOrigin: {
76
- vertical: 'top',
77
- horizontal: 'left',
78
- },
65
+ style: { maxHeight: 400, top: 2 },
66
+ anchorOrigin: { vertical: 'bottom', horizontal: 'left' },
67
+ transformOrigin: { vertical: 'top', horizontal: 'left' },
79
68
  }}
80
69
  sx={{
81
- width: '100%',
82
- fieldset: {
83
- display: 'none',
84
- },
85
- '&.Mui-focused:has(div[aria-expanded="false"])': {
86
- fieldset: {
87
- display: 'block',
70
+ '&.Mui-focused:has(div[aria-expanded="false"])': { fieldset: { display: 'block' } },
71
+ '.MuiSelect-select': { padding: 1, paddingRight: '24px !important' },
72
+ svg: { right: 4, top: 10 },
73
+ '&:hover': {
74
+ 'fieldset.MuiOutlinedInput-notchedOutline': {
75
+ borderColor: colors.dividerColor,
88
76
  },
89
77
  },
90
- '.MuiSelect-select': {
91
- padding: '8px',
92
- paddingRight: '24px !important',
93
- },
94
- svg: {
95
- right: 0,
96
- },
97
- '.MuiMenuItem-root': {
98
- justifyContent: 'flex-start',
78
+ 'fieldset.MuiOutlinedInput-notchedOutline': {
79
+ borderColor: colors.dividerColor,
99
80
  },
81
+ '.MuiMenuItem-root': { justifyContent: 'flex-start' },
100
82
  ...sx,
101
83
  }}
102
84
  value={value}
103
- onChange={onCountryChange}
104
- renderValue={renderCountryContent}>
85
+ onChange={(e) => onChange?.(e.target.value as CountryIso2)}
86
+ // eslint-disable-next-line react/no-unstable-nested-components
87
+ IconComponent={(props) => <ArrowDownwardIcon {...props} width={20} height={20} />}
88
+ renderValue={() => renderCountryContent}>
105
89
  <Box sx={{ p: 1, position: 'sticky', top: 0, bgcolor: 'background.paper', zIndex: 1 }}>
106
90
  <TextField
107
91
  size="small"
@@ -111,22 +95,16 @@ const CountrySelect = forwardRef<HTMLDivElement, CountrySelectProps>(
111
95
  onChange={(e) => setSearchQuery(e.target.value)}
112
96
  onClick={(e) => e.stopPropagation()}
113
97
  onKeyDown={(e) => e.stopPropagation()}
114
- sx={{
115
- '& .MuiOutlinedInput-root': {
116
- '& fieldset': {
117
- borderColor: 'divider',
118
- },
119
- },
120
- }}
121
98
  />
122
99
  </Box>
123
- {filteredCountries.map((c: any) => {
100
+
101
+ {filteredCountries.map((c) => {
124
102
  const parsed = parseCountry(c);
125
103
  return (
126
104
  <MenuItem key={parsed.iso2} value={parsed.iso2}>
127
- <FlagEmoji iso2={parsed.iso2} style={{ marginRight: '8px', width: '24px' }} />
128
- <Typography marginRight="8px">{parsed.name}</Typography>
129
- <Typography color="gray">+{parsed.dialCode}</Typography>
105
+ {hideFlag ? null : <FlagEmoji iso2={parsed.iso2} style={{ marginRight: 8, width: 24 }} />}
106
+ <Typography marginRight={1}>{parsed.name}</Typography>
107
+ <Typography color="gray">(+{parsed.dialCode})</Typography>
130
108
  </MenuItem>
131
109
  );
132
110
  })}
@@ -1,12 +1,13 @@
1
1
  import { useMemo } from 'react';
2
- import { Box, InputAdornment, TextField, TextFieldProps, Typography } from '@mui/material';
2
+ import { Box, TextField, TextFieldProps, Typography } from '@mui/material';
3
3
  import { defaultCountries, CountryIso2, usePhoneInput, parseCountry } from 'react-international-phone';
4
4
  import isMobilePhone from 'validator/lib/isMobilePhone';
5
5
  import CountrySelect, { CountryDisplayOptions } from './country-select';
6
+ import { mergeSx } from '../Util/style';
6
7
 
7
8
  export interface PhoneValue {
8
9
  country: CountryIso2;
9
- phone: string;
10
+ phone: string; // 带区号的完整电话号码,如 +86123456789
10
11
  }
11
12
 
12
13
  export interface PhoneInputProps extends Omit<TextFieldProps, 'value' | 'onChange'> {
@@ -17,18 +18,30 @@ export interface PhoneInputProps extends Omit<TextFieldProps, 'value' | 'onChang
17
18
  allowDial?: boolean; // 是否允许拨号, 只在 preview 为 true 时有效
18
19
  }
19
20
 
20
- export function getCountryCallingCode(country: CountryIso2): string {
21
- const countryData = defaultCountries.find((c) => parseCountry(c).iso2 === country);
22
- if (countryData) {
23
- return parseCountry(countryData).dialCode.replace('+', '');
24
- }
25
- return '';
26
- }
27
-
28
21
  export function validatePhoneNumber(phone: string): boolean {
29
22
  return isMobilePhone(phone.replace(/[\s\-()]+/g, ''), 'any', { strictMode: true });
30
23
  }
31
24
 
25
+ // 从带区号的电话号码中提取纯号码部分
26
+ function extractPhoneWithoutCode(phone: string, dialCode: string): string {
27
+ if (!phone) return '';
28
+ // 先去除区号
29
+ const phoneWithoutCode = phone.replace(new RegExp(`^\\+${dialCode}`), '');
30
+ // 去除非数字字符,但保留括号
31
+ return phoneWithoutCode.replace(/[^\d()]/g, '');
32
+ }
33
+
34
+ // 添加区号到纯号码
35
+ function addCountryCodeToPhone(phone: string, dialCode: string): string {
36
+ if (!phone) return '';
37
+ return phone.startsWith('+') ? phone : `+${dialCode}${phone}`;
38
+ }
39
+
40
+ // 获取不带+号的区号
41
+ function getDialCodeWithoutPlus(dialCode: string): string {
42
+ return dialCode.replace(/^\+/, '');
43
+ }
44
+
32
45
  export default function PhoneInput({
33
46
  value = { country: 'us', phone: '' },
34
47
  onChange,
@@ -38,36 +51,67 @@ export default function PhoneInput({
38
51
  allowDial = true,
39
52
  ...props
40
53
  }: PhoneInputProps) {
41
- // 在使用 usePhoneInput 之前处理电话号码
42
- const phoneWithPrefix = useMemo(() => {
43
- if (!value.phone) return '';
44
- // 如果电话号码已经包含 + 号,说明已经有区号了
45
- if (value.phone.startsWith('+')) return value.phone;
46
- // 获取国家区号并添加到号码前
47
- const countryCode = getCountryCallingCode(value.country);
48
- return `+${countryCode}${value.phone}`;
49
- }, [value.phone, value.country]);
50
-
51
- const { phone, handlePhoneValueChange, inputRef, country, setCountry } = usePhoneInput({
54
+ // 创建国家代码映射表,避免重复解析
55
+ const countryMap = useMemo(() => {
56
+ const map = new Map<CountryIso2, ReturnType<typeof parseCountry>>();
57
+ defaultCountries.forEach((country) => {
58
+ const parsed = parseCountry(country);
59
+ map.set(parsed.iso2, parsed);
60
+ });
61
+ return map;
62
+ }, []);
63
+
64
+ // 使用 react-international-phone hook
65
+ const { phone, inputRef, country } = usePhoneInput({
52
66
  defaultCountry: value.country,
53
- value: phoneWithPrefix,
67
+ value: value.phone,
54
68
  countries: defaultCountries,
55
69
  onChange: (data) => {
56
- // 确保类型匹配 PhoneValue 接口
57
- const phoneValue: PhoneValue = {
58
- country: data.country,
59
- phone: data.phone,
60
- };
61
- onChange?.(phoneValue);
70
+ if (!preview && onChange) {
71
+ onChange({
72
+ country: data.country,
73
+ phone: data.phone,
74
+ });
75
+ }
62
76
  },
63
77
  });
64
78
 
65
- const onCountryChange = (v: CountryIso2) => {
66
- if (!preview) {
67
- setCountry(v);
68
- }
79
+ // 获取当前国家的区号(不带+号)
80
+ const currentDialCode = useMemo(() => {
81
+ const countryData = countryMap.get(country);
82
+ return countryData ? getDialCodeWithoutPlus(countryData.dialCode) : '';
83
+ }, [country, countryMap]);
84
+
85
+ // 从完整电话号码中提取不带区号的部分用于显示,并去除格式化字符
86
+ const displayPhone = useMemo(() => extractPhoneWithoutCode(phone, currentDialCode), [phone, currentDialCode]);
87
+
88
+ // 创建更新后的 PhoneValue 对象
89
+ const createUpdatedPhoneValue = (countryIso2: CountryIso2, phoneWithoutCode: string): PhoneValue => {
90
+ const countryData = countryMap.get(countryIso2);
91
+ if (!countryData) return { country: countryIso2, phone: '' };
92
+
93
+ const dialCode = getDialCodeWithoutPlus(countryData.dialCode);
94
+ return {
95
+ country: countryIso2,
96
+ phone: addCountryCodeToPhone(phoneWithoutCode, dialCode),
97
+ };
98
+ };
99
+
100
+ // 处理电话号码变更
101
+ const onPhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
102
+ if (preview) return;
103
+ // 去除用户输入中的非数字字符,但保留括号
104
+ const cleanedValue = e.target.value.replace(/[^\d()]/g, '');
105
+ onChange?.(createUpdatedPhoneValue(country, cleanedValue));
69
106
  };
70
107
 
108
+ // 处理国家变更
109
+ const onCountryChange = (newCountry: CountryIso2) => {
110
+ if (preview) return;
111
+ onChange?.(createUpdatedPhoneValue(newCountry, displayPhone));
112
+ };
113
+
114
+ // 预览模式
71
115
  if (preview) {
72
116
  const isValid = phone && validatePhoneNumber(phone);
73
117
  const canDial = allowDial && isValid;
@@ -95,57 +139,43 @@ export default function PhoneInput({
95
139
  },
96
140
  }}>
97
141
  <CountrySelect value={country} preview={preview} />
98
- <Typography sx={{ ml: 0.5 }}>{phone}</Typography>
142
+ <Typography sx={{ ml: 0.5 }}>{displayPhone}</Typography>
99
143
  </Box>
100
144
  );
101
145
  }
146
+ // 编辑模式
102
147
  return (
103
- <TextField
104
- {...props}
105
- value={phone}
106
- onChange={preview ? undefined : handlePhoneValueChange}
107
- placeholder={placeholder}
108
- className="phone-input"
109
- inputRef={inputRef}
110
- disabled={preview}
111
- sx={{
112
- '&>.MuiInputBase-root': {
113
- paddingLeft: '8px',
114
- cursor: preview ? 'default' : undefined,
115
- },
116
- ...(props.sx ?? {}),
117
- ...(preview
118
- ? {
119
- '& .Mui-disabled': {
120
- '-webkit-text-fill-color': 'inherit',
121
- color: 'inherit',
122
- },
123
- fieldset: {
124
- display: 'none',
125
- },
126
- }
127
- : {}),
128
- }}
129
- InputProps={{
130
- startAdornment: (
131
- <InputAdornment position="start" style={{ marginRight: '2px', marginLeft: '-8px' }}>
132
- <CountrySelect
133
- value={country}
134
- onChange={onCountryChange}
135
- preview={preview}
136
- selectCountryProps={countryDisplayOptions}
137
- sx={{
138
- '&.Mui-focused .MuiOutlinedInput-notchedOutline': {
139
- borderColor: 'transparent',
140
- },
141
- opacity: preview ? 1 : undefined,
142
- cursor: preview ? 'default' : undefined,
143
- }}
144
- />
145
- </InputAdornment>
146
- ),
147
- ...(props.InputProps ?? {}),
148
- }}
149
- />
148
+ <Box display="flex" alignItems="flex-start" gap={0.5}>
149
+ <CountrySelect
150
+ value={country}
151
+ onChange={onCountryChange}
152
+ preview={false}
153
+ selectCountryProps={countryDisplayOptions}
154
+ />
155
+ <TextField
156
+ {...props}
157
+ value={displayPhone}
158
+ onChange={onPhoneChange}
159
+ placeholder={placeholder}
160
+ className="phone-input"
161
+ inputRef={inputRef}
162
+ sx={mergeSx(
163
+ {
164
+ '& .MuiInputBase-input': {
165
+ padding: 1,
166
+ },
167
+ '& .MuiFormHelperText-root': {
168
+ position: 'absolute',
169
+ bottom: 0,
170
+ left: 0,
171
+ transform: 'translateY(100%)',
172
+ margin: 0,
173
+ },
174
+ },
175
+ props.sx as any // 这里传入的可能是一个数组或一个对象
176
+ )}
177
+ InputProps={props.InputProps ?? {}}
178
+ />
179
+ </Box>
150
180
  );
151
181
  }