design-system-next 2.26.1 → 2.26.3

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.
@@ -44,10 +44,18 @@ export const inputCurrencyPropTypes = {
44
44
  type: Number,
45
45
  default: 2,
46
46
  },
47
+ baseValue: {
48
+ type: Number,
49
+ default: undefined,
50
+ },
47
51
  displayAsCode: {
48
52
  type: Boolean,
49
53
  default: true,
50
54
  },
55
+ displayAsSymbol: {
56
+ type: Boolean,
57
+ default: false,
58
+ },
51
59
  disableRounding: {
52
60
  type: Boolean,
53
61
  default: false,
@@ -66,19 +74,8 @@ export const inputCurrencyEmitTypes = {
66
74
  typeof value.symbol === 'string' &&
67
75
  (value.numericValue === null || (typeof value.numericValue === 'number' && !isNaN(value.numericValue))) &&
68
76
  (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),
77
+ getCurrencyValue: (value: number | null): value is number | null =>
78
+ value === null || (typeof value === 'number' && !isNaN(value)),
82
79
  };
83
80
 
84
81
  export interface InputCurrencyEmit {
@@ -92,8 +89,7 @@ export interface InputCurrencyEmit {
92
89
  rawValue: string | null;
93
90
  },
94
91
  ): void;
95
- (event: 'getCurrencyErrors', value: Array<{ title: string; message: string }>): void;
96
- (event: 'getNumericValue', value: number): void;
92
+ (event: 'getCurrencyValue', value: number | null): void;
97
93
  }
98
94
 
99
95
  export type InputCurrencyPropTypes = ExtractPropTypes<typeof inputCurrencyPropTypes>;
@@ -10,6 +10,10 @@
10
10
  @input="handleCurrencyInput"
11
11
  @blur="handleBlur"
12
12
  >
13
+ <template v-for="(_, slotName) in $slots" #[slotName]>
14
+ <slot :name="slotName" />
15
+ </template>
16
+
13
17
  <template #prefix>
14
18
  <spr-dropdown
15
19
  :id="dropdownId"
@@ -22,10 +22,10 @@ export const useInputCurrency = (props: InputCurrencyPropTypes, emit: SetupConte
22
22
  preSelectedCurrency,
23
23
  disabledCountryCurrency,
24
24
  disabled,
25
- autoFormat,
26
25
  maxDecimals,
27
26
  minDecimals,
28
27
  disableRounding,
28
+ baseValue,
29
29
  } = toRefs(props);
30
30
 
