@umbra.ui/core 0.4.5 → 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 (76) 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/DatePicker/DatePicker.vue +6 -0
  12. package/dist/components/pickers/IconPicker/IconPicker.vue +53 -26
  13. package/dist/components/pickers/IconPicker/README.md +43 -0
  14. package/package.json +5 -4
  15. package/src/components/inputs/InputCard/InputCard.vue +46 -9
  16. package/src/components/inputs/InputCryptoAddress/InputCryptoAddress.vue +23 -4
  17. package/src/components/inputs/InputEmail/InputEmail.vue +14 -7
  18. package/src/components/inputs/InputNumber/InputNumber.vue +36 -3
  19. package/src/components/inputs/InputPhone/InputPhone.vue +36 -3
  20. package/src/components/inputs/InputSecure/InputSecure.vue +27 -3
  21. package/src/components/pickers/CollectionPicker/CollectionPicker.vue +31 -9
  22. package/src/components/pickers/CollectionPicker/README.md +50 -0
  23. package/src/components/pickers/ColorPicker/ColorPicker.vue +27 -4
  24. package/src/components/pickers/ColorPicker/README.md +50 -0
  25. package/src/components/pickers/DatePicker/DatePicker.vue +6 -0
  26. package/src/components/pickers/IconPicker/IconPicker.vue +53 -26
  27. package/src/components/pickers/IconPicker/README.md +43 -0
  28. package/src/skills/README.md +108 -0
  29. package/src/skills/umbra.ui-colors-application/SKILL.md +102 -0
  30. package/src/skills/umbra.ui-colors-application/reference.md +178 -0
  31. package/src/skills/umbra.ui-control-button/SKILL.md +34 -0
  32. package/src/skills/umbra.ui-control-checkbox/SKILL.md +34 -0
  33. package/src/skills/umbra.ui-control-dropdown/SKILL.md +34 -0
  34. package/src/skills/umbra.ui-control-icon-button/SKILL.md +33 -0
  35. package/src/skills/umbra.ui-control-inline-dropdown/SKILL.md +32 -0
  36. package/src/skills/umbra.ui-control-radio/SKILL.md +33 -0
  37. package/src/skills/umbra.ui-control-range-slider/SKILL.md +33 -0
  38. package/src/skills/umbra.ui-control-segmented-control/SKILL.md +33 -0
  39. package/src/skills/umbra.ui-control-slider/SKILL.md +33 -0
  40. package/src/skills/umbra.ui-control-stepper/SKILL.md +33 -0
  41. package/src/skills/umbra.ui-control-switch/SKILL.md +33 -0
  42. package/src/skills/umbra.ui-dialog-alert/SKILL.md +34 -0
  43. package/src/skills/umbra.ui-dialog-toast/SKILL.md +35 -0
  44. package/src/skills/umbra.ui-indicator-progress-bar/SKILL.md +33 -0
  45. package/src/skills/umbra.ui-indicator-tooltip/SKILL.md +33 -0
  46. package/src/skills/umbra.ui-input-card/SKILL.md +42 -0
  47. package/src/skills/umbra.ui-input-card/reference.md +36 -0
  48. package/src/skills/umbra.ui-input-crypto-address/SKILL.md +40 -0
  49. package/src/skills/umbra.ui-input-crypto-address/reference.md +40 -0
  50. package/src/skills/umbra.ui-input-email/SKILL.md +45 -0
  51. package/src/skills/umbra.ui-input-number/SKILL.md +39 -0
  52. package/src/skills/umbra.ui-input-otp/SKILL.md +44 -0
  53. package/src/skills/umbra.ui-input-phone/SKILL.md +45 -0
  54. package/src/skills/umbra.ui-input-phone/reference.md +35 -0
  55. package/src/skills/umbra.ui-input-search/SKILL.md +43 -0
  56. package/src/skills/umbra.ui-input-search/reference.md +45 -0
  57. package/src/skills/umbra.ui-input-secure/SKILL.md +43 -0
  58. package/src/skills/umbra.ui-input-secure/reference.md +44 -0
  59. package/src/skills/umbra.ui-input-string-capture/SKILL.md +41 -0
  60. package/src/skills/umbra.ui-input-tags/SKILL.md +46 -0
  61. package/src/skills/umbra.ui-input-tags/reference.md +44 -0
  62. package/src/skills/umbra.ui-input-text/SKILL.md +46 -0
  63. package/src/skills/umbra.ui-menu-action-menu/SKILL.md +34 -0
  64. package/src/skills/umbra.ui-model-popover/SKILL.md +35 -0
  65. package/src/skills/umbra.ui-model-sheet/SKILL.md +35 -0
  66. package/src/skills/umbra.ui-model-sidebar/SKILL.md +35 -0
  67. package/src/skills/umbra.ui-picker-collection/SKILL.md +36 -0
  68. package/src/skills/umbra.ui-picker-collection/reference.md +34 -0
  69. package/src/skills/umbra.ui-picker-color/SKILL.md +36 -0
  70. package/src/skills/umbra.ui-picker-color/reference.md +34 -0
  71. package/src/skills/umbra.ui-picker-date/SKILL.md +36 -0
  72. package/src/skills/umbra.ui-picker-date/reference.md +26 -0
  73. package/src/skills/umbra.ui-picker-file/SKILL.md +42 -0
  74. package/src/skills/umbra.ui-picker-file/reference.md +39 -0
  75. package/src/skills/umbra.ui-picker-icon/SKILL.md +36 -0
  76. package/src/skills/umbra.ui-picker-icon/reference.md +28 -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));
