v-uixy 1.9.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v-uixy",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
4
4
  "private": false,
5
5
  "author": {
6
6
  "name": "Alan Haber",
@@ -10,37 +10,49 @@
10
10
  @keydown="handleKeydownSelect"
11
11
  v-bind="filteredAttrs"
12
12
  >
13
- <p
14
- v-if="props.placeholder && (!model || model.length === 0)"
15
- class="pointer-events-none select-none text-gray-500 dark:text-gray-700"
16
- >
17
- {{ props.placeholder }}
18
- </p>
19
- <div
20
- v-if="props.multiple"
21
- :class="badgesWrapperStyles({ disabled: props.disabled })"
22
- >
23
- <uixy-badge
24
- v-for="(item, index) in model as string[]"
25
- data-badge
26
- variant="tertiary"
27
- size="xs"
28
- shape="pill"
29
- :key="`${item}--${index}`"
30
- @click.stop="handleClickSelectedOption(item)"
13
+ <div :class="valueWrapperStyles()">
14
+ <p
15
+ v-if="props.placeholder && (!model || model.length === 0)"
16
+ class="pointer-events-none select-none truncate text-gray-500 dark:text-gray-700"
31
17
  >
32
- {{ getLabel(item) }}
33
- </uixy-badge>
18
+ {{ props.placeholder }}
19
+ </p>
20
+ <div
21
+ v-if="props.multiple"
22
+ :class="badgesWrapperStyles({ disabled: props.disabled })"
23
+ >
24
+ <uixy-badge
25
+ v-for="(item, index) in model as string[]"
26
+ data-badge
27
+ variant="tertiary"
28
+ size="xs"
29
+ shape="pill"
30
+ class="max-w-full"
31
+ :key="`${item}--${index}`"
32
+ @click.stop="handleClickSelectedOption(item)"
33
+ >
34
+ <span class="min-w-0 truncate">{{ getLabel(item) }}</span>
35
+ </uixy-badge>
36
+ </div>
37
+ <p v-else class="truncate">{{ getLabel(model as string) }}</p>
38
+ </div>
39
+
40
+ <div :class="trailingStyles()">
41
+ <span
42
+ v-if="hasMax"
43
+ :class="counterStyles({ full: isAtMax, size })"
44
+ aria-live="polite"
45
+ >
46
+ {{ selectedValues.length }}/{{ props.max }}
47
+ </span>
48
+ <uixy-icon
49
+ v-if="!props.multiple && model && minSelected === 0"
50
+ name="close"
51
+ :class="deselectIconStyles({ size })"
52
+ @click.stop="handleDeselect"
53
+ />
54
+ <uixy-icon name="chevron-down" :class="iconStyles({ open, size })" />
34
55
  </div>
35
- <p v-else>{{ getLabel(model as string) }}</p>
36
-
37
- <uixy-icon
38
- v-if="props.deselectable && !props.multiple && model"
39
- name="close"
40
- :class="deselectIconStyles({ size })"
41
- @click.stop="handleDeselect"
42
- />
43
- <uixy-icon name="chevron-down" :class="iconStyles({ open, size })" />
44
56
  </div>
45
57
 
46
58
  <teleport to="body">
@@ -78,6 +90,7 @@
78
90
  :depth="0"
79
91
  :is-selected="isSelected"
80
92
  :is-partially-selected="isPartiallySelected"
93
+ :is-blocked="isBlocked"
81
94
  :expanded-items="effectiveExpandedItems"
82
95
  :leaf-only="props.leafOnly"
83
96
  :multiple="props.multiple"
@@ -105,12 +118,14 @@
105
118
  <ul v-else class="flex flex-col gap-1 py-1">
106
119
  <li
107
120
  v-for="option in filteredOptions"
108
- :tabIndex="option.disabled ? -1 : 0"
121
+ :tabIndex="option.disabled || isBlocked(option) ? -1 : 0"
122
+ :aria-disabled="isBlocked(option) || undefined"
109
123
  :key="option.value ?? option.label"
110
124
  :class="
111
125
  itemStyles({
112
126
  selected: isSelected(option),
113
127
  disabled: !!option.disabled,
128
+ blocked: isBlocked(option),
114
129
  size,
115
130
  })
116
131
  "
@@ -197,6 +212,9 @@
197
212
  dropdownPanelStyles,
198
213
  listItemPaddingStyles,
199
214
  checkIconStyles,
215
+ valueWrapperStyles,
216
+ trailingStyles,
217
+ counterStyles,
200
218
  } from "./Select.styles";
