v-uixy 1.3.0 → 1.3.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v-uixy",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "private": false,
5
5
  "author": {
6
6
  "name": "Alan Haber",
@@ -164,6 +164,10 @@
164
164
  @apply cursor-default border-gray-500 dark:border-gray-500;
165
165
  }
166
166
 
167
+ .slider-color-picker::-webkit-slider-thumb {
168
+ @apply relative z-10 size-4.5 cursor-pointer appearance-none rounded-full border-2 border-solid border-gray-900 bg-gray-100;
169
+ }
170
+
167
171
  .disable-transitions * {
168
172
  transition: none !important;
169
173
  }
@@ -5,9 +5,9 @@ export const badgeStyles = styles(
5
5
  {
6
6
  variant: {
7
7
  primary:
8
- "bg-gray-800 hover:bg-gray-700 dark:hover:bg-gray-200 dark:bg-gray-300 text-gray-100 dark:text-black dark:disabled:bg-gray-400 dark:disabled:text-gray-500 disabled:text-gray-600",
8
+ "bg-gray-800 hover:bg-gray-700 border border-transparent dark:hover:bg-gray-200 dark:bg-gray-300 text-gray-100 dark:text-black dark:disabled:bg-gray-400 dark:disabled:text-gray-500 disabled:text-gray-600",
9
9
  secondary:
10
- "bg-gray-300 hover:bg-gray-200 dark:bg-gray-900 dark:hover:bg-gray-700 dark:text-gray-100 text-black dark:disabled:bg-gray-600 dark:disabled:text-gray-700 disabled:text-gray-500",
10
+ "bg-gray-300 hover:bg-gray-200 border border-transparent dark:bg-gray-900 dark:hover:bg-gray-700 dark:text-gray-100 text-black dark:disabled:bg-gray-600 dark:disabled:text-gray-700 disabled:text-gray-500",
11
11
  tertiary:
12
12
  "bg-transparent border border-solid border-gray-300 dark:border-gray-800 hover:bg-gray-200 dark:hover:bg-gray-800 text-black dark:text-gray-100 dark:disabled:border-gray-900 dark:disabled:text-gray-500 dark:disabled:bg-gray-800 disabled:bg-gray-300 disabled:text-gray-500 disabled:border-gray-400",
13
13
  },
@@ -13,7 +13,7 @@ export const iconStyles = styles("h-3 w-3", {
13
13
  },
14
14
  });
15
15
 
16
- export const labelStyles = styles("text-sm", {
16
+ export const labelStyles = styles("text-xs", {
17
17
  disabled: {
18
18
  true: "text-gray-500 dark:text-gray-600",
19
19
  false: "",
@@ -0,0 +1,287 @@
1
+ <template>
2
+ <uixy-card class="p-2 flex flex-col gap-4">
3
+ <div
4
+ ref="svRef"
5
+ class="relative cursor-crosshair overflow-hidden rounded-1 w-64 h-48"
6
+ :style="svBackgroundStyle"
7
+ @mousedown="onSvMouseDown"
8
+ @touchstart.prevent="onSvTouchStart"
9
+ >
10
+ <div
11
+ class="pointer-events-none absolute -mt-2 -ml-2 size-4 rounded-full border-2 shadow-sm transition-colors border-white"
12
+ :style="svHandleStyle"
13
+ />
14
+ </div>
15
+ <div class="flex flex-col gap-4">
16
+ <input
17
+ type="range"
18
+ min="0"
19
+ max="360"
20
+ step="1"
21
+ v-model.number="hue"
22
+ :disabled="props.disabled"
23
+ class="h-2.5 w-full appearance-none rounded-full outline-none slider-color-picker border border-gray-300 dark:border-gray-900"
24
+ :style="hueSliderStyle"
25
+ />
26
+ <input
27
+ type="range"
28
+ min="0"
29
+ max="100"
30
+ step="1"
31
+ v-model.number="alpha"
32
+ :disabled="props.disabled"
33
+ class="h-2.5 w-full appearance-none rounded-full outline-none slider-color-picker border border-gray-300 dark:border-gray-900"
34
+ :style="alphaSliderStyle"
35
+ />
36
+ </div>
37
+ <div class="flex gap-2 items-center mt-2 justify-between">
38
+ <uixy-card class="p-1">
39
+ <div
40
+ class="w-12 h-4 rounded-1 shadow-sm"
41
+ :style="{ backgroundColor: model }"
42
+ ></div>
43
+ </uixy-card>
44
+ <p class="text-xs font-500">{{ model }}</p>
45
+ </div>
46
+ </uixy-card>
47
+ </template>
48
+
49
+ <script setup lang="ts">
50
+ import type { UixyColorPickerProps } from "./ColorPicker.types";
51
+ import { UixyCard } from "../Card";
52
+
53
+ const props = defineProps<UixyColorPickerProps>();
54
+
55
+ const model = defineModel<string>({ default: "#ff0000" });
56
+
57
+ const hue = ref(0);
58
+ const sat = ref(100);
59
+ const val = ref(100);
60
+ const alpha = ref(100);
61
+
62
+ const clamp = (n: number, min: number, max: number) =>
63
+ Math.min(max, Math.max(min, n));
64
+
65
+ const componentToHex = (c: number) => c.toString(16).padStart(2, "0");
66
+
67
+ const rgbToHex = (r: number, g: number, b: number) =>
68
+ `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`;
69
+
70
+ const rgbToHex8 = (r: number, g: number, b: number, a: number) =>
71
+ `#${componentToHex(r)}${componentToHex(g)}${componentToHex(
72
+ b
73
+ )}${componentToHex(clamp(a, 0, 255))}`;
74
+
75
+ const hexToRgbA = (
76
+ hex?: string
77
+ ): {
78
+ r: number;
79
+ g: number;
80
+ b: number;
81
+ a: number;
82
+ hasAlpha: boolean;
83
+ } | null => {
84
+ if (!hex) return null;
85
+ const cleaned = hex.trim().replace(/^#/, "");
86
+ if (cleaned.length === 6) {
87
+ const r = parseInt(cleaned.slice(0, 2), 16);
88
+ const g = parseInt(cleaned.slice(2, 4), 16);
89
+ const b = parseInt(cleaned.slice(4, 6), 16);
90
+ return { r, g, b, a: 255, hasAlpha: false } as const;
91
+ }
92
+ if (cleaned.length === 8) {
93
+ const r = parseInt(cleaned.slice(0, 2), 16);
94
+ const g = parseInt(cleaned.slice(2, 4), 16);
95
+ const b = parseInt(cleaned.slice(4, 6), 16);
96
+ const a = parseInt(cleaned.slice(6, 8), 16);
97
+ return { r, g, b, a, hasAlpha: true } as const;
98
+ }
99
+ return null;
100
+ };
101
+
102
+ const hsvToRgb = (h: number, s: number, v: number) => {
103
+ const c = v * s;
104
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
105
+ const m = v - c;
106
+
107
+ let r1 = 0,
108
+ g1 = 0,
109
+ b1 = 0;
110
+
111
+ if (h >= 0 && h < 60) {
112
+ r1 = c;
113
+ g1 = x;
114
+ b1 = 0;
115
+ } else if (h < 120) {
116
+ r1 = x;
117
+ g1 = c;
118
+ b1 = 0;
119
+ } else if (h < 180) {
120
+ r1 = 0;
121
+ g1 = c;
122
+ b1 = x;
123
+ } else if (h < 240) {
124
+ r1 = 0;
125
+ g1 = x;
126
+ b1 = c;
127
+ } else if (h < 300) {
128
+ r1 = x;
129
+ g1 = 0;
130
+ b1 = c;
131
+ } else {
132
+ r1 = c;
133
+ g1 = 0;
134
+ b1 = x;
135
+ }
136
+ return {
137
+ r: Math.round((r1 + m) * 255),
138
+ g: Math.round((g1 + m) * 255),
139
+ b: Math.round((b1 + m) * 255),
140
+ };
141
+ };
142
+
143
+ const rgbToHsv = (r: number, g: number, b: number) => {
144
+ r /= 255;
145
+ g /= 255;
146
+ b /= 255;
147
+ const max = Math.max(r, g, b),
148
+ min = Math.min(r, g, b);
149
+ const d = max - min;
150
+ let h = 0;
151
+ if (d === 0) h = 0;
152
+ else if (max === r) h = ((g - b) / d) % 6;
153
+ else if (max === g) h = (b - r) / d + 2;
154
+ else h = (r - g) / d + 4;
155
+ h = Math.round(h * 60);
156
+ if (h < 0) h += 360;
157
+ const s = max === 0 ? 0 : d / max;
158
+ const v = max;
159
+ return { h, s, v };
160
+ };
161
+
162
+ const svRef = ref<HTMLElement | null>(null);
163
+
164
+ const rgb = computed(() =>
165
+ hsvToRgb(hue.value, sat.value / 100, val.value / 100)
166
+ );
167
+
168
+ const updatingFromModel = ref(false);
169
+
170
+ watch([hue, sat, val, alpha], () => {
171
+ if (updatingFromModel.value) return;
172
+ const a = Math.round((alpha.value / 100) * 255);
173
+ if (a === 255) {
174
+ model.value = rgbToHex(rgb.value.r, rgb.value.g, rgb.value.b);
175
+ } else {
176
+ model.value = rgbToHex8(rgb.value.r, rgb.value.g, rgb.value.b, a);
177
+ }
178
+ });
179
+
180
+ const applyModel = (value?: string) => {
181
+ const parsed = hexToRgbA(value ?? model.value ?? "#ff0000");
182
+ if (!parsed) return;
183
+ const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b);
184
+ updatingFromModel.value = true;
185
+ hue.value = hsv.h;
186
+ sat.value = Math.round(hsv.s * 100);
187
+ val.value = Math.round(hsv.v * 100);
188
+ alpha.value = clamp(Math.round((parsed.a / 255) * 100), 0, 100);
189
+
190
+ let normalized: string;
191
+ if (!parsed.hasAlpha || parsed.a === 255) {
192
+ normalized = rgbToHex(parsed.r, parsed.g, parsed.b);
193
+ } else {
194
+ normalized = rgbToHex8(parsed.r, parsed.g, parsed.b, parsed.a);
195
+ }
196
+ if (model.value !== normalized) model.value = normalized;
197
+ updatingFromModel.value = false;
198
+ };
199
+
200
+ onMounted(() => applyModel());
201
+ watch(model, (v) => applyModel(v));
202
+
203
+ const svBackgroundStyle = computed(() => ({
204
+ background: `linear-gradient(to top, black, transparent), linear-gradient(to right, white, hsl(${hue.value}, 100%, 50%))`,
205
+ }));
206
+
207
+ const svHandleStyle = computed(() => ({
208
+ left: `${sat.value}%`,
209
+ top: `${100 - val.value}%`,
210
+ }));
211
+
212
+ const hueSliderStyle = computed(() => ({
213
+ background:
214
+ "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
215
+ }));
216
+
217
+ const alphaSliderStyle = computed(() => {
218
+ const { r, g, b } = rgb.value;
219
+ return {
220
+ background: `linear-gradient(to right, rgba(${r}, ${g}, ${b}, 0), rgba(${r}, ${g}, ${b}, 1))`,
221
+ } as Record<string, string>;
222
+ });
223
+
224
+ const updateSvFromEvent = (e: MouseEvent) => {
225
+ if (!svRef.value) return;
226
+ const rect = svRef.value.getBoundingClientRect();
227
+ const x = clamp(((e.clientX - rect.left) / rect.width) * 100, 0, 100);
228
+ const y = clamp(((e.clientY - rect.top) / rect.height) * 100, 0, 100);
229
+ sat.value = Math.round(x);
230
+ val.value = Math.round(100 - y);
231
+ };
232
+
233
+ const updateSvFromTouch = (e: TouchEvent) => {
234
+ if (!svRef.value) return;
235
+ const t = e.touches?.[0] ?? e.changedTouches?.[0];
236
+ if (!t) return;
237
+ const rect = svRef.value.getBoundingClientRect();
238
+ const x = clamp(((t.clientX - rect.left) / rect.width) * 100, 0, 100);
239
+ const y = clamp(((t.clientY - rect.top) / rect.height) * 100, 0, 100);
240
+ sat.value = Math.round(x);
241
+ val.value = Math.round(100 - y);
242
+ };
243
+
244
+ let dragging = false;
245
+
246
+ const onSvMouseDown = (e: MouseEvent) => {
247
+ if (props.disabled) return;
248
+
249
+ dragging = true;
250
+
251
+ updateSvFromEvent(e);
252
+
253
+ window.addEventListener("mousemove", onMouseMove);
254
+ window.addEventListener("mouseup", onMouseUp);
255
+ };
256
+
257
+ const onMouseMove = (e: MouseEvent) => {
258
+ if (!dragging) return;
259
+
260
+ updateSvFromEvent(e);
261
+ };
262
+
263
+ const onMouseUp = () => {
264
+ dragging = false;
265
+
266
+ window.removeEventListener("mousemove", onMouseMove);
267
+ window.removeEventListener("mouseup", onMouseUp);
268
+ };
269
+
270
+ const onSvTouchStart = (e: TouchEvent) => {
271
+ if (props.disabled) return;
272
+
273
+ updateSvFromTouch(e);
274
+
275
+ window.addEventListener("touchmove", onTouchMove, { passive: false });
276
+ window.addEventListener("touchend", onTouchEnd);
277
+ };
278
+
279
+ const onTouchMove = (e: TouchEvent) => {
280
+ updateSvFromTouch(e);
281
+ };
282
+
283
+ const onTouchEnd = () => {
284
+ window.removeEventListener("touchmove", onTouchMove);
285
+ window.removeEventListener("touchend", onTouchEnd);
286
+ };
287
+ </script>
@@ -0,0 +1,3 @@
1
+ export interface UixyColorPickerProps {
2
+ disabled?: boolean;
3
+ }
@@ -0,0 +1,4 @@
1
+ import UixyColorPicker from "./ColorPicker.component.vue";
2
+ import type { UixyColorPickerProps } from "./ColorPicker.types";
3
+
4
+ export { UixyColorPicker, type UixyColorPickerProps };
@@ -27,19 +27,29 @@
27
27
  {{ props.label }}
28
28
  </label>
29
29
  </div>
30
- <div v-if="!props.hideHelper" class="h-4">
31
- <animate-presence mode="wait" :initial="false">
32
- <motion.div
33
- :initial="{ opacity: 0, y: '-4px' }"
34
- :animate="{ opacity: 1, y: 0 }"
35
- :exit="{ opacity: 0, y: '-4px' }"
36
- :transition="{ duration: 0.125 }"
37
- :key="status"
38
- >
39
- <p :class="helperStyles({ status })">{{ text }}</p>
40
- </motion.div>
41
- </animate-presence>
42
- </div>
30
+ <animate-presence mode="wait" :initial="false">
31
+ <motion.div
32
+ v-if="!!text"
33
+ class="overflow-hidden"
34
+ :initial="{ height: 0 }"
35
+ :animate="{ height: 'auto' }"
36
+ :exit="{ height: 0 }"
37
+ :transition="{ duration: 0.1, ease: 'easeInOut' }"
38
+ >
39
+ <animate-presence mode="wait" :initial="false">
40
+ <motion.p
41
+ :key="status + text"
42
+ :class="helperStyles({ status })"
43
+ :initial="{ opacity: 0, y: -4 }"
44
+ :animate="{ opacity: 1, y: 0 }"
45
+ :exit="{ opacity: 0, y: 4 }"
46
+ :transition="{ duration: 0.12, ease: 'easeInOut', delay: 0.1 }"
47
+ >
48
+ {{ text }}
49
+ </motion.p>
50
+ </animate-presence>
51
+ </motion.div>
52
+ </animate-presence>
43
53
  </div>
44
54
  </template>
45
55
 
@@ -45,7 +45,7 @@ export const iconStyles = styles(
45
45
  );
46
46
 
47
47
  export const labelStyles = styles(
48
- "transition-colors ease-in-out duration-150 text-sm font-500",
48
+ "transition-colors ease-in-out duration-150 text-xs font-500",
49
49
  {
50
50
  status: {
51
51
  default: "",
@@ -11,7 +11,6 @@ export interface UixyInputProps {
11
11
  helperText?: string;
12
12
  errorText?: string;
13
13
  autoFocus?: boolean;
14
- hideHelper?: boolean;
15
14
  }
16
15
 
17
16
  export interface UixyInputEmits {
@@ -52,7 +52,6 @@
52
52
  icon="search"
53
53
  iconPositon="right"
54
54
  placeholder="Search..."
55
- hide-helper
56
55
  auto-focus
57
56
  />
58
57
  </div>
@@ -90,19 +89,29 @@
90
89
  {{ props.label }}
91
90
  </div>
92
91
  </div>
93
- <div v-if="!props.hideHelper" class="h-4">
94
- <animate-presence mode="wait" :initial="false">
95
- <motion.div
96
- :initial="{ opacity: 0, y: '-4px' }"
97
- :animate="{ opacity: 1, y: 0 }"
98
- :exit="{ opacity: 0, y: '-4px' }"
99
- :transition="{ duration: 0.125 }"
100
- :key="status"
101
- >
102
- <p :class="helperStyles({ status })">{{ text }}</p>
103
- </motion.div>
104
- </animate-presence>
105
- </div>
92
+ <animate-presence mode="wait" :initial="false">
93
+ <motion.div
94
+ v-if="!!text"
95
+ class="overflow-hidden"
96
+ :initial="{ height: 0 }"
97
+ :animate="{ height: 'auto' }"
98
+ :exit="{ height: 0 }"
99
+ :transition="{ duration: 0.1, ease: 'easeInOut' }"
100
+ >
101
+ <animate-presence mode="wait" :initial="false">
102
+ <motion.p
103
+ :key="status + text"
104
+ :class="helperStyles({ status })"
105
+ :initial="{ opacity: 0, y: -4 }"
106
+ :animate="{ opacity: 1, y: 0 }"
107
+ :exit="{ opacity: 0, y: 4 }"
108
+ :transition="{ duration: 0.12, ease: 'easeInOut', delay: 0.1 }"
109
+ >
110
+ {{ text }}
111
+ </motion.p>
112
+ </animate-presence>
113
+ </motion.div>
114
+ </animate-presence>
106
115
  </div>
107
116
  </template>
108
117
 
@@ -27,7 +27,7 @@ export const iconStyles = styles(
27
27
  );
28
28
 
29
29
  export const labelStyles = styles(
30
- "transition-colors ease-in-out duration-150 text-sm font-500",
30
+ "transition-colors ease-in-out duration-150 text-xs font-500",
31
31
  {
32
32
  status: {
33
33
  default: "",
@@ -26,6 +26,5 @@ export type UixySelectProps = {
26
26
  helperText?: string;
27
27
  errorText?: string;
28
28
  autoFocus?: boolean;
29
- hideHelper?: boolean;
30
29
  max?: number;
31
30
  } & (UixyMultipleSelectProps | UixySingleSelectProps);
@@ -79,4 +79,10 @@
79
79
  setScale(1);
80
80
  }
81
81
  );
82
+
83
+ onUnmounted(async () => {
84
+ await nextTick();
85
+
86
+ setScale(1);
87
+ });
82
88
  </script>
@@ -19,28 +19,34 @@
19
19
  {{ props.label }}
20
20
  </label>
21
21
  </div>
22
- <div class="h-4" v-if="!props.hideHelper">
23
- <animate-presence mode="wait" :initial="false">
24
- <motion.div
25
- :initial="{ opacity: 0, y: '-4px' }"
26
- :animate="{ opacity: 1, y: 0 }"
27
- :exit="{ opacity: 0, y: '-4px' }"
28
- :transition="{ duration: 0.125 }"
29
- :key="status"
30
- >
31
- <p
22
+ <animate-presence mode="wait" :initial="false">
23
+ <motion.div
24
+ v-if="!!text"
25
+ class="overflow-hidden"
26
+ :initial="{ height: 0 }"
27
+ :animate="{ height: 'auto' }"
28
+ :exit="{ height: 0 }"
29
+ :transition="{ duration: 0.1, ease: 'easeInOut' }"
30
+ >
31
+ <animate-presence mode="wait" :initial="false">
32
+ <motion.p
33
+ :key="status + text"
32
34
  :class="
33
35
  helperStyles({
34
36
  status,
35
37
  align: props.alignText ?? 'left',
36
38
  })
37
39
  "
40
+ :initial="{ opacity: 0, y: -4 }"
41
+ :animate="{ opacity: 1, y: 0 }"
42
+ :exit="{ opacity: 0, y: 4 }"
43
+ :transition="{ duration: 0.12, ease: 'easeInOut', delay: 0.1 }"
38
44
  >
39
45
  {{ text }}
40
- </p>
41
- </motion.div>
42
- </animate-presence>
43
- </div>
46
+ </motion.p>
47
+ </animate-presence>
48
+ </motion.div>
49
+ </animate-presence>
44
50
  </div>
45
51
  </template>
46
52
 
@@ -21,7 +21,7 @@ export const textareaStyles = styles(
21
21
  );
22
22
 
23
23
  export const labelStyles = styles(
24
- "transition-colors ease-in-out duration-150 text-sm font-500",
24
+ "transition-colors ease-in-out duration-150 text-xs font-500",
25
25
  {
26
26
  status: {
27
27
  default: "",
@@ -10,5 +10,4 @@ export interface UixyTextareaProps {
10
10
  noResize?: boolean;
11
11
  rows?: number;
12
12
  maxLength?: number;
13
- hideHelper?: boolean;
14
13
  }
@@ -0,0 +1,35 @@
1
+ export * from "./Accordion";
2
+ export * from "./Alert";
3
+ export * from "./Avatar";
4
+ export * from "./Badge";
5
+ export * from "./Button";
6
+ export * from "./Calendar";
7
+ export * from "./Card";
8
+ export * from "./Checkbox";
9
+ export * from "./Command";
10
+ export * from "./Icon";
11
+ export * from "./IconButton";
12
+ export * from "./Input";
13
+ export * from "./InputOTP";
14
+ export * from "./Link";
15
+ export * from "./Loader";
16
+ export * from "./Modal";
17
+ export * from "./PieChart";
18
+ export * from "./Popover";
19
+ export * from "./Radio";
20
+ export * from "./Scale";
21
+ export * from "./Select";
22
+ export * from "./Separator";
23
+ export * from "./Sheet";
24
+ export * from "./Skeleton";
25
+ export * from "./Slider";
26
+ export * from "./Switch";
27
+ export * from "./TableOfContents";
28
+ export * from "./Tabs";
29
+ export * from "./Textarea";
30
+ export * from "./Theme";
31
+ export * from "./TimePicker";
32
+ export * from "./Toast";
33
+ export * from "./Toggle";
34
+ export * from "./Tooltip";
35
+ export * from "./ColorPicker";
@@ -48,6 +48,12 @@
48
48
  "packages": ["motion-v"],
49
49
  "composables": []
50
50
  },
51
+ {
52
+ "name": "ColorPicker",
53
+ "related-components": ["Card"],
54
+ "packages": [],
55
+ "composables": []
56
+ },
51
57
  {
52
58
  "name": "Command",
53
59
  "related-components": ["Modal", "Card", "Link", "Icon", "Separator"],
@@ -70,7 +76,7 @@
70
76
  "name": "Input",
71
77
  "related-components": ["Icon"],
72
78
  "packages": ["motion-v"],
73
- "composables": []
79
+ "composables": ["useInputValidation"]
74
80
  },
75
81
  {
76
82
  "name": "InputOTP",
@@ -124,7 +130,7 @@
124
130
  "name": "Select",
125
131
  "related-components": ["Badge", "Icon", "Input"],
126
132
  "packages": ["motion-v"],
127
- "composables": []
133
+ "composables": ["useInputValidation"]
128
134
  },
129
135
  {
130
136
  "name": "Separator",
@@ -0,0 +1,89 @@
1
+ import { ref, computed, type Ref } from "vue";
2
+
3
+ export type UseValidationProps = {
4
+ validation: ((value: string) => true | string)[];
5
+ base?: string;
6
+ };
7
+
8
+ export function useInputValidation<
9
+ T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement
10
+ >({ validation, base }: UseValidationProps) {
11
+ const value = ref(base ?? "");
12
+ const touched = ref(false);
13
+ const error = ref<string | undefined>(undefined);
14
+ const elRef: Ref<T | null> = ref(null);
15
+
16
+ const onInput = (e: Event) => {
17
+ const target = e.target as T | null;
18
+ if (error.value) error.value = undefined;
19
+ if (target) value.value = target.value;
20
+ };
21
+
22
+ const onBlur = () => {
23
+ touched.value = true;
24
+ };
25
+
26
+ const valid = computed(
27
+ () => !error.value && validation.every((v) => v(value.value) === true)
28
+ );
29
+
30
+ const status = computed<"valid" | "error" | "default">(() => {
31
+ if (!touched.value) return "default";
32
+ if (error.value || !valid.value) return "error";
33
+ if (valid.value) return "valid";
34
+
35
+ return "default";
36
+ });
37
+
38
+ const parsedValidation = computed(() => {
39
+ const found = validation.find((v) => v(value.value) !== true);
40
+ return found as ((v: string) => string) | undefined;
41
+ });
42
+
43
+ const errorText = computed(() =>
44
+ touched.value
45
+ ? error.value || parsedValidation.value?.(value.value)
46
+ : undefined
47
+ );
48
+
49
+ const reset = (hard = false) => {
50
+ touched.value = !hard;
51
+ error.value = undefined;
52
+ value.value = "";
53
+ };
54
+
55
+ const setError = async (message: string) => {
56
+ error.value = message;
57
+ touched.value = true;
58
+ };
59
+
60
+ const bindings = computed<Record<string, any>>(() => ({
61
+ ref: elRef,
62
+ modelValue: value.value,
63
+ ["onUpdate:modelValue"]: (v: string) => {
64
+ value.value = v;
65
+ },
66
+ status: status.value,
67
+ errorText: errorText.value,
68
+ onInput,
69
+ onBlur,
70
+ "aria-invalid": status.value === "error",
71
+ }));
72
+
73
+ return {
74
+ ref: elRef,
75
+ value,
76
+ touched,
77
+ errorText,
78
+ status,
79
+ valid,
80
+ error,
81
+ reset,
82
+ setError,
83
+ onInput,
84
+ onBlur,
85
+ bindings,
86
+ };
87
+ }
88
+
89
+ export default useInputValidation;