Compare commits

..

No commits in common. "main" and "worktree-primevue-to-shadcn-vue" have entirely different histories.

9 changed files with 13 additions and 209 deletions

View file

@ -15,6 +15,10 @@
"./style": {
"import": "./src/assets/css/tailwind.css",
"types": "./src/index.d.ts"
},
"./theme": {
"import": "./src/assets/css/tailwind.css",
"types": "./src/index.d.ts"
}
},
"files": [

View file

@ -77,7 +77,7 @@
--border-lo: #38342a;
--primary: #d4b278;
--primary-fg: #fff8e8;
--primary-fg: #0e0c08;
--warn: #e08840;
--warn-subtle: #281808;
@ -92,7 +92,7 @@
--info: #88aec8;
--info-subtle: #101e2c;
--info-border: #1e3850;
--info-fg: #fff8e8;
--info-fg: #0a1828;
--neutral: #7a6e60;
--neutral-fg: #fff8e8;

View file

@ -4,7 +4,7 @@ import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex w-fit shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 text-xs font-serif font-semibold [&>svg]:size-3',
'inline-flex w-fit shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 text-xs font-semibold [&>svg]:size-3',
{
variants: {
variant: {

View file

@ -4,7 +4,7 @@ import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-transparent font-serif font-medium transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
'inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-transparent font-medium transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
{
variants: {
variant: {

View file

@ -1,200 +0,0 @@
import { h, type SetupContext } from 'vue'
export type LibComponent =
| { new (...args: unknown[]): unknown } // Constructor components
| { (...args: unknown[]): unknown } // Functional components
| Record<string, unknown> // Object components
export type Props<T> = T extends { new (...args: unknown[]): { $props: infer P } }
? P
: T extends { props: infer P }
? P
: string
export type Key<T> = T extends { new (...args: unknown[]): { $props: infer P } }
? keyof P
: T extends { props: infer P }
? keyof P
: string
// Try to extract unstyled type from component
// If extraction fails, accept any value via unknown
type ExtractUnstyled<T> = T extends { props: infer P }
? P extends { unstyled: infer U }
? U
: unknown
: T extends { new (...args: unknown[]): { $props: infer P } }
? P extends { unstyled: infer U }
? U
: unknown
: unknown
export type Themeable<
T extends LibComponent,
K extends Key<T> = Key<T>,
I extends Key<T> = Key<T>,
> = {
component: T
propMutator?: (props: Record<Key<T>, unknown>) => Record<Key<T>, unknown>
classes?: string[]
styles?: string[]
injectionKeys?: {
class?: K
style?: I
}
unstyled?: ExtractUnstyled<T>
slots?: Record<string, LibComponent>
}
export type ThemeLibrary<
T extends Record<string, Themeable<LibComponent>> = Record<string, Themeable<LibComponent>>,
> = {
components: T
}
export class ThemeEngine {
readonly lib: ThemeLibrary
constructor(library: ThemeLibrary) {
this.lib = library
}
getComponent<I extends LibComponent>(key: keyof ThemeLibrary['components']): I {
const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } =
this.lib.components[key]
// Build a MINIMAL pass-through wrapper rather than cloning the wrapped
// component. Cloning dragged along the component's `props`, `emits` and
// `extends` chain — which made Vue treat listeners like `onUpdate:modelValue`
// as the wrapper's own declared events and withhold them from `ctx.attrs`,
// silently breaking two-way `v-model`. By declaring only the engine's own
// props here, every other prop, listener and attribute falls through
// `ctx.attrs` and is forwarded verbatim to the real component below.
const componentObj: Record<string, unknown> = {
name: `Nych${String(key)}`,
inheritAttrs: false,
props: {
injectedClasses: { default: classes },
injectedStyles: { default: styles },
stylePlug: { type: String },
unstyled: { default: unstyled },
},
}
const applyThemeProps = (props: Record<string, unknown>) => {
const mergedProps = { ...props }
const stylePlug =
typeof mergedProps['stylePlug'] === 'string' ? mergedProps['stylePlug'] : undefined
const defaultClassValues = Array.isArray(mergedProps['injectedClasses'])
? mergedProps['injectedClasses']
: []
const defaultStyleValues = Array.isArray(mergedProps['injectedStyles'])
? mergedProps['injectedStyles']
: []
const classKey = injectionKeys?.class ?? 'class'
const styleKey = injectionKeys?.style ?? 'style'
const classValue = mergedProps[classKey] ?? []
const styleValue =
typeof mergedProps[styleKey] === 'string'
? mergedProps[styleKey]
.split(';')
.map((s) => s.trim())
.filter(Boolean)
: []
let classArray: string[] | undefined = undefined
if (typeof classValue === 'object') {
if (Array.isArray(classValue)) {
classArray = classValue
} else if (classKey !== 'pt') {
// Support Vue's object-form class syntax: { 'my-class': condition }
classArray = Object.entries(classValue as Record<string, boolean>)
.filter(([, active]) => Boolean(active))
.map(([name]) => name)
}
}
if (typeof classValue === 'string') {
classArray = classValue.split(' ')
}
if (!classArray) {
classArray = []
}
const finalClasses = stylePlug
? [...classArray, ...defaultClassValues]
.map((cls) => (cls.startsWith(`${stylePlug}-`) ? cls : `${stylePlug}-${cls}`))
.join(' ')
: [...classArray, ...defaultClassValues].join(' ')
const finalStyles = [...styleValue, ...defaultStyleValues].join('; ')
if (classKey === 'pt' || styleKey === 'pt') {
mergedProps['unstyled'] = true
const existingPt =
typeof mergedProps[classKey] === 'object'
? (mergedProps[classKey] as Record<string, unknown>)
: {}
const existingRoot = (existingPt.root as { class?: string; style?: string }) ?? {}
mergedProps[classKey] = {
...existingPt,
root: {
...existingRoot,
class: [existingRoot.class, finalClasses].filter(Boolean).join(' '),
style: [existingRoot.style, finalStyles].filter(Boolean).join('; '),
},
}
} else {
mergedProps[classKey] = finalClasses
}
if (styleKey !== 'pt') {
mergedProps[styleKey] = finalStyles
}
if (propMutator) {
// Supports both in-place mutation (returns same ref) and returning a new props object
const mutated = propMutator(mergedProps as Record<Key<typeof component>, unknown>)
if (mutated !== mergedProps) {
Object.assign(mergedProps, mutated)
}
}
return mergedProps
}
// Always wrap via a render function so the original component runs its own
// setup naturally through Vue's component system with the modified props.
// Slots are merged here so no extra wrapper component layer is needed.
componentObj.setup = (props: Record<string, unknown>, ctx: SetupContext) => {
return () => {
const themeSlots: Record<string, unknown> = {}
for (const [name, slotComponent] of Object.entries(slots || {})) {
themeSlots[name] = (scope?: Record<string, unknown>) => [h(slotComponent, scope || {})]
}
const originalSlots = ctx.slots || {}
const mergedSlots = { ...themeSlots, ...originalSlots }
// Merge attrs into props so the engine sees class/style from fallthrough attrs
// before merging theme values — prevents ctx.attrs from overwriting engine output.
// Running inside the render function also keeps mergedProps reactive to prop/attr changes.
const mergedProps = applyThemeProps({ ...props, ...ctx.attrs })
for (const key of ['injectedClasses', 'injectedStyles', 'stylePlug']) {
delete mergedProps[key as keyof typeof mergedProps]
}
return h(component as LibComponent, mergedProps, mergedSlots)
}
}
return componentObj as I
}
}

View file

@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const cssPath = resolve(import.meta.dirname, '../src/assets/css/tailwind.css')
const cssPath = resolve(process.cwd(), 'src/assets/css/tailwind.css')
describe('tailwind token mapping', () => {
const css = readFileSync(cssPath, 'utf-8')

View file

@ -1,6 +1,6 @@
import type { Preview } from '@storybook/vue3-vite'
import { useGlobals } from 'storybook/preview-api'
import '@nychthemeron/library/style'
import '@nychthemeron/library/theme'
const preview: Preview = {
parameters: {

View file

@ -59,7 +59,7 @@ export const WithLabels: Story = {
return { honey, wine, laurel }
},
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; font-family: var(--font-serif); color: var(--text-body);">
<div style="display: flex; flex-direction: column; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body);">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox v-model="honey" /> Honey
</label>
@ -85,7 +85,7 @@ export const Group: Story = {
return { honey, wine, laurel }
},
template: `
<div style="font-family: var(--font-serif); color: var(--text-body);">
<div style="font-family: var(--font-sans); color: var(--text-body);">
<NychCheckboxGroup name="offerings">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox v-model="honey" /> Honey

View file

@ -51,7 +51,7 @@ export const WithLabels: Story = {
return { god }
},
template: `
<div style="font-family: var(--font-serif); color: var(--text-body);">
<div style="font-family: var(--font-sans); color: var(--text-body);">
<NychRadioGroup v-model="god">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioGroupItem value="apollo" /> Apollo