diff --git a/eslint.config.ts b/eslint.config.ts index bee1408..2fa78c4 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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__/*'], diff --git a/packages/library/src/components/index.ts b/packages/library/src/components/index.ts index 6fac0a2..e6f05a2 100644 --- a/packages/library/src/components/index.ts +++ b/packages/library/src/components/index.ts @@ -21,3 +21,15 @@ export { SelectLabel, SelectSeparator, } from './ui/select' +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, + DialogOverlay, + DialogScrollContent, +} from './ui/dialog' diff --git a/packages/library/src/components/ui/dialog/Dialog.vue b/packages/library/src/components/ui/dialog/Dialog.vue new file mode 100644 index 0000000..ade5260 --- /dev/null +++ b/packages/library/src/components/ui/dialog/Dialog.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogClose.vue b/packages/library/src/components/ui/dialog/DialogClose.vue new file mode 100644 index 0000000..c5fae04 --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogClose.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogContent.vue b/packages/library/src/components/ui/dialog/DialogContent.vue new file mode 100644 index 0000000..a39285c --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogContent.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogDescription.vue b/packages/library/src/components/ui/dialog/DialogDescription.vue new file mode 100644 index 0000000..6f4faa1 --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogFooter.vue b/packages/library/src/components/ui/dialog/DialogFooter.vue new file mode 100644 index 0000000..92f81b8 --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogFooter.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogHeader.vue b/packages/library/src/components/ui/dialog/DialogHeader.vue new file mode 100644 index 0000000..5c7928f --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogOverlay.vue b/packages/library/src/components/ui/dialog/DialogOverlay.vue new file mode 100644 index 0000000..edc6bad --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogOverlay.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogScrollContent.vue b/packages/library/src/components/ui/dialog/DialogScrollContent.vue new file mode 100644 index 0000000..65683fc --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogScrollContent.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogTitle.vue b/packages/library/src/components/ui/dialog/DialogTitle.vue new file mode 100644 index 0000000..d4d034e --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogTitle.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/library/src/components/ui/dialog/DialogTrigger.vue b/packages/library/src/components/ui/dialog/DialogTrigger.vue new file mode 100644 index 0000000..49667e9 --- /dev/null +++ b/packages/library/src/components/ui/dialog/DialogTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/library/src/components/ui/dialog/index.ts b/packages/library/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..6768b09 --- /dev/null +++ b/packages/library/src/components/ui/dialog/index.ts @@ -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" diff --git a/packages/library/src/index.ts b/packages/library/src/index.ts index 5c54557..babe237 100644 --- a/packages/library/src/index.ts +++ b/packages/library/src/index.ts @@ -33,6 +33,15 @@ export { CardFooter, CardAction, Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, + DialogOverlay, + DialogScrollContent, Alert, AlertTitle, AlertDescription, diff --git a/packages/library/src/lib/index.ts b/packages/library/src/lib/index.ts index df08540..67f78ae 100644 --- a/packages/library/src/lib/index.ts +++ b/packages/library/src/lib/index.ts @@ -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-`) onto `pt.root`. This factory layers a - * `nych--
` 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 = ( - component: T, - base: string, - sections: string[] = [], -): Themeable => ({ - component, - unstyled: true as Themeable['unstyled'], - classes: [`nych-${base}`], - injectionKeys: { class: 'pt' as Key }, - propMutator: ((props: Record) => { - const pt = (props.pt as Record) ?? {} - - 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['propMutator'], -}) - const loadingIconThemeable: Themeable = { component: NychLoadingIcon, unstyled: false, @@ -114,21 +97,12 @@ const loadingIconThemeable: Themeable = { 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('LoadingIcon') -export const Dialog: typeof PrimeDialog = _engine.getComponent('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) diff --git a/packages/library/tests/Dialog.spec.ts b/packages/library/tests/Dialog.spec.ts new file mode 100644 index 0000000..2b1f390 --- /dev/null +++ b/packages/library/tests/Dialog.spec.ts @@ -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') + }) +}) diff --git a/packages/library/tests/lib.spec.ts b/packages/library/tests/lib.spec.ts index dec9161..3b12ad3 100644 --- a/packages/library/tests/lib.spec.ts +++ b/packages/library/tests/lib.spec.ts @@ -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) => { - const mergedProps: Record = {} - - 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) || {} - } - 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).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') - }) -}) diff --git a/packages/playground/stories/Dialog.stories.ts b/packages/playground/stories/Dialog.stories.ts index a089f61..aa746eb 100644 --- a/packages/playground/stories/Dialog.stories.ts +++ b/packages/playground/stories/Dialog.stories.ts @@ -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 export const Default: Story = { render: () => ({ - components: { NychDialog, NychButton }, - setup() { - const visible = ref(false) - return { visible } - }, + components: { NychDialog, NychDialogTrigger, NychDialogContent, NychDialogHeader, NychDialogTitle, NychDialogFooter, NychButton }, template: ` -
- - -

+ + + Summon the Oracle + + + + Oracle of Delphi + +

Seekers travelled from across the Aegean to hear the Pythia speak the will of Apollo in riddling verse. Approach, and ask your question.

- -
-
+ + Withdraw + Ask + + + `, }), } -// 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: ` - -

- 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. -

- + + + + Passage of the Styx + +

+ 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. +

+ + Turn back + Pay the toll + +
`, }), @@ -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: ` - -

A crow has settled upon the western gate. Interpret it as you will.

+ + + + An Omen + +

A crow has settled upon the western gate. Interpret it as you will.

+
`, }),