v-uixy 1.3.1 → 1.4.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 (34) hide show
  1. package/package.json +1 -1
  2. package/templates/assets/css/main.css +4 -0
  3. package/templates/components/Avatar/Avatar.component.vue +7 -3
  4. package/templates/components/Avatar/Avatar.styles.ts +3 -3
  5. package/templates/components/Button/Button.styles.ts +3 -3
  6. package/templates/components/Calendar/Calendar.component.vue +26 -15
  7. package/templates/components/Calendar/Calendar.styles.ts +11 -3
  8. package/templates/components/Calendar/Calendar.types.ts +1 -0
  9. package/templates/components/Checkbox/Checkbox.styles.ts +1 -1
  10. package/templates/components/ColorPicker/ColorPicker.component.vue +4 -4
  11. package/templates/components/Command/Command.component.vue +6 -6
  12. package/templates/components/Command/Command.types.ts +1 -0
  13. package/templates/components/Input/Input.component.vue +27 -27
  14. package/templates/components/Input/Input.styles.ts +5 -5
  15. package/templates/components/Input/Input.types.ts +1 -0
  16. package/templates/components/Modal/Modal.component.vue +28 -4
  17. package/templates/components/Modal/Modal.types.ts +3 -1
  18. package/templates/components/Popover/Popover.component.vue +33 -2
  19. package/templates/components/Popover/Popover.types.ts +1 -0
  20. package/templates/components/Select/Select.component.vue +253 -108
  21. package/templates/components/Select/Select.styles.ts +6 -6
  22. package/templates/components/Select/Select.types.ts +12 -0
  23. package/templates/components/Select/SelectTreeItem.vue +154 -0
  24. package/templates/components/Select/composables/useSelect.composable.ts +260 -42
  25. package/templates/components/Tabs/Tabs.component.vue +11 -2
  26. package/templates/components/Tabs/Tabs.types.ts +1 -0
  27. package/templates/components/Textarea/Textarea.component.vue +23 -32
  28. package/templates/components/Textarea/Textarea.styles.ts +4 -4
  29. package/templates/components/Textarea/Textarea.types.ts +1 -0
  30. package/templates/components/TimePicker/TimePicker.component.vue +21 -18
  31. package/templates/components/TimePicker/TimePicker.styles.ts +1 -1
  32. package/templates/components/TimePicker/TimePicker.types.ts +1 -0
  33. package/templates/components.json +1 -1
  34. package/templates/composables/usePosition.ts +17 -2
@@ -1,33 +1,238 @@
1
- import { onWatcherCleanup, watch } from "vue";
2
- import type { UixySelectOptionType } from "../Select.types";
1
+ import {
2
+ onWatcherCleanup,
3
+ watch,
4
+ computed,
5
+ ref,
6
+ onUnmounted,
7
+ type Ref,
8
+ } from "vue";
9
+ import type { UixySelectOptionType, UixySelectProps } from "../Select.types";
3
10
 