201
219
  import type { UixySelectEmits, UixySelectProps } from "./Select.types";
202
220
  import { UixyBadge } from "../Badge";
@@ -207,7 +225,10 @@
207
225
  import { useOverlayLayer } from "~/composables";
208
226
  import SelectTreeItem from "./SelectTreeItem.vue";
209
227
 
210
- const props = defineProps<UixySelectProps>();
228
+ const props = withDefaults(defineProps<UixySelectProps>(), {
229
+ deselectable: true,
230
+ maxBehavior: "block",
231
+ });
211
232
 
212
233
  const emits = defineEmits<UixySelectEmits>();
213
234
 
@@ -249,8 +270,13 @@
249
270
  filteredOptions,
250
271
  effectiveExpandedItems,
251
272
  customAddVisible,
273
+ selectedValues,
274
+ hasMax,
275
+ isAtMax,
276
+ minSelected,
252
277
  isSelected,
253
278
  isPartiallySelected,
279
+ isBlocked,
254
280
  getLabel,
255
281
  toggleExpand,
256
282
  handleOpen,
@@ -340,7 +366,7 @@
340
366
 
341
367
  if (!isOpen) return;
342
368
 
343
- acquireLayer();
369
+ acquireLayer(refTrigger.value);
344
370
 
345
371
  void updateDropdownPosition();
346
372
 
@@ -1,7 +1,7 @@
1
1
  import styles from "~/utils/styles";
2
2
 