31
31
  const inputCurrencyClasses: ComputedRef<InputCurrencyClasses> = computed(() => {
@@ -121,97 +121,35 @@ export const useInputCurrency = (props: InputCurrencyPropTypes, emit: SetupConte
121
121
  return cleaned || null;
122
122
  });
123
123
 
124
- /**
125
- * Derive the default fraction digits for a currency using Intl metadata.
126
- * Falls back to 2 when unavailable.
127
- */
128
- const getCurrencyFractionDigits = (code: string): number => {
129
- try {
130
- const fmt = new Intl.NumberFormat('en', { style: 'currency', currency: code });
131
- const resolved = fmt.resolvedOptions();
132
- if (resolved && typeof resolved.minimumFractionDigits === 'number') {
133
- return resolved.minimumFractionDigits;
134
- }
135
- } catch {
136
- /* ignore */
137
- }
138
- return 2;
139
- };
140
-
141
- const currentCurrencyFractionDigits = computed(() => getCurrencyFractionDigits(selected.value.currency));
142
-
143
124
  const effectiveMinDecimals = computed(() => {
144
- // Minimum is the greater of user minDecimals and native fraction digits lower bound (native min == native max in most cases)
125
+ // Minimum is just the user's minDecimals value
145
126
  const userMin = Math.min(Math.max(0, minDecimals.value), 6);
146
- return Math.min(6, Math.max(0, Math.min(userMin, maxDecimals.value)));
127
+ return Math.min(6, Math.max(0, userMin));
147
128
  });
148
129
 
149
130
  const effectiveMaxDecimals = computed(() => {
150
- // Use native fraction digits as baseline but allow user to reduce (rare) or extend (not beyond 6)
151
- const native = currentCurrencyFractionDigits.value; // e.g. JPY => 0, USD => 2
131
+ // Use the user-provided maxDecimals value directly (clamped to 0-6)
152
132
  const userMax = Math.min(Math.max(0, maxDecimals.value), 6);
153
- // If user sets max below native, keep native to respect currency standard.
154
- const adjustedMax = Math.max(native, userMax);
155
133
  // Ensure max >= min
156
- return Math.max(adjustedMax, effectiveMinDecimals.value);
134
+ return Math.max(userMax, effectiveMinDecimals.value);
157
135
  });
158
136
 
159
- const clampFractionDigits = (frac: string): string => frac.slice(0, effectiveMaxDecimals.value);
160
-
161
137
  const formatNumberForBlur = (value: number): string => {
162
138
  const fmt = buildNumberFormat(effectiveMinDecimals.value, effectiveMaxDecimals.value);
163
- // Remove currency symbol from formatted output (component is responsible only for numeric part in input)
164
- const parts = fmt.formatToParts(value).filter((p) => p.type !== 'currency');
165
- return parts
166
- .map((p) => p.value)
167
- .join('')
168
- .replace(/\s+/g, '');
169
- };
170
-
171
- /**
172
- * Formats a numeric string (no currency symbol) applying grouping and truncating decimals but not padding.
173
- * Used while user is still interacting (on input) if autoFormat.
174
- */
175
- const handleFormatDisplay = (raw: string): string => {
176
- if (!autoFormat.value) return raw;
177
- if (!raw) return '';
178
- // Detect user decimal char as either '.' or last ','
179
- let work = raw;
180
- // Strip spaces
181
- work = work.replace(/\s+/g, '');
182
- // Allow only digits, separators, minus
183
- work = work.replace(/[^0-9,.-]/g, '');
184
- let sign = '';
185
- if (work.startsWith('-')) {
186
- sign = '-';
187
- work = work.slice(1);
188
- }
189
- const lastDot = work.lastIndexOf('.');
190
- const lastComma = work.lastIndexOf(',');
191
- const lastSep = Math.max(lastDot, lastComma);
192
- let intPart: string;
193
- let fracPart = '';
194
- if (lastSep !== -1) {
195
- intPart = work.slice(0, lastSep).replace(/[.,]/g, '');
196
- fracPart = work.slice(lastSep + 1);
197
- } else {
198
- intPart = work.replace(/[.,]/g, '');
199
- }
200
- if (!/^[0-9]*$/.test(intPart) || !/^[0-9]*$/.test(fracPart)) return raw;
201
- const truncatedFrac = clampFractionDigits(fracPart);
202
- const intNumber = Number(intPart || '0');
203
- // Use Intl just for grouping integer portion
204
- const fmtInt = buildNumberFormat(0, 0);
205
- const groupedInt = fmtInt
206
- .formatToParts(intNumber)
207
- .filter((p) => p.type === 'integer' || p.type === 'group')
208
- .map((p) => p.value)
209
- .join('');
210
- if (truncatedFrac.length > 0 || lastSep !== -1) {
211
- // Use current locale decimal symbol
212
- return sign + groupedInt + localeSeparators.value.decimal + truncatedFrac;
139
+ // Format the number and get parts
140
+ const parts = fmt.formatToParts(value);
141
+
142
+ // Extract only numeric parts (number, decimal, group)
143
+ let result = '';
144
+ for (const part of parts) {
145
+ if (part.type === 'currency') {
146
+ // Skip currency symbol/code
147
+ continue;
148
+ }
149
+ result += part.value;
213
150
  }
214
- return sign + groupedInt;
151
+
152
+ return result.trim();
215
153
  };
216
154
 
217
155
  /**
@@ -258,39 +196,42 @@ export const useInputCurrency = (props: InputCurrencyPropTypes, emit: SetupConte
258
196
  return formatNumberForBlur(numeric);
259
197
  };
260
198
 
261
- const handleCurrencyInput = (event: InputEvent) => {
262
- const inputEl = event.target as HTMLInputElement;
263
-
264
- let raw = inputEl.value;
199
+ const handleCurrencyInput = (event: Event) => {
200
+ const target = event.target as HTMLInputElement;
201
+ let raw = target.value;
265
202
 
203
+ // Remove spaces
266
204
  raw = raw.replace(/\s+/g, '');
267
205
 
268
- let sign = '';
206
+ // Only allow digits and dots (.) as decimal separator
207
+ // Reject commas (,) during typing - they will be added during formatting on blur
208
+ raw = raw.replace(/[^0-9.]/g, '');
269
209
 
270
- if (raw.startsWith('-')) {
271
- sign = '-';
272
- raw = raw.slice(1);
210
+ // Handle multiple dots (keep only the last one as decimal separator)
211
+ const lastDot = raw.lastIndexOf('.');
212
+ if (lastDot !== -1) {
213
+ const beforeDecimal = raw.slice(0, lastDot).replace(/\./g, '');
214
+ const afterDecimal = raw.slice(lastDot + 1).replace(/\./g, '');
215
+ raw = beforeDecimal + '.' + afterDecimal;
273
216
  }
274
217
 
275
- raw = raw.replace(/[^0-9.,-]/g, '');
276
-
277
- const firstDot = raw.indexOf('.');
278
-
279
- if (firstDot !== -1) {
280
- raw = raw.slice(0, firstDot + 1) + raw.slice(firstDot + 1).replace(/\./g, '');
218
+ // Just update the value without formatting during typing
219
+ // Formatting happens on blur to avoid cursor jumping
220
+ modelValue.value = raw;
221
+ // Emit numeric value while typing (null for empty, parsed value otherwise)
222
+ const parsedNumeric = raw === '' ? null : Number(raw);
223
+ // Only emit if it's a valid number (not NaN) or null
224
+ if (parsedNumeric === null || !isNaN(parsedNumeric)) {
225
+ emit('getCurrencyValue', parsedNumeric);
281
226
  }
282
-
283
- // Do not truncate fractional digits while typing; allow user to input freely.
284
- const formatted = handleFormatDisplay(sign + raw);
285
-
286
- modelValue.value = formatted;
287
-
288
- emit('getCurrencyErrors', []);
289
227
  };
290
228
 
291
229
  const handleBlur = () => {
292
- // Format only if there is a value
293
- if (modelValue.value) {
230
+ // If input is empty and baseValue is provided, use baseValue
231
+ if (!modelValue.value && baseValue && baseValue.value !== undefined) {
232
+ modelValue.value = formatNumberForBlur(baseValue.value);
233
+ } else if (modelValue.value) {
234
+ // Format only if there is a value
294
235
  let out = modelValue.value;
295
236
 
296
237
  out = formatOnBlur(out);
@@ -306,7 +247,11 @@ export const useInputCurrency = (props: InputCurrencyPropTypes, emit: SetupConte
306
247
  rawValue: rawNumericString.value,
307
248
  });
308
249
 
309
- if (numericValue.value !== null) emit('getNumericValue', numericValue.value);
250
+ // Emit the numeric value after blur (null for empty, numeric value otherwise)
251
+ // Use setTimeout to ensure the formatted value is set before emitting
252
+ setTimeout(() => {
253
+ emit('getCurrencyValue', numericValue.value ?? null);
254
+ }, 0);
310
255
  };
311
256
 
312
257
  const handleSelectedCurrency = (currencyRaw: unknown) => {
@@ -370,7 +315,7 @@ export const useInputCurrency = (props: InputCurrencyPropTypes, emit: SetupConte
370
315
  // Re-format current input according to new currency fraction rules only if currency actually changed
371
316
  if (preSwitchNumeric !== null && previousCurrency !== found.currency) {
372
317
  modelValue.value = formatNumberForBlur(preSwitchNumeric);
373
- emit('getNumericValue', preSwitchNumeric);
318
+ emit('getCurrencyValue', preSwitchNumeric);
374
319
  }
375
320
  }
376
321
  };
@@ -379,7 +324,12 @@ export const useInputCurrency = (props: InputCurrencyPropTypes, emit: SetupConte
379
324
  popperState.value = state;
380
325
  };
381
326
 
382
- const dropdownDisplayText = computed(() => (props.displayAsCode ? selected.value.currency : selected.value.symbol));
327
+ const dropdownDisplayText = computed(() => {
328
+ if (props.displayAsSymbol) {
329
+ return selected.value.symbol;
330
+ }
331
+ return props.displayAsCode ? selected.value.currency : selected.value.symbol;
332
+ });
383
333
 
384
334
  // #region - Set Currency Options
385
335
  // Collect currency codes first so we can derive ambiguity and symbols deterministically.
@@ -508,13 +458,32 @@ export const useInputCurrency = (props: InputCurrencyPropTypes, emit: SetupConte
508
458
  handleSelectedCurrency(preSelectedCurrency.value);
509
459
  if (modelValue.value && numericValue.value !== null) {
510
460
  modelValue.value = formatNumberForBlur(numericValue.value);
511
- emit('getNumericValue', numericValue.value);
461
+ emit('getCurrencyValue', numericValue.value);
512
462
  emit('getSelectedCurrencyMeta', {
513
463
  currency: selected.value.currency,
514
464
  symbol: selected.value.symbol,
515
465
  numericValue: numericValue.value,
516
466
  rawValue: rawNumericString.value,
517
467
  });
468
+ } else if (!modelValue.value && baseValue && baseValue.value !== undefined) {
469
+ // If empty on mount and baseValue is provided, use it
470
+ modelValue.value = formatNumberForBlur(baseValue.value);
471
+ emit('getCurrencyValue', baseValue.value);
472
+ emit('getSelectedCurrencyMeta', {
473
+ currency: selected.value.currency,
474
+ symbol: selected.value.symbol,
475
+ numericValue: baseValue.value,
476
+ rawValue: String(baseValue.value),
477
+ });
478
+ } else {
479
+ // Always emit null for empty on mount
480
+ emit('getCurrencyValue', null);
481
+ emit('getSelectedCurrencyMeta', {
482
+ currency: selected.value.currency,
483
+ symbol: selected.value.symbol,
484
+ numericValue: null,
485
+ rawValue: null,
486
+ });
518
487
  }
519
488
  });
520
489
 
@@ -3,6 +3,10 @@
3
3
  v-bind="$attrs"
4
4
  :class="{ 'spr-cursor-pointer': $attrs.readonly === '' || $attrs.readonly === 'true' || $attrs.readonly }"
5
5
  >
6
+ <template v-for="(_, slotName) in $slots" #[slotName]>
7
+ <slot :name="slotName" />
8
+ </template>
9
+
6
10
  <template #icon>
7
11
  <Icon icon="ph:caret-down" />
8
12
  </template>
@@ -1,5 +1,9 @@
1
1
  <template>
2
2
  <spr-input v-bind="$attrs">
3
+ <template v-for="(_, slotName) in $slots" #[slotName]>
4
+ <slot :name="slotName" />
5
+ </template>
6
+
3
7
  <template #icon>
4
8
  <Icon icon="ph:magnifying-glass" />
5
9
  </template>
@@ -101,6 +101,7 @@ export const inputPropTypes = {
101
101
 
102
102
  export const inputEmitTypes = {
103
103
  'update:modelValue': (value: string | number): boolean => typeof value === 'string' || typeof value === 'number',
104
+ blur: (event: Event): boolean => event instanceof Event,
104
105
  };
105
106
 
106
107
  export type InputEmitTypes = { 'update:modelValue': typeof inputEmitTypes };
@@ -24,6 +24,7 @@
24
24
  :disabled="props.disabled"
25
25
  :readonly="props.readonly"
26
26
  @input="onInput"
27
+ @blur="onBlur"
27
28
  />
28
29
  <div v-if="$slots.trailing" :class="inputClasses.trailingSlotClasses">
29
30
  <slot name="trailing" />
@@ -59,7 +60,7 @@ const emit = defineEmits(inputEmitTypes);
59
60
  const props = defineProps(inputPropTypes);
60
61
  const slots = useSlots();
61
62
 
62
- const { inputClasses, inputTextRef, onInput, disableClickEvent, currentLength } = useInput(props, emit, slots);
63
+ const { inputClasses, inputTextRef, onInput, onBlur, disableClickEvent, currentLength } = useInput(props, emit, slots);
63
64
  </script>
64
65
 
65
66
  <style scoped>
@@ -161,6 +161,10 @@ export const useInput = (
161
161
  modelValue.value = value;
162
162
  };
163
163
 
164
+ const onBlur = (event: Event) => {
165
+ emit('blur', event);
166
+ };
167
+
164
168
  const disableClickEvent = (event: Event) => {
165
169
  if (disabled.value) {
166
170
  event.preventDefault();
@@ -172,6 +176,7 @@ export const useInput = (
172
176
  inputTextRef,
173
177
  inputClasses,
174
178
  onInput,
179
+ onBlur,
175
180
  disableClickEvent,
176
181
  currentLength,
177
182
  hasCharLimit,
@@ -1,60 +1,60 @@
1
- import type { PropType, ExtractPropTypes } from 'vue';
2
- import type { MenuListType } from '../list';
3
-
4
- export const listItemPropTypes = {
5
- item: {
6
- type: Object as PropType<MenuListType>,
7
- required: true,
8
- },
9
- isSelected: {
10
- type: Boolean,
11
- required: true,
12
- },
13
- classes: {
14
- type: [String, Array, Object] as PropType<string | string[] | Record<string, boolean>>,
15
- required: true,
16
- },
17
- multiSelect: {
18
- type: Boolean,
19
- default: false,
20
- },
21
- lozenge: {
22
- type: Boolean,
23
- default: false,
24
- },
25
- ladderized: {
26
- type: Boolean,
27
- default: false,
28
- },
29
- noCheck: {
30
- type: Boolean,
31
- default: false,
32
- },
33
- itemIcon: {
34
- type: String,
35
- default: '',
36
- },
37
- itemIconTone: {
38
- type: String,
39
- default: 'plain',
40
- },
41
- itemIconFill: {
42
- type: Boolean,
43
- default: false,
44
- },
45
- disabledUnselectedItems: {
46
- type: Boolean,
47
- default: false,
48
- },
49
- radioList: {
50
- type: Boolean,
51
- default: false,
52
- },
53
- };
54
-
55
- export const listItemEmitTypes = {
56
- select: () => true,
57
- };
58
-
59
- export type ListItemPropTypes = ExtractPropTypes<typeof listItemPropTypes>;
60
- export type ListItemEmitTypes = typeof listItemEmitTypes;
1
+ import type { PropType, ExtractPropTypes } from 'vue';
2
+ import type { MenuListType } from '../list';
3
+
4
+ export const listItemPropTypes = {
5
+ item: {
6
+ type: Object as PropType<MenuListType>,
7
+ required: true,
8
+ },
9
+ isSelected: {
10
+ type: Boolean,
11
+ required: true,
12
+ },
13
+ classes: {
14
+ type: [String, Array, Object] as PropType<string | string[] | Record<string, boolean>>,
15
+ required: true,
16
+ },
17
+ multiSelect: {
18
+ type: Boolean,
19
+ default: false,
20
+ },
21
+ lozenge: {
22
+ type: Boolean,
23
+ default: false,
24
+ },
25
+ ladderized: {
26
+ type: Boolean,
27
+ default: false,
28
+ },
29
+ noCheck: {
30
+ type: Boolean,
31
+ default: false,
32
+ },
33
+ itemIcon: {
34
+ type: String,
35
+ default: '',
36
+ },
37
+ itemIconTone: {
38
+ type: String,
39
+ default: 'plain',
40
+ },
41
+ itemIconFill: {
42
+ type: Boolean,
43
+ default: false,
44
+ },
45
+ disabledUnselectedItems: {
46
+ type: Boolean,
47
+ default: false,
48
+ },
49
+ radioList: {
50
+ type: Boolean,
51
+ default: false,
52
+ },
53
+ };
54
+
55
+ export const listItemEmitTypes = {
56
+ select: () => true,
57
+ };
58
+
59
+ export type ListItemPropTypes = ExtractPropTypes<typeof listItemPropTypes>;
60
+ export type ListItemEmitTypes = typeof listItemEmitTypes;
@@ -375,7 +375,7 @@ export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>[
375
375
  const getListItemClasses = (item: MenuListType) => ({
376
376
  [listClasses.value.listItemClasses]: !item.disabled && !(disabledUnselectedItems.value && !isItemSelected(item)),
377
377
  'spr-background-color-single-active': isItemSelected(item) && !item.disabled && !noCheck.value,
378
- 'spr-cursor-not-allowed spr-flex spr-items-center spr-gap-1.5 spr-rounded-lg':
378
+ 'spr-cursor-not-allowed spr-flex spr-items-center spr-justify-between spr-gap-1.5 spr-rounded-lg':
379
379
  item.disabled || (disabledUnselectedItems.value && !isItemSelected(item)),
380
380
  'spr-p-size-spacing-3xs': !props.lozenge,
381
381
  'spr-py-size-spacing-3xs spr-px-size-spacing-4xs': props.lozenge,
@@ -527,13 +527,13 @@ export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>[
527
527
  } else {
528
528
  handleSingleSelect(item);
529
529
  }
530
- }
530
+ }
531
531
  };
532
532
 
533
533
  const handleDeselect = (item: MenuListType) => {
534
534
  if (selectedItems.value.length === 0 || !isItemSelected(item)) {
535
- selectedItems.value = [item];
536
- emit('get-single-selected-item', item);
535
+ selectedItems.value = [item];
536
+ emit('get-single-selected-item', item);
537
537
  } else {
538
538
  selectedItems.value = [];
539
539
  emit('get-single-deselected-item', item);
@@ -542,8 +542,8 @@ export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>[
542
542
 
543
543
  const handleSingleSelect = (item: MenuListType) => {
544
544
  selectedItems.value = [item];
545
- if (item.onClickFn) item.onClickFn();
546
- emit('get-single-selected-item', item);
545
+ if (item.onClickFn) item.onClickFn();
546
+ emit('get-single-selected-item', item);
547
547
  };
548
548
  // #endregion - Helper Methods
549
549