v-uixy 1.9.1 → 1.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,8 +15,8 @@ const getValue = (option: UixySelectOptionType): string =>
15
15
  option.value ?? option.label;
16
16
 
17
17
  const collectLeafValues = (option: UixySelectOptionType): string[] => {
18
- if (!option.children || option.children.length === 0)
19
- return [getValue(option)];
18
+ if (!option.children?.length) return [getValue(option)];
19
+
20
20
  return option.children.flatMap(collectLeafValues);
21
21
  };
22
22
 
@@ -24,16 +24,23 @@ const findInTree = (
24
24
  options: UixySelectOptionType[],
25
25
  value: string,
26
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);
27
+ for (const option of options) {
28
+ if (getValue(option) === value) return option;
29
+
30
+ if (option.children?.length) {
31
+ const found = findInTree(option.children, value);
32
+
31
33
  if (found) return found;
32
34
  }
33
35
  }
36
+
34
37
  return null;
35
38
  };
36
39
 
40
+ const isNotNull = (
41
+ option: UixySelectOptionType | null,
42
+ ): option is UixySelectOptionType => option !== null;
43
+
37
44
  export const useSelect = <T extends string | string[]>(
38
45
  props: UixySelectProps,
39
46
  model: Ref<T>,
@@ -46,39 +53,43 @@ export const useSelect = <T extends string | string[]>(
46
53
  const lastScrollAt = ref(0);
47
54
  const expandedItems = ref<string[]>([]);
48
55
 
56
+ const selectedValues = computed<string[]>(() =>
57
+ Array.isArray(model.value) ? (model.value as string[]) : [],
58
+ );
59
+
49
60
  const filterTreeOptions = (
50
61
  items: UixySelectOptionType[],
51
62
  searchTerm: string,
52
63
  ): UixySelectOptionType[] => {
53
64
  if (!searchTerm) return items;
54
- const normSearch = normalizeForSearch(searchTerm);
65
+
66
+ const normalizedSearch = normalizeForSearch(searchTerm);
67
+
55
68
  const filterNode = (
56
69
  node: UixySelectOptionType,
57
70
  ): 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;
71
+ const matches = normalizeForSearch(node.label).includes(normalizedSearch);
72
+ const filteredChildren = (node.children ?? [])
73
+ .map(filterNode)
74
+ .filter(isNotNull);
75
+
76
+ if (!matches && filteredChildren.length === 0) return null;
77
+
78
+ return { ...node, children: filteredChildren };
69
79
  };
70
- return items
71
- .map(filterNode)
72
- .filter((n): n is UixySelectOptionType => n !== null);
80
+
81
+ return items.map(filterNode).filter(isNotNull);
73
82
  };
74
83
 
75
84
  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
- ),
85
+ const options = props.options ?? [];
86
+
87
+ if (props.tree) return filterTreeOptions(options, search.value);
88
+
89
+ const normalizedSearch = normalizeForSearch(search.value);
90
+
91
+ return options.filter((option) =>
92
+ normalizeForSearch(option.label).includes(normalizedSearch),
82
93
  );
83
94
  });
84
95
 
@@ -87,133 +98,221 @@ export const useSelect = <T extends string | string[]>(
87
98
  searchTerm: string,
88
99
  ): string[] => {
89
100
  if (!searchTerm) return [];
101
+
90
102
  const lowerSearch = searchTerm.toLowerCase();
91
103
  const parentsToExpand: string[] = [];
104
+
92
105
  const checkNode = (node: UixySelectOptionType): boolean => {
93
106
  const nodeMatches = node.label.toLowerCase().includes(lowerSearch);
94
107
  let hasMatchingDescendant = false;
95
- if (node.children?.length) {
96
- for (const child of node.children) {
97
- if (checkNode(child)) hasMatchingDescendant = true;
98
- }
108
+
109
+ for (const child of node.children ?? []) {
110
+ if (checkNode(child)) hasMatchingDescendant = true;
99
111
  }
112
+
100
113
  if (hasMatchingDescendant) parentsToExpand.push(getValue(node));
114
+
101
115
  return nodeMatches || hasMatchingDescendant;
102
116
  };
117
+
103
118
  for (const item of items) checkNode(item);
119
+
104
120
  return parentsToExpand;
105
121
  };
106
122
 
107
123
  const effectiveExpandedItems = computed(() => {
108
124
  if (!search.value) return expandedItems.value;
125
+
109
126
  const autoExpanded = collectParentsWithMatchingDescendants(
110
127
  props.options,
111
128
  search.value,
112
129
  );
130
+
113
131
  return Array.from(new Set([...expandedItems.value, ...autoExpanded]));
114
132
  });
115
133
 
116
134
  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);
