@umbra.ui/core 0.4.6 → 0.5.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.
Files changed (74) hide show
  1. package/dist/components/inputs/InputCard/InputCard.vue +46 -9
  2. package/dist/components/inputs/InputCryptoAddress/InputCryptoAddress.vue +23 -4
  3. package/dist/components/inputs/InputEmail/InputEmail.vue +14 -7
  4. package/dist/components/inputs/InputNumber/InputNumber.vue +36 -3
  5. package/dist/components/inputs/InputPhone/InputPhone.vue +36 -3
  6. package/dist/components/inputs/InputSecure/InputSecure.vue +27 -3
  7. package/dist/components/pickers/CollectionPicker/CollectionPicker.vue +31 -9
  8. package/dist/components/pickers/CollectionPicker/README.md +50 -0
  9. package/dist/components/pickers/ColorPicker/ColorPicker.vue +27 -4
  10. package/dist/components/pickers/ColorPicker/README.md +50 -0
  11. package/dist/components/pickers/IconPicker/IconPicker.vue +53 -26
  12. package/dist/components/pickers/IconPicker/README.md +43 -0
  13. package/package.json +5 -4
  14. package/src/components/inputs/InputCard/InputCard.vue +46 -9
  15. package/src/components/inputs/InputCryptoAddress/InputCryptoAddress.vue +23 -4
  16. package/src/components/inputs/InputEmail/InputEmail.vue +14 -7
  17. package/src/components/inputs/InputNumber/InputNumber.vue +36 -3
  18. package/src/components/inputs/InputPhone/InputPhone.vue +36 -3
  19. package/src/components/inputs/InputSecure/InputSecure.vue +27 -3
  20. package/src/components/pickers/CollectionPicker/CollectionPicker.vue +31 -9
  21. package/src/components/pickers/CollectionPicker/README.md +50 -0
  22. package/src/components/pickers/ColorPicker/ColorPicker.vue +27 -4
  23. package/src/components/pickers/ColorPicker/README.md +50 -0
  24. package/src/components/pickers/IconPicker/IconPicker.vue +53 -26
  25. package/src/components/pickers/IconPicker/README.md +43 -0
  26. package/src/skills/README.md +108 -0
  27. package/src/skills/umbra.ui-colors-application/SKILL.md +102 -0
  28. package/src/skills/umbra.ui-colors-application/reference.md +178 -0
  29. package/src/skills/umbra.ui-control-button/SKILL.md +34 -0
  30. package/src/skills/umbra.ui-control-checkbox/SKILL.md +34 -0
  31. package/src/skills/umbra.ui-control-dropdown/SKILL.md +34 -0
  32. package/src/skills/umbra.ui-control-icon-button/SKILL.md +33 -0
  33. package/src/skills/umbra.ui-control-inline-dropdown/SKILL.md +32 -0
  34. package/src/skills/umbra.ui-control-radio/SKILL.md +33 -0
  35. package/src/skills/umbra.ui-control-range-slider/SKILL.md +33 -0
  36. package/src/skills/umbra.ui-control-segmented-control/SKILL.md +33 -0
  37. package/src/skills/umbra.ui-control-slider/SKILL.md +33 -0
  38. package/src/skills/umbra.ui-control-stepper/SKILL.md +33 -0
  39. package/src/skills/umbra.ui-control-switch/SKILL.md +33 -0
  40. package/src/skills/umbra.ui-dialog-alert/SKILL.md +34 -0
  41. package/src/skills/umbra.ui-dialog-toast/SKILL.md +35 -0
  42. package/src/skills/umbra.ui-indicator-progress-bar/SKILL.md +33 -0
  43. package/src/skills/umbra.ui-indicator-tooltip/SKILL.md +33 -0
  44. package/src/skills/umbra.ui-input-card/SKILL.md +42 -0
  45. package/src/skills/umbra.ui-input-card/reference.md +36 -0
  46. package/src/skills/umbra.ui-input-crypto-address/SKILL.md +40 -0
  47. package/src/skills/umbra.ui-input-crypto-address/reference.md +40 -0
  48. package/src/skills/umbra.ui-input-email/SKILL.md +45 -0
  49. package/src/skills/umbra.ui-input-number/SKILL.md +39 -0
  50. package/src/skills/umbra.ui-input-otp/SKILL.md +44 -0
  51. package/src/skills/umbra.ui-input-phone/SKILL.md +45 -0
  52. package/src/skills/umbra.ui-input-phone/reference.md +35 -0
  53. package/src/skills/umbra.ui-input-search/SKILL.md +43 -0
  54. package/src/skills/umbra.ui-input-search/reference.md +45 -0
  55. package/src/skills/umbra.ui-input-secure/SKILL.md +43 -0
  56. package/src/skills/umbra.ui-input-secure/reference.md +44 -0
  57. package/src/skills/umbra.ui-input-string-capture/SKILL.md +41 -0
  58. package/src/skills/umbra.ui-input-tags/SKILL.md +46 -0
  59. package/src/skills/umbra.ui-input-tags/reference.md +44 -0
  60. package/src/skills/umbra.ui-input-text/SKILL.md +46 -0
  61. package/src/skills/umbra.ui-menu-action-menu/SKILL.md +34 -0
  62. package/src/skills/umbra.ui-model-popover/SKILL.md +35 -0
  63. package/src/skills/umbra.ui-model-sheet/SKILL.md +35 -0
  64. package/src/skills/umbra.ui-model-sidebar/SKILL.md +35 -0
  65. package/src/skills/umbra.ui-picker-collection/SKILL.md +36 -0
  66. package/src/skills/umbra.ui-picker-collection/reference.md +34 -0
  67. package/src/skills/umbra.ui-picker-color/SKILL.md +36 -0
  68. package/src/skills/umbra.ui-picker-color/reference.md +34 -0
  69. package/src/skills/umbra.ui-picker-date/SKILL.md +36 -0
  70. package/src/skills/umbra.ui-picker-date/reference.md +26 -0
  71. package/src/skills/umbra.ui-picker-file/SKILL.md +42 -0
  72. package/src/skills/umbra.ui-picker-file/reference.md +39 -0
  73. package/src/skills/umbra.ui-picker-icon/SKILL.md +36 -0
  74. package/src/skills/umbra.ui-picker-icon/reference.md +28 -0
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch, onBeforeUnmount, nextTick, computed } from "vue";
2
+ import {
3
+ ref,
4
+ watch,
5
+ onBeforeUnmount,
6
+ nextTick,
7
+ computed,
8
+ useSlots,
9
+ } from "vue";
3
10
  import { colors, colorPickerColors } from "./colors";