3
3
  export const selectStyles = styles(
4
- "relative select-none peer transition-colors ease-in-out duration-150 border border-gray-300 dark:border-gray-900 rounded-1 placeholder:text-gray-500 dark:placeholder:text-gray-700 dark:outline-gray-800 outline-offset-1 outline-gray-300",
4
+ "relative flex items-center gap-2 select-none peer transition-colors ease-in-out duration-150 border border-gray-300 dark:border-gray-900 rounded-1 placeholder:text-gray-500 dark:placeholder:text-gray-700 dark:outline-gray-800 outline-offset-1 outline-gray-300",
5
5
  {
6
6
  status: {
7
7
  default:
@@ -14,35 +14,56 @@ export const selectStyles = styles(
14
14
  "bg-white dark:bg-gray-1000 focus-visible:border-gray-500 dark:focus-visible:border-gray-600 cursor-pointer",
15
15
  },
16
16
  size: {
17
- sm: "pl-2.5 pr-5 py-1.5 text-xs min-h-[32px]",
18
- md: "pl-3 pr-6 py-2 text-sm min-h-[38px]",
19
- xl: "pl-3.5 pr-7 py-2.5 text-base min-h-[42px]",
17
+ sm: "pl-2.5 pr-1.5 py-1.5 text-xs min-h-[32px]",
18
+ md: "pl-3 pr-2 py-2 text-sm min-h-[38px]",
19
+ xl: "pl-3.5 pr-2.5 py-2.5 text-base min-h-[42px]",
20
+ },
21
+ },
22
+ );
23
+
24
+ export const valueWrapperStyles = styles("min-w-0 flex-1");
25
+
26
+ export const trailingStyles = styles(
27
+ "ml-auto flex shrink-0 items-center gap-1.5",
28
+ );
29
+
30
+ export const counterStyles = styles(
31
+ "pointer-events-none shrink-0 select-none tabular-nums transition-colors duration-150 ease-in-out",
32
+ {
33
+ full: {
34
+ true: "text-gray-900 dark:text-gray-300",
35
+ false: "text-gray-500 dark:text-gray-600",
36
+ },
37
+ size: {
38
+ sm: "text-[10px]",
39
+ md: "text-xs",
40
+ xl: "text-sm",
20
41
  },
21
42
  },
22
43
  );
23
44
 
24
45
  export const iconStyles = styles(
25
- "pointer-events-none absolute top-1/2 -translate-y-1/2 select-none transition-transform duration-200 ease-in-out",
46
+ "pointer-events-none shrink-0 select-none transition-transform duration-200 ease-in-out",
26
47
  {
27
48
  open: {
28
49
  true: "rotate-180",
29
50
  false: "",
30
51
  },
31
52
  size: {
32
- sm: "h-3.5 w-3.5 right-1.5",
33
- md: "h-4 w-4 right-2",
34
- xl: "h-4 w-4 right-2.5",
53
+ sm: "h-3.5 w-3.5",
54
+ md: "h-4 w-4",
55
+ xl: "h-4 w-4",
35
56
  },
36
57
  },
37
58
  );
38
59
 
39
60
  export const deselectIconStyles = styles(
40
- "absolute top-1/2 -translate-y-1/2 cursor-pointer z-10 text-gray-500 dark:text-gray-600 hover:text-black dark:hover:text-gray-400",
61
+ "shrink-0 cursor-pointer text-gray-500 dark:text-gray-600 hover:text-black dark:hover:text-gray-400",
41
62
  {
42
63
  size: {
43
- sm: "h-3.5 w-3.5 right-6",
44
- md: "h-4 w-4 right-7",
45
- xl: "h-4 w-4 right-8",
64
+ sm: "h-3.5 w-3.5",
65
+ md: "h-4 w-4",
66
+ xl: "h-4 w-4",
46
67
  },
47
68
  },
48
69
  );
@@ -71,6 +92,10 @@ export const itemStyles = styles(
71
92
  true: "pointer-events-none text-gray-500 dark:text-gray-700",
72
93
  false: "",
73
94
  },
95
+ blocked: {
96
+ true: "cursor-not-allowed text-gray-400 dark:text-gray-700 hover:bg-transparent dark:hover:bg-transparent focus-visible:bg-transparent dark:focus-visible:bg-transparent",
97
+ false: "",
98
+ },
74
99
  size: {
75
100
  sm: "py-1.5 px-2.5",
76
101
  md: "py-2 px-3",
@@ -20,6 +20,8 @@ export interface UixySelectEmits {
20
20
  (event: "openChange", isOpen: boolean): void;
21
21
  }
22
22
 
23
+ export type UixySelectMaxBehavior = "block" | "replace";
24
+
23
25
  export type UixySelectProps = {
24
26
  options: UixySelectOptionType[];
25
27
  status: "valid" | "error" | "default";
@@ -31,7 +33,9 @@ export type UixySelectProps = {
31
33
  helperText?: string;
32
34
  errorText?: string;
33
35
  autoFocus?: boolean;
36
+ min?: number;
34
37
  max?: number;
38
+ maxBehavior?: UixySelectMaxBehavior;
35
39
  tree?: boolean;
36
40
  leafOnly?: boolean;
37
41
  autoOpen?: boolean;
@@ -1,17 +1,19 @@
1
1
  <template>
2
2
  <li class="flex flex-col">
3
3
  <div
4
- :tabIndex="option.disabled ? -1 : 0"
4
+ :tabIndex="option.disabled || isOptionBlocked ? -1 : 0"
5
+ :aria-disabled="isOptionBlocked || undefined"
5
6
  :class="
6
7
  itemStyles({
7
8
  selected: isSelected(option),
8
9
  disabled: !!option.disabled,
10
+ blocked: isOptionBlocked,
9
11
  size: props.size ?? 'md',
10
12
  })
11
13
  "
12
14
  :style="{
13
15
  paddingLeft: `${depth * 28 + 12}px`,
14
- cursor: option.disabled ? 'not-allowed' : 'pointer',
16
+ cursor: option.disabled || isOptionBlocked ? 'not-allowed' : 'pointer',
15
17
  }"
16
18
  @click.stop="handleItemClick"
17
19
  >
@@ -65,6 +67,7 @@
65
67
  :depth="depth + 1"
66
68
  :is-selected="isSelected"
67
69
  :is-partially-selected="isPartiallySelected"
70
+ :is-blocked="isBlocked"
68
71
  :expanded-items="expandedItems"
69
72
  :leaf-only="leafOnly"
70
73
  :multiple="multiple"
@@ -91,6 +94,7 @@
91
94
  depth: number;
92
95
  isSelected: (option: UixySelectOptionType) => boolean;
93
96
  isPartiallySelected?: (option: UixySelectOptionType) => boolean;
97
+ isBlocked?: (option: UixySelectOptionType) => boolean;
94
98
  expandedItems: string[];
95
99
  leafOnly?: boolean;
96
100
  multiple?: boolean;
@@ -139,6 +143,10 @@
139
143
  props.expandedItems.includes(getValue(props.option)),
140
144
  );
141
145
 
146
+ const isOptionBlocked = computed(
147
+ () => !!props.isBlocked?.(props.option),
148
+ );
149
+
142
150
  const isNotSelectable = computed(
143
151
  () =>
144
152
  props.option.disabled ||
@@ -146,7 +154,7 @@
146
154
  );
147
155
 
148
156
  const handleItemClick = () => {
149
- if (props.option.disabled) return;
157
+ if (props.option.disabled || isOptionBlocked.value) return;
150
158
 
151
159
  if (props.leafOnly && hasChildrenInOriginal.value && !props.multiple) {
152
160
  emit("toggle-expand", getValue(props.option));
@@ -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
  };
@@ -1,73 +1,123 @@
1
1
  import { computed, nextTick, onScopeDispose, ref, type ComputedRef } from "vue";
2
2
 
3
+ const PAGE_ANCHORED_Z = 30;
3
4
  const BASE_Z = 60;
4
5
  const STEP_Z = 10;
5
6
 
7
+ const MANAGED_ATTR = "data-uixy-inert-managed";
8
+ const OVERLAY_ATTR = "data-uixy-overlay";
9
+ export const MODAL_ATTR = "data-uixy-modal";
10
+ export const MODAL_ID_ATTR = "data-uixy-modal-id";
11
+
6
12
  let seq = 0;
7
13
 
8
14
  const layers = ref<number[]>([]);
9
15
 
16
+ const modalStack = ref<number[]>([]);
17
+
18
+ const zOf = (el: Element): number => {
19
+ const raw = window.getComputedStyle(el).zIndex;
20
+ const parsed = Number.parseInt(raw ?? "", 10);
21
+
22
+ return Number.isFinite(parsed) ? parsed : 0;
23
+ };
24
+
25
+ const hasPinnedAncestor = (el: HTMLElement | null): boolean => {
26
+ while (el) {
27
+ const { position } = window.getComputedStyle(el);
28
+
29
+ if (position === "fixed" || position === "sticky") return true;
30
+
31
+ el = el.parentElement;
32
+ }
33
+
34
+ return false;
35
+ };
36
+
37
+ const isAnchoredToPage = (anchor: HTMLElement): boolean => {
38
+ const overlay = anchor.closest(`[${OVERLAY_ATTR}]`);
39
+
40
+ if (overlay && zOf(overlay) > PAGE_ANCHORED_Z) return false;
41
+
42
+ return !hasPinnedAncestor(anchor);
43
+ };
44
+
10
45
  export interface UseOverlayLayerReturn {
11
46
  id: number;
12
47
  zIndex: ComputedRef<number>;
13
48
  isTop: ComputedRef<boolean>;
14
49
  isActive: ComputedRef<boolean>;
15
- acquire: () => void;
50
+ acquire: (anchor?: HTMLElement | null) => void;
16
51
  release: () => void;
17
52
  }
18
53
 
19
54
  export function useOverlayLayer(): UseOverlayLayerReturn {
20
55
  const id = ++seq;
21
56
 
57
+ const pageAnchored = ref(false);
58
+
22
59
  const index = computed(() => layers.value.indexOf(id));
23
60
  const isActive = computed(() => index.value !== -1);
24
61
 
25
- const zIndex = computed(() =>
26
- isActive.value ? BASE_Z + (index.value + 1) * STEP_Z : BASE_Z + STEP_Z,
27
- );
62
+ const zIndex = computed(() => {
63
+ if (pageAnchored.value) return PAGE_ANCHORED_Z;
64
+
65
+ const position = isActive.value ? index.value + 1 : 1;
66
+
67
+ return BASE_Z + position * STEP_Z;
68
+ });
28
69
 
29
70
  const isTop = computed(
30
71
  () => isActive.value && index.value === layers.value.length - 1,
31
72
  );
32
73
 
33
- const acquire = () => {
34
- if (!layers.value.includes(id)) layers.value = [...layers.value, id];
35
- };
36
-
37
74
  const release = () => {
38
- if (layers.value.includes(id))
75
+ pageAnchored.value = false;
76
+
77
+ if (layers.value.includes(id)) {
39
78
  layers.value = layers.value.filter((x) => x !== id);
79
+ }
40
80
  };
41
81
 
42
- onScopeDispose(release);
82
+ const acquire = (anchor?: HTMLElement | null) => {
83
+ if (anchor && isAnchoredToPage(anchor)) {
84
+ release();
43
85
 
44
- return { id, zIndex, isTop, isActive, acquire, release };
45
- }
86
+ pageAnchored.value = true;
46
87
 
47
- const MANAGED_ATTR = "data-uixy-inert-managed";
48
- const OVERLAY_ATTR = "data-uixy-overlay";
49
- export const MODAL_ATTR = "data-uixy-modal";
50
- export const MODAL_ID_ATTR = "data-uixy-modal-id";
88
+ return;
89
+ }
51
90
 
52
- const modalStack = ref<number[]>([]);
91
+ pageAnchored.value = false;
53
92
 
54
- const zOf = (el: Element): number => {
55
- const raw = window.getComputedStyle(el).zIndex;
56
- const parsed = Number.parseInt(raw ?? "", 10);
57
- return Number.isFinite(parsed) ? parsed : 0;
58
- };
93
+ if (!layers.value.includes(id)) {
94
+ layers.value = [...layers.value, id];
95
+ }
96
+ };
97
+
98
+ onScopeDispose(release);
99
+
100
+ return { id, zIndex, isTop, isActive, acquire, release };
101
+ }
59
102
 
60
103
  export const getTopModalEl = (): HTMLElement | null => {
61
104
  if (typeof document === "undefined") return null;
62
- const els = Array.from(
105
+
106
+ const modals = Array.from(
63
107
  document.querySelectorAll<HTMLElement>(`[${MODAL_ATTR}]`),
64
- ).filter((el) => {
65
- const raw = el.getAttribute(MODAL_ID_ATTR);
66
- if (raw === null) return true;
67
- return modalStack.value.includes(Number(raw));
108
+ );
109
+
110
+ const openModals = modals.filter((el) => {
111
+ const rawId = el.getAttribute(MODAL_ID_ATTR);
112
+
113
+ if (rawId === null) return true;
114
+
115
+ return modalStack.value.includes(Number(rawId));
68
116
  });
69
- if (els.length === 0) return null;
70
- return els.reduce((a, b) => (zOf(b) >= zOf(a) ? b : a));
117
+
118
+ if (openModals.length === 0) return null;
119
+
120
+ return openModals.reduce((a, b) => (zOf(b) >= zOf(a) ? b : a));
71
121
  };
72
122
 
73
123
  function reconcileInert() {
@@ -80,6 +130,7 @@ function reconcileInert() {
80
130
  });
81
131
 
82
132
  const topEl = getTopModalEl();
133
+
83
134
  if (!topEl) return;
84
135
 
85
136
  const topZ = zOf(topEl);
@@ -87,7 +138,6 @@ function reconcileInert() {
87
138
  Array.from(document.body.children).forEach((el) => {
88
139
  if (!(el instanceof HTMLElement)) return;
89
140
  if (el === topEl || el.contains(topEl)) return;
90
-
91
141
  if (el.hasAttribute(OVERLAY_ATTR) && zOf(el) > topZ) return;
92
142
 
93
143
  el.setAttribute("inert", "");
@@ -113,14 +163,19 @@ export function useModalLayer(): UseModalLayerReturn {
113
163
 
114
164
  const open = () => {
115
165
  layer.acquire();
116
- if (!modalStack.value.includes(layer.id))
166
+
167
+ if (!modalStack.value.includes(layer.id)) {
117
168
  modalStack.value = [...modalStack.value, layer.id];
169
+ }
170
+
118
171
  nextTick(reconcileInert);
119
172
  };
120
173
 
121
174
  const close = () => {
122
175
  layer.release();
176
+
123
177
  modalStack.value = modalStack.value.filter((x) => x !== layer.id);
178
+
124
179
  reconcileInert();
125
180
  nextTick(reconcileInert);
126
181
  };
@@ -118,7 +118,7 @@ export function usePosition({
118
118
  [active, () => direction, () => align],
119
119
  ([isActive]) => {
120
120
  if (isActive) {
121
- acquire();
121
+ acquire(target.value);
122
122
  updatePosition();
123
123
  observeResize();
124
124
  window.addEventListener("resize", updatePosition);