135
+ const index = expandedItems.value.indexOf(value);
136
+
137
+ if (index >= 0) {
138
+ expandedItems.value.splice(index, 1);
139
+ } else {
140
+ expandedItems.value.push(value);
141
+ }
120
142
  };
121
143
 
122
144
  const isSelected = (option: UixySelectOptionType): boolean => {
123
- const v = getValue(option);
124
145
  if (props.tree && props.multiple && option.children?.length) {
125
146
  const leaves = collectLeafValues(option);
126
- const arr = (model.value as string[]) ?? [];
127
- return leaves.length > 0 && leaves.every((l) => arr.includes(l));
147
+
148
+ return (
149
+ leaves.length > 0 &&
150
+ leaves.every((leaf) => selectedValues.value.includes(leaf))
151
+ );
128
152
  }
129
- return Array.isArray(model.value)
130
- ? (model.value as string[]).includes(v)
131
- : (model.value as string) === v;
153
+
154
+ const value = getValue(option);
155
+
156
+ if (Array.isArray(model.value)) return selectedValues.value.includes(value);
157
+
158
+ return (model.value as string) === value;
132
159
  };
133
160
 
134
161
  const isPartiallySelected = (option: UixySelectOptionType): boolean => {
135
- if (!props.tree || !props.multiple || !option.children?.length)
162
+ if (!props.tree || !props.multiple || !option.children?.length) {
136
163
  return false;
164
+ }
165
+
137
166
  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;
167
+ const selectedCount = leaves.filter((leaf) =>
168
+ selectedValues.value.includes(leaf),
169
+ ).length;
170
+
171
+ return selectedCount > 0 && selectedCount < leaves.length;
172
+ };
173
+
174
+ const minSelected = computed(() => {
175
+ if (!props.multiple) return props.deselectable ? 0 : 1;
176
+
177
+ const min = Math.max(props.min ?? 0, 0);
178
+
179
+ return props.deselectable ? min : Math.max(min, 1);
180
+ });
181
+
182
+ const hasMax = computed(
183
+ () => !!props.multiple && typeof props.max === "number",
184
+ );
185
+
186
+ const isAtMax = computed(
187
+ () => hasMax.value && selectedValues.value.length >= props.max!,
188
+ );
189
+
190
+ const canReplace = computed(
191
+ () => props.maxBehavior === "replace" && props.max !== 0,
192
+ );
193
+
194
+ const isBlocked = (option: UixySelectOptionType): boolean => {
195
+ if (!hasMax.value || canReplace.value || isSelected(option)) return false;
196
+
197
+ const prev = selectedValues.value;
198
+ const values =
199
+ props.tree && option.children?.length
200
+ ? collectLeafValues(option)
201
+ : [getValue(option)];
202
+ const added = values.filter((v) => !prev.includes(v)).length;
203
+
204
+ return prev.length + added > props.max!;
141
205
  };
142
206
 
143
207
  const setMultiValues = (next: string[]) => {
144
208
  (model.value as string[]) = next;
145
209
  };
146
210
 
