Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/content/docs/1.getting-started/2.installation/1.nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,28 @@ export default defineNuxtConfig({
This option adds the `transition-colors` class on components with hover or active states.
::

### `theme.unstyled` :badge{label="Soon" class="align-text-top"}

Use the `theme.unstyled` option to remove all default theme classes from components, keeping only their structure and the classes you provide through `class`, `ui` or `app.config.ui`.

- Default: `false`{lang="ts-type"}

```ts [nuxt.config.ts] {4-8}
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
ui: {
theme: {
unstyled: true
}
}
})
```

::warning
This strips **structural** classes too (positioning, transitions, flex/grid), not just cosmetic ones. Layout-heavy components like `Modal`, `Drawer` or `Calendar` will need you to re-supply their layout, similar to PrimeVue's unstyled mode.
::

### `theme.defaultVariants`

Use the `theme.defaultVariants` option to override the default `color` and `size` variants for components.
Expand Down
27 changes: 27 additions & 0 deletions docs/content/docs/1.getting-started/2.installation/2.vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,33 @@ export default defineConfig({
This option adds the `transition-colors` class on components with hover or active states.
::

### `theme.unstyled` :badge{label="Soon" class="align-text-top"}

Use the `theme.unstyled` option to remove all default theme classes from components, keeping only their structure and the classes you provide through `class`, `ui` or `app.config.ui`.

- Default: `false`{lang="ts-type"}

```ts [vite.config.ts] {9-11}
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'

export default defineConfig({
plugins: [
vue(),
ui({
theme: {
unstyled: true
}
})
]
})
```

::warning
This strips **structural** classes too (positioning, transitions, flex/grid), not just cosmetic ones. Layout-heavy components like `Modal`, `Drawer` or `Calendar` will need you to re-supply their layout, similar to PrimeVue's unstyled mode.
::

### `theme.defaultVariants`

Use the `theme.defaultVariants` option to override the default `color` and `size` variants for components.
Expand Down
8 changes: 8 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export interface ModuleOptions {
*/
transitions?: boolean

/**
* Remove all default theme classes from components, keeping only their
* structure and the classes you supply via `class`, `ui` or `app.config.ui`.
* @defaultValue `false`
* @see https://ui.nuxt.com/docs/getting-started/installation/nuxt#themeunstyled
*/
unstyled?: boolean

/**
* The default variants to use for components
* @see https://ui.nuxt.com/docs/getting-started/installation/nuxt#themedefaultvariants
Expand Down
8 changes: 6 additions & 2 deletions src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { addTemplate, addTypeTemplate, hasNuxtModule, logger, updateTemplates, g
import type { Nuxt, NuxtTemplate, NuxtTypeTemplate } from '@nuxt/schema'
import type { Resolver } from '@nuxt/kit'
import type { ModuleOptions } from './module'
import { applyDefaultVariants, applyPrefixToObject } from './utils/theme'
import { applyDefaultVariants, applyPrefixToObject, applyUnstyled } from './utils/theme'
import { detectUsedComponents } from './utils/components'
import * as theme from './theme'
import * as themeProse from './theme/prose'
Expand Down Expand Up @@ -34,6 +34,8 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record<string, an
result = applyDefaultVariants(result, options.theme?.defaultVariants)
// Apply Tailwind prefix if configured
result = applyPrefixToObject(result, options.theme?.prefix)
// Strip default theme classes if `unstyled` is enabled
result = applyUnstyled(result, options.theme?.unstyled)

const variants = Object.entries(result.variants || {})
.filter(([_, values]) => {
Expand Down Expand Up @@ -65,15 +67,17 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record<string, an
const themeUtilsPath = fileURLToPath(new URL('./utils/theme', import.meta.url))
const defaultVariantsJson = JSON.stringify(options.theme?.defaultVariants) ?? 'undefined'
const prefixJson = JSON.stringify(options.theme?.prefix) ?? 'undefined'
const unstyledJson = JSON.stringify(options.theme?.unstyled) ?? 'undefined'

return [
`import template from ${JSON.stringify(templatePath)}`,
`import { applyDefaultVariants, applyPrefixToObject } from ${JSON.stringify(themeUtilsPath)}`,
`import { applyDefaultVariants, applyPrefixToObject, applyUnstyled } from ${JSON.stringify(themeUtilsPath)}`,
...generateVariantDeclarations(variants),
`const options = ${JSON.stringify(options, null, 2)}`,
`let result = typeof template === 'function' ? (template as Function)(options) : template`,
`result = applyDefaultVariants(result, ${defaultVariantsJson})`,
`result = applyPrefixToObject(result, ${prefixJson})`,
`result = applyUnstyled(result, ${unstyledJson})`,
`const theme = ${json}`,
`export default result as typeof theme`
].join('\n\n')
Expand Down
1 change: 1 addition & 0 deletions src/utils/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const defaultOptions = {
theme: {
colors: undefined,
transitions: true,
unstyled: false,
defaultVariants: {
color: undefined,
size: undefined
Expand Down
44 changes: 44 additions & 0 deletions src/utils/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,50 @@ export function applyPrefixToObject(obj: any, prefix?: string, context: string[]
return obj
}

/**
* Blank every class string in a theme so components render without their
* default styles, keeping only what the user supplies via `class`, `ui` or
* `app.config.ui`. All keys are preserved (slots stay callable, `variants` and
* `defaultVariants` keep their values) so variant props still type-check and
* validate. Mirrors `tailwind-variants` shapes: a slot value is either a class
* string/array or, inside `variants`/`compoundVariants`, an object mapping slot
* names to classes.
* @param result - The theme result object
* @param unstyled - Whether to strip the theme classes
* @returns The theme result with blanked class strings
*/
export function applyUnstyled(result: any, unstyled?: boolean): any {
if (!result || !unstyled) {
return result
}

const blank = (value: unknown): unknown => (value && typeof value === 'object' && !Array.isArray(value))
? Object.fromEntries(Object.keys(value as Record<string, unknown>).map(slot => [slot, '']))
: ''

if (result.slots) {
result.slots = Object.fromEntries(Object.keys(result.slots).map(slot => [slot, '']))
}

if (result.variants) {
result.variants = Object.fromEntries(
Object.entries(result.variants).map(([name, group]) => [
name,
Object.fromEntries(Object.entries(group as Record<string, unknown>).map(([key, value]) => [key, blank(value)]))
])
)
}

if (result.compoundVariants) {
result.compoundVariants = result.compoundVariants.map((entry: Record<string, unknown>) => {
const { class: cls, ...selectors } = entry
return { ...selectors, class: blank(cls) }
})
}

return result
}

/**
* Override default variants from module options
* @param result - The theme result object
Expand Down
62 changes: 62 additions & 0 deletions test/utils/theme.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import { applyUnstyled } from '../../src/utils/theme'

describe('applyUnstyled', () => {
const theme = () => ({
slots: {
base: 'inline-flex rounded-md',
label: 'truncate'
},
variants: {
color: {
primary: 'bg-primary text-inverted',
neutral: { base: 'bg-inverted', label: 'text-default' }
},
size: {
md: { base: 'px-2.5 text-sm' }
}
},
compoundVariants: [
{ color: 'primary', variant: 'solid', class: 'bg-primary' },
{ size: 'md', class: { base: 'gap-1.5' } }
],
defaultVariants: {
color: 'primary',
size: 'md'
}
})

it('returns the theme untouched when unstyled is falsy', () => {
const input = theme()
expect(applyUnstyled(input, false)).toBe(input)
expect(applyUnstyled(input, undefined)).toBe(input)
expect(input).toEqual(theme())
})

it('blanks every slot class but keeps the slot keys', () => {
const result = applyUnstyled(theme(), true)
expect(result.slots).toEqual({ base: '', label: '' })
})

it('blanks variant classes in both string and slot-object forms', () => {
const result = applyUnstyled(theme(), true)
expect(result.variants.color.primary).toBe('')
expect(result.variants.color.neutral).toEqual({ base: '', label: '' })
expect(result.variants.size.md).toEqual({ base: '' })
})

it('blanks compoundVariants classes but keeps the selectors', () => {
const result = applyUnstyled(theme(), true)
expect(result.compoundVariants).toEqual([
{ color: 'primary', variant: 'solid', class: '' },
{ size: 'md', class: { base: '' } }
])
})

it('preserves defaultVariants and variant keys so props still validate', () => {
const result = applyUnstyled(theme(), true)
expect(result.defaultVariants).toEqual({ color: 'primary', size: 'md' })
expect(Object.keys(result.variants)).toEqual(['color', 'size'])
expect(Object.keys(result.variants.color)).toEqual(['primary', 'neutral'])
})
})
Loading