feat: migrate Dialog to shadcn-vue compound API

Also: fixes DialogContent/DialogFooter's close/action buttons to use our
actual Button variant set (secondary) instead of the CLI's default
ghost/outline variants, which don't exist on our re-themed Button.
Adds an eslint override so shadcn-vue's single-word ui/ component names
(Button.vue, Dialog.vue, ...) aren't flagged by vue/multi-word-component-names.
This was the last PrimeVue-wrapped component, so lib/index.ts no longer
imports anything from primevue or uses the ptThemeable helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Matthew McPeak 2026-07-11 14:57:06 -04:00
parent 4755f9ab6e
commit 7bb554aeb0
18 changed files with 451 additions and 135 deletions

View file

@ -22,6 +22,16 @@ export default defineConfigWithVueTs(
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
// shadcn-vue's generated ui/ components use single-word names by convention
// (Button.vue, Input.vue, ...) so their file names match the CLI's own output.
name: 'app/shadcn-vue-component-names',
files: ['**/components/ui/**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
},
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],

View file

@ -21,3 +21,15 @@ export {
SelectLabel,
SelectSeparator,
} from './ui/select'
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
} from './ui/dialog'

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import { XIcon } from '@lucide/vue';
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
as-child
>
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent hover:bg-black/10" size="icon">
<XIcon class="size-3.5" />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
>
<slot />
</DialogDescription>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { DialogClose } from "reka-ui"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
showCloseButton?: boolean
}>(), {
showCloseButton: false,
})
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="secondary">
Close
</Button>
</DialogClose>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('gap-2 flex flex-col', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
>
<slot />
</DialogOverlay>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import { XIcon } from '@lucide/vue';
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<XIcon class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
>
<slot />
</DialogTitle>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View file

@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

View file

@ -33,6 +33,15 @@ export {
CardFooter,
CardAction,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
Alert,
AlertTitle,
AlertDescription,

View file

@ -1,8 +1,5 @@
import type { App } from 'vue'
import {
Dialog as PrimeDialog,
} from 'primevue'
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
import { ThemeLibrary, type Themeable, ThemeEngine } from '../engine'
import {
NychLoadingIcon,
Button,
@ -36,6 +33,16 @@ import {
SelectGroup,
SelectLabel,
SelectSeparator,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
} from '../components'
export {
@ -70,42 +77,18 @@ export {
SelectGroup,
SelectLabel,
SelectSeparator,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
}
/**
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
*
* The engine injects the base class (`nych-<base>`) onto `pt.root`. This factory layers a
* `nych-<base>-<section>` class onto every additional section so multi-part components
* (Checkbox box, Select overlay, Card body, ) can be fully styled in unstyled mode, where
* PrimeVue emits no classes of its own.
*/
const ptThemeable = <T extends LibComponent>(
component: T,
base: string,
sections: string[] = [],
): Themeable<T> => ({
component,
unstyled: true as Themeable<T>['unstyled'],
classes: [`nych-${base}`],
injectionKeys: { class: 'pt' as Key<T> },
propMutator: ((props: Record<string, unknown>) => {
const pt = (props.pt as Record<string, { class?: string }>) ?? {}
for (const section of sections) {
const existing = pt[section]?.class
pt[section] = {
...(pt[section] ?? {}),
class: [existing, `nych-${base}-${section}`].filter(Boolean).join(' '),
}
}
props.pt = pt
return props
}) as Themeable<T>['propMutator'],
})
const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
component: NychLoadingIcon,
unstyled: false,
@ -114,21 +97,12 @@ const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
const nychthemeron: ThemeLibrary = {
components: {
LoadingIcon: loadingIconThemeable,
Dialog: ptThemeable(PrimeDialog, 'dialog', [
'mask',
'header',
'title',
'headerActions',
'content',
'footer',
]),
},
}
const _engine = new ThemeEngine(nychthemeron)
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
export const createNychthemeron = (): {
Button: typeof Button
@ -155,7 +129,16 @@ export const createNychthemeron = (): {
CardContent: typeof CardContent
CardFooter: typeof CardFooter
CardAction: typeof CardAction
Dialog: typeof PrimeDialog
Dialog: typeof Dialog
DialogTrigger: typeof DialogTrigger
DialogContent: typeof DialogContent
DialogHeader: typeof DialogHeader
DialogTitle: typeof DialogTitle
DialogDescription: typeof DialogDescription
DialogFooter: typeof DialogFooter
DialogClose: typeof DialogClose
DialogOverlay: typeof DialogOverlay
DialogScrollContent: typeof DialogScrollContent
Alert: typeof Alert
AlertTitle: typeof AlertTitle
AlertDescription: typeof AlertDescription
@ -188,6 +171,15 @@ export const createNychthemeron = (): {
CardFooter,
CardAction,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
Alert,
AlertTitle,
AlertDescription,
@ -219,6 +211,15 @@ export const createNychthemeron = (): {
app.component('NychCardFooter', CardFooter)
app.component('NychCardAction', CardAction)
app.component('NychDialog', Dialog)
app.component('NychDialogTrigger', DialogTrigger)
app.component('NychDialogContent', DialogContent)
app.component('NychDialogHeader', DialogHeader)
app.component('NychDialogTitle', DialogTitle)
app.component('NychDialogDescription', DialogDescription)
app.component('NychDialogFooter', DialogFooter)
app.component('NychDialogClose', DialogClose)
app.component('NychDialogOverlay', DialogOverlay)
app.component('NychDialogScrollContent', DialogScrollContent)
app.component('NychAlert', Alert)
app.component('NychAlertTitle', AlertTitle)
app.component('NychAlertDescription', AlertDescription)

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { h, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { Dialog, DialogContent, DialogTitle } from '../src/lib'
describe('Dialog', () => {
it('does not render content when closed', async () => {
mount(Dialog, {
props: { open: false },
slots: {
default: () => h(DialogContent, undefined, {
default: () => h(DialogTitle, undefined, { default: () => 'Oracle of Delphi' }),
}),
},
attachTo: document.body,
})
await nextTick()
expect(document.body.textContent).not.toContain('Oracle of Delphi')
})
it('renders content in a teleported portal when open', async () => {
mount(Dialog, {
props: { open: true },
slots: {
default: () => h(DialogContent, undefined, {
default: () => h(DialogTitle, undefined, { default: () => 'Oracle of Delphi' }),
}),
},
attachTo: document.body,
})
await nextTick()
await nextTick()
expect(document.body.textContent).toContain('Oracle of Delphi')
})
})

View file

@ -1,35 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import type { App } from 'vue'
import {
createNychthemeron,
LoadingIcon,
Dialog,
} from '../src/index'
//eslint-disable-next-line
const callSetup = (component: any, props: Record<string, unknown>) => {
const mergedProps: Record<string, unknown> = {}
if (component.props) {
for (const [key, propDef] of Object.entries(component.props)) {
if (typeof propDef === 'object' && propDef !== null && 'default' in propDef) {
mergedProps[key] = (propDef as { default: unknown }).default
}
}
}
Object.assign(mergedProps, props)
if (component.setup) {
const result = component.setup(mergedProps, { slots: {}, attrs: {} })
if (typeof result === 'function') {
const vnode = result()
return (vnode?.props as Record<string, unknown>) || {}
}
return result
}
return mergedProps
}
import { createNychthemeron, LoadingIcon } from '../src/index'
describe('createNychthemeron', () => {
it('returns a Vue plugin with install method', () => {
@ -57,26 +28,3 @@ describe('LoadingIcon', () => {
expect(LoadingIcon).toBeDefined()
})
})
describe('themed pt components', () => {
const cases = [
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' },
]
it.each(cases)('$name is a valid Vue component', ({ component }) => {
expect(component).toBeDefined()
expect(typeof (component as Record<string, unknown>).setup).toBe('function')
})
it.each(cases)('$name injects its base class onto pt.root', ({ component, base }) => {
const result = callSetup(component, { pt: {} })
expect(result.pt?.root?.class).toContain(base)
})
it.each(cases)('$name does not leak engine internal props', ({ component }) => {
const result = callSetup(component, { pt: {} })
expect(result).not.toHaveProperty('injectedClasses')
expect(result).not.toHaveProperty('injectedStyles')
expect(result).not.toHaveProperty('stylePlug')
})
})

View file

@ -1,7 +1,15 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Dialog as NychDialog, Button as NychButton } from '@nychthemeron/library'
import {
Dialog as NychDialog,
DialogTrigger as NychDialogTrigger,
DialogContent as NychDialogContent,
DialogHeader as NychDialogHeader,
DialogTitle as NychDialogTitle,
DialogFooter as NychDialogFooter,
Button as NychButton,
} from '@nychthemeron/library'
const meta = {
title: 'Components/Dialog',
@ -17,47 +25,52 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychDialog, NychButton },
setup() {
const visible = ref(false)
return { visible }
},
components: { NychDialog, NychDialogTrigger, NychDialogContent, NychDialogHeader, NychDialogTitle, NychDialogFooter, NychButton },
template: `
<div>
<NychButton label="Summon the Oracle" @click="visible = true" />
<NychDialog v-model:visible="visible" modal header="Oracle of Delphi" style="width: 28rem;">
<p style="margin: 0;">
<NychDialog>
<NychDialogTrigger as-child>
<NychButton>Summon the Oracle</NychButton>
</NychDialogTrigger>
<NychDialogContent style="width: 28rem;">
<NychDialogHeader>
<NychDialogTitle>Oracle of Delphi</NychDialogTitle>
</NychDialogHeader>
<p>
Seekers travelled from across the Aegean to hear the Pythia speak the
will of Apollo in riddling verse. Approach, and ask your question.
</p>
<template #footer>
<NychButton label="Withdraw" severity="secondary" @click="visible = false" />
<NychButton label="Ask" @click="visible = false" />
</template>
<NychDialogFooter>
<NychButton variant="secondary">Withdraw</NychButton>
<NychButton>Ask</NychButton>
</NychDialogFooter>
</NychDialogContent>
</NychDialog>
</div>
`,
}),
}
// Rendered open by default so the dialog surface is visible in docs/snapshots.
export const Open: Story = {
render: () => ({
components: { NychDialog, NychButton },
components: { NychDialog, NychDialogContent, NychDialogHeader, NychDialogTitle, NychDialogFooter, NychButton },
setup() {
const visible = ref(true)
return { visible }
const open = ref(true)
return { open }
},
template: `
<NychDialog v-model:visible="visible" modal header="Passage of the Styx" style="width: 28rem;">
<p style="margin: 0;">
<NychDialog v-model:open="open">
<NychDialogContent style="width: 28rem;">
<NychDialogHeader>
<NychDialogTitle>Passage of the Styx</NychDialogTitle>
</NychDialogHeader>
<p>
Charon ferries the souls of the dead across the river that divides the
world of the living from the world of the dead. One obol is required.
</p>
<template #footer>
<NychButton label="Turn back" severity="secondary" @click="visible = false" />
<NychButton label="Pay the toll" @click="visible = false" />
</template>
<NychDialogFooter>
<NychButton variant="secondary" @click="open = false">Turn back</NychButton>
<NychButton @click="open = false">Pay the toll</NychButton>
</NychDialogFooter>
</NychDialogContent>
</NychDialog>
`,
}),
@ -65,14 +78,19 @@ export const Open: Story = {
export const WithoutFooter: Story = {
render: () => ({
components: { NychDialog },
components: { NychDialog, NychDialogContent, NychDialogHeader, NychDialogTitle },
setup() {
const visible = ref(true)
return { visible }
const open = ref(true)
return { open }
},
template: `
<NychDialog v-model:visible="visible" modal header="An Omen" style="width: 26rem;">
<p style="margin: 0;">A crow has settled upon the western gate. Interpret it as you will.</p>
<NychDialog v-model:open="open">
<NychDialogContent style="width: 26rem;">
<NychDialogHeader>
<NychDialogTitle>An Omen</NychDialogTitle>
</NychDialogHeader>
<p>A crow has settled upon the western gate. Interpret it as you will.</p>
</NychDialogContent>
</NychDialog>
`,
}),