4
- export const useSelect = <T>(
11
+ const normalizeForSearch = (str: string): string =>
12
+ str.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
13
+
14
+ const getValue = (option: UixySelectOptionType): string =>
15
+ option.value ?? option.label;
16
+
17
+ const collectLeafValues = (option: UixySelectOptionType): string[] => {
18
+ if (!option.children || option.children.length === 0)
19
+ return [getValue(option)];
20
+ return option.children.flatMap(collectLeafValues);
21
+ };
22
+
23
+ const findInTree = (
5
24
  options: UixySelectOptionType[],
6
- disabled: boolean,
7
- onChange: (e: string) => void
25
+ value: string,
26
+ ): UixySelectOptionType | null => {
27
+ for (const opt of options) {
28
+ if (getValue(opt) === value) return opt;
29
+ if (opt.children?.length) {
30
+ const found = findInTree(opt.children, value);
31
+ if (found) return found;
32
+ }
33
+ }
34
+ return null;
35
+ };
36
+
37
+ export const useSelect = <T extends string | string[]>(
38
+ props: UixySelectProps,
39
+ model: Ref<T>,
40
+ emit: (event: "change", v: string) => void,
41
+ dropdownRef?: Ref<HTMLElement | undefined>,
8
42
  ) => {
9
43
  const open = ref(false);
10
44
  const search = ref("");
11
45
  const refOptions = ref<HTMLDivElement>();
46
+ const lastScrollAt = ref(0);
47
+ const expandedItems = ref<string[]>([]);
12
48
 
13
- const filteredOptions = computed(() =>
14
- options.filter((option) =>
15
- option.label.toLowerCase().includes(search.value.toLowerCase())
16
- )
17
- );
49
+ const filterTreeOptions = (
50
+ items: UixySelectOptionType[],
51
+ searchTerm: string,
52
+ ): UixySelectOptionType[] => {
53
+ if (!searchTerm) return items;
54
+ const normSearch = normalizeForSearch(searchTerm);
55
+ const filterNode = (
56
+ node: UixySelectOptionType,
57
+ ): UixySelectOptionType | null => {
58
+ const matches = normalizeForSearch(node.label).includes(normSearch);
59
+ let filteredChildren: UixySelectOptionType[] = [];
60
+ if (node.children?.length) {
61
+ filteredChildren = node.children
62
+ .map(filterNode)
63
+ .filter((n): n is UixySelectOptionType => n !== null);
64
+ }
65
+ if (matches || filteredChildren.length > 0) {
66
+ return { ...node, children: filteredChildren };
67
+ }
68
+ return null;
69
+ };
70
+ return items
71
+ .map(filterNode)
72
+ .filter((n): n is UixySelectOptionType => n !== null);
73
+ };
18
74
 
19
- const handleOpen = (e?: MouseEvent) => {
20
- if (disabled || (e && "badge" in (e?.target as HTMLDivElement).dataset))
75
+ const filteredOptions = computed(() => {
76
+ const list = props.options ?? [];
77
+ if (props.tree) return filterTreeOptions(list, search.value);
78
+ return list.filter((option) =>
79
+ normalizeForSearch(option.label).includes(
80
+ normalizeForSearch(search.value),
81
+ ),
82
+ );
83
+ });
84
+
85
+ const collectParentsWithMatchingDescendants = (
86
+ items: UixySelectOptionType[],
87
+ searchTerm: string,
88
+ ): string[] => {
89
+ if (!searchTerm) return [];
90
+ const lowerSearch = searchTerm.toLowerCase();
91
+ const parentsToExpand: string[] = [];
92
+ const checkNode = (node: UixySelectOptionType): boolean => {
93
+ const nodeMatches = node.label.toLowerCase().includes(lowerSearch);
94
+ let hasMatchingDescendant = false;
95
+ if (node.children?.length) {
96
+ for (const child of node.children) {
97
+ if (checkNode(child)) hasMatchingDescendant = true;
98
+ }
99
+ }
100
+ if (hasMatchingDescendant) parentsToExpand.push(getValue(node));
101
+ return nodeMatches || hasMatchingDescendant;
102
+ };
103
+ for (const item of items) checkNode(item);
104
+ return parentsToExpand;
105
+ };
106
+
107
+ const effectiveExpandedItems = computed(() => {
108
+ if (!search.value) return expandedItems.value;
109
+ const autoExpanded = collectParentsWithMatchingDescendants(
110
+ props.options,
111
+ search.value,
112
+ );
113
+ return Array.from(new Set([...expandedItems.value, ...autoExpanded]));
114
+ });
115
+
116
+ const toggleExpand = (value: string) => {
117
+ const idx = expandedItems.value.indexOf(value);
118
+ if (idx >= 0) expandedItems.value.splice(idx, 1);
119
+ else expandedItems.value.push(value);
120
+ };
121
+
122
+ const isSelected = (option: UixySelectOptionType): boolean => {
123
+ const v = getValue(option);
124
+ if (props.tree && props.multiple && option.children?.length) {
125
+ const leaves = collectLeafValues(option);
126
+ const arr = (model.value as string[]) ?? [];
127
+ return leaves.length > 0 && leaves.every((l) => arr.includes(l));
128
+ }
129
+ return Array.isArray(model.value)
130
+ ? (model.value as string[]).includes(v)
131
+ : (model.value as string) === v;
132
+ };
133
+
134
+ const isPartiallySelected = (option: UixySelectOptionType): boolean => {
135
+ if (!props.tree || !props.multiple || !option.children?.length)
136
+ return false;
137
+ const leaves = collectLeafValues(option);
138
+ const arr = (model.value as string[]) ?? [];
139
+ const selected = leaves.filter((l) => arr.includes(l)).length;
140
+ return selected > 0 && selected < leaves.length;
141
+ };
142
+
143
+ const setMultiValues = (next: string[]) => {
144
+ (model.value as string[]) = next;
145
+ };
146
+
147
+ const toggleMultiValue = (v: string) => {
148
+ const prev = Array.isArray(model.value) ? (model.value as string[]) : [];
149
+ if (prev.includes(v)) {
150
+ setMultiValues(prev.filter((item) => item !== v));
151
+ return;
152
+ }
153
+ if (props.max && prev.length >= props.max) {
154
+ setMultiValues([...prev.slice(1), v]);
21
155
  return;
156
+ }
157
+ setMultiValues([...prev, v]);
158
+ };
159
+
160
+ const cascadeMultiTree = (option: UixySelectOptionType) => {
161
+ const leaves = collectLeafValues(option);
162
+ const prev = Array.isArray(model.value) ? (model.value as string[]) : [];
163
+ const allSelected = leaves.every((l) => prev.includes(l));
164
+ if (allSelected) {
165
+ setMultiValues(prev.filter((v) => !leaves.includes(v)));
166
+ return;
167
+ }
168
+ const merged = Array.from(new Set([...prev, ...leaves]));
169
+ if (props.max && merged.length > props.max) {
170
+ setMultiValues(merged.slice(merged.length - props.max));
171
+ return;
172
+ }
173
+ setMultiValues(merged);
174
+ };
22
175
 
176
+ const applyChange = (v: string) => {
177
+ if (!props.multiple) {
178
+ (model.value as string) = v;
179
+ return;
180
+ }
181
+ if (props.tree) {
182
+ const node = findInTree(props.options, v);
183
+ if (node?.children?.length) {
184
+ cascadeMultiTree(node);
185
+ return;
186
+ }
187
+ }
188
+ toggleMultiValue(v);
189
+ };
190
+
191
+ const handleClick = (value: string) => {
192
+ applyChange(value);
193
+ emit("change", value);
194
+ if (props.clearSearchOnSelect) search.value = "";
195
+ if (!props.multiple) open.value = false;
196
+ };
197
+
198
+ const handleClickSelectedOption = (item: string) => {
199
+ applyChange(item);
200
+ emit("change", item);
201
+ };
202
+
203
+ const handleDeselect = () => {
204
+ (model.value as string) = "";
205
+ };
206
+
207
+ const getLabel = (value: string): string => {
208
+ if (!value) return "";
209
+ return findInTree(props.options, value)?.label ?? value;
210
+ };
211
+
212
+ const customAddVisible = computed(() => {
213
+ if (!props.allowCustom || !search.value) return false;
214
+ const lowerSearch = search.value.toLowerCase();
215
+ return !filteredOptions.value.some(
216
+ (opt) => opt.label.toLowerCase() === lowerSearch,
217
+ );
218
+ });
219
+
220
+ const handleCustomAdd = () => {
221
+ handleClick(search.value);
222
+ };
223
+
224
+ const handleOpen = (e?: MouseEvent) => {
225
+ if (
226
+ props.disabled ||
227
+ (e && "badge" in (e?.target as HTMLDivElement).dataset)
228
+ )
229
+ return;
23
230
  open.value = !open.value;
24
231
  };
25
232
 
26
233
  const handleKeydownSelect = (e: KeyboardEvent) => {
27
234
  const key = e.key;
28
-
29
235
  if (key === "Tab") return;
30
-
31
236
  if (key === "ArrowDown" || key === "Enter" || key === " ") {
32
237
  e.preventDefault();
33
238
  open.value = !open.value;
@@ -36,73 +241,76 @@ export const useSelect = <T>(
36
241
 
37
242
  const handleKeydownOption = (
38
243
  e: KeyboardEvent,
39
- option: UixySelectOptionType
244
+ option: UixySelectOptionType,
40
245
  ) => {
41
246
  const key = e.key;
42
-
43
247
  if ((key === "Enter" || key === " ") && !option.disabled) {
44
248
  e.preventDefault();
45
- return onChange(option.label);
249
+ return handleClick(getValue(option));
46
250
  }
47
-
48
251
  if (key !== "ArrowDown" && key !== "ArrowUp") return;
49
-
50
- const container = refOptions.value;
51
-
252
+ const container = dropdownRef?.value ?? refOptions.value;
52
253
  if (!container) return;
53
-
54
254
  const focusables = Array.from(
55
255
  container.querySelectorAll<HTMLElement>(
56
- "[select-dropdown] input, [select-dropdown] li"
57
- )
256
+ "[select-dropdown] input, [select-dropdown] li",
257
+ ),
58
258
  ).filter(
59
259
  (el) =>
60
260
  el.tabIndex >= 0 &&
61
261
  el.getAttribute("aria-disabled") !== "true" &&
62
- !el.hasAttribute("disabled")
262
+ !el.hasAttribute("disabled"),
63
263
  );
64
-
65
264
  if (!focusables.length) return;
66
-
67
265
  const currentEl = (e.currentTarget as HTMLElement) || null;
68
266
  let index = currentEl ? focusables.indexOf(currentEl) : -1;
69
-
70
267
  e.preventDefault();
71
-
72
268
  const val = key === "ArrowDown" ? 1 : -1;
73
-
74
269
  index = (index + val + focusables.length) % focusables.length;
75
-
76
- const nextEl = focusables[index];
77
- nextEl?.focus();
270
+ focusables[index]?.focus();
78
271
  };
79
272
 
80
- const handleClickSelectedOption = (item: string) => {
81
- onChange(item);
273
+ const handleClickOutside = (e: MouseEvent) => {
274
+ if (Date.now() - lastScrollAt.value < 250) return;
275
+ const target = e.target as Node;
276
+ if (refOptions.value?.contains(target)) return;
277
+ if (dropdownRef?.value?.contains(target)) return;
278
+ const el = target as HTMLElement;
279
+ if (el.closest?.("[select-dropdown]")) return;
280
+ open.value = false;
82
281
  };
83
282
 
84
- const handleClickOutside = (e: MouseEvent) => {
85
- if (refOptions.value && !refOptions.value.contains(e.target as Node))
86
- open.value = false;
283
+ const markScroll = () => {
284
+ lastScrollAt.value = Date.now();
87
285
  };
88
286
 
89
287
  watch(
90
288
  () => open.value,
91
- (_, prevVal) => {
289
+ (isOpen, prevVal) => {
290
+ if (!isOpen) {
291
+ expandedItems.value = [];
292
+ }
92
293
  if (prevVal) return;
93
-
94
294
  requestAnimationFrame(() => {
95
295
  window.addEventListener("click", handleClickOutside);
296
+ window.addEventListener("scroll", markScroll, true);
297
+ window.addEventListener("wheel", markScroll, { passive: true });
298
+ window.addEventListener("touchmove", markScroll, { passive: true });
96
299
  });
97
-
98
300
  onWatcherCleanup(() => {
99
301
  window.removeEventListener("click", handleClickOutside);
302
+ window.removeEventListener("scroll", markScroll, true);
303
+ window.removeEventListener("wheel", markScroll as any);
304
+ window.removeEventListener("touchmove", markScroll as any);
100
305
  });
101
- }
306
+ },
102
307
  );
103
308
 
104
309
  onUnmounted(() => {
105
310
  window.removeEventListener("click", handleClickOutside);
311
+ window.removeEventListener("scroll", markScroll, true);
312
+ window.removeEventListener("wheel", markScroll as any);
313
+ window.removeEventListener("touchmove", markScroll as any);
106
314
  });
107
315
 
108
316
  return {
@@ -110,9 +318,19 @@ export const useSelect = <T>(
110
318
  search,
111
319
  refOptions,
112
320
  filteredOptions,
321
+ expandedItems,
322
+ effectiveExpandedItems,
323
+ customAddVisible,
324
+ isSelected,
325
+ isPartiallySelected,
326
+ getLabel,
327
+ toggleExpand,
113
328
  handleOpen,
114
329
  handleKeydownSelect,
115
330
  handleKeydownOption,
116
331
  handleClickSelectedOption,
332
+ handleClick,
333
+ handleDeselect,
334
+ handleCustomAdd,
117
335
  };
118
336
  };
@@ -23,7 +23,16 @@
23
23
  </div>
24
24
  <slot name="nav-extra" />
25
25
  <div>
26
- <animate-presence mode="wait" :initial="false">
26
+ <template v-if="props.keepAlive">
27
+ <div
28
+ v-for="tab in props.tabs"
29
+ :key="tab.value"
30
+ v-show="active === tab.value"
31
+ >
32
+ <slot :name="tab.value" />
33
+ </div>
34
+ </template>
35
+ <animate-presence v-else mode="wait" :initial="false">
27
36
  <motion.div
28
37
  :key="active"
29
38
  :initial="{ opacity: 0, y: 4 }"
@@ -55,6 +64,6 @@
55
64
  const layoutId = computed(() => `uixy-tabs-${uuidv4()}`);
56
65
 
57
66
  onBeforeMount(() => {
58
- if (props.tabs.length > 0) active.value = props.tabs[0].value;
67
+ if (props.tabs.length > 0) active.value = props.tabs[0]?.value;
59
68
  });
60
69
  </script>
@@ -4,4 +4,5 @@ export interface UixyTabsProps {
4
4
  value: string | number;
5
5
  }[];
6
6
  variant: "primary" | "secondary";
7
+ keepAlive?: boolean;
7
8
  }
@@ -9,44 +9,35 @@
9
9
  :auto-focus="props.autoFocus"
10
10
  :rows="props.rows ?? 3"
11
11
  :max-length="props.maxLength"
12
- :class="textareaStyles(
13
- { status, noResize: !!props.noResize },
14
- $attrs.class as string,
15
- )"
12
+ :class="
13
+ textareaStyles(
14
+ { status, noResize: !!props.noResize },
15
+ $attrs.class as string,
16
+ )
17
+ "
16
18
  v-bind="filteredAttrs"
17
19
  />
18
20
  <label v-if="props.label" :for="id" :class="labelStyles({ status })">
19
21
  {{ props.label }}
20
22
  </label>
21
23
  </div>
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"
34
- :class="
35
- helperStyles({
36
- status,
37
- align: props.alignText ?? 'left',
38
- })
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 }"
24
+ <div v-if="!props.hideHelper" class="h-4">
25
+ <animate-presence mode="wait" :initial="false">
26
+ <motion.div
27
+ :initial="{ opacity: 0, y: '-4px' }"
28
+ :animate="{ opacity: 1, y: 0 }"
29
+ :exit="{ opacity: 0, y: '-4px' }"
30
+ :transition="{ duration: 0.125 }"
31
+ :key="status"
32
+ >
33
+ <p
34
+ :class="helperStyles({ status, align: props.alignText ?? 'left' })"
44
35
  >
45
36
  {{ text }}
46
- </motion.p>
47
- </animate-presence>
48
- </motion.div>
49
- </animate-presence>
37
+ </p>
38
+ </motion.div>
39
+ </animate-presence>
40
+ </div>
50
41
  </div>
51
42
  </template>
52
43
 
@@ -74,7 +65,7 @@
74
65
  const id = useId();
75
66
 
76
67
  const status = computed(() =>
77
- props.disabled ? "disabled" : props.status ?? "default"
68
+ props.disabled ? "disabled" : (props.status ?? "default"),
78
69
  );
79
70
 
80
71
  const text = computed(
@@ -84,6 +75,6 @@
84
75
  default: props.helperText,
85
76
  disabled: "",
86
77
  valid: "",
87
- }[props.status ?? "default"])
78
+ })[props.status ?? "default"],
88
79
  );