147
- const toggleMultiValue = (v: string) => {
148
- const prev = Array.isArray(model.value) ? (model.value as string[]) : [];
211
+ const toggleMultiValue = (v: string): boolean => {
212
+ const prev = selectedValues.value;
213
+
149
214
  if (prev.includes(v)) {
215
+ if (prev.length <= minSelected.value) return false;
216
+
150
217
  setMultiValues(prev.filter((item) => item !== v));
151
- return;
218
+
219
+ return true;
152
220
  }
153
- if (props.max && prev.length >= props.max) {
154
- setMultiValues([...prev.slice(1), v]);
155
- return;
221
+
222
+ if (hasMax.value && prev.length >= props.max!) {
223
+ if (!canReplace.value) return false;
224
+
225
+ setMultiValues([...prev.slice(prev.length - props.max! + 1), v]);
226
+
227
+ return true;
156
228
  }
229
+
157
230
  setMultiValues([...prev, v]);
231
+
232
+ return true;
158
233
  };
159
234
 
160
- const cascadeMultiTree = (option: UixySelectOptionType) => {
235
+ const cascadeMultiTree = (option: UixySelectOptionType): boolean => {
161
236
  const leaves = collectLeafValues(option);
162
- const prev = Array.isArray(model.value) ? (model.value as string[]) : [];
163
- const allSelected = leaves.every((l) => prev.includes(l));
237
+ const prev = selectedValues.value;
238
+ const allSelected = leaves.every((leaf) => prev.includes(leaf));
239
+
164
240
  if (allSelected) {
165
- setMultiValues(prev.filter((v) => !leaves.includes(v)));
166
- return;
241
+ const next = prev.filter((v) => !leaves.includes(v));
242
+
243
+ if (next.length < minSelected.value) return false;
244
+
245
+ setMultiValues(next);
246
+
247
+ return true;
167
248
  }
249
+
168
250
  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;
251
+
252
+ if (hasMax.value && merged.length > props.max!) {
253
+ if (!canReplace.value) return false;
254
+
255
+ setMultiValues(merged.slice(merged.length - props.max!));
256
+
257
+ return true;
172
258
  }
259
+
173
260
  setMultiValues(merged);
261
+
262
+ return true;
174
263
  };
175
264
 
176
- const applyChange = (v: string) => {
265
+ const applyChange = (v: string): boolean => {
177
266
  if (!props.multiple) {
178
267
  (model.value as string) = v;
179
- return;
268
+
269
+ return true;
180
270
  }
271
+
181
272
  if (props.tree) {
182
273
  const node = findInTree(props.options, v);
183
- if (node?.children?.length) {
184
- cascadeMultiTree(node);
185
- return;
186
- }
274
+
275
+ if (node?.children?.length) return cascadeMultiTree(node);
187
276
  }
188
- toggleMultiValue(v);
277
+
278
+ return toggleMultiValue(v);
189
279
  };
190
280
 
191
281
  const handleClick = (value: string) => {
192
- applyChange(value);
282
+ if (!applyChange(value)) return;
283
+
193
284
  emit("change", value);
285
+
194
286
  if (props.clearSearchOnSelect) search.value = "";
195
287
  if (!props.multiple) open.value = false;
196
288
  };
197
289
 
198
290
  const handleClickSelectedOption = (item: string) => {
199
- applyChange(item);
291
+ if (!applyChange(item)) return;
292
+
200
293
  emit("change", item);
201
294
  };
202
295
 
203
296
  const handleDeselect = () => {
297
+ if (minSelected.value > 0) return;
298
+
204
299
  (model.value as string) = "";
205
300
  };
206
301
 
207
302
  const getLabel = (value: string): string => {
208
303
  if (!value) return "";
304
+
209
305
  return findInTree(props.options, value)?.label ?? value;
210
306
  };
211
307
 
212
308
  const customAddVisible = computed(() => {
213
309
  if (!props.allowCustom || !search.value) return false;
310
+ if (isAtMax.value && !canReplace.value) return false;
311
+
214
312
  const lowerSearch = search.value.toLowerCase();
313
+
215
314
  return !filteredOptions.value.some(
216
- (opt) => opt.label.toLowerCase() === lowerSearch,
315
+ (option) => option.label.toLowerCase() === lowerSearch,
217
316
  );
218
317
  });
219
318
 
@@ -222,61 +321,79 @@ export const useSelect = <T extends string | string[]>(
222
321
  };
223
322
 
224
323
  const handleOpen = (e?: MouseEvent) => {
225
- if (
226
- props.disabled ||
227
- (e && "badge" in (e?.target as HTMLDivElement).dataset)
228
- )
229
- return;
324
+ const isBadgeClick =
325
+ !!e && "badge" in (e.target as HTMLDivElement).dataset;
326
+
327
+ if (props.disabled || isBadgeClick) return;
328
+
230
329
  open.value = !open.value;
231
330
  };
232
331
 
233
332
  const handleKeydownSelect = (e: KeyboardEvent) => {
234
- const key = e.key;
235
- if (key === "Tab") return;
236
- if (key === "ArrowDown" || key === "Enter" || key === " ") {
333
+ if (e.key === "Tab") return;
334
+
335
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
237
336
  e.preventDefault();
337
+
238
338
  open.value = !open.value;
239
339
  }
240
340
  };
241
341
 
342
+ const getFocusableOptions = (): HTMLElement[] => {
343
+ const container = dropdownRef?.value ?? refOptions.value;
344
+
345
+ if (!container) return [];
346
+
347
+ const elements = container.querySelectorAll<HTMLElement>(
348
+ "[select-dropdown] input, [select-dropdown] li",
349
+ );
350
+
351
+ return Array.from(elements).filter(
352
+ (el) =>
353
+ el.tabIndex >= 0 &&
354
+ el.getAttribute("aria-disabled") !== "true" &&
355
+ !el.hasAttribute("disabled"),
356
+ );
357
+ };
358
+
242
359
  const handleKeydownOption = (
243
360
  e: KeyboardEvent,
244
361
  option: UixySelectOptionType,
245
362
  ) => {
246
- const key = e.key;
247
- if ((key === "Enter" || key === " ") && !option.disabled) {
363
+ const isSelectKey = e.key === "Enter" || e.key === " ";
364
+
365
+ if (isSelectKey && !option.disabled) {
248
366
  e.preventDefault();
367
+
249
368
  return handleClick(getValue(option));
250
369
  }
251
- if (key !== "ArrowDown" && key !== "ArrowUp") return;
252
- const container = dropdownRef?.value ?? refOptions.value;
253
- if (!container) return;
254
- const focusables = Array.from(
255
- container.querySelectorAll<HTMLElement>(
256
- "[select-dropdown] input, [select-dropdown] li",
257
- ),
258
- ).filter(
259
- (el) =>
260
- el.tabIndex >= 0 &&
261
- el.getAttribute("aria-disabled") !== "true" &&
262
- !el.hasAttribute("disabled"),
263
- );
370
+
371
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
372
+
373
+ const focusables = getFocusableOptions();
374
+
264
375
  if (!focusables.length) return;
265
- const currentEl = (e.currentTarget as HTMLElement) || null;
266
- let index = currentEl ? focusables.indexOf(currentEl) : -1;
376
+
267
377
  e.preventDefault();
268
- const val = key === "ArrowDown" ? 1 : -1;
269
- index = (index + val + focusables.length) % focusables.length;
270
- focusables[index]?.focus();
378
+
379
+ const current = e.currentTarget as HTMLElement | null;
380
+ const currentIndex = current ? focusables.indexOf(current) : -1;
381
+ const step = e.key === "ArrowDown" ? 1 : -1;
382
+ const nextIndex =
383
+ (currentIndex + step + focusables.length) % focusables.length;
384
+
385
+ focusables[nextIndex]?.focus();
271
386
  };
272
387
 
273
388
  const handleClickOutside = (e: MouseEvent) => {
274
389
  if (Date.now() - lastScrollAt.value < 250) return;
275
- const target = e.target as Node;
390
+
391
+ const target = e.target as HTMLElement;
392
+
276
393
  if (refOptions.value?.contains(target)) return;
277
394
  if (dropdownRef?.value?.contains(target)) return;
278
- const el = target as HTMLElement;
279
- if (el.closest?.("[select-dropdown]")) return;
395
+ if (target.closest?.("[select-dropdown]")) return;
396
+
280
397
  open.value = false;
281
398
  };
282
399
 
@@ -284,34 +401,34 @@ export const useSelect = <T extends string | string[]>(
284
401
  lastScrollAt.value = Date.now();
285
402
  };
286
403
 
404
+ const addWindowListeners = () => {
405
+ window.addEventListener("click", handleClickOutside);
406
+ window.addEventListener("scroll", markScroll, true);
407
+ window.addEventListener("wheel", markScroll, { passive: true });
408
+ window.addEventListener("touchmove", markScroll, { passive: true });
409
+ };
410
+
411
+ const removeWindowListeners = () => {
412
+ window.removeEventListener("click", handleClickOutside);
413
+ window.removeEventListener("scroll", markScroll, true);
414
+ window.removeEventListener("wheel", markScroll);
415
+ window.removeEventListener("touchmove", markScroll);
416
+ };
417
+
287
418
  watch(
288
419
  () => open.value,
289
- (isOpen, prevVal) => {
290
- if (!isOpen) {
291
- expandedItems.value = [];
292
- }
293
- if (prevVal) return;
294
- requestAnimationFrame(() => {
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 });
299
- });
300
- onWatcherCleanup(() => {
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);
305
- });
420
+ (isOpen, wasOpen) => {
421
+ if (!isOpen) expandedItems.value = [];
422
+
423
+ if (wasOpen) return;
424
+
425
+ requestAnimationFrame(addWindowListeners);
426
+
427
+ onWatcherCleanup(removeWindowListeners);
306
428
  },
307
429
  );
308
430
 
309
- onUnmounted(() => {
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);
314
- });
431
+ onUnmounted(removeWindowListeners);
315
432
 
316
433
  return {
317
434
  open,
@@ -321,8 +438,13 @@ export const useSelect = <T extends string | string[]>(
321
438
  expandedItems,
322
439
  effectiveExpandedItems,
323
440
  customAddVisible,
441
+ selectedValues,
442
+ hasMax,
443
+ isAtMax,
444
+ minSelected,
324
445
  isSelected,
325
446
  isPartiallySelected,
447
+ isBlocked,
326
448
  getLabel,
327
449
  toggleExpand,
328
450
  handleOpen,
@@ -4,6 +4,7 @@ import {
4
4
  type UixySelectOptionType,
5
5
  type UixySingleSelectProps,
6
6
  type UixyMultipleSelectProps,
7
+ type UixySelectMaxBehavior,
7
8
  } from "./Select.types";
8
9
 
9
10
  export {
@@ -12,4 +13,5 @@ export {
12
13
  type UixySelectOptionType,
13
14
  type UixySingleSelectProps,
14
15
  type UixyMultipleSelectProps,
16
+ type UixySelectMaxBehavior,
15
17
  };
@@ -11,7 +11,7 @@
11
11
  :style="{ zIndex }"
12
12
  data-uixy-overlay
13
13
  @click.self="handleClick"
14
- class="fixed left-0 top-0 size-full bg-white/5 backdrop-blur-[2px] dark:bg-gray-900/5"
14
+ class="fixed left-0 top-0 size-full bg-white/5 [html[data-uixy-gpu=on]_&]:backdrop-blur-[2px] dark:bg-gray-900/5"
15
15
  />
16
16
  <motion.div
17
17
  initial="initial"
@@ -37,7 +37,7 @@
37
37
  <script setup lang="ts">
38
38
  import { AnimatePresence, motion } from "motion-v";
39
39
  import { useScale } from "../Scale";
40
- import { useOverlayLayer } from "~/composables";
40
+ import { useOverlayLayer, useGpuAcceleration } from "~/composables";
41
41
  import type { UixySheetProps } from "./Sheet.types";
42
42
  import { sheetStyles } from "./Sheet.styles";
43
43
 
@@ -72,6 +72,8 @@
72
72
 
73
73
  const { zIndex, acquire, release } = useOverlayLayer();
74
74
 
75
+ useGpuAcceleration();
76
+
75
77
  const open = defineModel<boolean>();
76
78
 
77
79
  const handleClick = () => {
@@ -2,11 +2,17 @@ export * from "./Accordion";
2
2
  export * from "./Alert";
3
3
  export * from "./Avatar";
4
4
  export * from "./Badge";
5
+ export * from "./Breadcrumb";
5
6
  export * from "./Button";
6
7
  export * from "./Calendar";
7
8
  export * from "./Card";
9
+ export * from "./Chart";
8
10
  export * from "./Checkbox";
11
+ export * from "./ColorPicker";
9
12
  export * from "./Command";
13
+ export * from "./DatePicker";
14
+ export * from "./DateRangePicker";
15
+ export * from "./FileUpload";
10
16
  export * from "./Icon";
11
17
  export * from "./IconButton";
12
18
  export * from "./Input";
@@ -18,12 +24,14 @@ export * from "./PieChart";
18
24
  export * from "./Popover";
19
25
  export * from "./Radio";
20
26
  export * from "./Scale";
27
+ export * from "./SegmentedControl";
21
28
  export * from "./Select";
22
29
  export * from "./Separator";
23
30
  export * from "./Sheet";
24
31
  export * from "./Skeleton";
25
32
  export * from "./Slider";
26
33
  export * from "./Switch";
34
+ export * from "./Table";
27
35
  export * from "./TableOfContents";
28
36
  export * from "./Tabs";
29
37
  export * from "./Textarea";
@@ -32,4 +40,3 @@ export * from "./TimePicker";
32
40
  export * from "./Toast";
33
41
  export * from "./Toggle";
34
42
  export * from "./Tooltip";
35
- export * from "./ColorPicker";
@@ -130,12 +130,12 @@
130
130
  "name": "Modal",
131
131
  "related-components": [],
132
132
  "packages": ["motion-v"],
133
- "composables": ["useOverlayLayer"]
133
+ "composables": ["useOverlayLayer", "useGpuAcceleration"]
134
134
  },
135
135
  {
136
136
  "name": "PieChart",
137
137
  "related-components": [],
138
- "packages": ["motion-v"],
138
+ "packages": [],
139
139
  "composables": []
140
140
  },
141
141
  {
@@ -178,7 +178,7 @@
178
178
  "name": "Sheet",
179
179
  "related-components": ["Scale"],
180
180
  "packages": ["motion-v"],
181
- "composables": ["useOverlayLayer"]
181
+ "composables": ["useOverlayLayer", "useGpuAcceleration"]
182
182
  },
183
183
  {
184
184
  "name": "Skeleton",
@@ -213,7 +213,7 @@
213
213
  {
214
214
  "name": "Tabs",
215
215
  "related-components": [],
216
- "packages": ["motion-v"],
216
+ "packages": ["motion-v", "uuid"],
217
217
  "composables": []
218
218
  },
219
219
  {