@alikhalilll/ui 1.2.3 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/entries/drawer/components/ADrawer.vue +16 -0
  2. package/entries/drawer/components/ADrawerContent.vue +35 -0
  3. package/entries/drawer/components/ADrawerOverlay.vue +25 -0
  4. package/entries/drawer/components/ADrawerTrigger.vue +13 -0
  5. package/entries/drawer/index.ts +4 -0
  6. package/entries/input/components/AInput.vue +111 -0
  7. package/entries/input/index.ts +1 -0
  8. package/entries/popover/components/APopover.vue +19 -0
  9. package/entries/popover/components/APopoverContent.vue +65 -0
  10. package/entries/popover/components/APopoverOverlay.vue +69 -0
  11. package/entries/popover/components/APopoverTrigger.vue +13 -0
  12. package/entries/popover/composables/useEventScrollLock.ts +193 -0
  13. package/entries/popover/index.ts +8 -0
  14. package/entries/responsive-popover/components/AResponsivePopover.vue +67 -0
  15. package/entries/responsive-popover/components/AResponsivePopoverContent.vue +80 -0
  16. package/entries/responsive-popover/components/AResponsivePopoverTrigger.vue +23 -0
  17. package/entries/responsive-popover/composables/useResponsivePopoverContext.ts +20 -0
  18. package/entries/responsive-popover/index.ts +3 -0
  19. package/entries/tell-input/components/ACountryFlag.vue +68 -0
  20. package/entries/tell-input/components/ACountrySelect.vue +522 -0
  21. package/entries/tell-input/components/ATellInput.vue +616 -0
  22. package/entries/tell-input/composables/useCountryDetection.ts +247 -0
  23. package/entries/tell-input/composables/useCountryMatching.ts +213 -0
  24. package/entries/tell-input/composables/usePhoneValidation.ts +573 -0
  25. package/entries/tell-input/composables/useTellInputValidation.ts +136 -0
  26. package/entries/tell-input/composables/useTypingPhase.ts +88 -0
  27. package/entries/tell-input/index.ts +29 -0
  28. package/entries/tell-input/utils/digits.ts +42 -0
  29. package/entries/tell-input/utils/flag-url.ts +10 -0
  30. package/entries/tell-input/utils/types.ts +169 -0
  31. package/package.json +4 -1
  32. package/utils/cn.ts +6 -0
  33. package/utils/index.ts +10 -0
  34. package/utils/sizes.ts +48 -0
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { DrawerRoot, type DrawerRootEmits, type DrawerRootProps } from 'vaul-vue';
3
+ import { useForwardPropsEmits } from 'reka-ui';
4
+
5
+ const props = withDefaults(defineProps<DrawerRootProps>(), {
6
+ shouldScaleBackground: true,
7
+ });
8
+ const emits = defineEmits<DrawerRootEmits>();
9
+ const forwarded = useForwardPropsEmits(props, emits);
10
+ </script>
11
+
12
+ <template>
13
+ <DrawerRoot data-slot="drawer" v-bind="forwarded">
14
+ <slot />
15
+ </DrawerRoot>
16
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { DrawerContent, DrawerPortal } from 'vaul-vue';
4
+ import type { DialogContentEmits, DialogContentProps } from 'reka-ui';
5
+ import { reactiveOmit } from '@vueuse/core';
6
+ import { useForwardPropsEmits } from 'reka-ui';
7
+ import { cn } from '@/utils';
8
+ import ADrawerOverlay from './ADrawerOverlay.vue';
9
+
10
+ defineOptions({ inheritAttrs: false });
11
+
12
+ const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>();
13
+ const emits = defineEmits<DialogContentEmits>();
14
+ const delegated = reactiveOmit(props, 'class');
15
+ const forwarded = useForwardPropsEmits(delegated, emits);
16
+ </script>
17
+
18
+ <template>
19
+ <DrawerPortal>
20
+ <ADrawerOverlay />
21
+ <DrawerContent
22
+ data-slot="drawer-content"
23
+ v-bind="{ ...$attrs, ...forwarded }"
24
+ :class="
25
+ cn(
26
+ 'bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] shadow-2xl shadow-black/30 outline-none',
27
+ props.class
28
+ )
29
+ "
30
+ >
31
+ <div class="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full" />
32
+ <slot />
33
+ </DrawerContent>
34
+ </DrawerPortal>
35
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { DrawerOverlay } from 'vaul-vue';
4
+ import type { DialogOverlayProps } from 'reka-ui';
5
+ import { reactiveOmit } from '@vueuse/core';
6
+ import { useForwardProps } from 'reka-ui';
7
+ import { cn } from '@/utils';
8
+
9
+ const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>();
10
+ const delegated = reactiveOmit(props, 'class');
11
+ const forwarded = useForwardProps(delegated);
12
+ </script>
13
+
14
+ <template>
15
+ <DrawerOverlay
16
+ data-slot="drawer-overlay"
17
+ v-bind="forwarded"
18
+ :class="
19
+ cn(
20
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-40 bg-black/70',
21
+ props.class
22
+ )
23
+ "
24
+ />
25
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import { DrawerTrigger, type DrawerTriggerProps } from 'vaul-vue';
3
+ import { useForwardProps } from 'reka-ui';
4
+
5
+ const props = defineProps<DrawerTriggerProps>();
6
+ const forwarded = useForwardProps(props);
7
+ </script>
8
+
9
+ <template>
10
+ <DrawerTrigger data-slot="drawer-trigger" v-bind="forwarded">
11
+ <slot />
12
+ </DrawerTrigger>
13
+ </template>
@@ -0,0 +1,4 @@
1
+ export { default as ADrawer } from './components/ADrawer.vue';
2
+ export { default as ADrawerTrigger } from './components/ADrawerTrigger.vue';
3
+ export { default as ADrawerContent } from './components/ADrawerContent.vue';
4
+ export { default as ADrawerOverlay } from './components/ADrawerOverlay.vue';
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { computed, useSlots } from 'vue';
4
+ import { useVModel } from '@vueuse/core';
5
+ import { cn } from '@/utils';
6
+ import { controlHeight, controlPaddingX, controlTextSize, DEFAULT_SIZE, type Size } from '@/utils';
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ defaultValue?: string | number;
11
+ modelValue?: string | number;
12
+ class?: HTMLAttributes['class'];
13
+ /** Classes for the inner &lt;input&gt; element (useful when prefix/suffix are present). */
14
+ inputClass?: HTMLAttributes['class'];
15
+ /** Classes for the prefix wrapper. */
16
+ prefixClass?: HTMLAttributes['class'];
17
+ /** Classes for the suffix wrapper. */
18
+ suffixClass?: HTMLAttributes['class'];
19
+ size?: Size;
20
+ }>(),
21
+ { size: DEFAULT_SIZE }
22
+ );
23
+
24
+ const emits = defineEmits<{
25
+ (e: 'update:modelValue', payload: string | number): void;
26
+ }>();
27
+
28
+ defineSlots<{
29
+ /** Content rendered inside the input's border, left of the field. */
30
+ prefix?: () => unknown;
31
+ /** Content rendered inside the input's border, right of the field. */
32
+ suffix?: () => unknown;
33
+ }>();
34
+
35
+ const slots = useSlots();
36
+ const hasPrefix = computed(() => !!slots.prefix);
37
+ const hasSuffix = computed(() => !!slots.suffix);
38
+ const hasAdornment = computed(() => hasPrefix.value || hasSuffix.value);
39
+
40
+ const modelValue = useVModel(props, 'modelValue', emits, {
41
+ passive: true,
42
+ defaultValue: props.defaultValue,
43
+ });
44
+
45
+ const sizeHeight = computed(() => controlHeight[props.size]);
46
+ const sizePaddingX = computed(() => controlPaddingX[props.size]);
47
+ const sizeText = computed(() => controlTextSize[props.size]);
48
+ </script>
49
+
50
+ <template>
51
+ <!--
52
+ When prefix or suffix slots are filled we render a wrapper that owns the border,
53
+ background and focus ring — so the visible "input" is the whole bar, not just the
54
+ native element. Otherwise we render the plain native input directly so consumers
55
+ can use AInput as a drop-in for <input>.
56
+ -->
57
+ <div
58
+ v-if="hasAdornment"
59
+ :data-size="props.size"
60
+ :class="
61
+ cn(
62
+ 'border-input bg-background ring-offset-background focus-within:ring-ring inline-flex w-full items-center rounded-md border shadow-sm transition-colors focus-within:ring-1',
63
+ sizeHeight,
64
+ sizePaddingX,
65
+ sizeText,
66
+ props.class
67
+ )
68
+ "
69
+ >
70
+ <span
71
+ v-if="hasPrefix"
72
+ :class="cn('text-muted-foreground flex shrink-0 items-center pr-2', props.prefixClass)"
73
+ >
74
+ <slot name="prefix" />
75
+ </span>
76
+
77
+ <input
78
+ v-model="modelValue"
79
+ data-slot="input"
80
+ :class="
81
+ cn(
82
+ 'placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent outline-none disabled:cursor-not-allowed disabled:opacity-50',
83
+ props.inputClass
84
+ )
85
+ "
86
+ />
87
+
88
+ <span
89
+ v-if="hasSuffix"
90
+ :class="cn('text-muted-foreground flex shrink-0 items-center pl-2', props.suffixClass)"
91
+ >
92
+ <slot name="suffix" />
93
+ </span>
94
+ </div>
95
+
96
+ <input
97
+ v-else
98
+ v-model="modelValue"
99
+ data-slot="input"
100
+ :data-size="props.size"
101
+ :class="
102
+ cn(
103
+ 'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex w-full rounded-md border py-1 shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
104
+ sizeHeight,
105
+ sizePaddingX,
106
+ sizeText,
107
+ props.class
108
+ )
109
+ "
110
+ />
111
+ </template>
@@ -0,0 +1 @@
1
+ export { default as AInput } from './components/AInput.vue';
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui';
3
+ import { PopoverRoot, useForwardPropsEmits } from 'reka-ui';
4
+
5
+ /**
6
+ * Defaults `modal` to `true` so the popover locks page scroll, traps focus, and an overlay
7
+ * (rendered by APopoverContent when `overlay` is set) actually dims the page.
8
+ * Pass `:modal="false"` for tooltip-style non-modal popovers.
9
+ */
10
+ const props = withDefaults(defineProps<PopoverRootProps>(), { modal: true });
11
+ const emits = defineEmits<PopoverRootEmits>();
12
+ const forwarded = useForwardPropsEmits(props, emits);
13
+ </script>
14
+
15
+ <template>
16
+ <PopoverRoot v-slot="slotProps" data-slot="popover" v-bind="forwarded">
17
+ <slot v-bind="slotProps" />
18
+ </PopoverRoot>
19
+ </template>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { reactiveOmit } from '@vueuse/core';
4
+ import {
5
+ PopoverContent,
6
+ type PopoverContentEmits,
7
+ type PopoverContentProps,
8
+ PopoverPortal,
9
+ useForwardPropsEmits,
10
+ } from 'reka-ui';
11
+ import { cn } from '@/utils';
12
+ import APopoverOverlay from './APopoverOverlay.vue';
13
+
14
+ defineOptions({ inheritAttrs: false });
15
+
16
+ const props = withDefaults(
17
+ defineProps<
18
+ PopoverContentProps & {
19
+ class?: HTMLAttributes['class'];
20
+ /** Dim the entire viewport behind the popover and block all interaction with the
21
+ * page (clicks are captured by the overlay). Pair with `<APopover :modal="true">`
22
+ * (the default) for the full modal behaviour. */
23
+ overlay?: boolean;
24
+ overlayClass?: HTMLAttributes['class'];
25
+ /**
26
+ * When true, the overlay also locks page scroll via `body { overflow: hidden }`.
27
+ * Off by default — `AResponsivePopover` opts in to this when `scrollLock="body"`.
28
+ */
29
+ overlayLockScroll?: boolean;
30
+ }
31
+ >(),
32
+ { align: 'center', sideOffset: 4, overlay: false, overlayLockScroll: false }
33
+ );
34
+ const emits = defineEmits<PopoverContentEmits>();
35
+ const delegated = reactiveOmit(props, 'class', 'overlay', 'overlayClass', 'overlayLockScroll');
36
+ const forwarded = useForwardPropsEmits(delegated, emits);
37
+ </script>
38
+
39
+ <template>
40
+ <PopoverPortal>
41
+ <!--
42
+ Overlay is a sibling of PopoverContent inside the same portal. Reka-ui's
43
+ DismissableLayer treats any pointer-down outside the content as a dismiss,
44
+ so clicking the overlay closes the popover for free. The overlay component
45
+ locks body scroll on mount and restores it on unmount.
46
+ -->
47
+ <APopoverOverlay
48
+ v-if="props.overlay"
49
+ :class="props.overlayClass"
50
+ :lock-scroll="props.overlayLockScroll"
51
+ />
52
+ <PopoverContent
53
+ data-slot="popover-content"
54
+ v-bind="{ ...$attrs, ...forwarded }"
55
+ :class="
56
+ cn(
57
+ 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[60] w-72 rounded-md border border-border/70 p-4 shadow-xl shadow-black/15 outline-none',
58
+ props.class
59
+ )
60
+ "
61
+ >
62
+ <slot />
63
+ </PopoverContent>
64
+ </PopoverPortal>
65
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { onBeforeUnmount, onMounted } from 'vue';
4
+ import { cn } from '@/utils';
5
+
6
+ defineOptions({ inheritAttrs: false });
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ class?: HTMLAttributes['class'];
11
+ /**
12
+ * When true, set `body { overflow: hidden; touchAction: none }` for the lifetime of
13
+ * the overlay. Off by default because it breaks `position: sticky` on the host page.
14
+ * Prefer the event-based lock (see `AResponsivePopover`'s `scrollLock` prop) which
15
+ * keeps the page scrollbar in place.
16
+ */
17
+ lockScroll?: boolean;
18
+ }>(),
19
+ { lockScroll: false }
20
+ );
21
+
22
+ let prevBodyOverflow = '';
23
+ let prevBodyTouchAction = '';
24
+ let prevPaddingRight = '';
25
+
26
+ function getScrollbarWidth() {
27
+ if (typeof window === 'undefined') return 0;
28
+ return window.innerWidth - document.documentElement.clientWidth;
29
+ }
30
+
31
+ onMounted(() => {
32
+ if (!props.lockScroll) return;
33
+ if (typeof document === 'undefined') return;
34
+ const body = document.body;
35
+ const sbw = getScrollbarWidth();
36
+ prevBodyOverflow = body.style.overflow;
37
+ prevBodyTouchAction = body.style.touchAction;
38
+ prevPaddingRight = body.style.paddingRight;
39
+ body.style.overflow = 'hidden';
40
+ body.style.touchAction = 'none';
41
+ // Compensate for the missing scrollbar so the layout doesn't jump.
42
+ if (sbw > 0) body.style.paddingRight = `${sbw}px`;
43
+ });
44
+
45
+ onBeforeUnmount(() => {
46
+ if (!props.lockScroll) return;
47
+ if (typeof document === 'undefined') return;
48
+ const body = document.body;
49
+ body.style.overflow = prevBodyOverflow;
50
+ body.style.touchAction = prevBodyTouchAction;
51
+ body.style.paddingRight = prevPaddingRight;
52
+ });
53
+ </script>
54
+
55
+ <template>
56
+ <div
57
+ data-slot="popover-overlay"
58
+ aria-hidden="true"
59
+ :class="
60
+ cn(
61
+ // `fixed inset-0` covers the entire viewport; `pointer-events-auto` captures every
62
+ // click so it can never reach the page underneath. `z-50` keeps us above any normal
63
+ // page chrome; the popover content sits at `z-[60]`.
64
+ 'fixed inset-0 z-50 bg-black/70 pointer-events-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
65
+ props.class
66
+ )
67
+ "
68
+ />
69
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import type { PopoverTriggerProps } from 'reka-ui';
3
+ import { PopoverTrigger, useForwardProps } from 'reka-ui';
4
+
5
+ const props = defineProps<PopoverTriggerProps>();
6
+ const forwarded = useForwardProps(props);
7
+ </script>
8
+
9
+ <template>
10
+ <PopoverTrigger data-slot="popover-trigger" v-bind="forwarded">
11
+ <slot />
12
+ </PopoverTrigger>
13
+ </template>
@@ -0,0 +1,193 @@
1
+ import { onBeforeUnmount, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue';
2
+
3
+ export interface UseEventScrollLockOptions {
4
+ /**
5
+ * Element(s) inside the popover whose internal scroll should be preserved.
6
+ * Events whose target is inside one of these are allowed to scroll the
7
+ * element (subject to boundary clamping); everything else is preventDefault'd.
8
+ */
9
+ allowedScrollContainer: MaybeRefOrGetter<HTMLElement | HTMLElement[] | null | undefined>;
10
+ /** Lock activates when this becomes true and tears down when false. */
11
+ active: Ref<boolean> | (() => boolean);
12
+ }
13
+
14
+ // Module-level so multiple popovers stacking share one listener set.
15
+ let refCount = 0;
16
+ const allowedContainers = new Set<HTMLElement>();
17
+
18
+ const SCROLL_KEYS = new Set([
19
+ 'ArrowUp',
20
+ 'ArrowDown',
21
+ 'ArrowLeft',
22
+ 'ArrowRight',
23
+ 'PageUp',
24
+ 'PageDown',
25
+ 'Home',
26
+ 'End',
27
+ 'Space',
28
+ ' ',
29
+ ]);
30
+
31
+ function insideAllowed(target: EventTarget | null): boolean {
32
+ if (!(target instanceof Node)) return false;
33
+ for (const el of allowedContainers) if (el.contains(target)) return true;
34
+ return false;
35
+ }
36
+
37
+ // Walk up from the event target; if any ancestor (up to and including the
38
+ // allowed container) is a scroller that can absorb the delta in this direction
39
+ // without overshooting its boundary, allow the event.
40
+ function canConsume(el: HTMLElement, dx: number, dy: number): boolean {
41
+ let node: HTMLElement | null = el;
42
+ while (node) {
43
+ const s = getComputedStyle(node);
44
+ const scrollsY =
45
+ (s.overflowY === 'auto' || s.overflowY === 'scroll') && node.scrollHeight > node.clientHeight;
46
+ if (scrollsY && dy !== 0) {
47
+ const atTop = node.scrollTop <= 0;
48
+ const atBottom = node.scrollTop + node.clientHeight >= node.scrollHeight - 1;
49
+ if (!(dy < 0 && atTop) && !(dy > 0 && atBottom)) return true;
50
+ }
51
+ const scrollsX =
52
+ (s.overflowX === 'auto' || s.overflowX === 'scroll') && node.scrollWidth > node.clientWidth;
53
+ if (scrollsX && dx !== 0) {
54
+ const atLeft = node.scrollLeft <= 0;
55
+ const atRight = node.scrollLeft + node.clientWidth >= node.scrollWidth - 1;
56
+ if (!(dx < 0 && atLeft) && !(dx > 0 && atRight)) return true;
57
+ }
58
+ if (allowedContainers.has(node)) break;
59
+ node = node.parentElement;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ function onWheel(e: WheelEvent) {
65
+ const t = (e.composedPath()[0] ?? e.target) as HTMLElement | null;
66
+ if (!t || !insideAllowed(t)) {
67
+ e.preventDefault();
68
+ return;
69
+ }
70
+ if (!canConsume(t, e.deltaX, e.deltaY)) e.preventDefault();
71
+ }
72
+
73
+ let touchStartY = 0;
74
+ let touchStartX = 0;
75
+ function onTouchStart(e: TouchEvent) {
76
+ if (e.touches.length === 1) {
77
+ touchStartY = e.touches[0]!.clientY;
78
+ touchStartX = e.touches[0]!.clientX;
79
+ }
80
+ }
81
+ function onTouchMove(e: TouchEvent) {
82
+ const t = (e.composedPath()[0] ?? e.target) as HTMLElement | null;
83
+ if (!t || !insideAllowed(t)) {
84
+ e.preventDefault();
85
+ return;
86
+ }
87
+ if (e.touches.length !== 1) {
88
+ e.preventDefault();
89
+ return;
90
+ }
91
+ const dy = touchStartY - e.touches[0]!.clientY;
92
+ const dx = touchStartX - e.touches[0]!.clientX;
93
+ if (!canConsume(t, dx, dy)) e.preventDefault();
94
+ }
95
+
96
+ function onKeyDown(e: KeyboardEvent) {
97
+ if (!SCROLL_KEYS.has(e.key)) return;
98
+ // Popover owns its own keyboard model (search input, list navigation).
99
+ if (insideAllowed(e.target)) return;
100
+ e.preventDefault();
101
+ }
102
+
103
+ function activate() {
104
+ if (refCount === 0) {
105
+ document.addEventListener('wheel', onWheel, {
106
+ passive: false,
107
+ capture: true,
108
+ });
109
+ document.addEventListener('touchstart', onTouchStart, {
110
+ passive: true,
111
+ capture: true,
112
+ });
113
+ document.addEventListener('touchmove', onTouchMove, {
114
+ passive: false,
115
+ capture: true,
116
+ });
117
+ document.addEventListener('keydown', onKeyDown, { capture: true });
118
+ }
119
+ refCount++;
120
+ }
121
+
122
+ function deactivate() {
123
+ refCount = Math.max(0, refCount - 1);
124
+ if (refCount === 0) {
125
+ document.removeEventListener('wheel', onWheel, { capture: true });
126
+ document.removeEventListener('touchstart', onTouchStart, { capture: true });
127
+ document.removeEventListener('touchmove', onTouchMove, { capture: true });
128
+ document.removeEventListener('keydown', onKeyDown, { capture: true });
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Sticky-safe scroll lock: prevents page scroll by intercepting wheel/touch/key
134
+ * events at `document` capture phase, instead of mutating `body { overflow }`.
135
+ * The page scrollbar stays visible and `position: sticky` keeps working.
136
+ *
137
+ * Pass the element(s) whose own scroll should still work (e.g. a popover's
138
+ * inner search list) as `allowedScrollContainer`.
139
+ */
140
+ export function useEventScrollLock(opts: UseEventScrollLockOptions): void {
141
+ if (typeof document === 'undefined') return;
142
+
143
+ let registered: HTMLElement[] = [];
144
+ let activeNow = false;
145
+
146
+ const register = () => {
147
+ const raw = toValue(opts.allowedScrollContainer);
148
+ const list = Array.isArray(raw) ? (raw.filter(Boolean) as HTMLElement[]) : raw ? [raw] : [];
149
+ for (const el of list) allowedContainers.add(el);
150
+ registered = list;
151
+ };
152
+ const unregister = () => {
153
+ for (const el of registered) allowedContainers.delete(el);
154
+ registered = [];
155
+ };
156
+
157
+ const stopActive = watch(
158
+ () => (typeof opts.active === 'function' ? opts.active() : opts.active.value),
159
+ (v) => {
160
+ if (v && !activeNow) {
161
+ register();
162
+ activate();
163
+ activeNow = true;
164
+ } else if (!v && activeNow) {
165
+ deactivate();
166
+ unregister();
167
+ activeNow = false;
168
+ }
169
+ },
170
+ { immediate: true, flush: 'post' }
171
+ );
172
+
173
+ const stopContainer = watch(
174
+ () => toValue(opts.allowedScrollContainer),
175
+ () => {
176
+ if (activeNow) {
177
+ unregister();
178
+ register();
179
+ }
180
+ },
181
+ { flush: 'post' }
182
+ );
183
+
184
+ onBeforeUnmount(() => {
185
+ stopActive();
186
+ stopContainer();
187
+ if (activeNow) {
188
+ deactivate();
189
+ unregister();
190
+ activeNow = false;
191
+ }
192
+ });
193
+ }
@@ -0,0 +1,8 @@
1
+ export { default as APopover } from './components/APopover.vue';
2
+ export { default as APopoverTrigger } from './components/APopoverTrigger.vue';
3
+ export { default as APopoverContent } from './components/APopoverContent.vue';
4
+ export { default as APopoverOverlay } from './components/APopoverOverlay.vue';
5
+ export {
6
+ useEventScrollLock,
7
+ type UseEventScrollLockOptions,
8
+ } from './composables/useEventScrollLock';
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useMediaQuery } from '@vueuse/core';
4
+ import { APopover } from '@/entries/popover';
5
+ import { ADrawer } from '@/entries/drawer';
6
+ import { provideResponsivePopoverContext } from '../composables/useResponsivePopoverContext';
7
+
8
+ export type ScrollLockMode = 'events' | 'body' | 'none';
9
+
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ /** CSS media query for the desktop break. Below this width we render a vaul drawer. */
13
+ breakpoint?: string;
14
+ /**
15
+ * @deprecated prefer `scrollLock`. Kept for back-compat: `modal=false` is a shorthand
16
+ * for `scrollLock="none"` (tooltip-style popover). `modal=true` (default) defers to
17
+ * `scrollLock`, which controls how page scroll is blocked.
18
+ */
19
+ modal?: boolean;
20
+ /**
21
+ * How desktop page scroll is blocked while the popover is open:
22
+ * - `'events'` (default) — wheel/touch/keyboard intercepted at document level.
23
+ * Page scrollbar stays visible; `position: sticky` keeps working.
24
+ * - `'body'` — legacy `document.body.style.overflow='hidden'` lock. Use when the
25
+ * page must reflow as the scrollbar goes away.
26
+ * - `'none'` — no page-scroll lock at all.
27
+ *
28
+ * Drawer (mobile) branch is unaffected — vaul-vue owns its own lock.
29
+ */
30
+ scrollLock?: ScrollLockMode;
31
+ }>(),
32
+ { breakpoint: '(min-width: 768px)', modal: true, scrollLock: 'events' }
33
+ );
34
+
35
+ const open = defineModel<boolean>('open');
36
+
37
+ const isDesktop = useMediaQuery(() => props.breakpoint);
38
+
39
+ /**
40
+ * Pre-imported on both branches — do NOT lazy-load. Switching the component identity at runtime
41
+ * means we still hydrate the right tree client-side.
42
+ */
43
+ const Root = computed(() => (isDesktop.value ? APopover : ADrawer));
44
+
45
+ /**
46
+ * Only `scrollLock='body'` triggers reka-ui's `PopoverContentModal` (and its
47
+ * `useBodyScrollLock`). For `'events'` we install our own document-level event lock in
48
+ * `AResponsivePopoverContent`. For `'none'` nothing locks. Legacy `modal=false` still
49
+ * forces non-modal regardless of `scrollLock`.
50
+ */
51
+ const rekaModal = computed(() => {
52
+ if (props.modal === false) return false;
53
+ return props.scrollLock === 'body';
54
+ });
55
+
56
+ provideResponsivePopoverContext({
57
+ open: computed(() => open.value ?? false),
58
+ isDesktop: computed(() => isDesktop.value),
59
+ scrollLock: computed(() => props.scrollLock),
60
+ });
61
+ </script>
62
+
63
+ <template>
64
+ <component :is="Root" v-model:open="open" :modal="rekaModal" data-slot="responsive-popover">
65
+ <slot :is-desktop="isDesktop" />
66
+ </component>
67
+ </template>