fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -0,0 +1,28 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
export interface PopoverAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeMount, onUnmounted } from 'vue';
import { PopperAnchor } from '../popper';
import { usePopoverContext } from './context';
const props = defineProps<PopoverAnchorProps>();
const ctx = usePopoverContext();
onBeforeMount(() => {
ctx.hasCustomAnchor.value = true;
});
onUnmounted(() => {
ctx.hasCustomAnchor.value = false;
});
</script>
<template>
<PopperAnchor v-bind="props">
<slot />
</PopperAnchor>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
export interface PopoverArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../popper';
const { width = 10, height = 5 } = defineProps<PopoverArrowProps>();
</script>
<template>
<PopperArrow :width="width" :height="height">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface PopoverCloseProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
import { usePopoverContext } from './context';
const { as = 'button' } = defineProps<PopoverCloseProps>();
const { forwardRef } = useForwardExpose();
const ctx = usePopoverContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
@click="ctx.onOpenChange(false)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { PopoverContentImplEmits, PopoverContentImplProps } from './PopoverContentImpl.vue';
export interface PopoverContentProps extends PopoverContentImplProps {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
}
export type PopoverContentEmits = PopoverContentImplEmits;
</script>
<script setup lang="ts">
import PopoverContentModal from './PopoverContentModal.vue';
import PopoverContentNonModal from './PopoverContentNonModal.vue';
import { Presence } from '../presence';
import { usePopoverContext } from './context';
const { forceMount = false, ...contentProps } = defineProps<PopoverContentProps>();
const emit = defineEmits<PopoverContentEmits>();
const ctx = usePopoverContext();
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<PopoverContentModal
v-if="ctx.modal.value"
v-bind="contentProps"
@open-auto-focus="emit('openAutoFocus', $event)"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</PopoverContentModal>
<PopoverContentNonModal
v-else
v-bind="contentProps"
@open-auto-focus="emit('openAutoFocus', $event)"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</PopoverContentNonModal>
</Presence>
</template>
@@ -0,0 +1,78 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../dismissable-layer';
import type { FocusScopeEmits } from '../focus-scope';
import type { PopperContentProps } from '../popper';
export interface PopoverContentImplProps extends PopperContentProps {
/** Trap focus inside the content (modal popovers). */
trapFocus?: boolean;
/** Block outside pointer events (modal popovers). */
disableOutsidePointerEvents?: boolean;
}
export interface PopoverContentImplEmits {
openAutoFocus: FocusScopeEmits['mountAutoFocus'];
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
focusOutside: DismissableLayerEmits['focusOutside'];
interactOutside: DismissableLayerEmits['interactOutside'];
dismiss: [];
}
</script>
<script setup lang="ts">
import { DismissableLayer } from '../dismissable-layer';
import { FocusScope } from '../focus-scope';
import { PopperContent } from '../popper';
import { usePopoverContext } from './context';
const {
trapFocus = false,
disableOutsidePointerEvents = false,
as = 'div',
...popperProps
} = defineProps<PopoverContentImplProps>();
const emit = defineEmits<PopoverContentImplEmits>();
const ctx = usePopoverContext();
</script>
<template>
<FocusScope
as="template"
:loop="true"
:trapped="trapFocus"
@mount-auto-focus="emit('openAutoFocus', $event)"
@unmount-auto-focus="emit('closeAutoFocus', $event)"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="disableOutsidePointerEvents"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="ctx.onOpenChange(false)"
>
<PopperContent
:id="ctx.contentId.value"
:as="as"
v-bind="popperProps"
:data-state="ctx.open.value ? 'open' : 'closed'"
:aria-labelledby="ctx.triggerId.value"
role="dialog"
:style="{
'--popover-content-transform-origin': 'var(--popper-transform-origin)',
'--popover-content-available-width': 'var(--popper-available-width)',
'--popover-content-available-height': 'var(--popper-available-height)',
'--popover-trigger-width': 'var(--popper-anchor-width)',
'--popover-trigger-height': 'var(--popper-anchor-height)',
}"
>
<slot />
</PopperContent>
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { PopoverContentImplEmits, PopoverContentImplProps } from './PopoverContentImpl.vue';
import PopoverContentImpl from './PopoverContentImpl.vue';
import { ref } from 'vue';
import { useBodyScrollLock } from '@robonen/vue';
import { usePopoverContext } from './context';
const props = defineProps<PopoverContentImplProps>();
const emit = defineEmits<PopoverContentImplEmits>();
const ctx = usePopoverContext();
const isRightClickOutsideRef = ref(false);
useBodyScrollLock();
</script>
<template>
<PopoverContentImpl
v-bind="props"
:trap-focus="ctx.open.value"
disable-outside-pointer-events
@close-auto-focus.prevent="(event: Event) => {
emit('closeAutoFocus', event);
if (!isRightClickOutsideRef) ctx.triggerElement.value?.focus();
}"
@pointer-down-outside="(event: PointerEvent | MouseEvent) => {
emit('pointerDownOutside', event);
const ctrlLeftClick = event.button === 0 && event.ctrlKey === true;
isRightClickOutsideRef = event.button === 2 || ctrlLeftClick;
}"
@focus-outside.prevent
@escape-key-down="emit('escapeKeyDown', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</PopoverContentImpl>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { PopoverContentImplEmits, PopoverContentImplProps } from './PopoverContentImpl.vue';
import PopoverContentImpl from './PopoverContentImpl.vue';
import { ref } from 'vue';
import { usePopoverContext } from './context';
const props = defineProps<PopoverContentImplProps>();
const emit = defineEmits<PopoverContentImplEmits>();
const ctx = usePopoverContext();
const hasInteractedOutsideRef = ref(false);
const hasPointerDownOutsideRef = ref(false);
</script>
<template>
<PopoverContentImpl
v-bind="props"
:trap-focus="false"
:disable-outside-pointer-events="false"
@close-auto-focus="(event: Event) => {
emit('closeAutoFocus', event);
if (!event.defaultPrevented) {
if (!hasInteractedOutsideRef) ctx.triggerElement.value?.focus();
event.preventDefault();
}
hasInteractedOutsideRef = false;
hasPointerDownOutsideRef = false;
}"
@interact-outside="(event: PointerEvent | MouseEvent | FocusEvent) => {
emit('interactOutside', event);
if (!event.defaultPrevented) {
hasInteractedOutsideRef = true;
if (event.type === 'pointerdown') hasPointerDownOutsideRef = true;
}
const target = event.target as HTMLElement;
if (ctx.triggerElement.value?.contains(target)) event.preventDefault();
if (event.type === 'focusin' && hasPointerDownOutsideRef) event.preventDefault();
}"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@dismiss="emit('dismiss')"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</PopoverContentImpl>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../teleport';
export interface PopoverPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import PortalPrimitive from '../teleport/Teleport.vue';
const props = defineProps<PopoverPortalProps>();
</script>
<template>
<PortalPrimitive v-bind="props">
<slot />
</PortalPrimitive>
</template>
@@ -0,0 +1,56 @@
<script lang="ts">
export interface PopoverRootProps {
/** Uncontrolled initial open state. Ignored once `v-model:open` is bound. */
defaultOpen?: boolean;
/**
* Modal mode traps focus, locks scroll, and disables outside pointer events.
* @default false
*/
modal?: boolean;
}
</script>
<script setup lang="ts">
import { ref, toRef } from 'vue';
import { PopperRoot } from '../popper';
import { providePopoverContext } from './context';
import { useId } from '../config-provider';
defineOptions({ inheritAttrs: false });
const { defaultOpen = false, modal = false } = defineProps<PopoverRootProps>();
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
const triggerId = useId(undefined, 'popover-trigger');
const contentId = useId(undefined, 'popover-content');
const triggerElement = ref<HTMLElement>();
const hasCustomAnchor = ref(false);
providePopoverContext({
open,
// Identity passthrough via `toRef` — reactive without `computed`'s effect/cache.
modal: toRef(() => modal),
triggerId,
contentId,
triggerElement,
hasCustomAnchor,
onOpenChange: (value) => { open.value = value; },
onOpenToggle: () => { open.value = !open.value; },
});
</script>
<template>
<PopperRoot>
<slot :open="open" :close="() => open = false" />
</PopperRoot>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface PopoverTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { PopperAnchor } from '../popper';
import { Primitive } from '../primitive';
import { onMounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { usePopoverContext } from './context';
const { as = 'button' } = defineProps<PopoverTriggerProps>();
const ctx = usePopoverContext();
const { forwardRef, currentElement: triggerElement } = useForwardExpose();
onMounted(() => {
ctx.triggerElement.value = triggerElement.value;
});
</script>
<template>
<component :is="ctx.hasCustomAnchor.value ? Primitive : PopperAnchor" as="template">
<Primitive
:id="ctx.triggerId.value"
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-haspopup="dialog"
:aria-expanded="ctx.open.value"
:aria-controls="ctx.contentId.value"
:data-state="ctx.open.value ? 'open' : 'closed'"
@click="ctx.onOpenToggle"
>
<slot />
</Primitive>
</component>
</template>
@@ -0,0 +1,169 @@
import {
PopoverClose,
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from '../index';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { userEvent } from 'vitest/browser';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountPopover(options: {
open?: boolean;
defaultOpen?: boolean;
modal?: boolean;
onUpdateOpen?: (v: boolean) => void;
} = {}) {
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{
open: options.open,
defaultOpen: options.defaultOpen,
modal: options.modal,
'onUpdate:open': options.onUpdateOpen,
},
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { forceMount: true }, {
default: () => [
h('p', 'Popover body'),
h(PopoverClose, null, { default: () => 'Close' }),
],
}),
],
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
describe('Popover', () => {
it('renders trigger', () => {
const wrapper = mountPopover();
const trigger = wrapper.find('button');
expect(trigger.text()).toBe('Toggle');
expect(trigger.attributes('aria-haspopup')).toBe('dialog');
expect(trigger.attributes('data-state')).toBe('closed');
});
it('opens on trigger click', async () => {
const wrapper = mountPopover();
const trigger = wrapper.find('button');
await trigger.trigger('click');
await nextTick();
expect(trigger.attributes('aria-expanded')).toBe('true');
expect(trigger.attributes('data-state')).toBe('open');
});
it('toggles with v-model:open', async () => {
const onUpdate = vi.fn();
const wrapper = mountPopover({ onUpdateOpen: onUpdate });
const trigger = wrapper.find('button');
await trigger.trigger('click');
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(true);
});
it('opens with defaultOpen', async () => {
const wrapper = mountPopover({ defaultOpen: true });
await nextTick();
const trigger = wrapper.find('button');
expect(trigger.attributes('data-state')).toBe('open');
});
it('close button closes the popover', async () => {
const onUpdate = vi.fn();
const wrapper = mountPopover({ defaultOpen: true, onUpdateOpen: onUpdate });
await nextTick();
const closeBtn = wrapper.findAll('button').find(b => b.text() === 'Close');
expect(closeBtn).toBeDefined();
await closeBtn!.trigger('click');
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(false);
});
it('content has role="dialog"', async () => {
const wrapper = mountPopover({ defaultOpen: true });
await nextTick();
const content = wrapper.find('[role="dialog"]');
expect(content.exists()).toBe(true);
expect(content.attributes('data-state')).toBe('open');
});
it('closes on Escape key', async () => {
const onUpdate = vi.fn();
mountPopover({ defaultOpen: true, onUpdateOpen: onUpdate });
await nextTick();
await userEvent.keyboard('{Escape}');
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(false);
});
it('supports controlled open', async () => {
const Wrapper = defineComponent({
setup() {
const open = ref(false);
return () => h(
PopoverRoot,
{ open: open.value, 'onUpdate:open': (v: boolean) => { open.value = v; } },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { forceMount: true }, { default: () => 'Body' }),
],
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(wrapper.find('button').attributes('data-state')).toBe('closed');
await wrapper.find('button').trigger('click');
await nextTick();
expect(wrapper.find('button').attributes('data-state')).toBe('open');
});
it('trigger has aria-controls pointing to content id', async () => {
const wrapper = mountPopover({ defaultOpen: true });
await nextTick();
const trigger = wrapper.find('button');
const contentId = wrapper.find('[role="dialog"]').attributes('id');
expect(trigger.attributes('aria-controls')).toBe(contentId);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+18
View File
@@ -0,0 +1,18 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface PopoverContext {
open: Ref<boolean>;
modal: Ref<boolean>;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
triggerElement: Ref<HTMLElement | undefined>;
hasCustomAnchor: Ref<boolean>;
onOpenChange: (value: boolean) => void;
onOpenToggle: () => void;
}
const ctx = useContextFactory<PopoverContext>('PopoverContext');
export const providePopoverContext = ctx.provide;
export const usePopoverContext = ctx.inject;
+18
View File
@@ -0,0 +1,18 @@
export { default as PopoverRoot } from './PopoverRoot.vue';
export { default as PopoverTrigger } from './PopoverTrigger.vue';
export { default as PopoverAnchor } from './PopoverAnchor.vue';
export { default as PopoverContent } from './PopoverContent.vue';
export { default as PopoverPortal } from './PopoverPortal.vue';
export { default as PopoverArrow } from './PopoverArrow.vue';
export { default as PopoverClose } from './PopoverClose.vue';
export { usePopoverContext } from './context';
export type { PopoverContext } from './context';
export type { PopoverRootProps } from './PopoverRoot.vue';
export type { PopoverTriggerProps } from './PopoverTrigger.vue';
export type { PopoverAnchorProps } from './PopoverAnchor.vue';
export type { PopoverContentEmits, PopoverContentProps } from './PopoverContent.vue';
export type { PopoverPortalProps } from './PopoverPortal.vue';
export type { PopoverArrowProps } from './PopoverArrow.vue';
export type { PopoverCloseProps } from './PopoverClose.vue';