@alikhalilll/a-responsive-popover 1.0.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.
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useMediaQuery } from '@vueuse/core';
4
+ import { APopover } from '@alikhalilll/a-popover';
5
+ import { ADrawer } from '@alikhalilll/a-drawer';
6
+ import { provideResponsivePopoverContext } from '../composables/useResponsivePopoverContext';
7
+ import type { AResponsivePopoverProps, AResponsivePopoverSlots } from '../types';
8
+
9
+ const props = withDefaults(defineProps<AResponsivePopoverProps>(), {
10
+ breakpoint: '(min-width: 768px)',
11
+ modal: true,
12
+ scrollLock: 'events',
13
+ });
14
+
15
+ defineSlots<AResponsivePopoverSlots>();
16
+
17
+ const open = defineModel<boolean>('open');
18
+
19
+ const isDesktop = useMediaQuery(() => props.breakpoint);
20
+
21
+ /**
22
+ * Pre-imported on both branches — do NOT lazy-load. Switching the component identity at runtime
23
+ * means we still hydrate the right tree client-side.
24
+ */
25
+ const Root = computed(() => (isDesktop.value ? APopover : ADrawer));
26
+
27
+ /**
28
+ * Per-branch `modal` resolution — the two roots interpret the prop differently:
29
+ *
30
+ * APopover (desktop, reka-ui): `modal=true` triggers `PopoverContentModal` + its
31
+ * `useBodyScrollLock`. We only want that when the caller explicitly opted into the
32
+ * body-level scroll lock; for `'events'`/`'none'` we install our own lock in
33
+ * `AResponsivePopoverContent`. Legacy `modal=false` still forces non-modal.
34
+ *
35
+ * ADrawer (mobile, vaul-vue): `modal=false` SUPPRESSES THE OVERLAY entirely. Drawers
36
+ * are modal by convention (a dimmed backdrop is the affordance), so default to modal
37
+ * unless the caller explicitly turned the whole thing off.
38
+ */
39
+ const rekaModal = computed(() => {
40
+ if (props.modal === false) return false;
41
+ return props.scrollLock === 'body';
42
+ });
43
+ const drawerModal = computed(() => props.modal !== false);
44
+ const rootModal = computed(() => (isDesktop.value ? rekaModal.value : drawerModal.value));
45
+
46
+ provideResponsivePopoverContext({
47
+ open: computed(() => open.value ?? false),
48
+ isDesktop: computed(() => isDesktop.value),
49
+ scrollLock: computed(() => props.scrollLock),
50
+ });
51
+ </script>
52
+
53
+ <template>
54
+ <component :is="Root" v-model:open="open" :modal="rootModal" data-slot="responsive-popover">
55
+ <slot :is-desktop="isDesktop" />
56
+ </component>
57
+ </template>
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { computed } from 'vue';
4
+ import { useMediaQuery } from '@vueuse/core';
5
+ import { APopoverContent, useEventScrollLock } from '@alikhalilll/a-popover';
6
+ import { ADrawerContent } from '@alikhalilll/a-drawer';
7
+ import { useResponsivePopoverContext } from '../composables/useResponsivePopoverContext';
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ breakpoint?: string;
12
+ /** Classes applied on both branches. Avoid width / inset classes here. */
13
+ class?: HTMLAttributes['class'];
14
+ /** Classes applied only when the popover (desktop) branch is rendered. */
15
+ popoverClass?: HTMLAttributes['class'];
16
+ /** Classes applied only when the drawer (mobile) branch is rendered. */
17
+ drawerClass?: HTMLAttributes['class'];
18
+ /**
19
+ * Render the dimmed overlay on the desktop popover branch. Defaults to `false` — popovers
20
+ * on desktop are non-modal-looking by convention. The mobile drawer always has its own
21
+ * overlay (vaul-vue's `DrawerOverlay`) regardless of this prop.
22
+ */
23
+ overlay?: boolean;
24
+ align?: 'start' | 'center' | 'end';
25
+ sideOffset?: number;
26
+ }>(),
27
+ {
28
+ breakpoint: '(min-width: 768px)',
29
+ align: 'start',
30
+ sideOffset: 4,
31
+ overlay: false,
32
+ }
33
+ );
34
+
35
+ const ctx = useResponsivePopoverContext();
36
+
37
+ // Prefer the root's media query (so both layers agree). Fall back to a local one when this
38
+ // component is used outside `AResponsivePopover` (unusual but supported).
39
+ const fallbackIsDesktop = useMediaQuery(() => props.breakpoint);
40
+ const isDesktop = computed(() => ctx?.isDesktop.value ?? fallbackIsDesktop.value);
41
+
42
+ const scrollLockMode = computed(() => ctx?.scrollLock.value ?? 'events');
43
+ const overlayLockScroll = computed(() => scrollLockMode.value === 'body');
44
+
45
+ const mergedClass = computed(() => [
46
+ props.class,
47
+ isDesktop.value ? props.popoverClass : props.drawerClass,
48
+ ]);
49
+
50
+ // Sticky-safe scroll lock — only active while the popover is open on desktop and the root
51
+ // asked for the event-based strategy. The getter resolves every responsive popover content
52
+ // element currently in the DOM, which lets stacked popovers share the lock cleanly.
53
+ useEventScrollLock({
54
+ allowedScrollContainer: () => {
55
+ if (typeof document === 'undefined') return [];
56
+ return Array.from(
57
+ document.querySelectorAll<HTMLElement>('[data-responsive-popover-scroll-container="true"]')
58
+ );
59
+ },
60
+ active: computed(() => !!ctx?.open.value && isDesktop.value && scrollLockMode.value === 'events'),
61
+ });
62
+ </script>
63
+
64
+ <template>
65
+ <APopoverContent
66
+ v-if="isDesktop"
67
+ :overlay="props.overlay"
68
+ :overlay-lock-scroll="overlayLockScroll"
69
+ :align="props.align"
70
+ :side-offset="props.sideOffset"
71
+ :class="mergedClass"
72
+ data-slot="responsive-popover-content"
73
+ data-responsive-popover-scroll-container="true"
74
+ >
75
+ <slot />
76
+ </APopoverContent>
77
+ <ADrawerContent v-else :class="mergedClass" data-slot="responsive-popover-content">
78
+ <slot />
79
+ </ADrawerContent>
80
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useMediaQuery } from '@vueuse/core';
4
+ import { APopoverTrigger } from '@alikhalilll/a-popover';
5
+ import { ADrawerTrigger } from '@alikhalilll/a-drawer';
6
+
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ breakpoint?: string;
10
+ asChild?: boolean;
11
+ }>(),
12
+ { breakpoint: '(min-width: 768px)' }
13
+ );
14
+
15
+ const isDesktop = useMediaQuery(() => props.breakpoint);
16
+ const Trigger = computed(() => (isDesktop.value ? APopoverTrigger : ADrawerTrigger));
17
+ </script>
18
+
19
+ <template>
20
+ <component :is="Trigger" :as-child="props.asChild" data-slot="responsive-popover-trigger">
21
+ <slot />
22
+ </component>
23
+ </template>
@@ -0,0 +1,20 @@
1
+ import { inject, provide, type ComputedRef, type InjectionKey } from 'vue';
2
+ import type { ScrollLockMode } from '../types';
3
+
4
+ export interface ResponsivePopoverContext {
5
+ open: ComputedRef<boolean>;
6
+ isDesktop: ComputedRef<boolean>;
7
+ scrollLock: ComputedRef<ScrollLockMode>;
8
+ }
9
+
10
+ const RESPONSIVE_POPOVER_CONTEXT: InjectionKey<ResponsivePopoverContext> = Symbol(
11
+ 'AResponsivePopoverContext'
12
+ );
13
+
14
+ export function provideResponsivePopoverContext(ctx: ResponsivePopoverContext) {
15
+ provide(RESPONSIVE_POPOVER_CONTEXT, ctx);
16
+ }
17
+
18
+ export function useResponsivePopoverContext(): ResponsivePopoverContext | null {
19
+ return inject(RESPONSIVE_POPOVER_CONTEXT, null);
20
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { default as AResponsivePopover } from './components/AResponsivePopover.vue';
2
+ export { default as AResponsivePopoverTrigger } from './components/AResponsivePopoverTrigger.vue';
3
+ export { default as AResponsivePopoverContent } from './components/AResponsivePopoverContent.vue';
4
+
5
+ export type {
6
+ AResponsivePopoverProps,
7
+ AResponsivePopoverEmits,
8
+ AResponsivePopoverSlots,
9
+ ScrollLockMode,
10
+ } from './types';
@@ -0,0 +1,37 @@
1
+ import { defineNuxtModule, addComponent } from '@nuxt/kit';
2
+ import type { NuxtModule } from '@nuxt/schema';
3
+
4
+ /**
5
+ * `@alikhalilll/a-responsive-popover/nuxt` — registers the responsive-popover
6
+ * components for Nuxt auto-import. Also import the a-popover and a-drawer
7
+ * stylesheets (this component renders them), plus a-ui-base tokens.
8
+ */
9
+
10
+ export interface ModuleOptions {
11
+ prefix?: string;
12
+ }
13
+
14
+ const COMPONENTS: Record<string, string> = {
15
+ AResponsivePopover: '@alikhalilll/a-responsive-popover',
16
+ AResponsivePopoverContent: '@alikhalilll/a-responsive-popover',
17
+ AResponsivePopoverTrigger: '@alikhalilll/a-responsive-popover',
18
+ };
19
+
20
+ const module: NuxtModule<ModuleOptions> = defineNuxtModule<ModuleOptions>({
21
+ meta: {
22
+ name: '@alikhalilll/a-responsive-popover',
23
+ configKey: 'aResponsivePopover',
24
+ compatibility: { nuxt: '>=3.0.0' },
25
+ },
26
+ defaults: { prefix: '' },
27
+ setup(opts) {
28
+ const prefix = opts.prefix ?? '';
29
+ for (const [exportName, from] of Object.entries(COMPONENTS)) {
30
+ const baseName = exportName.startsWith('A') ? exportName.slice(1) : exportName;
31
+ const registeredName = `${prefix}${prefix ? baseName : exportName}`;
32
+ addComponent({ name: registeredName, export: exportName, filePath: from });
33
+ }
34
+ },
35
+ });
36
+
37
+ export default module;
@@ -0,0 +1,29 @@
1
+ import type { ComponentResolver } from 'unplugin-vue-components';
2
+
3
+ /**
4
+ * `@alikhalilll/a-responsive-popover/resolver` — auto-import resolver for
5
+ * non-Nuxt Vite/Webpack consumers via `unplugin-vue-components`.
6
+ */
7
+
8
+ export interface ResolverOptions {
9
+ prefix?: string;
10
+ }
11
+
12
+ const COMPONENT_TO_ENTRY: Record<string, string> = {
13
+ AResponsivePopover: '@alikhalilll/a-responsive-popover',
14
+ AResponsivePopoverContent: '@alikhalilll/a-responsive-popover',
15
+ AResponsivePopoverTrigger: '@alikhalilll/a-responsive-popover',
16
+ };
17
+
18
+ export default function AResponsivePopoverResolver(opts: ResolverOptions = {}): ComponentResolver {
19
+ const prefix = opts.prefix ?? '';
20
+ return {
21
+ type: 'component',
22
+ resolve(name) {
23
+ const bare = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name;
24
+ const from = COMPONENT_TO_ENTRY[bare];
25
+ if (!from) return;
26
+ return { name: bare, from };
27
+ },
28
+ };
29
+ }
package/src/types.ts ADDED
@@ -0,0 +1,39 @@
1
+ export type ScrollLockMode = 'events' | 'body' | 'none';
2
+
3
+ export interface AResponsivePopoverProps {
4
+ /** CSS media query for the desktop break. Below this width we render a vaul drawer. */
5
+ breakpoint?: string;
6
+ /**
7
+ * @deprecated prefer `scrollLock`. Kept for back-compat: `modal=false` is a shorthand
8
+ * for `scrollLock="none"` (tooltip-style popover). `modal=true` (default) defers to
9
+ * `scrollLock`, which controls how page scroll is blocked.
10
+ */
11
+ modal?: boolean;
12
+ /**
13
+ * How desktop page scroll is blocked while the popover is open:
14
+ * - `'events'` (default) — wheel/touch/keyboard intercepted at document level.
15
+ * Page scrollbar stays visible; `position: sticky` keeps working.
16
+ * - `'body'` — legacy `document.body.style.overflow='hidden'` lock. Use when the
17
+ * page must reflow as the scrollbar goes away.
18
+ * - `'none'` — no page-scroll lock at all.
19
+ *
20
+ * Drawer (mobile) branch is unaffected — vaul-vue owns its own lock.
21
+ */
22
+ scrollLock?: ScrollLockMode;
23
+ }
24
+
25
+ /**
26
+ * Emit map for {@link AResponsivePopover}. `update:open` is the `v-model:open`.
27
+ */
28
+ export type AResponsivePopoverEmits = {
29
+ 'update:open': [value: boolean | undefined];
30
+ };
31
+
32
+ /**
33
+ * Slot prop shape for {@link AResponsivePopover}. The default slot receives
34
+ * `isDesktop` so consumers can branch content on the active breakpoint without
35
+ * a separate `useMediaQuery` call.
36
+ */
37
+ export interface AResponsivePopoverSlots {
38
+ default?: (props: { isDesktop: boolean }) => unknown;
39
+ }
package/web-types.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/web-types",
3
+ "name": "@alikhalilll/a-responsive-popover",
4
+ "version": "1.0.1",
5
+ "js-types-syntax": "typescript",
6
+ "description-markup": "markdown",
7
+ "framework": "vue",
8
+ "framework-config": {
9
+ "enable-when": {
10
+ "node-packages": [
11
+ "vue"
12
+ ]
13
+ }
14
+ },
15
+ "contributions": {
16
+ "html": {
17
+ "vue-components": [
18
+ {
19
+ "name": "AResponsivePopover",
20
+ "source": {
21
+ "module": "@alikhalilll/a-responsive-popover",
22
+ "symbol": "AResponsivePopover"
23
+ },
24
+ "props": [
25
+ {
26
+ "name": "breakpoint",
27
+ "type": "string",
28
+ "required": false,
29
+ "description": "CSS media query for the desktop break. Below this width we render a vaul drawer."
30
+ },
31
+ {
32
+ "name": "modal",
33
+ "type": "boolean",
34
+ "required": false,
35
+ "deprecated": true
36
+ },
37
+ {
38
+ "name": "scrollLock",
39
+ "type": "ScrollLockMode",
40
+ "required": false,
41
+ "description": "How desktop page scroll is blocked while the popover is open:\n- `'events'` (default) — wheel/touch/keyboard intercepted at document level.\n Page scrollbar stays visible; `position: sticky` keeps working.\n- `'body'` — legacy `document.body.style.overflow='hidden'` lock. Use when the\n page must reflow as the scrollbar goes away.\n- `'none'` — no page-scroll lock at all.\n\nDrawer (mobile) branch is unaffected — vaul-vue owns its own lock."
42
+ }
43
+ ],
44
+ "slots": [
45
+ {
46
+ "name": "default"
47
+ }
48
+ ],
49
+ "js": {
50
+ "events": [
51
+ {
52
+ "name": "update:open"
53
+ }
54
+ ]
55
+ }
56
+ },
57
+ {
58
+ "name": "AResponsivePopoverTrigger",
59
+ "source": {
60
+ "module": "@alikhalilll/a-responsive-popover",
61
+ "symbol": "AResponsivePopoverTrigger"
62
+ }
63
+ },
64
+ {
65
+ "name": "AResponsivePopoverContent",
66
+ "source": {
67
+ "module": "@alikhalilll/a-responsive-popover",
68
+ "symbol": "AResponsivePopoverContent"
69
+ }
70
+ }
71
+ ]
72
+ }
73
+ }
74
+ }