@@ -28,13 +28,35 @@ const emit = defineEmits<{
28
28
  const internalValue = ref(props.value);
29
29
  const isOutOfRange = ref(false);
30
30
  const rangeErrorMessage = ref("");
31
+ const isFocused = ref(false);
32
+ const hasBlurred = ref(false);
31
33
 
32
34
  // Computed property to determine the actual state
33
35
  const actualState = computed(() => {
34
- if (isOutOfRange.value) return "error";
36
+ if (props.state === "error") return "error";
37
+ if (props.state === "disabled" || props.state === "readonly") {
38
+ return props.state;
39
+ }
40
+ if (
41
+ !isFocused.value &&
42
+ hasBlurred.value &&
43
+ isOutOfRange.value &&
44
+ internalValue.value !== undefined
45
+ ) {
46
+ return "error";
47
+ }
35
48
  return props.state;
36
49
  });
37
50
 
51
+ const showInternalRangeError = computed(() => {
52
+ return (
53
+ !isFocused.value &&
54
+ hasBlurred.value &&
55
+ isOutOfRange.value &&
56
+ internalValue.value !== undefined
57
+ );
58
+ });
59
+
38
60
  // Get the appropriate text color for icons based on state
39
61
  const getIconColor = () => {
40
62
  return "var(--input-text-filled)";
@@ -76,6 +98,15 @@ const handleInput = (event: Event) => {
76
98
  emit("update:value", numValue || 0);
77
99
  };
78
100
 
101
+ const handleFocus = () => {
102
+ isFocused.value = true;
103
+ };
104
+
105
+ const handleBlur = () => {
106
+ isFocused.value = false;
107
+ hasBlurred.value = true;
108
+ };
109
+
79
110
  const getPlaceholder = computed(() => {
80
111
  if (props.state === "readonly") {
81
112
  return "Field Cannot Be Edited";
@@ -95,11 +126,13 @@ const getPlaceholder = computed(() => {
95
126
  :min="min"
96
127
  :max="max"
97
128
  @input="handleInput"
129
+ @focus="handleFocus"
130
+ @blur="handleBlur"
98
131
  :disabled="state === 'disabled'"
99
132
  :readonly="state === 'readonly'"
100
133
  />
101
134
  <RippleAnimOutlineIcon
102
- v-if="state === 'active' && !isOutOfRange"
135
+ v-if="state === 'active' && !showInternalRangeError"
103
136
  :class="$style.icon"
104
137
  :size="16"
105
138
  :color="getIconColor()"
@@ -115,7 +148,7 @@ const getPlaceholder = computed(() => {
115
148
  >
116
149
  </div>
117
150
  <p
118
- v-if="actualState === 'error'"
151
+ v-if="props.state === 'error' || showInternalRangeError"
119
152
  :class="[$style.error_message, 'footnote']"
120
153
  >
121
154
  {{ rangeErrorMessage || "Information About The Error" }}
@@ -34,6 +34,8 @@ const emit = defineEmits<{
34
34
  const internalValue = ref(props.value);
35
35
  const cursorPosition = ref<number | null>(null);
36
36
  const inputRef = ref<HTMLInputElement | null>(null);
37
+ const isFocused = ref(false);
38
+ const hasBlurred = ref(false);
37
39
 
38
40
  // Add this computed property to track current digit count
39
41
  const currentDigitCount = computed(() => {
@@ -238,12 +240,32 @@ const currentPlaceholder = computed(() => {
238
240
 
239
241
  // Override state to show error when phone number is invalid
240
242
  const effectiveState = computed(() => {
241
- if (!isValid.value && internalValue.value.length > 0) {
243
+ if (props.state === "error") {
244
+ return "error";
245
+ }
246
+ if (props.state === "disabled" || props.state === "readonly") {
247
+ return props.state;
248
+ }
249
+ if (
250
+ !isFocused.value &&
251
+ hasBlurred.value &&
252
+ !isValid.value &&
253
+ internalValue.value.length > 0
254
+ ) {
242
255
  return "error";
243
256
  }
244
257
  return props.state;
245
258
  });
246
259
 
260
+ const showInternalError = computed(() => {
261
+ return (
262
+ !isFocused.value &&
263
+ hasBlurred.value &&
264
+ !isValid.value &&
265
+ internalValue.value.length > 0
266
+ );
267
+ });
268
+
247
269
  // Get the appropriate text color for icons based on state
248
270
  const getIconColor = () => {
249
271
  return "var(--input-text-filled)";
@@ -424,6 +446,15 @@ const getPlaceholder = computed(() => {
424
446
  }
425
447
  return currentPlaceholder.value;
426
448
  });
449
+
450
+ const handleFocus = () => {
451
+ isFocused.value = true;
452
+ };
453
+
454
+ const handleBlur = () => {
455
+ isFocused.value = false;
456
+ hasBlurred.value = true;
457
+ };
427
458
  </script>
428
459
 
429
460
  <template>
@@ -439,6 +470,8 @@ const getPlaceholder = computed(() => {
439
470
  @input="handleInput"
440
471
  @paste="handlePaste"
441
472
  @keydown="handleKeyDown"
473
+ @focus="handleFocus"
474
+ @blur="handleBlur"
442
475
  :maxlength="20"
443
476
  :disabled="state === 'disabled'"
444
477
  :readonly="state === 'readonly'"
@@ -446,7 +479,7 @@ const getPlaceholder = computed(() => {
446
479
  <PhoneIcon v-if="!internalValue" :size="16" />
447
480
  <transition name="fade">
448
481
  <div
449
- v-if="!isValid && internalValue.length > 0"
482
+ v-if="showInternalError"
450
483
  :class="$style.error_icon"
451
484
  >
452
485
  <TriangleWarningIcon size="16" />
@@ -455,7 +488,7 @@ const getPlaceholder = computed(() => {
455
488
  </div>
456
489
  <transition name="slide-fade">
457
490
  <p
458
- v-if="!isValid && internalValue.length > 0"
491
+ v-if="showInternalError"
459
492
  :class="[$style.error_message, 'footnote']"
460
493
  >
461
494
  Please enter a valid phone number
@@ -77,6 +77,7 @@ const showRequirementsUI = computed(() => {
77
77
  const showInput = ref<boolean>(false);
78
78
  const internalValue = ref(props.value);
79
79
  const isFocused = ref(false);
80
+ const hasBlurred = ref(false);
80
81
  const inputRef = ref<HTMLInputElement | null>(null);
81
82
 
82
83
  // Common weak passwords to check against
@@ -210,12 +211,32 @@ const strengthIndicator = computed(() => {
210
211
 
211
212
  // Override state to show error when password is invalid
212
213
  const effectiveState = computed(() => {
213
- if (internalValue.value && !isValid.value) {
214
+ if (props.state === "error") {
215
+ return "error";
216
+ }
217
+ if (props.state === "disabled" || props.state === "readonly") {
218
+ return props.state;
219
+ }
220
+ if (
221
+ !isFocused.value &&
222
+ hasBlurred.value &&
223
+ internalValue.value &&
224
+ !isValid.value
225
+ ) {
214
226
  return "error";
215
227
  }
216
228
  return props.state;
217
229
  });
218
230
 
231
+ const showInternalError = computed(() => {
232
+ return (
233
+ !isFocused.value &&
234
+ hasBlurred.value &&
235
+ !!internalValue.value &&
236
+ !isValid.value
237
+ );
238
+ });
239
+
219
240
  // Get the appropriate text color for icons based on state
220
241
  const getIconColor = () => {
221
242
  return "var(--input-text-filled)";
@@ -299,6 +320,7 @@ const handleFocus = () => {
299
320
  };
300
321
 
301
322
  const handleBlur = () => {
323
+ hasBlurred.value = true;
302
324
  // Delay to allow clicks on requirements
303
325
  setTimeout(() => {
304
326
  isFocused.value = false;
@@ -411,7 +433,9 @@ const getPlaceholder = computed(() => {
411
433
  <!-- Requirements list -->
412
434
  <transition name="slide-fade">
413
435
  <div
414
- v-if="showRequirementsUI && (isFocused || !isValid) && internalValue"
436
+ v-if="
437
+ showRequirementsUI && (isFocused || showInternalError) && internalValue
438
+ "
415
439
  :class="$style.requirements"
416
440
  >
417
441
  <div
@@ -442,7 +466,7 @@ const getPlaceholder = computed(() => {
442
466
 
443
467
  <transition name="slide-fade">
444
468
  <p
445
- v-if="!isValid && isFocused && customValidationMessage"
469
+ v-if="showInternalError && customValidationMessage"
446
470
  :class="[$style.error_message, 'footnote']"
447
471
  >
448
472
  {{ customValidationMessage }}
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, nextTick, onUnmounted } from "vue";
2
+ import {
3
+ ref,
4
+ onMounted,
5
+ nextTick,
6
+ onUnmounted,
7
+ useSlots,
8
+ computed,
9
+ } from "vue";
3
10
  import { icons } from "@umbra.ui/icons";
4
11
  import { IconButton } from "@umbra.ui/core";
5
12
  import {
@@ -24,6 +31,7 @@ export interface Props {
24
31
  items: CollectionItem[];
25
32
  selectedItem: CollectionItem | null;
26
33
  loading?: boolean;
34
+ label?: boolean;
27
35
  // Customizable labels
28
36
  buttonLabel?: string;
29
37
  headerLabel?: string;
@@ -40,6 +48,7 @@ const props = withDefaults(defineProps<Props>(), {
40
48
  items: () => [],
41
49
  selectedItem: null,
42
50
  loading: false,
51
+ label: true,
43
52
  buttonLabel: "Select Item",
44
53
  headerLabel: "Choose an Item",
45
54
  newItemLabel: "New Item",
@@ -48,6 +57,8 @@ const props = withDefaults(defineProps<Props>(), {
48
57
  allowCreate: true,
49
58
  placeholder: "Enter name...",
50
59
  });
60
+ const slots = useSlots();
61
+ const hasCustomLabelSlot = computed(() => Boolean(slots.label));
51
62
 
52
63
  // - Emits (Pure Actions)
53
64
  const emits = defineEmits<{
@@ -190,17 +201,21 @@ const getIconComponent = (iconName: string) => {
190
201
  <div
191
202
  :class="[
192
203
  $style.button,
193
- showPopover ? $style.button_selected : $style.button_normal,
204
+ hasCustomLabelSlot ? $style.button_unstyled : null,
205
+ !hasCustomLabelSlot &&
206
+ (showPopover ? $style.button_selected : $style.button_normal),
194
207
  ]"
195
208
  @click="togglePopover"
196
209
  ref="button"
197
210
  >
198
- <p v-if="selectedItem" class="callout" :class="$style.button_label">
199
- {{ selectedItem.title }}
200
- </p>
201
- <p v-else class="callout" :class="$style.button_label">
202
- {{ buttonLabel }}
203
- </p>
211
+ <slot v-if="label" name="label">
212
+ <p v-if="selectedItem" class="callout" :class="$style.button_label">
213
+ {{ selectedItem.title }}
214
+ </p>
215
+ <p v-else class="callout" :class="$style.button_label">
216
+ {{ buttonLabel }}
217
+ </p>
218
+ </slot>
204
219
  </div>
205
220
 
206
221
  <!-- Teleport the overlay and picker to body -->
@@ -278,10 +293,17 @@ const getIconComponent = (iconName: string) => {
278
293
  transition: padding-left 0.3s, padding-right 0.3s, background-color 0.3s,
279
294
  box-shadow 0.3s;
280
295
  cursor: default;
296
+ }
297
+ .button_unstyled {
298
+ padding: 0;
299
+ gap: 0;
300
+ transition: none;
301
+ }
302
+ .button_normal {
281
303
  background-color: var(--picker-button-bg);
282
304
  border: var(--picker-button-border);
283
305
  }
284
- .button:hover {
306
+ .button_normal:hover {
285
307
  background-color: var(--picker-button-hover-bg);
286
308
  padding-left: 0.588rem;
287
309
  padding-right: 0.588rem;
@@ -82,6 +82,55 @@ const handleItemCreate = (title: string) => {
82
82
  </style>
83
83
  ```
84
84
 
85
+ ## Custom Label Slot
86
+
87
+ When you provide a custom `label` slot, the default trigger visuals are removed so
88
+ you can fully control the trigger content, styling, and animation behavior.
89
+
90
+ ```vue
91
+ <script setup lang="ts">
92
+ import { computed, ref } from "vue";
93
+ import { CollectionPicker } from "@umbra-ui/core";
94
+ import type { CollectionItem } from "@umbra-ui/core";
95
+
96
+ const items = ref<CollectionItem[]>([
97
+ { id: "1", title: "Roadmap" },
98
+ { id: "2", title: "Backlog" },
99
+ ]);
100
+ const selectedItem = ref<CollectionItem | null>(items.value[0]);
101
+ const triggerLabel = computed(() => selectedItem.value?.title ?? "Select collection");
102
+ </script>
103
+
104
+ <template>
105
+ <CollectionPicker v-model:selected-item="selectedItem" :items="items">
106
+ <template #label>
107
+ <span class="collection-chip">{{ triggerLabel }}</span>
108
+ </template>
109
+ </CollectionPicker>
110
+ </template>
111
+
112
+ <style module>
113
+ .collection-chip {
114
+ display: inline-flex;
115
+ align-items: center;
116
+ padding: 0.353rem 0.588rem;
117
+ border-radius: 999px;
118
+ background: var(--background-dark, #111827);
119
+ color: var(--text-light, #ffffff);
120
+ }
121
+ </style>
122
+ ```
123
+
124
+ ### Hiding the Label
125
+
126
+ ```vue
127
+ <CollectionPicker
128
+ v-model:selected-item="selectedItem"
129
+ :items="items"
130
+ :label="false"
131
+ />
132
+ ```
133
+
85
134
  ## Props
86
135
 
87
136
  | Prop Name | Type | Required | Default | Description |
@@ -89,6 +138,7 @@ const handleItemCreate = (title: string) => {
89
138
  | `items` | `CollectionItem[]` | Yes | `[]` | Array of items to display in the picker |
90
139
  | `selectedItem` | `CollectionItem \| null` | No | `null` | Currently selected item |
91
140
  | `loading` | `boolean` | No | `false` | Whether the picker is in loading state |
141
+ | `label` | `boolean` | No | `true` | Show or hide the trigger label/slot |
92
142
  | `buttonLabel` | `string` | No | `"Select Item"` | Label for the trigger button |
93
143
  | `headerLabel` | `string` | No | `"Choose an Item"` | Header text in the picker |
94
144
  | `newItemLabel` | `string` | No | `"New Item"` | Label for the create new item option |