89
80
  </script>
@@ -17,11 +17,11 @@ export const textareaStyles = styles(
17
17
  true: "resize-none",
18
18
  false: "",
19
19
  },
20
- }
20
+ },
21
21
  );
22
22
 
23
23
  export const labelStyles = styles(
24
- "transition-colors ease-in-out duration-150 text-xs font-500",
24
+ "transition-colors ease-in-out duration-150 text-sm font-500",
25
25
  {
26
26
  status: {
27
27
  default: "",
@@ -29,7 +29,7 @@ export const labelStyles = styles(
29
29
  disabled: "text-gray-500 dark:text-gray-700",
30
30
  valid: "",
31
31
  },
32
- }
32
+ },
33
33
  );
34
34
 
35
35
  export const helperStyles = styles(
@@ -46,5 +46,5 @@ export const helperStyles = styles(
46
46
  right: "text-right",
47
47
  center: "text-center",
48
48
  },
49
- }
49
+ },
50
50
  );
@@ -10,4 +10,5 @@ export interface UixyTextareaProps {
10
10
  noResize?: boolean;
11
11
  rows?: number;
12
12
  maxLength?: number;
13
+ hideHelper?: boolean;
13
14
  }
@@ -62,29 +62,34 @@
62
62
 
63
63
  const validateAndFormatTime = (
64
64
  value: string,
65
- type: "hour" | "minute" | "second"
66
- ) => {
65
+ type: "hour" | "minute" | "second",
66
+ ): string => {
67
67
  const num = parseInt(value, 10);
68
68
  if (isNaN(num)) return "00";
69
-
70
- let validatedNum = num;
71
-
72
- if (type === "hour") validatedNum = Math.min(23, Math.max(0, num));
73
- else if (type === "minute") validatedNum = Math.min(59, Math.max(0, num));
74
- else validatedNum = Math.min(59, Math.max(0, num));
75
-
76
- return validatedNum < 10 ? `0${validatedNum}` : `${validatedNum}`;
69
+ const max = type === "hour" ? 23 : 59;
70
+ const clamped = Math.max(0, Math.min(max, num));
71
+ return clamped < 10 ? `0${clamped}` : `${clamped}`;
77
72
  };
