v-uixy 1.8.0 → 1.9.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.
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.0",
3
+ "version": "1.9.0",
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 && !props.loading"
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
@@ -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: "",
@@ -61,6 +61,7 @@
61
61
  <animate-presence>
62
62
  <motion.div
63
63
  v-if="active"
64
+ data-uixy-overlay
64
65
  :initial="{ opacity: 0 }"
65
66
  :animate="{ opacity: 1 }"
66
67
  :exit="{ opacity: 0 }"
@@ -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 }"
@@ -4,19 +4,24 @@
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-overlay
13
16
  @click.self="handleClick"
14
17
  >
15
18
  <motion.div
19
+ class="flex w-full justify-center"
16
20
  :initial="{ scale: 0.8 }"
17
21
  :animate="{ scale: 1 }"
18
22
  :exit="{ scale: 0.8 }"
19
23
  :transition="{ duration: 0.15, ease: 'easeInOut' }"
24
+ @click.self="handleClick"
20
25
  >
21
26
  <slot />
22
27
  </motion.div>
@@ -30,6 +35,7 @@
30
35
  import type { UixyModalProps } from "./Modal.types";
31
36
  import { modalStyles } from "./Modal.styles";
32
37
  import { AnimatePresence, motion } from "motion-v";
38
+ import { useModalLayer, MODAL_ATTR, getTopModalEl } from "~/composables";
33
39
 
34
40
  const props = defineProps<UixyModalProps>();
35
41
 
@@ -39,13 +45,26 @@
39
45
 
40
46
  const open = defineModel<boolean>();
41
47
 
48
+ const rootRef = ref<HTMLElement>();
49
+
50
+ const {
51
+ zIndex,
52
+ isTopModal,
53
+ open: openLayer,
54
+ close: closeLayer,
55
+ } = useModalLayer();
56
+
57
+ const modalAttr = computed(() => (open.value ? MODAL_ATTR : null));
58
+
42
59
  const handleClick = () => {
43
- if (props.persistent) return;
60
+ if (props.persistent || props.loading) return;
44
61
 
45
62
  open.value = false;
46
63
  };
47
64
 
48
65
  const handleKeyDown = (e: KeyboardEvent) => {
66
+ if (!isTopModal.value) return;
67
+
49
68
  if (e.key === "Escape") {
50
69
  handleClick();
51
70
  return;
@@ -53,39 +72,21 @@
53
72
  if (e.key === "Enter") {
54
73
  const target = e.target as HTMLElement;
55
74
  if (target.tagName === "TEXTAREA" || target.tagName === "BUTTON") return;
56
- const modalEl = document.querySelector("[data-modal]");
57
- const form = modalEl?.querySelector("form");
75
+ const form =
76
+ rootRef.value?.querySelector("form") ??
77
+ getTopModalEl()?.querySelector("form");
58
78
  form?.requestSubmit();
59
79
  }
60
80
  };
61
81
 
62
- const toggleSiblingsInert = (enable?: boolean) => {
63
- if (typeof document === "undefined") return;
64
-
65
- const modalEl = document.querySelector(
66
- "[data-modal]",
67
- ) as HTMLElement | null;
68
-
69
- Array.from(document.body.children).forEach((el) => {
70
- if (modalEl && (el === modalEl || el.contains(modalEl))) return;
71
-
72
- if (enable) {
73
- el.setAttribute("inert", "");
74
- el.setAttribute("aria-hidden", "true");
75
- } else {
76
- el.removeAttribute("inert");
77
- el.removeAttribute("aria-hidden");
78
- }
79
- });
80
- };
81
-
82
82
  watch(
83
83
  open,
84
84
  (isOpen) => {
85
- toggleSiblingsInert(isOpen);
86
85
  if (isOpen) {
86
+ openLayer();
87
87
  window.addEventListener("keydown", handleKeyDown);
88
88
  } else {
89
+ closeLayer();
89
90
  window.removeEventListener("keydown", handleKeyDown);
90
91
  }
91
92
  },
@@ -94,5 +95,6 @@
94
95
 
95
96
  onUnmounted(() => {
96
97
  window.removeEventListener("keydown", handleKeyDown);
98
+ closeLayer();
97
99
  });
98
100
  </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-20 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 };
@@ -1,3 +1,4 @@
1
1
  export interface UixyModalProps {
2
2
  persistent?: boolean;
3
+ loading?: boolean;
3
4
  }
@@ -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 }"
@@ -39,6 +40,7 @@
39
40
 
