v-uixy 1.8.1 → 1.9.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/commands/add.js CHANGED
@@ -72,7 +72,25 @@ async function copyComponent(name) {
72
72
  console.log(chalk.green(`✔ Copied ${name} to app/components/ui/${name}`));
73
73
  }
74
74
 
75
- async function copyComposable(name) {
75
+ async function resolveComposableDeps(src, seen) {
76
+ const content = await fs.readFile(src, "utf8");
77
+ const deps = new Set();
78
+
79
+ for (const match of content.matchAll(
80
+ /(?:import|export)[^"']*from\s+["']\.\/([A-Za-z0-9_-]+)["']/g
81
+ )) {
82
+ deps.add(match[1]);
83
+ }
84
+
85
+ for (const dep of deps) {
86
+ if (!seen.has(dep)) await copyComposable(dep, seen);
87
+ }
88
+ }
89
+
90
+ async function copyComposable(name, seen = new Set()) {
91
+ if (seen.has(name)) return;
92
+ seen.add(name);
93
+
76
94
  const src = path.join(templatesRoot, `composables/${name}.ts`);
77
95
  const { composablesDir } = await getProjectPaths();
78
96
  const dest = path.join(composablesDir, `${name}.ts`);
@@ -87,6 +105,8 @@ async function copyComposable(name) {
87
105
  console.log(chalk.green(`✔ Copied composable: ${name}.ts`));
88
106
 
89
107
  await updateComposablesIndex(name);
108
+
109
+ await resolveComposableDeps(src, seen);
90
110
  }
91
111
 
92
112
  async function getExistingIndexComponents() {
@@ -212,12 +232,32 @@ async function ensureTsconfig() {
212
232
  }
213
233
  }
214
234
 
235
+ async function copyTypes() {
236
+ const typesSrcDir = path.join(templatesRoot, "types");
237
+ if (!(await fs.pathExists(typesSrcDir))) return;
238
+
239
+ const typesDestDir = path.join(projectRoot, "app/types");
240
+ await fs.ensureDir(typesDestDir);
241
+
242
+ const files = await fs.readdir(typesSrcDir);
243
+ for (const file of files) {
244
+ await fs.copy(
245
+ path.join(typesSrcDir, file),
246
+ path.join(typesDestDir, file),
247
+ { overwrite: true }
248
+ );
249
+ }
250
+
251
+ console.log(chalk.green("✔ Synced type definitions to app/types"));
252
+ }
253
+
215
254
  export default async function add(componentName) {
216
255
  const registry = await loadRegistry();
217
256
  let componentsToAdd = new Set();
218
257
 
219
258
  await ensureTypeScriptInstalled();
220
259
  await ensureTsconfig();
260
+ await copyTypes();
221
261
 
222
262
  if (componentName.toLowerCase() === "all") {
223
263
  componentsToAdd = new Set(registry.map((c) => c.name));
@@ -226,6 +266,7 @@ export default async function add(componentName) {
226
266
  }
227
267
 
228
268
  const copied = [];
269
+ const copiedComposables = new Set();
229
270
 
230
271
  for (const name of componentsToAdd) {
231
272
  await copyComponent(name);
@@ -233,7 +274,7 @@ export default async function add(componentName) {
233
274
 
234
275
  const def = registry.find((c) => c.name === name);
235
276
  for (const composable of def?.composables || []) {
236
- await copyComposable(composable);
277
+ await copyComposable(composable, copiedComposables);
237
278
  }
238
279
  }
239
280
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v-uixy",
3
- "version": "1.8.1",
3
+ "version": "1.9.1",
4
4
  "private": false,
5
5
  "author": {
6
6
  "name": "Alan Haber",
@@ -24,7 +24,7 @@
24
24
  "tailwindcss"
25
25
  ],
26
26
  "engines": {
27
- "node": ">=20"
27
+ "node": ">=22.13.0"
28
28
  },
29
29
  "files": [
30
30
  "bin/",
@@ -39,10 +39,10 @@
39
39
  "url": "git+https://github.com/haberalan/v-uixy.git"
40
40
  },
41
41
  "dependencies": {
42
- "chalk": "^5.4.1",
43
- "commander": "^14.0.0",
44
- "execa": "^9.5.3",
45
- "fs-extra": "^11.3.0",
46
- "inquirer": "^12.6.3"
42
+ "chalk": "^6.0.0",
43
+ "commander": "^15.0.0",
44
+ "execa": "^10.0.1",
45
+ "fs-extra": "^11.4.0",
46
+ "inquirer": "^14.2.1"
47
47
  }
48
48
  }
@@ -1,40 +1,41 @@
1
1
  <template>
2
2
  <uixy-link
3
3
  v-if="props.link"
4
- :class="linkStyles({ disabled: !!props.disabled }, $attrs.class as string)"
4
+ :class="[
5
+ buttonStyles({ variant, size, rounded: !!props.rounded }, $attrs.class as string),
6
+ linkStyles({ disabled: !!props.disabled }),
7
+ ]"
5
8
  :tabindex="props.disabled ? -1 : 0"
6
9
  v-bind="{ ...props.link, ...filteredAttrs }"
7
10
  >
8
- <div :class="buttonStyles({ variant, size, rounded: !!props.rounded })">
9
- <animate-presence :initial="false">
10
- <motion.div
11
- v-if="props.loading"
12
- class="absolute top-1/2 flex size-6 -translate-y-1/2 items-center justify-center"
13
- :initial="{ opacity: 0 }"
14
- :animate="{ opacity: 1 }"
15
- :exit="{ opacity: 0 }"
16
- :transition="{ ease: 'easeInOut', duration: 0.15 }"
17
- >
18
- <uixy-loader size="sm" />
19
- </motion.div>
20
- </animate-presence>
11
+ <animate-presence :initial="false">
12
+ <motion.div
13
+ v-if="props.loading"
14
+ class="absolute top-1/2 flex size-6 -translate-y-1/2 items-center justify-center"
15
+ :initial="{ opacity: 0 }"
16
+ :animate="{ opacity: 1 }"
17
+ :exit="{ opacity: 0 }"
18
+ :transition="{ ease: 'easeInOut', duration: 0.15 }"
19
+ >
20
+ <uixy-loader size="sm" />
21
+ </motion.div>
22
+ </animate-presence>
21
23
 
22
- <animate-presence :initial="false">
23
- <motion.div
24
- class="flex items-center justify-center gap-1"
25
- :initial="{ opacity: 0 }"
26
- :animate="{ opacity: props.loading ? 0 : 1 }"
27
- :transition="{ ease: 'easeInOut', duration: 0.15 }"
28
- >
29
- <uixy-icon
30
- v-if="props.icon && !props.loading"
31
- :name="props.icon"
32
- :class="iconStyles({ position: props.iconPosition ?? 'left' })"
33
- />
34
- <slot />
35
- </motion.div>
36
- </animate-presence>
37
- </div>
24
+ <animate-presence :initial="false">
25
+ <motion.div
26
+ class="flex items-center justify-center gap-1"
27
+ :initial="{ opacity: 0 }"
28
+ :animate="{ opacity: props.loading ? 0 : 1 }"
29
+ :transition="{ ease: 'easeInOut', duration: 0.15 }"
30
+ >
31
+ <uixy-icon
32
+ v-if="props.icon"
33
+ :name="props.icon"
34
+ :class="iconStyles({ position: props.iconPosition ?? 'left' })"
35
+ />
36
+ <slot />
37
+ </motion.div>
38
+ </animate-presence>
38
39
  </uixy-link>
39
40
 
40
41
  <button
@@ -66,7 +67,7 @@
66
67
  :transition="{ ease: 'easeInOut', duration: 0.15 }"
67
68
  >
68
69
  <uixy-icon
69
- v-if="props.icon && !props.loading"
70
+ v-if="props.icon"
70
71
  :name="props.icon"
71
72
  :class="iconStyles({ position: props.iconPosition ?? 'left' })"
72
73
  />
@@ -32,7 +32,7 @@ export const iconStyles = styles("h-5 w-5 shrink-0", {
32
32
  },
33
33
  });
34
34
 
35
- export const linkStyles = styles("rounded-1", {
35
+ export const linkStyles = styles("", {
36
36
  disabled: {
37
37
  true: "pointer-events-none",
38
38
  false: "",
@@ -1,10 +1,25 @@
1
1
  <template>
2
- <div class="flex w-full flex-col gap-1">
3
- <label v-if="props.label" :class="labelStyles({ status })">
2
+ <div :class="['flex flex-col gap-1', $slots.trigger ? 'w-fit' : 'w-full']">
3
+ <label v-if="props.label && !$slots.trigger" :class="labelStyles({ status })">
4
4
  {{ props.label }}
5
5
  </label>
6
6
 
7
7
  <div
8
+ v-if="$slots.trigger"
9
+ ref="triggerRef"
10
+ class="inline-flex w-fit"
11
+ role="button"
12
+ :tabindex="props.disabled ? -1 : 0"
13
+ :aria-expanded="active"
14
+ @click="onTriggerClick"
15
+ @keydown.enter.prevent="onTriggerClick"
16
+ @keydown.space.prevent="onTriggerClick"
17
+ >
18
+ <slot name="trigger" :open="active" :value="model" />
19
+ </div>
20
+
21
+ <div
22
+ v-else
8
23
  ref="triggerRef"
9
24
  role="button"
10
25
  :tabindex="props.disabled ? -1 : 0"
@@ -61,6 +76,7 @@
61
76
  <animate-presence>
62
77
  <motion.div
63
78
  v-if="active"
79
+ data-uixy-overlay
64
80
  :initial="{ opacity: 0 }"
65
81
  :animate="{ opacity: 1 }"
66
82
  :exit="{ opacity: 0 }"
@@ -149,7 +165,12 @@
149
165
 
150
166
  const triggerRef = ref<HTMLElement>();
151
167
 
152
- const draftTime = ref<UixyTimeParts>(timePartsOf(model.value));
168
+ const seedTime = (): UixyTimeParts =>
169
+ model.value
170
+ ? timePartsOf(model.value)
171
+ : { hour: props.defaultHour ?? 0, minute: 0, second: 0 };
172
+
173
+ const draftTime = ref<UixyTimeParts>(seedTime());
153
174
 
154
175
  const status = computed(() => {
155
176
  if (props.disabled) return "disabled";
@@ -208,11 +229,11 @@
208
229
  const clear = () => {
209
230
  model.value = null;
210
231
 
211
- draftTime.value = { hour: 0, minute: 0, second: 0 };
232
+ draftTime.value = { hour: props.defaultHour ?? 0, minute: 0, second: 0 };
212
233
  };
213
234
 
214
235
  watch(active, (isActive) => {
215
- if (isActive) draftTime.value = timePartsOf(model.value);
236
+ if (isActive) draftTime.value = seedTime();
216
237
  });
217
238
 
218
239
  const onDocClick = (event: MouseEvent) => {
@@ -11,6 +11,7 @@ export interface UixyDatePickerProps {
11
11
  icon?: IconName;
12
12
  withTime?: boolean;
13
13
  withSeconds?: boolean;
14
+ defaultHour?: number;
14
15
  startOfWeek?: "Sunday" | "Monday";
15
16
  isDateDisabled?: (date: Date) => boolean;
16
17
  direction?: "top" | "bottom";
@@ -64,6 +64,7 @@
64
64
  <animate-presence>
65
65
  <motion.div
66
66
  v-if="active"
67
+ data-uixy-overlay
67
68
  :initial="{ opacity: 0 }"
68
69
  :animate="{ opacity: 1 }"
69
70
  :exit="{ opacity: 0 }"
@@ -18,14 +18,16 @@ export const inputStyles = styles(
18
18
  xl: "px-3.5 py-2.5 text-base",
19
19
  },
20
20
  icon: {
21
- left: "pl-8",
22
- right: "pr-8",
21
+ left: "",
22
+ right: "",
23
23
  none: "",
24
24
  },
25
25
  },
26
26
  {
27
27
  "size.sm+icon.left": "pl-7",
28
28
  "size.sm+icon.right": "pr-7",
29
+ "size.md+icon.left": "pl-9",
30
+ "size.md+icon.right": "pr-9",
29
31
  "size.xl+icon.left": "pl-9",
30
32
  "size.xl+icon.right": "pr-9",
31
33
  },
@@ -4,12 +4,16 @@
4
4
  <animate-presence>
5
5
  <motion.div
6
6
  v-if="open"
7
+ ref="rootRef"
7
8
  :initial="{ opacity: 0 }"
8
9
  :animate="{ opacity: 1 }"
9
10
  :exit="{ opacity: 0 }"
10
11
  :transition="{ duration: 0.15, ease: 'easeInOut' }"
11
12
  :class="modalStyles($attrs.class as string)"
12
- data-modal
13
+ :style="{ zIndex }"
14
+ :[modalAttr]="''"
15
+ :data-uixy-modal-id="id"
16
+ data-uixy-overlay
13
17
  @click.self="handleClick"
14
18
  >
15
19
  <motion.div
@@ -32,6 +36,7 @@
32
36
  import type { UixyModalProps } from "./Modal.types";
33
37
  import { modalStyles } from "./Modal.styles";
34
38
  import { AnimatePresence, motion } from "motion-v";
39
+ import { useModalLayer, MODAL_ATTR, getTopModalEl } from "~/composables";
35
40
 
36
41
  const props = defineProps<UixyModalProps>();
37
42
 
@@ -41,6 +46,18 @@
41
46
 
42
47
  const open = defineModel<boolean>();
43
48
 
49
+ const rootRef = ref<HTMLElement>();
50
+
51
+ const {
52
+ id,
53
+ zIndex,
54
+ isTopModal,
55
+ open: openLayer,
56
+ close: closeLayer,
57
+ } = useModalLayer();
58
+
59
+ const modalAttr = computed(() => (open.value ? MODAL_ATTR : null));
60
+
44
61
  const handleClick = () => {
45
62
  if (props.persistent || props.loading) return;
46
63
 
@@ -48,6 +65,8 @@
48
65
  };
49
66
 
50
67
  const handleKeyDown = (e: KeyboardEvent) => {
68
+ if (!isTopModal.value) return;
69
+
51
70
  if (e.key === "Escape") {
52
71
  handleClick();
53
72
  return;
@@ -55,39 +74,21 @@
55
74
  if (e.key === "Enter") {
56
75
  const target = e.target as HTMLElement;
57
76
  if (target.tagName === "TEXTAREA" || target.tagName === "BUTTON") return;
58
- const modalEl = document.querySelector("[data-modal]");
59
- const form = modalEl?.querySelector("form");
77
+ const form =
78
+ rootRef.value?.querySelector("form") ??
79
+ getTopModalEl()?.querySelector("form");
60
80
  form?.requestSubmit();
61
81
  }
62
82
  };
63
83
 
64
- const toggleSiblingsInert = (enable?: boolean) => {
65
- if (typeof document === "undefined") return;
66
-
67
- const modalEl = document.querySelector(
68
- "[data-modal]",
69
- ) as HTMLElement | null;
70
-
71
- Array.from(document.body.children).forEach((el) => {
72
- if (modalEl && (el === modalEl || el.contains(modalEl))) return;
73
-
74
- if (enable) {
75
- el.setAttribute("inert", "");
76
- el.setAttribute("aria-hidden", "true");
77
- } else {
78
- el.removeAttribute("inert");
79
- el.removeAttribute("aria-hidden");
80
- }
81
- });
82
- };
83
-
84
84
  watch(
85
85
  open,
86
86
  (isOpen) => {
87
- toggleSiblingsInert(isOpen);
88
87
  if (isOpen) {
88
+ openLayer();
89
89
  window.addEventListener("keydown", handleKeyDown);
90
90
  } else {
91
+ closeLayer();
91
92
  window.removeEventListener("keydown", handleKeyDown);
92
93
  }
93
94
  },
@@ -96,5 +97,6 @@
96
97
 
97
98
  onUnmounted(() => {
98
99
  window.removeEventListener("keydown", handleKeyDown);
100
+ closeLayer();
99
101
  });
100
102
  </script>
@@ -1,7 +1,7 @@
1
1
  import styles from "~/utils/styles";
2
2
 
3
3
  const modalStyles = styles(
4
- "fixed left-0 top-0 z-[60] flex size-full items-center justify-center bg-white/5 backdrop-blur-[2px] dark:bg-gray-900/5"
4
+ "fixed left-0 top-0 flex size-full items-center justify-center bg-white/5 backdrop-blur-[2px] dark:bg-gray-900/5"
5
5
  );
6
6
 
7
7
  export { modalStyles };
@@ -4,6 +4,7 @@
4
4
  <animate-presence>
5
5
  <motion.div
6
6
  v-if="active"
7
+ data-uixy-overlay
7
8
  :initial="{ opacity: 0 }"
8
9
  :animate="{ opacity: 1 }"
9
10
  :exit="{ opacity: 0 }"
@@ -47,8 +47,9 @@
47
47
  <animate-presence>
48
48
  <motion.div
49
49
  v-if="open && !props.disabled"
50
+ data-uixy-overlay
50
51
  :class="dropdownPanelStyles({ size })"
51
- :style="dropdownStyles"
52
+ :style="[dropdownStyles, { zIndex: dropdownZIndex }]"
52
53
  :initial="{ opacity: 0, y: -4, scale: 0.98 }"
53
54
  :animate="{ opacity: 1, y: 0, scale: 1 }"
54
55
  :exit="{ opacity: 0, scale: 0.98 }"
@@ -203,6 +204,7 @@
203
204
  import { UixyInput } from "../Input";
204
205
  import { motion, AnimatePresence } from "motion-v";
205
206
  import { useSelect } from "./composables";
207
+ import { useOverlayLayer } from "~/composables";
206
208
  import SelectTreeItem from "./SelectTreeItem.vue";
207
209
 
208
210
  const props = defineProps<UixySelectProps>();
@@ -234,6 +236,12 @@
234
236
  width: "0px",
235
237
  });
236
238
 
239
+ const {
240
+ zIndex: dropdownZIndex,
241
+ acquire: acquireLayer,
242
+ release: releaseLayer,
243
+ } = useOverlayLayer();
244
+
237
245
  const {
238
246
  refOptions,
239
247
  open,
@@ -332,6 +340,8 @@
332
340
 
333
341
  if (!isOpen) return;
334
342
 
343
+ acquireLayer();
344
+
335
345
  void updateDropdownPosition();
336
346
 
337
347
  if (props.search) {
@@ -351,6 +361,7 @@
351
361
  }
352
362
 
353
363
  onWatcherCleanup(() => {
364
+ releaseLayer();
354
365
  window.removeEventListener("resize", onResize);
355
366
  window.removeEventListener("scroll", onAnyScroll, true);
356
367
  dropdownResizeObserver?.disconnect();
@@ -359,6 +370,7 @@
359
370
  );
360
371
 
361
372
  onUnmounted(() => {
373
+ releaseLayer();
362
374
  dropdownResizeObserver?.disconnect();
363
375
  });
364
376
 
@@ -88,7 +88,7 @@ export const listItemPaddingStyles = styles("", {
88
88
  });
89
89
 
90
90
  export const dropdownPanelStyles = styles(
91
- "scrollbar z-50 max-h-60 overflow-y-auto rounded-1 border border-gray-300 bg-white p-1 shadow-sm dark:border-gray-900 dark:bg-gray-1000",
91
+ "scrollbar max-h-60 overflow-y-auto rounded-1 border border-gray-300 bg-white p-1 shadow-sm dark:border-gray-900 dark:bg-gray-1000",
92
92
  {
93
93
  size: {
94
94
  sm: "text-xs",
@@ -8,8 +8,10 @@
8
8
  :animate="{ opacity: 1 }"
9
9
  :exit="{ opacity: 0 }"
10
10
  :transition="{ ease: 'easeInOut', duration: 0.15 }"
11
+ :style="{ zIndex }"
12
+ data-uixy-overlay
11
13
  @click.self="handleClick"
12
- class="fixed left-0 top-0 z-20 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 backdrop-blur-[2px] dark:bg-gray-900/5"
13
15
  />
14
16
  <motion.div
15
17
  initial="initial"
@@ -17,6 +19,8 @@
17
19
  exit="exit"
18
20
  :variants="ANIMATIONS[props.direction ?? 'right']"
19
21
  :transition="{ ease: 'easeInOut', duration: 0.3 }"
22
+ :style="{ zIndex }"
23
+ data-uixy-overlay
20
24
  :class="
21
25
  sheetStyles({ direction: props.direction ?? 'right' }, $attrs.class as string)
22
26
  "
@@ -33,6 +37,7 @@
33
37
  <script setup lang="ts">
34
38
  import { AnimatePresence, motion } from "motion-v";
35
39
  import { useScale } from "../Scale";
40
+ import { useOverlayLayer } from "~/composables";
36
41
  import type { UixySheetProps } from "./Sheet.types";
37
42
  import { sheetStyles } from "./Sheet.styles";
38
43
 
@@ -65,6 +70,8 @@
65
70
 
66
71
  const { setScale } = useScale();
67
72
 
73
+ const { zIndex, acquire, release } = useOverlayLayer();
74
+
68
75
  const open = defineModel<boolean>();
69
76
 
70
77
  const handleClick = () => {
@@ -74,13 +81,19 @@
74
81
  watch(
75
82
  () => open.value,
76
83
  (v) => {
77
- if (v) return setScale(0.98);
84
+ if (v) {
85
+ acquire();
86
+ return setScale(0.98);
87
+ }
78
88
 
89
+ release();
79
90
  setScale(1);
80
91
  }
81
92
  );
82
93
 
83
94
  onUnmounted(async () => {
95
+ release();
96
+
84
97
  await nextTick();
85
98
 
86
99
  setScale(1);
@@ -1,7 +1,7 @@
1
1
  import styles from "~/utils/styles";
2
2
 
3
3
  export const sheetStyles = styles(
4
- "fixed top-0 z-50 h-full w-full max-w-[320px] border-gray-300 bg-gray-100 shadow-xl dark:border-gray-900 dark:bg-black",
4
+ "fixed top-0 h-full w-full max-w-[320px] border-gray-300 bg-gray-100 shadow-xl dark:border-gray-900 dark:bg-black",
5
5
  {
6
6
  direction: {
7
7
  left: "left-0 border-r",
@@ -10,6 +10,7 @@
10
10
  <animate-presence>
11
11
  <motion.div
12
12
  v-if="active"
13
+ data-uixy-overlay
13
14
  class="max-w-[100vw]"
14
15
  :initial="{ opacity: 0 }"
15
16
  :animate="{ opacity: 1 }"
@@ -76,13 +76,13 @@
76
76
  "name": "DatePicker",
77
77
  "related-components": ["Icon", "Button", "Calendar"],
78
78
  "packages": ["motion-v"],
79
- "composables": ["usePosition"]
79
+ "composables": ["usePosition", "useOverlayLayer"]
80
80
  },
81
81
  {
82
82
  "name": "DateRangePicker",
83
83
  "related-components": ["Icon", "Button", "Calendar", "DatePicker"],
84
84
  "packages": ["motion-v"],
85
- "composables": ["usePosition"]
85
+ "composables": ["usePosition", "useOverlayLayer"]
86
86
  },
87
87
  {
88
88
  "name": "FileUpload",
@@ -130,7 +130,7 @@
130
130
  "name": "Modal",
131
131
  "related-components": [],
132
132
  "packages": ["motion-v"],
133
- "composables": []
133
+ "composables": ["useOverlayLayer"]
134
134
  },
135
135
  {
136
136
  "name": "PieChart",
@@ -142,7 +142,7 @@
142
142
  "name": "Popover",
143
143
  "related-components": [],
144
144
  "packages": ["motion-v", "uuid"],
145
- "composables": ["usePosition"]
145
+ "composables": ["usePosition", "useOverlayLayer"]
146
146
  },
147
147
  {
148
148
  "name": "Radio",
@@ -166,7 +166,7 @@
166
166
  "name": "Select",
167
167
  "related-components": ["Badge", "Icon", "Input"],
168
168
  "packages": ["motion-v"],
169
- "composables": ["useInputValidation"]
169
+ "composables": ["useInputValidation", "useOverlayLayer"]
170
170
  },
171
171
  {
172
172
  "name": "Separator",
@@ -178,7 +178,7 @@
178
178
  "name": "Sheet",
179
179
  "related-components": ["Scale"],
180
180
  "packages": ["motion-v"],
181
- "composables": []
181
+ "composables": ["useOverlayLayer"]
182
182
  },
183
183
  {
184
184
  "name": "Skeleton",
@@ -250,7 +250,7 @@
250
250
  "name": "Tooltip",
251
251
  "related-components": [],
252
252
  "packages": ["motion-v"],
253
- "composables": ["usePosition"]
253
+ "composables": ["usePosition", "useOverlayLayer"]
254
254
  }
255
255
  ]
256
256
  }
@@ -0,0 +1,131 @@
1
+ import { computed, nextTick, onScopeDispose, ref, type ComputedRef } from "vue";
2
+
3
+ const BASE_Z = 60;
4
+ const STEP_Z = 10;
5
+
6
+ let seq = 0;
7
+
8
+ const layers = ref<number[]>([]);
9
+
10
+ export interface UseOverlayLayerReturn {
11
+ id: number;
12
+ zIndex: ComputedRef<number>;
13
+ isTop: ComputedRef<boolean>;
14
+ isActive: ComputedRef<boolean>;
15
+ acquire: () => void;
16
+ release: () => void;
17
+ }
18
+
19
+ export function useOverlayLayer(): UseOverlayLayerReturn {
20
+ const id = ++seq;
21
+
22
+ const index = computed(() => layers.value.indexOf(id));
23
+ const isActive = computed(() => index.value !== -1);
24
+
25
+ const zIndex = computed(() =>
26
+ isActive.value ? BASE_Z + (index.value + 1) * STEP_Z : BASE_Z + STEP_Z,
27
+ );
28
+
29
+ const isTop = computed(
30
+ () => isActive.value && index.value === layers.value.length - 1,
31
+ );
32
+
33
+ const acquire = () => {
34
+ if (!layers.value.includes(id)) layers.value = [...layers.value, id];
35
+ };
36
+
37
+ const release = () => {
38
+ if (layers.value.includes(id))
39
+ layers.value = layers.value.filter((x) => x !== id);
40
+ };
41
+
42
+ onScopeDispose(release);
43
+
44
+ return { id, zIndex, isTop, isActive, acquire, release };
45
+ }
46
+
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";
51
+
52
+ const modalStack = ref<number[]>([]);
53
+
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
+ };
59
+
60
+ export const getTopModalEl = (): HTMLElement | null => {
61
+ if (typeof document === "undefined") return null;
62
+ const els = Array.from(
63
+ 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));
68
+ });
69
+ if (els.length === 0) return null;
70
+ return els.reduce((a, b) => (zOf(b) >= zOf(a) ? b : a));
71
+ };
72
+
73
+ function reconcileInert() {
74
+ if (typeof document === "undefined") return;
75
+
76
+ document.querySelectorAll(`[${MANAGED_ATTR}]`).forEach((el) => {
77
+ el.removeAttribute("inert");
78
+ el.removeAttribute("aria-hidden");
79
+ el.removeAttribute(MANAGED_ATTR);
80
+ });
81
+
82
+ const topEl = getTopModalEl();
83
+ if (!topEl) return;
84
+
85
+ const topZ = zOf(topEl);
86
+
87
+ Array.from(document.body.children).forEach((el) => {
88
+ if (!(el instanceof HTMLElement)) return;
89
+ if (el === topEl || el.contains(topEl)) return;
90
+
91
+ if (el.hasAttribute(OVERLAY_ATTR) && zOf(el) > topZ) return;
92
+
93
+ el.setAttribute("inert", "");
94
+ el.setAttribute("aria-hidden", "true");
95
+ el.setAttribute(MANAGED_ATTR, "");
96
+ });
97
+ }
98
+
99
+ export interface UseModalLayerReturn {
100
+ id: number;
101
+ zIndex: ComputedRef<number>;
102
+ isTopModal: ComputedRef<boolean>;
103
+ open: () => void;
104
+ close: () => void;
105
+ }
106
+
107
+ export function useModalLayer(): UseModalLayerReturn {
108
+ const layer = useOverlayLayer();
109
+
110
+ const isTopModal = computed(
111
+ () => modalStack.value[modalStack.value.length - 1] === layer.id,
112
+ );
113
+
114
+ const open = () => {
115
+ layer.acquire();
116
+ if (!modalStack.value.includes(layer.id))
117
+ modalStack.value = [...modalStack.value, layer.id];
118
+ nextTick(reconcileInert);
119
+ };
120
+
121
+ const close = () => {
122
+ layer.release();
123
+ modalStack.value = modalStack.value.filter((x) => x !== layer.id);
124
+ reconcileInert();
125
+ nextTick(reconcileInert);
126
+ };
127
+
128
+ onScopeDispose(close);
129
+
130
+ return { id: layer.id, zIndex: layer.zIndex, isTopModal, open, close };
131
+ }
@@ -1,4 +1,5 @@
1
1
  import { ref, computed, watch, onBeforeUnmount, nextTick, type Ref } from "vue";
2
+ import { useOverlayLayer } from "./useOverlayLayer";
2
3
 
3
4
  type Direction = "top" | "bottom";
4
5
  type Align = "center" | "start";
@@ -28,10 +29,12 @@ export function usePosition({
28
29
  const active = ref(false);
29
30
  const target = ref<HTMLElement | null>(null);
30
31
 
32
+ const { zIndex: layerZIndex, acquire, release } = useOverlayLayer();
33
+
31
34
  const baseStyles = computed(() => ({
32
35
  position: hasFixedParent(target.value) ? "fixed" : "absolute",
33
36
  width: "max-content",
34
- zIndex: 70,
37
+ zIndex: layerZIndex.value,
35
38
  }));
36
39
 
37
40
  const styles = ref<Record<string, string | number>>({
@@ -115,10 +118,12 @@ export function usePosition({
115
118
  [active, () => direction, () => align],
116
119
  ([isActive]) => {
117
120
  if (isActive) {
121
+ acquire();
118
122
  updatePosition();
119
123
  observeResize();
120
124
  window.addEventListener("resize", updatePosition);
121
125
  } else {
126
+ release();
122
127
  unobserveResize();
123
128
  window.removeEventListener("resize", updatePosition);
124
129
  }
@@ -126,7 +131,12 @@ export function usePosition({
126
131
  { flush: "post" },
127
132
  );
128
133
 
134
+ watch(layerZIndex, (z) => {
135
+ styles.value = { ...styles.value, zIndex: z };
136
+ });
137
+
129
138
  onBeforeUnmount(() => {
139
+ release();
130
140
  unobserveResize();
131
141
  window.removeEventListener("resize", updatePosition);
132
142
  });