78
73
 
79
74
  const handleChange = (event: Event, type: "hour" | "minute" | "second") => {
80
75
  const target = event.target as HTMLInputElement;
81
- const valueStr = validateAndFormatTime(target.value, type);
82
- const valueNum = Number.parseInt(valueStr, 10);
76
+ const value = target.value.replace(/\D/g, "");
77
+
78
+ if (value === "") return;
79
+
80
+ let num = parseInt(value, 10);
83
81
 
84
- model.value = {
85
- ...model.value,
86
- [type]: Number.isFinite(valueNum) ? valueNum : 0,
87
- };
82
+ if (type === "hour") {
83
+ if (props.allowMidnight && num >= 24) {
84
+ num = 0;
85
+ } else if (num > 23) {
86
+ num = 23;
87
+ }
88
+ } else {
89
+ if (num > 59) num = 59;
90
+ }
91
+
92
+ model.value = { ...model.value, [type]: num };
88
93
  };
89
94
 
90
95
  const displaySeparator = (order: "first" | "second") => {
@@ -92,7 +97,6 @@
92
97
 
93
98
  if (order === "first") {
94
99
  if (!model.value?.hasOwnProperty("hour")) return false;
95
-
96
100
  if (
97
101
  model.value?.hasOwnProperty("minute") ||
98
102
  model.value?.hasOwnProperty("second")
@@ -102,7 +106,6 @@
102
106
 
103
107
  if (order === "second") {
104
108
  if (!model.value?.hasOwnProperty("second")) return false;
105
-
106
109
  if (
107
110
  model.value?.hasOwnProperty("hour") ||
108
111
  model.value?.hasOwnProperty("minute")
@@ -1,5 +1,5 @@
1
1
  import styles from "~/utils/styles";
2
2
 
3
3
  export const timePickerStyles = styles(
4
- "flex items-center justify-between gap-3 rounded-2 border border-gray-300 bg-gray-100 p-2 shadow-xs dark:border-gray-900 dark:bg-black"
4
+ "flex items-center justify-start gap-2 rounded-1 border border-gray-300 bg-white dark:bg-gray-1000 px-2 py-0.5 min-h-[38px] text-sm transition-colors ease-in-out duration-150 shadow-xs dark:border-gray-900",
5
5
  );
@@ -1,3 +1,4 @@
1
1
  export interface UixyTimePickerProps {
2
2
  disabled?: boolean;
3
+ allowMidnight?: boolean;
3
4
  }
@@ -111,7 +111,7 @@
111
111
  {
112
112
  "name": "Popover",
113
113
  "related-components": [],
114
- "packages": ["motion-v"],
114
+ "packages": ["motion-v", "uuid"],
115
115
  "composables": ["usePosition"]
116
116
  },
117
117
  {
@@ -39,12 +39,27 @@ export function usePosition({ direction = "bottom" }: UsePositionOptions) {
39
39
  const tooltipRect = refElement.value.getBoundingClientRect();
40
40
  const targetRect = target.value.getBoundingClientRect();
41
41
  const winW = window.innerWidth;
42
+ const winH = window.innerHeight;
42
43
 
43
44
  let top: number;
44
45
  const scrollY = hasFixedParent(target.value) ? 0 : window.scrollY;
45
46
 
46
- top =
47
+ const spaceBelow = winH - targetRect.bottom;
48
+ const spaceAbove = targetRect.top;
49
+ const fitsBelow = spaceBelow >= tooltipRect.height + 6;
50
+ const fitsAbove = spaceAbove >= tooltipRect.height + 6;
51
+
52
+ const effectiveDirection =
47
53
  direction === "top"
54
+ ? fitsAbove || !fitsBelow
55
+ ? "top"
56
+ : "bottom"
57
+ : fitsBelow || !fitsAbove
58
+ ? "bottom"
59
+ : "top";
60
+
61
+ top =
62
+ effectiveDirection === "top"
48
63
  ? targetRect.top - tooltipRect.height - 6 + scrollY
49
64
  : targetRect.bottom + 6 + scrollY;
50
65
 
@@ -80,7 +95,7 @@ export function usePosition({ direction = "bottom" }: UsePositionOptions) {
80
95
  window.removeEventListener("resize", updatePosition);
81
96
  }
82
97
  },
83
- { flush: "post" }
98
+ { flush: "post" },
84
99
  );
85
100
 
86
101
  onBeforeUnmount(() => {