40
41
  const { active, handleOpen, handleLeave, styles, refElement } = usePosition({
41
42
  direction: props.direction ?? "bottom",
43
+ align: props.align ?? "center",
42
44
  });
43
45
 
44
46
  const handleClickOutside = (e: MouseEvent) => {
@@ -1,5 +1,6 @@
1
1
  export interface UixyPopoverProps {
2
2
  direction?: "top" | "bottom";
3
+ align?: "center" | "start";
3
4
  closeOnClick?: boolean;
4
5
  group?: string;
5
6
  }
@@ -14,6 +14,7 @@
14
14
  type="button"
15
15
  role="tab"
16
16
  :aria-selected="model === option.value"
17
+ :aria-label="option.ariaLabel"
17
18
  :disabled="props.disabled || option.disabled"
18
19
  :class="
19
20
  segmentStyles({
@@ -5,6 +5,7 @@ export interface UixySegmentedOption {
5
5
  value: string | number;
6
6
  icon?: IconName;
7
7
  disabled?: boolean;
8
+ ariaLabel?: string;
8
9
  }
9
10
 
10
11
  export interface UixySegmentedControlProps {
@@ -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,124 @@
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
+
51
+ const modalStack = ref<number[]>([]);
52
+
53
+ const zOf = (el: Element): number => {
54
+ const raw = window.getComputedStyle(el).zIndex;
55
+ const parsed = Number.parseInt(raw ?? "", 10);
56
+ return Number.isFinite(parsed) ? parsed : 0;
57
+ };
58
+
59
+ export const getTopModalEl = (): HTMLElement | null => {
60
+ if (typeof document === "undefined") return null;
61
+ const els = Array.from(
62
+ document.querySelectorAll<HTMLElement>(`[${MODAL_ATTR}]`),
63
+ );
64
+ if (els.length === 0) return null;
65
+ return els.reduce((a, b) => (zOf(b) >= zOf(a) ? b : a));
66
+ };
67
+
68
+ function reconcileInert() {
69
+ if (typeof document === "undefined") return;
70
+
71
+ document.querySelectorAll(`[${MANAGED_ATTR}]`).forEach((el) => {
72
+ el.removeAttribute("inert");
73
+ el.removeAttribute("aria-hidden");
74
+ el.removeAttribute(MANAGED_ATTR);
75
+ });
76
+
77
+ const topEl = getTopModalEl();
78
+ if (!topEl) return;
79
+
80
+ const topZ = zOf(topEl);
81
+
82
+ Array.from(document.body.children).forEach((el) => {
83
+ if (!(el instanceof HTMLElement)) return;
84
+ if (el === topEl || el.contains(topEl)) return;
85
+
86
+ if (el.hasAttribute(OVERLAY_ATTR) && zOf(el) > topZ) return;
87
+
88
+ el.setAttribute("inert", "");
89
+ el.setAttribute("aria-hidden", "true");
90
+ el.setAttribute(MANAGED_ATTR, "");
91
+ });
92
+ }
93
+
94
+ export interface UseModalLayerReturn {
95
+ zIndex: ComputedRef<number>;
96
+ isTopModal: ComputedRef<boolean>;
97
+ open: () => void;
98
+ close: () => void;
99
+ }
100
+
101
+ export function useModalLayer(): UseModalLayerReturn {
102
+ const layer = useOverlayLayer();
103
+
104
+ const isTopModal = computed(
105
+ () => modalStack.value[modalStack.value.length - 1] === layer.id,
106
+ );
107
+
108
+ const open = () => {
109
+ layer.acquire();
110
+ if (!modalStack.value.includes(layer.id))
111
+ modalStack.value = [...modalStack.value, layer.id];
112
+ nextTick(reconcileInert);
113
+ };
114
+
115
+ const close = () => {
116
+ layer.release();
117
+ modalStack.value = modalStack.value.filter((x) => x !== layer.id);
118
+ nextTick(reconcileInert);
119
+ };
120
+
121
+ onScopeDispose(close);
122
+
123
+ return { zIndex: layer.zIndex, isTopModal, open, close };
124
+ }
@@ -1,9 +1,12 @@
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";
5
+ type Align = "center" | "start";
4
6
 
5
7
  interface UsePositionOptions {
6
8
  direction?: Direction;
9
+ align?: Align;
7
10
  }
8
11
 
9
12
  const GAP = 6;
@@ -17,16 +20,21 @@ function hasFixedParent(el: HTMLElement | null): boolean {
17
20
  return false;
18
21
  }
19
22
 
20
- export function usePosition({ direction = "bottom" }: UsePositionOptions) {
23
+ export function usePosition({
24
+ direction = "bottom",
25
+ align = "center",
26
+ }: UsePositionOptions) {
21
27
  const refElement = ref<HTMLElement>();
22
28
 
23
29
  const active = ref(false);
24
30
  const target = ref<HTMLElement | null>(null);
25
31
 
32
+ const { zIndex: layerZIndex, acquire, release } = useOverlayLayer();
33
+
26
34
  const baseStyles = computed(() => ({
27
35
  position: hasFixedParent(target.value) ? "fixed" : "absolute",
28
36
  width: "max-content",
29
- zIndex: 20,
37
+ zIndex: layerZIndex.value,
30
38
  }));
31
39
 
32
40
  const styles = ref<Record<string, string | number>>({
@@ -65,7 +73,10 @@ export function usePosition({ direction = "bottom" }: UsePositionOptions) {
65
73
  ? targetRect.top - tooltipRect.height - GAP + scrollY
66
74
  : targetRect.bottom + GAP + scrollY;
67
75
 
68
- let left = targetRect.left + targetRect.width / 2 - tooltipRect.width / 2;
76
+ let left =
77
+ align === "start"
78
+ ? targetRect.left
79
+ : targetRect.left + targetRect.width / 2 - tooltipRect.width / 2;
69
80
  const newStyles: Record<string, string | number> = {
70
81
  ...baseStyles.value,
71
82
  top,
@@ -87,20 +98,46 @@ export function usePosition({ direction = "bottom" }: UsePositionOptions) {
87
98
  };
88
99
  };
89
100
 
101
+ let resizeObserver: ResizeObserver | null = null;
102
+
103
+ const observeResize = () => {
104
+ if (typeof ResizeObserver === "undefined") return;
105
+ resizeObserver = new ResizeObserver(() => {
106
+ void updatePosition();
107
+ });
108
+ if (target.value) resizeObserver.observe(target.value);
109
+ if (refElement.value) resizeObserver.observe(refElement.value);
110
+ };
111
+
112
+ const unobserveResize = () => {
113
+ resizeObserver?.disconnect();
114
+ resizeObserver = null;
115
+ };
116
+
90
117
  watch(
91
- [active, () => direction],
118
+ [active, () => direction, () => align],
92
119
  ([isActive]) => {
93
120
  if (isActive) {
121
+ acquire();
94
122
  updatePosition();
123
+ observeResize();
95
124
  window.addEventListener("resize", updatePosition);
96
125
  } else {
126
+ release();
127
+ unobserveResize();
97
128
  window.removeEventListener("resize", updatePosition);
98
129
  }
99
130
  },
100
131
  { flush: "post" },
101
132
  );
102
133
 
134
+ watch(layerZIndex, (z) => {
135
+ styles.value = { ...styles.value, zIndex: z };
136
+ });
137
+
103
138
  onBeforeUnmount(() => {
139
+ release();
140
+ unobserveResize();
104
141
  window.removeEventListener("resize", updatePosition);
105
142
  });
106
143