4
11
  import type { Color } from "./colors";
5
12
  import {
@@ -17,6 +24,7 @@ export interface Props {
17
24
  color: Color;
18
25
  pickerOffsetX: number;
19
26
  preventPopup: boolean;
27
+ label?: boolean;
20
28
  // Dot styling props
21
29
  dotSize?: number | string;
22
30
  dotRadius?: number | string;
@@ -31,6 +39,7 @@ const props = withDefaults(defineProps<Props>(), {
31
39
  color: () => colorPickerColors.gray700,
32
40
  pickerOffsetX: 0,
33
41
  preventPopup: false,
42
+ label: true,
34
43
  dotSize: "1.25rem",
35
44
  dotRadius: "999px",
36
45
  dotBorderWidth: 1,
@@ -38,6 +47,8 @@ const props = withDefaults(defineProps<Props>(), {
38
47
  dotBorderColorActive: "var(--colorpicker-dot-border-color-active)",
39
48
  showColorInfo: true,
40
49
  });
50
+ const slots = useSlots();
51
+ const hasCustomLabelSlot = computed(() => Boolean(slots.label));
41
52
 
42
53
  const emits = defineEmits(["update:color"]);
43
54
 
@@ -173,12 +184,16 @@ onBeforeUnmount(() => {
173
184
  <div
174
185
  :class="[
175
186
  $style.button,
176
- showPopover ? $style.button_selected : $style.button_normal,
187
+ hasCustomLabelSlot ? $style.button_unstyled : null,
188
+ !hasCustomLabelSlot &&
189
+ (showPopover ? $style.button_selected : $style.button_normal),
177
190
  ]"
178
191
  ref="button"
179
192
  @click="togglePopover"
180
193
  >
181
- <div :style="dotStyles" :class="$style.dot"></div>
194
+ <slot v-if="label" name="label">
195
+ <div :style="dotStyles" :class="$style.dot"></div>
196
+ </slot>
182
197
  </div>
183
198
 
184
199
  <!-- Teleport the overlay and picker to body -->
@@ -253,11 +268,19 @@ onBeforeUnmount(() => {
253
268
  transition: padding-left 0.3s, padding-right 0.3s, background-color 0.3s,
254
269
  box-shadow 0.3s;
255
270
  cursor: pointer;
271
+ }
272
+
273
+ .button_unstyled {
274
+ padding: 0;
275
+ transition: none;
276
+ }
277
+
278
+ .button_normal {
256
279
  background-color: var(--picker-button-bg);
257
280
  border: var(--picker-button-border);
258
281
  }
259
282
 
260
- .button:hover {
283
+ .button_normal:hover {
261
284
  padding-left: 0.588rem;
262
285
  padding-right: 0.588rem;
263
286
  box-shadow: 0px 1px 0px 0px var(--picker-button-hover-shadow),
@@ -92,6 +92,55 @@ const handleColorChange = (color: Color) => {
92
92
  </style>
93
93
  ```
94
94
 
95
+ ## Custom Label Slot
96
+
97
+ When you provide a custom `label` slot, the default dot/button visuals are removed
98
+ so you can fully control the trigger presentation and animation.
99
+
100
+ ```vue
101
+ <script setup lang="ts">
102
+ import { ref } from "vue";
103
+ import { ColorPicker, colorPickerColors } from "@umbra-ui/core";
104
+ import type { Color } from "@umbra-ui/core";
105
+
106
+ const selectedColor = ref<Color>(colorPickerColors.blue500);
107
+ </script>
108
+
109
+ <template>
110
+ <ColorPicker v-model:color="selectedColor">
111
+ <template #label>
112
+ <span class="color-chip">
113
+ <span class="swatch" :style="{ backgroundColor: selectedColor.hex }" />
114
+ {{ selectedColor.hex }}
115
+ </span>
116
+ </template>
117
+ </ColorPicker>
118
+ </template>
119
+
120
+ <style module>
121
+ .color-chip {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ gap: 0.353rem;
125
+ padding: 0.294rem 0.471rem;
126
+ border-radius: 0.471rem;
127
+ border: 1px solid var(--border-soft, #d1d5db);
128
+ }
129
+ .swatch {
130
+ width: 0.706rem;
131
+ height: 0.706rem;
132
+ border-radius: 999px;
133
+ border: 1px solid #ffffff;
134
+ }
135
+ </style>
136
+ ```
137
+
138
+ ### Hiding the Label
139
+
140
+ ```vue
141
+ <ColorPicker v-model:color="selectedColor" :label="false" />
142
+ ```
143
+
95
144
  ## Props
96
145
 
97
146
  | Prop Name | Type | Required | Default | Description |
@@ -99,6 +148,7 @@ const handleColorChange = (color: Color) => {
99
148
  | `color` | `Color` | Yes | `colorPickerColors.gray700` | Currently selected color |
100
149
  | `pickerOffsetX` | `number` | No | `0` | Horizontal offset for picker positioning |
101
150
  | `preventPopup` | `boolean` | No | `false` | Whether to prevent the popup from opening |
151
+ | `label` | `boolean` | No | `true` | Show or hide the trigger label/slot |
102
152
  | `dotSize` | `number \| string` | No | `"1.25rem"` | Size of the color dot |
103
153
  | `dotRadius` | `number \| string` | No | `"999px"` | Border radius of the color dot |
104
154
  | `dotBorderWidth` | `number` | No | `1` | Border width of the color dot |
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch, onBeforeUnmount, nextTick, computed } from "vue";
2
+ import {
3
+ ref,
4
+ watch,
5
+ onBeforeUnmount,
6
+ nextTick,
7
+ computed,
8
+ useSlots,
9
+ } from "vue";
3
10
  import {
4
11
  offset,
5
12
  flip,
@@ -16,6 +23,7 @@ export interface Props {
16
23
  icon: string;
17
24
  pickerOffsetX: number;
18
25
  preventPopup: boolean;
26
+ label?: boolean;
19
27
  iconList?: IconKey[];
20
28
  iconSize?: number;
21
29
  }
@@ -23,9 +31,12 @@ const props = withDefaults(defineProps<Props>(), {
23
31
  icon: "",
24
32
  pickerOffsetX: 0,
25
33
  preventPopup: false,
34
+ label: true,
26
35
  iconList: undefined,
27
36
  iconSize: 18,
28
37
  });
38
+ const slots = useSlots();
39
+ const hasCustomLabelSlot = computed(() => Boolean(slots.label));
29
40
 
30
41
  const emits = defineEmits(["update:icon"]);
31
42
 
@@ -139,30 +150,37 @@ onBeforeUnmount(() => {
139
150
  <template>
140
151
  <div :class="$style.container" ref="container">
141
152
  <div
142
- :class="[$style.button, { [$style.active]: showPopover }]"
153
+ :class="[
154
+ $style.button,
155
+ hasCustomLabelSlot ? $style.button_unstyled : null,
156
+ !hasCustomLabelSlot &&
157
+ (showPopover ? $style.button_selected : $style.button_normal),
158
+ ]"
143
159
  @click="togglePopover"
144
160
  ref="button"
145
161
  >
146
- <component
147
- v-if="selectedIcon && icons[selectedIcon as IconKey]"
148
- :is="icons[selectedIcon as IconKey]"
149
- :size="iconSize"
150
- :color="selectedColor"
151
- />
152
- <component
153
- v-else
154
- :is="icons.folder"
155
- :size="iconSize"
156
- :color="selectedColor"
157
- />
158
- <ChevronDownIcon
159
- :size="selectedSize - 4"
160
- :class="$style.chevron"
161
- :color="selectedColor"
162
- :style="{
163
- transform: `rotate(${showPopover ? 0 : -90}deg)`,
164
- }"
165
- />
162
+ <slot v-if="label" name="label">
163
+ <component
164
+ v-if="selectedIcon && icons[selectedIcon as IconKey]"
165
+ :is="icons[selectedIcon as IconKey]"
166
+ :size="iconSize"
167
+ :color="selectedColor"
168
+ />
169
+ <component
170
+ v-else
171
+ :is="icons.folder"
172
+ :size="iconSize"
173
+ :color="selectedColor"
174
+ />
175
+ <ChevronDownIcon
176
+ :size="selectedSize - 4"
177
+ :class="$style.chevron"
178
+ :color="selectedColor"
179
+ :style="{
180
+ transform: `rotate(${showPopover ? 0 : -90}deg)`,
181
+ }"
182
+ />
183
+ </slot>
166
184
  </div>
167
185
 
168
186
  <!-- Teleport the overlay and picker to body -->
@@ -229,12 +247,21 @@ onBeforeUnmount(() => {
229
247
  cursor: pointer;
230
248
  transition: background-color 0.3s ease, box-shadow 0.3s ease,
231
249
  padding 0.3s ease, gap 0.3s ease;
250
+ }
251
+
252
+ .button_unstyled {
253
+ padding: 0;
254
+ gap: 0;
255
+ transition: none;
256
+ }
257
+
258
+ .button_normal {
232
259
  background-color: var(--picker-button-bg);
233
260
  border: var(--picker-button-border);
234
261
  }
235
262
 
236
- .button:hover,
237
- .button.active {
263
+ .button_normal:hover,
264
+ .button_selected {
238
265
  background-color: var(--picker-button-hover-bg);
239
266
  box-shadow: 0px 1px 0px 0px var(--picker-button-hover-shadow),
240
267
  inset 0px 1px 0px 0px var(--picker-button-hover-inset-shadow);
@@ -248,8 +275,8 @@ onBeforeUnmount(() => {
248
275
  opacity: 0;
249
276
  }
250
277
 
251
- .button:hover .chevron,
252
- .button.active .chevron {
278
+ .button_normal:hover .chevron,
279
+ .button_selected .chevron {
253
280
  opacity: 1;
254
281
  }
255
282
 
@@ -64,6 +64,48 @@ const handleIconChange = (icon: IconKey) => {
64
64
  </style>
65
65
  ```
66
66
 
67
+ ## Custom Label Slot
68
+
69
+ When you provide a custom `label` slot, the default trigger icon/chevron visuals
70
+ are removed so you can fully control the trigger UI and animation.
71
+
72
+ ```vue
73
+ <script setup lang="ts">
74
+ import { computed, ref } from "vue";
75
+ import { IconPicker } from "@umbra-ui/core";
76
+ import type { IconKey } from "@umbra-ui/icons";
77
+
78
+ const selectedIcon = ref<IconKey>("home");
79
+ const triggerText = computed(() => `Icon: ${selectedIcon.value}`);
80
+ </script>
81
+
82
+ <template>
83
+ <IconPicker v-model:icon="selectedIcon">
84
+ <template #label>
85
+ <span class="icon-chip">{{ triggerText }}</span>
86
+ </template>
87
+ </IconPicker>
88
+ </template>
89
+
90
+ <style module>
91
+ .icon-chip {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ padding: 0.353rem 0.529rem;
95
+ border-radius: 0.471rem;
96
+ background: var(--background-subtle, #f3f4f6);
97
+ color: var(--text-primary, #111827);
98
+ border: 1px solid var(--border-soft, #d1d5db);
99
+ }
100
+ </style>
101
+ ```
102
+
103
+ ### Hiding the Label
104
+
105
+ ```vue
106
+ <IconPicker v-model:icon="selectedIcon" :label="false" />
107
+ ```
108
+
67
109
  ## Props
68
110
 
69
111
  | Prop Name | Type | Required | Default | Description |
@@ -71,6 +113,7 @@ const handleIconChange = (icon: IconKey) => {
71
113
  | `icon` | `string` | Yes | `""` | Currently selected icon key |
72
114
  | `pickerOffsetX` | `number` | No | `0` | Horizontal offset for picker positioning |
73
115
  | `preventPopup` | `boolean` | No | `false` | Whether to prevent the popup from opening |
116
+ | `label` | `boolean` | No | `true` | Show or hide the trigger label/slot |
74
117
  | `iconList` | `IconKey[]` | No | `undefined` | Custom list of available icons |
75
118
  | `iconSize` | `number` | No | `18` | Size of the displayed icon |
76
119
 
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@umbra.ui/core",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "Core components for Umbra UI",
5
+ "homepage": "https://www.umbraui.com/",
5
6
  "type": "module",
6
7
  "main": "dist/index.js",
7
8
  "types": "dist/index.d.ts",
@@ -35,9 +36,9 @@
35
36
  "@tiptap/markdown": "^3.19.0",
36
37
  "@tiptap/starter-kit": "^3.19.0",
37
38
  "@tiptap/vue-3": "^3.19.0",
38
- "@umbra.ui/colors": "^0.2.0",
39
- "@umbra.ui/icons": "^0.2.0",
40
- "@umbra.ui/typography": "^0.3.0",
39
+ "@umbra.ui/colors": "^0.5.0",
40
+ "@umbra.ui/icons": "^0.5.0",
41
+ "@umbra.ui/typography": "^0.5.0",
41
42
  "autosize": "^6.0.1",
42
43
  "@types/autosize": "^4.0.3",
43
44
  "gsap": "^3.13.0",
@@ -39,7 +39,7 @@ const currentDigitCount = computed(() => {
39
39
 
40
40
  const maxAllowedDigits = computed(() => {
41
41
  if (cardInfo.value) {
42
- return Math.max(...cardInfo.value.lengths);
42
+ return cardInfo.value.format.reduce((sum, group) => sum + group, 0);
43
43
  }
44
44
  // Default max for unknown cards
45
45
  return 16;
@@ -48,12 +48,14 @@ const maxAllowedDigits = computed(() => {
48
48
  const internalValue = ref(props.value);
49
49
  const cursorPosition = ref<number | null>(null);
50
50
  const inputRef = ref<HTMLInputElement | null>(null);
51
+ const isFocused = ref(false);
52
+ const hasBlurred = ref(false);
51
53
 
52
54
  // Card type patterns
53
55
  const cardPatterns = {
54
56
  visa: {
55
57
  pattern: /^4/,
56
- lengths: [16, 19],
58
+ lengths: [16],
57
59
  format: [4, 4, 4, 4],
58
60
  cvvLength: 3,
59
61
  name: "Visa",
@@ -187,15 +189,38 @@ const isComplete = computed(() => {
187
189
  if (!cardInfo.value) return false;
188
190
  return cardInfo.value.lengths.includes(cleaned.length);
189
191
  });
192
+ const hasCardInput = computed(() => {
193
+ return internalValue.value.replace(/\D/g, "").length > 0;
194
+ });
190
195
 
191
- // Override state to show error when card is complete but invalid
196
+ // Override state to show error on blur when card input is invalid
192
197
  const effectiveState = computed(() => {
193
- if (isComplete.value && !isValid.value) {
198
+ if (props.state === "error") {
199
+ return "error";
200
+ }
201
+ if (props.state === "disabled" || props.state === "readonly") {
202
+ return props.state;
203
+ }
204
+ if (
205
+ !isFocused.value &&
206
+ hasBlurred.value &&
207
+ hasCardInput.value &&
208
+ !isValid.value
209
+ ) {
194
210
  return "error";
195
211
  }
196
212
  return props.state;
197
213
  });
198
214
 
215
+ const showInternalError = computed(() => {
216
+ return (
217
+ !isFocused.value &&
218
+ hasBlurred.value &&
219
+ hasCardInput.value &&
220
+ !isValid.value
221
+ );
222
+ });
223
+
199
224
  // Watch for changes to value prop
200
225
  watch(
201
226
  () => props.value,
@@ -244,12 +269,13 @@ const handleInput = (event: Event) => {
244
269
  // Format the number
245
270
  const { formatted, cursorPos } = formatCardNumber(digitsOnly);
246
271
  internalValue.value = formatted;
272
+ const normalizedDigits = formatted.replace(/\D/g, "");
247
273
 
248
274
  // Update cursor position
249
275
  cursorPosition.value = cursorPos;
250
276
 
251
277
  // Emit events
252
- emit("update:value", digitsOnly); // Raw digits
278
+ emit("update:value", normalizedDigits); // Raw digits
253
279
  emit("update:formatted", formatted); // Formatted display
254
280
  emit("update:cardType", cardInfo.value?.name || "");
255
281
  emit("update:valid", isValid.value && isComplete.value);
@@ -275,7 +301,7 @@ const handlePaste = (event: ClipboardEvent) => {
275
301
  // Detect card type first to know the limit
276
302
  const cardType = detectCardType(cleanedNumber);
277
303
  const maxLength = cardType
278
- ? Math.max(...cardPatterns[cardType].lengths)
304
+ ? cardPatterns[cardType].format.reduce((sum, group) => sum + group, 0)
279
305
  : 16;
280
306
 
281
307
  // Enforce the limit
@@ -283,7 +309,7 @@ const handlePaste = (event: ClipboardEvent) => {
283
309
 
284
310
  const { formatted } = formatCardNumber(cleanedNumber);
285
311
  internalValue.value = formatted;
286
- emit("update:value", cleanedNumber);
312
+ emit("update:value", formatted.replace(/\D/g, ""));
287
313
  emit("update:formatted", formatted);
288
314
  emit("update:cardType", cardInfo.value?.name || "");
289
315
  emit("update:valid", isValid.value && isComplete.value);
@@ -323,6 +349,15 @@ const handleKeyDown = (event: KeyboardEvent) => {
323
349
  }
324
350
  };
325
351
 
352
+ const handleFocus = () => {
353
+ isFocused.value = true;
354
+ };
355
+
356
+ const handleBlur = () => {
357
+ isFocused.value = false;
358
+ hasBlurred.value = true;
359
+ };
360
+
326
361
  // SVG icons for card types
327
362
  const cardIcons = {
328
363
  visa: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><g class="nc-icon-wrapper"><rect x="2" y="7" width="28" height="18" rx="3" ry="3" fill="#1434cb" stroke-width="0"></rect><path d="m27,7H5c-1.657,0-3,1.343-3,3v12c0,1.657,1.343,3,3,3h22c1.657,0,3-1.343,3-3v-12c0-1.657-1.343-3-3-3Zm2,15c0,1.103-.897,2-2,2H5c-1.103,0-2-.897-2-2v-12c0-1.103.897-2,2-2h22c1.103,0,2,.897,2,2v12Z" stroke-width="0" opacity=".15"></path><path d="m27,8H5c-1.105,0-2,.895-2,2v1c0-1.105.895-2,2-2h22c1.105,0,2,.895,2,2v-1c0-1.105-.895-2-2-2Z" fill="#fff" opacity=".2" stroke-width="0"></path><path d="m13.392,12.624l-2.838,6.77h-1.851l-1.397-5.403c-.085-.332-.158-.454-.416-.595-.421-.229-1.117-.443-1.728-.576l.041-.196h2.98c.38,0,.721.253.808.69l.738,3.918,1.822-4.608h1.84Z" fill="#fff" stroke-width="0"></path><path d="m20.646,17.183c.008-1.787-2.47-1.886-2.453-2.684.005-.243.237-.501.743-.567.251-.032.943-.058,1.727.303l.307-1.436c-.421-.152-.964-.299-1.638-.299-1.732,0-2.95.92-2.959,2.238-.011.975.87,1.518,1.533,1.843.683.332.912.545.909.841-.005.454-.545.655-1.047.663-.881.014-1.392-.238-1.799-.428l-.318,1.484c.41.188,1.165.351,1.947.359,1.841,0,3.044-.909,3.05-2.317" fill="#fff" stroke-width="0"></path><path d="m25.423,12.624h-1.494c-.337,0-.62.195-.746.496l-2.628,6.274h1.839l.365-1.011h2.247l.212,1.011h1.62l-1.415-6.77Zm-2.16,4.372l.922-2.542.53,2.542h-1.452Z" fill="#fff" stroke-width="0"></path><path fill="#fff" stroke-width="0" d="M15.894 12.624L14.446 19.394 12.695 19.394 14.143 12.624 15.894 12.624z"></path></g></svg>`,
@@ -365,6 +400,8 @@ const getPlaceholder = computed(() => {
365
400
  @input="handleInput"
366
401
  @paste="handlePaste"
367
402
  @keydown="handleKeyDown"
403
+ @focus="handleFocus"
404
+ @blur="handleBlur"
368
405
  :maxlength="24"
369
406
  :disabled="state === 'disabled'"
370
407
  :readonly="state === 'readonly'"
@@ -377,7 +414,7 @@ const getPlaceholder = computed(() => {
377
414
  ></div>
378
415
  </transition>
379
416
  <transition name="fade">
380
- <div v-if="isComplete && !isValid" :class="$style.error_icon">
417
+ <div v-if="showInternalError" :class="$style.error_icon">
381
418
  <TriangleWarningIcon size="16" />
382
419
  </div>
383
420
  </transition>
@@ -385,7 +422,7 @@ const getPlaceholder = computed(() => {
385
422
  </div>
386
423
  <transition name="slide-fade">
387
424
  <p
388
- v-if="isComplete && !isValid"
425
+ v-if="showInternalError"
389
426
  :class="[$style.error_message, 'footnote']"
390
427
  >
391
428
  Please enter a valid card number
@@ -51,6 +51,7 @@ const emit = defineEmits<{
51
51
  const internalValue = ref(props.value);
52
52
  const inputRef = ref<HTMLInputElement | null>(null);
53
53
  const isFocused = ref(false);
54
+ const hasBlurred = ref(false);
54
55
 
55
56
  watch(
56
57
  () => props.value,
@@ -95,11 +96,22 @@ const validationState = computed(() => {
95
96
  return validateValue(value) ? "valid" : "invalid";
96
97
  });
97
98
 
99
+ const showInternalInvalid = computed(() => {
100
+ return (
101
+ !isFocused.value &&
102
+ hasBlurred.value &&
103
+ validationState.value === "invalid"
104
+ );
105
+ });
106
+
98
107
  const computedState = computed(() => {
108
+ if (props.state === "error") {
109
+ return "error";
110
+ }
99
111
  if (props.state === "disabled" || props.state === "readonly") {
100
112
  return props.state;
101
113
  }
102
- if (validationState.value === "invalid") return "error";
114
+ if (showInternalInvalid.value) return "error";
103
115
  return props.state;
104
116
  });
105
117
 
@@ -113,7 +125,7 @@ const iconColor = computed(() => {
113
125
  if (validationState.value === "valid") {
114
126
  return "black";
115
127
  }
116
- if (validationState.value === "invalid" || computedState.value === "error") {
128
+ if (computedState.value === "error") {
117
129
  return "var(--input-error-text)";
118
130
  }
119
131
  switch (computedState.value) {
@@ -140,9 +152,16 @@ const handleFocus = () => {
140
152
 
141
153
  const handleBlur = () => {
142
154
  isFocused.value = false;
155
+ hasBlurred.value = true;
143
156
  closeList();
144
157
  };
145
158
 
159
+ const showStatusMessage = computed(() => {
160
+ if (validationState.value === "idle") return false;
161
+ if (validationState.value === "valid") return true;
162
+ return showInternalInvalid.value;
163
+ });
164
+
146
165
  const focusInput = () => {
147
166
  if (
148
167
  computedState.value === "disabled" ||
@@ -265,7 +284,7 @@ onMounted(() => {
265
284
  $style.button,
266
285
  !itemsInDrawer ? $style.button_drawer_open : '',
267
286
  validationState === 'valid' ? $style.button_valid : '',
268
- validationState === 'invalid' ? $style.button_error : '',
287
+ computedState === 'error' ? $style.button_error : '',
269
288
  isFocused && !hasKnown ? $style.button_focus : '',
270
289
  ]"
271
290
  @click="focusInput"
@@ -311,7 +330,7 @@ onMounted(() => {
311
330
  ]"
312
331
  ></div>
313
332
  <p
314
- v-if="validationState !== 'idle'"
333
+ v-if="showStatusMessage"
315
334
  :class="[
316
335
  $style.status_message,
317
336
  validationState === 'invalid'
@@ -35,7 +35,8 @@ const internalValue = ref(props.value);
35
35
  const showSuggestionsList = ref(false);
36
36
  const selectedSuggestionIndex = ref(-1);
37
37
  const inputRef = ref<HTMLInputElement | null>(null);
38
- const hasInteracted = ref(false);
38
+ const hasBlurred = ref(false);
39
+ const isFocused = ref(false);
39
40
 
40
41
  // Common email domains for suggestions
41
42
  const commonDomains = [
@@ -193,15 +194,21 @@ const hasTypo = computed(() => {
193
194
 
194
195
  const showError = computed(() => {
195
196
  return (
196
- hasInteracted.value &&
197
+ !isFocused.value &&
198
+ hasBlurred.value &&
197
199
  !isValid.value &&
198
- internalValue.value.length > 0 &&
199
- (!props.validateOnType || internalValue.value.includes("@"))
200
+ internalValue.value.length > 0
200
201
  );
201
202
  });
202
203
 
203
204
  // Override state to show error when email is invalid
204
205
  const effectiveState = computed(() => {
206
+ if (props.state === "error") {
207
+ return "error";
208
+ }
209
+ if (props.state === "disabled" || props.state === "readonly") {
210
+ return props.state;
211
+ }
205
212
  if (showError.value) {
206
213
  return "error";
207
214
  }
@@ -245,7 +252,6 @@ const handleInput = (event: Event) => {
245
252
  }
246
253
 
247
254
  internalValue.value = value;
248
- hasInteracted.value = true;
249
255
 
250
256
  // Show suggestions when @ is typed
251
257
  if (value.includes("@") && props.showSuggestions) {
@@ -264,7 +270,8 @@ const handleInput = (event: Event) => {
264
270
 
265
271
  // Handle blur
266
272
  const handleBlur = () => {
267
- hasInteracted.value = true;
273
+ isFocused.value = false;
274
+ hasBlurred.value = true;
268
275
  // Delay hiding suggestions to allow click
269
276
  setTimeout(() => {
270
277
  showSuggestionsList.value = false;
@@ -273,6 +280,7 @@ const handleBlur = () => {
273
280
 
274
281
  // Handle focus
275
282
  const handleFocus = () => {
283
+ isFocused.value = true;
276
284
  if (
277
285
  internalValue.value.includes("@") &&
278
286
  generateSuggestions.value.length > 0
@@ -350,7 +358,6 @@ const handlePaste = (event: ClipboardEvent) => {
350
358
  }
351
359
 
352
360
  internalValue.value = cleaned;
353
- hasInteracted.value = true;
354
361
 
355
362
  emit("update:value", cleaned);
356
363
  emit("update:valid", isValidEmail(cleaned));