design-system-next 2.14.3 → 2.15.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.
@@ -23,9 +23,13 @@ export const COUNTRY_OPTIONS: CountryOption[] = getCountries().map((countryCode)
23
23
  });
24
24
 
25
25
  export const inputContactNumberPropTypes = {
26
+ id: {
27
+ type: String,
28
+ default: '',
29
+ },
26
30
  modelValue: {
27
31
  type: String,
28
- required: true,
32
+ default: '',
29
33
  },
30
34
  placeholder: {
31
35
  type: String,
@@ -69,13 +73,7 @@ export const inputContactNumberEmitTypes = {
69
73
 
70
74
  export interface InputContactNumberEmit {
71
75
  (event: 'update:modelValue', value: string): void;
72
- (
73
- event: 'getSelectedCountryCallingCode',
74
- value: {
75
- countryCode: string[];
76
- countryCallingCode: string[];
77
- },
78
- ): void;
76
+ (event: 'getSelectedCountryCallingCode', value: { countryCode: string; countryCallingCode: string }): void;
79
77
  (event: 'getContactNumberErrors', value: Array<{ title: string; message: string }>): void;
80
78
  }
81
79
 
@@ -12,9 +12,9 @@
12
12
  >
13
13
  <template #prefix>
14
14
  <spr-dropdown
15
- id="contact-number-country-dropdown"
15
+ :id="dropdownId"
16
16
  v-model="selectedCountry.countryCode"
17
- class="[&>#dropdown-wrapper]:spr-my-1"
17
+ :class="inputContactNumberClasses.dropdownBaseClasses"
18
18
  :menu-list="COUNTRY_OPTIONS"
19
19
  placement="bottom-start"
20
20
  :width="!props.disabledCountryCallingCode ? '45px' : '35px'"
@@ -23,10 +23,10 @@
23
23
  @update:model-value="handleSelectedCountryCode"
24
24
  @get-popper-state="handlePopperState"
25
25
  >
26
- <span :class="inputContactNumberClasses.countryCallingCodeClasses">
27
- +{{ selectedCountry.countryCallingCode[0] }}
26
+ <div :class="inputContactNumberClasses.dropdownWrappertClasses">
27
+ <span>+{{ selectedCountry.countryCallingCode }}</span>
28
28
  <icon v-if="!props.disabledCountryCallingCode" icon="ph:caret-down" width="16px" height="16px" />
29
- </span>
29
+ </div>
30
30
  </spr-dropdown>
31
31
  </template>
32
32
  </spr-input>
@@ -48,6 +48,7 @@ const emit = defineEmits(inputContactNumberEmitTypes);
48
48
 
49
49
  const {
50
50
  inputContactNumberClasses,
51
+ dropdownId,
51
52
  formattedValue,
52
53
  selectedCountry,
53
54
  popperState,
@@ -8,17 +8,26 @@ import parsePhoneNumber, { getCountries, getCountryCallingCode, CountryCode } fr
8
8
  import { type InputContactNumberEmitTypes, type InputContactNumberPropTypes } from './input-contact-number';
9
9
 
10
10
  interface InputContactNumberClasses {
11
- countryCallingCodeClasses: string;
11
+ dropdownBaseClasses: string;
12
+ dropdownWrappertClasses: string;
12
13
  }
13
14
 
14
15
  export const useInputContactNumber = (
15
16
  props: InputContactNumberPropTypes,
16
17
  emit: SetupContext<InputContactNumberEmitTypes>['emit'],
17
18
  ) => {
18
- const { preSelectedCountryCode, disabledCountryCallingCode, disabled } = toRefs(props);
19
+ const { id, preSelectedCountryCode, disabledCountryCallingCode, disabled } = toRefs(props);
19
20
 
20
21
  const inputContactNumberClasses: ComputedRef<InputContactNumberClasses> = computed(() => {
21
- const countryCallingCodeClasses = classNames(
22
+ const dropdownBaseClasses = classNames(
23
+ '[&_#dropdown-wrapper]:spr-my-1',
24
+ '[&_#dropdown-wrapper[data-popper-placement="bottom-start"]]:spr-ml-[-10px]',
25
+ '[&_#dropdown-wrapper[data-popper-placement="bottom-start"]]:spr-mt-[6px]',
26
+ '[&_#dropdown-wrapper[data-popper-placement="top-start"]]:spr-ml-[-10px]',
27
+ '[&_#dropdown-wrapper[data-popper-placement="top-start"]]:spr-mt-[-6px]',
28
+ );
29
+
30
+ const dropdownWrappertClasses = classNames(
22
31
  'spr-font-weight-regular spr-font-size-200 spr-line-height-500 spr-letter-spacing-none spr-font-main',
23
32
  'spr-flex spr-items-center spr-gap-size-spacing-5xs',
24
33
  {
@@ -29,20 +38,25 @@ export const useInputContactNumber = (
29
38
  );
30
39
 
31
40
  return {
32
- countryCallingCodeClasses,
41
+ dropdownBaseClasses,
42
+ dropdownWrappertClasses,
33
43
  };
34
44
  });
35
45
 
46
+ // fallback random id if user does not provide one (stable per component instance)
47
+ const fallbackId = ref(`currency-${Math.random().toString(36).slice(2, 8)}-dropdown`);
48
+ const dropdownId = computed(() => (id.value ? `${id.value}-dropdown` : fallbackId.value));
49
+
36
50
  const formattedValue = useVModel(props, 'modelValue', emit);
37
51
 
38
52
  const selectedCountry = ref({
39
- countryCode: ['PH'],
40
- countryCallingCode: ['63'],
53
+ countryCode: 'PH',
54
+ countryCallingCode: '63',
41
55
  });
42
56
 
43
57
  const popperState = ref(false);
44
58
 
45
- const setselectedCountry = (selectedCountryCode: string) => {
59
+ const setSelectedCountry = (selectedCountryCode: string) => {
46
60
  const countryCallingCode = getCountryCallingCode(selectedCountryCode as CountryCode);
47
61
 
48
62
  const countryCode = getCountries().find((country) => {
@@ -51,8 +65,8 @@ export const useInputContactNumber = (
51
65
 
52
66
  if (countryCode && countryCallingCode) {
53
67
  selectedCountry.value = {
54
- countryCode: [countryCode],
55
- countryCallingCode: [countryCallingCode],
68
+ countryCode: countryCode,
69
+ countryCallingCode: countryCallingCode,
56
70
  };
57
71
 
58
72
  formatContactNumber();
@@ -81,10 +95,10 @@ export const useInputContactNumber = (
81
95
  }
82
96
  };
83
97
 
84
- const handleSelectedCountryCode = (countryCode: string[]) => {
98
+ const handleSelectedCountryCode = (countryCode: string) => {
85
99
  selectedCountry.value = {
86
- countryCode: [countryCode[0]],
87
- countryCallingCode: [getCountryCallingCode(countryCode[0] as CountryCode)],
100
+ countryCode: countryCode,
101
+ countryCallingCode: getCountryCallingCode(countryCode as CountryCode),
88
102
  };
89
103
 
90
104
  emit('getContactNumberErrors', []);
@@ -92,27 +106,46 @@ export const useInputContactNumber = (
92
106
  formatContactNumber();
93
107
 
94
108
  emit('getSelectedCountryCallingCode', {
95
- countryCode: selectedCountry.value.countryCode[0],
96
- countryCallingCode: selectedCountry.value.countryCallingCode[0],
109
+ countryCode: selectedCountry.value.countryCode,
110
+ countryCallingCode: selectedCountry.value.countryCallingCode,
97
111
  });
98
112
  };
99
113
 
100
114
  const formatContactNumber = () => {
101
- if (!formattedValue.value) return;
102
-
103
- const normalizedNumber = formattedValue.value.replace(/\D/g, '');
115
+ if (!formattedValue.value) {
116
+ emit('getContactNumberErrors', []);
117
+ return;
118
+ }
104
119
 
105
- const phoneNumber = parsePhoneNumber(normalizedNumber, {
106
- defaultCountry: selectedCountry.value.countryCode[0] as CountryCode,
107
- extract: false,
108
- });
120
+ const original = formattedValue.value.trim();
121
+ const hasPlus = original.startsWith('+');
122
+ const normalizedNumber = hasPlus ? `+${original.replace(/[^0-9]/g, '')}` : original.replace(/\D/g, '');
123
+
124
+ let phoneNumber;
125
+
126
+ try {
127
+ phoneNumber = hasPlus
128
+ ? parsePhoneNumber(normalizedNumber)
129
+ : parsePhoneNumber(normalizedNumber, {
130
+ defaultCountry: selectedCountry.value.countryCode as CountryCode,
131
+ extract: false,
132
+ });
133
+ } catch {
134
+ phoneNumber = undefined;
135
+ }
109
136
 
110
137
  if (phoneNumber && phoneNumber.isValid()) {
111
138
  let formattedNumber = phoneNumber.formatInternational();
112
139
 
113
- formattedNumber = formattedNumber.replace(`+${selectedCountry.value.countryCallingCode[0]} `, '');
140
+ const prefix = `+${selectedCountry.value.countryCallingCode} `;
141
+
142
+ if (formattedNumber.startsWith(prefix)) {
143
+ formattedNumber = formattedNumber.slice(prefix.length);
144
+ }
114
145
 
115
146
  formattedValue.value = formattedNumber;
147
+
148
+ emit('getContactNumberErrors', []);
116
149
  } else {
117
150
  emit('getContactNumberErrors', [
118
151
  {
@@ -133,23 +166,24 @@ export const useInputContactNumber = (
133
166
 
134
167
  watch(preSelectedCountryCode, (newValue) => {
135
168
  if (newValue) {
136
- setselectedCountry(newValue);
169
+ setSelectedCountry(newValue);
137
170
  }
138
171
  });
139
172
 
140
173
  onMounted(() => {
141
174
  emit('getSelectedCountryCallingCode', {
142
- countryCode: selectedCountry.value.countryCode[0],
143
- countryCallingCode: selectedCountry.value.countryCallingCode[0],
175
+ countryCode: selectedCountry.value.countryCode,
176
+ countryCallingCode: selectedCountry.value.countryCallingCode,
144
177
  });
145
178
 
146
179
  if (preSelectedCountryCode.value) {
147
- setselectedCountry(preSelectedCountryCode.value);
180
+ setSelectedCountry(preSelectedCountryCode.value);
148
181
  }
149
182
  });
150
183
 
151
184
  return {
152
185
  inputContactNumberClasses,
186
+ dropdownId,
153
187
  formattedValue,
154
188
  selectedCountry,
155
189
  popperState,
@@ -158,5 +192,6 @@ export const useInputContactNumber = (
158
192
  formatContactNumber,
159
193
  handleUpdateModelValue,
160
194
  handlePopperState,
195
+ setSelectedCountry,
161
196
  };
162
197
  };
@@ -0,0 +1,100 @@
1
+ import type { ExtractPropTypes } from 'vue';
2
+
3
+ export interface CurrencyOption {
4
+ text: string;
5
+ value: string;
6
+ currency: string;
7
+ symbol: string;
8
+ }
9
+
10
+ export const inputCurrencyPropTypes = {
11
+ id: {
12
+ type: String,
13
+ default: '',
14
+ },
15
+ modelValue: {
16
+ type: String,
17
+ default: '',
18
+ },
19
+ placeholder: {
20
+ type: String,
21
+ default: '0.00',
22
+ },
23
+ preSelectedCurrency: {
24
+ type: String,
25
+ default: 'PHP',
26
+ },
27
+ disabled: {
28
+ type: Boolean,
29
+ default: false,
30
+ },
31
+ disabledCountryCurrency: {
32
+ type: Boolean,
33
+ default: false,
34
+ },
35
+ autoFormat: {
36
+ type: Boolean,
37
+ default: false,
38
+ },
39
+ maxDecimals: {
40
+ type: Number,
41
+ default: 2,
42
+ },
43
+ minDecimals: {
44
+ type: Number,
45
+ default: 2,
46
+ },
47
+ displayAsCode: {
48
+ type: Boolean,
49
+ default: true,
50
+ },
51
+ disableRounding: {
52
+ type: Boolean,
53
+ default: false,
54
+ },
55
+ };
56
+
57
+ export const inputCurrencyEmitTypes = {
58
+ 'update:modelValue': (value: string): value is string => typeof value === 'string',
59
+ getSelectedCurrencyMeta: (value: {
60
+ currency: string;
61
+ symbol: string;
62
+ numericValue: number | null;
63
+ rawValue: string | null;
64
+ }): value is { currency: string; symbol: string; numericValue: number | null; rawValue: string | null } =>
65
+ typeof value.currency === 'string' &&
66
+ typeof value.symbol === 'string' &&
67
+ (value.numericValue === null || (typeof value.numericValue === 'number' && !isNaN(value.numericValue))) &&
68
+ (value.rawValue === null || typeof value.rawValue === 'string'),
69
+ getCurrencyErrors: (value: Array<{ title: string; message: string }>) => {
70
+ return (
71
+ Array.isArray(value) &&
72
+ value.every(
73
+ (item) =>
74
+ item !== null &&
75
+ typeof item === 'object' &&
76
+ typeof item.title === 'string' &&
77
+ typeof item.message === 'string',
78
+ )
79
+ );
80
+ },
81
+ getNumericValue: (value: number): value is number => typeof value === 'number' && !isNaN(value),
82
+ };
83
+
84
+ export interface InputCurrencyEmit {
85
+ (event: 'update:modelValue', value: string): void;
86
+ (
87
+ event: 'getSelectedCurrencyMeta',
88
+ value: {
89
+ currency: string;
90
+ symbol: string;
91
+ numericValue: number | null;
92
+ rawValue: string | null;
93
+ },
94
+ ): void;
95
+ (event: 'getCurrencyErrors', value: Array<{ title: string; message: string }>): void;
96
+ (event: 'getNumericValue', value: number): void;
97
+ }
98
+
99
+ export type InputCurrencyPropTypes = ExtractPropTypes<typeof inputCurrencyPropTypes>;
100
+ export type InputCurrencyEmitTypes = typeof inputCurrencyEmitTypes;
@@ -0,0 +1,60 @@
1
+ <template>
2
+ <spr-input
3
+ v-bind="$attrs"
4
+ v-model="modelValue"
5
+ type="text"
6
+ :placeholder="props.placeholder"
7
+ :active="popperState"
8
+ :disabled="props.disabled"
9
+ data-testid="input-currency-text"
10
+ @input="handleCurrencyInput"
11
+ @blur="handleBlur"
12
+ >
13
+ <template #prefix>
14
+ <spr-dropdown
15
+ :id="dropdownId"
16
+ v-model="selected.value"
17
+ :class="inputCurrencyClasses.dropdownBaseClasses"
18
+ :menu-list="currencyOptions"
19
+ placement="bottom-start"
20
+ :width="!props.disabledCountryCurrency ? '45px' : '35px'"
21
+ popper-width="300px"
22
+ :disabled="props.disabled || props.disabledCountryCurrency"
23
+ data-testid="input-currency-dropdown"
24
+ @update:model-value="handleSelectedCurrency"
25
+ @get-popper-state="handlePopperState"
26
+ >
27
+ <span :class="inputCurrencyClasses.dropdownWrappertClasses">
28
+ <span>{{ dropdownDisplayText }}</span>
29
+ <icon v-if="!props.disabledCountryCurrency" icon="ph:caret-down" width="16px" height="16px" />
30
+ </span>
31
+ </spr-dropdown>
32
+ </template>
33
+ </spr-input>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import { Icon } from '@iconify/vue';
38
+
39
+ import SprInput from '@/components/input/input.vue';
40
+ import SprDropdown from '@/components/dropdown/dropdown.vue';
41
+ import { useInputCurrency } from './use-input-currency';
42
+ import { inputCurrencyPropTypes, inputCurrencyEmitTypes } from './input-currency';
43
+
44
+ const props = defineProps(inputCurrencyPropTypes);
45
+ const emit = defineEmits(inputCurrencyEmitTypes);
46
+
47
+ const {
48
+ inputCurrencyClasses,
49
+ dropdownId,
50
+ modelValue,
51
+ currencyOptions,
52
+ selected,
53
+ dropdownDisplayText,
54
+ popperState,
55
+ handleCurrencyInput,
56
+ handleBlur,
57
+ handleSelectedCurrency,
58
+ handlePopperState,
59
+ } = useInputCurrency(props, emit);
60
+ </script>