mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-23 16:30:45 +01:00
feat(module): add support for vue using unplugin (#2416)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
32
src/defaults.ts
Normal file
32
src/defaults.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import icons from './theme/icons'
|
||||
|
||||
import { pick } from './runtime/utils'
|
||||
|
||||
export const getDefaultUiConfig = (colors?: string[]) => ({
|
||||
colors: pick({
|
||||
primary: 'green',
|
||||
secondary: 'blue',
|
||||
success: 'green',
|
||||
info: 'blue',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
neutral: 'slate'
|
||||
}, [...(colors || []), 'neutral' as any]),
|
||||
icons
|
||||
})
|
||||
|
||||
export const defaultOptions = {
|
||||
prefix: 'U',
|
||||
fonts: true,
|
||||
colorMode: true,
|
||||
theme: {
|
||||
colors: undefined,
|
||||
transitions: true
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveColors = (colors?: string[]) => {
|
||||
return colors?.length
|
||||
? [...new Set(['primary', ...colors])]
|
||||
: ['primary', 'secondary', 'success', 'info', 'warning', 'error']
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defu } from 'defu'
|
||||
import { createResolver, defineNuxtModule, addComponentsDir, addImportsDir, addVitePlugin, addPlugin, installModule, hasNuxtModule } from '@nuxt/kit'
|
||||
import { addTemplates } from './templates'
|
||||
import icons from './theme/icons'
|
||||
import { pick } from './runtime/utils'
|
||||
import { defaultOptions, getDefaultUiConfig, resolveColors } from './defaults'
|
||||
|
||||
export type * from './runtime/types'
|
||||
|
||||
@@ -58,37 +57,18 @@ export default defineNuxtModule<ModuleOptions>({
|
||||
},
|
||||
docs: 'https://ui3.nuxt.dev/getting-started/installation'
|
||||
},
|
||||
defaults: {
|
||||
prefix: 'U',
|
||||
fonts: true,
|
||||
colorMode: true,
|
||||
theme: {
|
||||
colors: undefined,
|
||||
transitions: true
|
||||
}
|
||||
},
|
||||
defaults: defaultOptions,
|
||||
async setup(options, nuxt) {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
options.theme = options.theme || {}
|
||||
options.theme.colors = options.theme.colors?.length ? [...new Set(['primary', ...options.theme.colors])] : ['primary', 'secondary', 'success', 'info', 'warning', 'error']
|
||||
options.theme.colors = resolveColors(options.theme.colors)
|
||||
|
||||
nuxt.options.ui = options
|
||||
|
||||
nuxt.options.alias['#ui'] = resolve('./runtime')
|
||||
|
||||
nuxt.options.appConfig.ui = defu(nuxt.options.appConfig.ui || {}, {
|
||||
colors: pick({
|
||||
primary: 'green',
|
||||
secondary: 'blue',
|
||||
success: 'green',
|
||||
info: 'blue',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
neutral: 'slate'
|
||||
}, [...(options.theme?.colors || []), 'neutral' as any]),
|
||||
icons
|
||||
})
|
||||
nuxt.options.appConfig.ui = defu(nuxt.options.appConfig.ui || {}, getDefaultUiConfig(options.theme.colors))
|
||||
|
||||
// Isolate root node from portaled components
|
||||
nuxt.options.app.rootAttrs = nuxt.options.app.rootAttrs || {}
|
||||
|
||||
25
src/plugins/app-config.ts
Normal file
25
src/plugins/app-config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { UnpluginOptions } from 'unplugin'
|
||||
|
||||
import type { NuxtUIOptions } from '../unplugin'
|
||||
|
||||
/**
|
||||
* This plugin injects Nuxt UI configuration into the runtime build so Nuxt UI components can
|
||||
* access it.
|
||||
*/
|
||||
export default function AppConfigPlugin(options: NuxtUIOptions & { theme: NonNullable<NuxtUIOptions['theme']> }, appConfig: Record<string, any>) {
|
||||
return {
|
||||
name: 'nuxt:ui:app-config',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === '#build/app.config') {
|
||||
return 'virtual:nuxt-ui-app-config'
|
||||
}
|
||||
},
|
||||
loadInclude: id => id === 'virtual:nuxt-ui-app-config',
|
||||
load() {
|
||||
return `
|
||||
export default ${JSON.stringify(appConfig!)}
|
||||
`
|
||||
}
|
||||
} satisfies UnpluginOptions
|
||||
}
|
||||
59
src/plugins/components.ts
Normal file
59
src/plugins/components.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { join, normalize } from 'pathe'
|
||||
import type { UnpluginContextMeta, UnpluginOptions } from 'unplugin'
|
||||
import { globSync } from 'tinyglobby'
|
||||
import AutoImportComponents from 'unplugin-vue-components'
|
||||
|
||||
import { runtimeDir } from '../unplugin'
|
||||
import type { NuxtUIOptions } from '../unplugin'
|
||||
|
||||
/**
|
||||
* This plugin adds all the Nuxt UI components as auto-imports.
|
||||
*/
|
||||
export default function ComponentImportPlugin(framework: UnpluginContextMeta['framework'], options: NuxtUIOptions & { prefix: NonNullable<NuxtUIOptions['prefix']> }) {
|
||||
const components = globSync('**/*.vue', { cwd: join(runtimeDir, 'components') })
|
||||
const componentNames = new Set(components.map(c => `${options.prefix}${c.replace(/\.vue$/, '')}`))
|
||||
|
||||
const overrides = globSync('**/*.vue', { cwd: join(runtimeDir, 'vue/components') })
|
||||
const overrideNames = new Set(overrides.map(c => `${options.prefix}${c.replace(/\.vue$/, '')}`))
|
||||
|
||||
return [
|
||||
/**
|
||||
* This plugin aims to ensure we override certain components with Vue-compatible versions:
|
||||
* <UIcon> and <ULink> currently.
|
||||
*/
|
||||
{
|
||||
name: 'nuxt:ui:components',
|
||||
enforce: 'pre',
|
||||
resolveId(id, importer) {
|
||||
// only apply to runtime nuxt ui components
|
||||
if (!importer || !normalize(importer).includes(runtimeDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
// only apply to relative imports
|
||||
if (!RELATIVE_IMPORT_RE.test(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const filename = id.match(/([^/]+)\.vue$/)?.[1]
|
||||
if (filename && overrideNames.has(`${options.prefix}${filename}`)) {
|
||||
return join(runtimeDir, 'vue/components', `${filename}.vue`)
|
||||
}
|
||||
}
|
||||
},
|
||||
AutoImportComponents[framework]({
|
||||
dts: options.dts ?? true,
|
||||
exclude: [/[\\/]node_modules[\\/](?!\.pnpm|@nuxt\/ui)/, /[\\/]\.git[\\/]/, /[\\/]\.nuxt[\\/]/],
|
||||
resolvers: [
|
||||
(componentName) => {
|
||||
if (overrideNames.has(componentName))
|
||||
return { name: 'default', from: join(runtimeDir, 'vue/components', `${componentName.slice(options.prefix.length)}.vue`) }
|
||||
if (componentNames.has(componentName))
|
||||
return { name: 'default', from: join(runtimeDir, 'components', `${componentName.slice(options.prefix.length)}.vue`) }
|
||||
}
|
||||
]
|
||||
})
|
||||
] satisfies UnpluginOptions[]
|
||||
}
|
||||
|
||||
const RELATIVE_IMPORT_RE = /^\.{1,2}\//
|
||||
40
src/plugins/nuxt-environment.ts
Normal file
40
src/plugins/nuxt-environment.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { UnpluginOptions } from 'unplugin'
|
||||
import { join, normalize } from 'pathe'
|
||||
import { resolvePathSync } from 'mlly'
|
||||
import MagicString from 'magic-string'
|
||||
|
||||
import { runtimeDir } from '../unplugin'
|
||||
|
||||
/**
|
||||
* This plugin normalises Nuxt environment (#imports) and `import.meta.client` within the Nuxt UI components.
|
||||
*/
|
||||
export default function NuxtEnvironmentPlugin() {
|
||||
const stubPath = resolvePathSync(join(runtimeDir, 'vue/stubs'), { extensions: ['.ts', '.mjs', '.js'] })
|
||||
|
||||
return {
|
||||
name: 'nuxt:ui',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
// this is implemented here rather than in a vite `config` hook for cross-builder support
|
||||
if (id === '#imports') {
|
||||
return stubPath
|
||||
}
|
||||
},
|
||||
transformInclude(id) {
|
||||
return normalize(id).includes(runtimeDir)
|
||||
},
|
||||
transform(code) {
|
||||
if (code.includes('import.meta.client')) {
|
||||
const s = new MagicString(code)
|
||||
s.replaceAll('import.meta.client', 'true')
|
||||
|
||||
if (s.hasChanged()) {
|
||||
return {
|
||||
code: s.toString(),
|
||||
map: s.generateMap({ hires: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies UnpluginOptions
|
||||
}
|
||||
56
src/plugins/plugins.ts
Normal file
56
src/plugins/plugins.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { join } from 'pathe'
|
||||
import { globSync } from 'tinyglobby'
|
||||
import { genSafeVariableName } from 'knitwork'
|
||||
import MagicString from 'magic-string'
|
||||
import { resolvePathSync } from 'mlly'
|
||||
|
||||
import { runtimeDir, type NuxtUIOptions } from '../unplugin'
|
||||
|
||||
import type { UnpluginOptions } from 'unplugin'
|
||||
|
||||
/**
|
||||
* This plugin provides the necessary transforms to allow loading the
|
||||
* Nuxt UI _Nuxt_ plugins in `src/runtime/plugins/` in a pure Vue environment.
|
||||
*/
|
||||
export default function PluginsPlugin(options: NuxtUIOptions) {
|
||||
const plugins = globSync(['**/*', '!*.d.ts'], { cwd: join(runtimeDir, 'plugins'), absolute: true })
|
||||
|
||||
plugins.unshift(resolvePathSync(join(runtimeDir, 'vue/plugins/head'), { extensions: ['.ts', '.mjs', '.js'] }))
|
||||
if (options.colorMode) {
|
||||
plugins.push(resolvePathSync(join(runtimeDir, 'vue/plugins/color-mode'), { extensions: ['.ts', '.mjs', '.js'] }))
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'nuxt:ui:plugins',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === '@nuxt/ui/vue-plugin') {
|
||||
return 'virtual:nuxt-ui-plugins'
|
||||
}
|
||||
},
|
||||
transform(code, id) {
|
||||
if (plugins.some(p => id.startsWith(p)) && code.includes('import.meta.client')) {
|
||||
const s = new MagicString(code)
|
||||
s.replaceAll('import.meta.client', 'true')
|
||||
|
||||
if (s.hasChanged()) {
|
||||
return {
|
||||
code: s.toString(),
|
||||
map: s.generateMap({ hires: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
loadInclude: id => id === 'virtual:nuxt-ui-plugins',
|
||||
load() {
|
||||
return `
|
||||
${plugins.map(p => `import ${genSafeVariableName(p)} from "${p}"`).join('\n')}
|
||||
export default {
|
||||
install (app) {
|
||||
${plugins.map(p => ` app.use(${genSafeVariableName(p)})`).join('\n')}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
} satisfies UnpluginOptions
|
||||
}
|
||||
28
src/plugins/templates.ts
Normal file
28
src/plugins/templates.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NuxtUIOptions } from '../unplugin'
|
||||
|
||||
import { getTemplates } from '../templates'
|
||||
import type { UnpluginOptions } from 'unplugin'
|
||||
|
||||
/**
|
||||
* This plugin is responsible for getting the generated virtual templates and
|
||||
* making them available to the Vue build.
|
||||
*/
|
||||
export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record<string, any>) {
|
||||
const templates = getTemplates(options, appConfig.ui)
|
||||
const templateKeys = new Set(templates.map(t => `#build/${t.filename}`))
|
||||
|
||||
return {
|
||||
name: 'nuxt:ui:templates',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (templateKeys.has(id + '.ts')) {
|
||||
return id.replace('#build/', 'virtual:nuxt-ui-templates/') + '.ts'
|
||||
}
|
||||
},
|
||||
loadInclude: id => templateKeys.has(id.replace('virtual:nuxt-ui-templates/', '#build/')),
|
||||
load(id) {
|
||||
id = id.replace('virtual:nuxt-ui-templates/', '#build/')
|
||||
return templates.find(t => `#build/${t.filename}` === id)!.getContents!({} as any)
|
||||
}
|
||||
} satisfies UnpluginOptions
|
||||
}
|
||||
@@ -3,8 +3,54 @@ import type { ButtonHTMLAttributes } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import type { RouterLinkProps, RouteLocationRaw } from 'vue-router'
|
||||
import theme from '#build/ui/link'
|
||||
import type { NuxtLinkProps } from '#app'
|
||||
|
||||
interface NuxtLinkProps extends Omit<RouterLinkProps, 'to'> {
|
||||
/**
|
||||
* Route Location the link should navigate to when clicked on.
|
||||
*/
|
||||
to?: RouteLocationRaw // need to manually type to avoid breaking typedPages
|
||||
/**
|
||||
* An alias for `to`. If used with `to`, `href` will be ignored
|
||||
*/
|
||||
href?: NuxtLinkProps['to']
|
||||
/**
|
||||
* Forces the link to be considered as external (true) or internal (false). This is helpful to handle edge-cases
|
||||
*/
|
||||
external?: boolean
|
||||
/**
|
||||
* Where to display the linked URL, as the name for a browsing context.
|
||||
*/
|
||||
target?: '_blank' | '_parent' | '_self' | '_top' | (string & {}) | null
|
||||
/**
|
||||
* A rel attribute value to apply on the link. Defaults to "noopener noreferrer" for external links.
|
||||
*/
|
||||
rel?: 'noopener' | 'noreferrer' | 'nofollow' | 'sponsored' | 'ugc' | (string & {}) | null
|
||||
/**
|
||||
* If set to true, no rel attribute will be added to the link
|
||||
*/
|
||||
noRel?: boolean
|
||||
/**
|
||||
* A class to apply to links that have been prefetched.
|
||||
*/
|
||||
prefetchedClass?: string
|
||||
/**
|
||||
* When enabled will prefetch middleware, layouts and payloads of links in the viewport.
|
||||
*/
|
||||
prefetch?: boolean
|
||||
/**
|
||||
* Allows controlling when to prefetch links. By default, prefetch is triggered only on visibility.
|
||||
*/
|
||||
prefetchOn?: 'visibility' | 'interaction' | Partial<{
|
||||
visibility: boolean
|
||||
interaction: boolean
|
||||
}>
|
||||
/**
|
||||
* Escape hatch to disable `prefetch` attribute.
|
||||
*/
|
||||
noPrefetch?: boolean
|
||||
}
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { link: Partial<typeof theme> } }
|
||||
|
||||
|
||||
15
src/runtime/vue/components/Icon.vue
Normal file
15
src/runtime/vue/components/Icon.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
export interface IconProps {
|
||||
name: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
defineProps<IconProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon :icon="name.replace(/^i-/, '')" />
|
||||
</template>
|
||||
195
src/runtime/vue/components/Link.vue
Normal file
195
src/runtime/vue/components/Link.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import type { ButtonHTMLAttributes } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import type { RouterLinkProps, RouteLocationRaw } from 'vue-router'
|
||||
import theme from '#build/ui/link'
|
||||
|
||||
interface NuxtLinkProps extends Omit<RouterLinkProps, 'to'> {
|
||||
/**
|
||||
* Route Location the link should navigate to when clicked on.
|
||||
*/
|
||||
to?: RouteLocationRaw // need to manually type to avoid breaking typedPages
|
||||
/**
|
||||
* An alias for `to`. If used with `to`, `href` will be ignored
|
||||
*/
|
||||
href?: NuxtLinkProps['to']
|
||||
/**
|
||||
* Forces the link to be considered as external (true) or internal (false). This is helpful to handle edge-cases
|
||||
*/
|
||||
external?: boolean
|
||||
/**
|
||||
* Where to display the linked URL, as the name for a browsing context.
|
||||
*/
|
||||
target?: '_blank' | '_parent' | '_self' | '_top' | (string & {}) | null
|
||||
/**
|
||||
* A rel attribute value to apply on the link. Defaults to "noopener noreferrer" for external links.
|
||||
*/
|
||||
rel?: 'noopener' | 'noreferrer' | 'nofollow' | 'sponsored' | 'ugc' | (string & {}) | null
|
||||
/**
|
||||
* If set to true, no rel attribute will be added to the link
|
||||
*/
|
||||
noRel?: boolean
|
||||
/**
|
||||
* A class to apply to links that have been prefetched.
|
||||
*/
|
||||
prefetchedClass?: string
|
||||
/**
|
||||
* When enabled will prefetch middleware, layouts and payloads of links in the viewport.
|
||||
*/
|
||||
prefetch?: boolean
|
||||
/**
|
||||
* Allows controlling when to prefetch links. By default, prefetch is triggered only on visibility.
|
||||
*/
|
||||
prefetchOn?: 'visibility' | 'interaction' | Partial<{
|
||||
visibility: boolean
|
||||
interaction: boolean
|
||||
}>
|
||||
/**
|
||||
* Escape hatch to disable `prefetch` attribute.
|
||||
*/
|
||||
noPrefetch?: boolean
|
||||
}
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { link: Partial<typeof theme> } }
|
||||
|
||||
const link = tv({ extend: tv(theme), ...(appConfig.ui?.link || {}) })
|
||||
|
||||
export interface LinkProps extends NuxtLinkProps {
|
||||
/**
|
||||
* The element or component this component should render as when not a link.
|
||||
* @defaultValue 'button'
|
||||
*/
|
||||
as?: any
|
||||
/**
|
||||
* The type of the button when not a link.
|
||||
* @defaultValue 'button'
|
||||
*/
|
||||
type?: ButtonHTMLAttributes['type']
|
||||
disabled?: boolean
|
||||
/** Force the link to be active independent of the current route. */
|
||||
active?: boolean
|
||||
/** Will only be active if the current route is an exact match. */
|
||||
exact?: boolean
|
||||
/** Will only be active if the current route query is an exact match. */
|
||||
exactQuery?: boolean
|
||||
/** Will only be active if the current route hash is an exact match. */
|
||||
exactHash?: boolean
|
||||
/** The class to apply when the link is inactive. */
|
||||
inactiveClass?: string
|
||||
custom?: boolean
|
||||
/** When `true`, only styles from `class`, `activeClass`, and `inactiveClass` will be applied. */
|
||||
raw?: boolean
|
||||
class?: any
|
||||
}
|
||||
|
||||
export interface LinkSlots {
|
||||
default(props: { active: boolean }): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { isEqual } from 'ohash'
|
||||
import { useForwardProps } from 'radix-vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { hasProtocol } from 'ufo'
|
||||
import { useRoute } from '#imports'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<LinkProps>(), {
|
||||
as: 'button',
|
||||
type: 'button',
|
||||
active: undefined,
|
||||
activeClass: '',
|
||||
inactiveClass: ''
|
||||
})
|
||||
defineSlots<LinkSlots>()
|
||||
|
||||
const route = useRoute()
|
||||
const routerLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'exactQuery', 'exactHash', 'activeClass', 'inactiveClass', 'to'))
|
||||
|
||||
const ui = computed(() => tv({
|
||||
extend: link,
|
||||
variants: {
|
||||
active: {
|
||||
true: props.activeClass,
|
||||
false: props.inactiveClass
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const isExternal = computed(() => typeof props.to === 'string' && hasProtocol(props.to, { acceptRelative: true }))
|
||||
|
||||
function isLinkActive({ route: linkRoute, isActive, isExactActive }: any) {
|
||||
if (props.active !== undefined) {
|
||||
return props.active
|
||||
}
|
||||
|
||||
if (!props.to) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (props.exactQuery && !isEqual(linkRoute.query, route.query)) {
|
||||
return false
|
||||
}
|
||||
if (props.exactHash && linkRoute.hash !== route.hash) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (props.exact && isExactActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!props.exact && isActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveLinkClass({ route, isActive, isExactActive }: any) {
|
||||
const active = isLinkActive({ route, isActive, isExactActive })
|
||||
|
||||
if (props.raw) {
|
||||
return [props.class, active ? props.activeClass : props.inactiveClass]
|
||||
}
|
||||
|
||||
return ui.value({ class: props.class, active, disabled: props.disabled })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-slot="{ href, navigate, route: linkRoute, isActive, isExactActive }" v-bind="routerLinkProps" :to="to || '#'" custom>
|
||||
<template v-if="custom">
|
||||
<slot
|
||||
v-bind="{
|
||||
...$attrs,
|
||||
as,
|
||||
type,
|
||||
disabled,
|
||||
href: to ? (isExternal ? to as string : href) : undefined,
|
||||
navigate,
|
||||
active: isLinkActive({ route: linkRoute, isActive, isExactActive })
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<ULinkBase
|
||||
v-else
|
||||
v-bind="{
|
||||
...$attrs,
|
||||
as,
|
||||
type,
|
||||
disabled,
|
||||
href: to ? (isExternal ? to as string : href) : undefined,
|
||||
navigate
|
||||
}"
|
||||
:class="resolveLinkClass({ route: linkRoute, isActive: isActive, isExactActive: isExactActive })"
|
||||
>
|
||||
<slot :active="isLinkActive({ route: linkRoute, isActive, isExactActive })" />
|
||||
</ULinkBase>
|
||||
</RouterLink>
|
||||
</template>
|
||||
8
src/runtime/vue/plugins/color-mode.ts
Normal file
8
src/runtime/vue/plugins/color-mode.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useDark } from '@vueuse/core'
|
||||
import type { Plugin } from 'vue'
|
||||
|
||||
export default {
|
||||
install() {
|
||||
useDark()
|
||||
}
|
||||
} satisfies Plugin
|
||||
8
src/runtime/vue/plugins/head.ts
Normal file
8
src/runtime/vue/plugins/head.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createHead } from '@unhead/vue'
|
||||
import type { Plugin } from 'vue'
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
app.use(createHead())
|
||||
}
|
||||
} satisfies Plugin
|
||||
36
src/runtime/vue/stubs.ts
Normal file
36
src/runtime/vue/stubs.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref, Plugin as VuePlugin } from 'vue'
|
||||
|
||||
import appConfig from '#build/app.config'
|
||||
import type { NuxtApp } from '#app'
|
||||
|
||||
export { useHead } from '@unhead/vue'
|
||||
export { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export const useAppConfig = () => appConfig
|
||||
|
||||
const state: Record<string, any> = {}
|
||||
|
||||
export const useState = <T>(key: string, init: () => T): Ref<T> => {
|
||||
if (state[key]) {
|
||||
return state[key] as Ref<T>
|
||||
}
|
||||
const value = ref(init())
|
||||
state[key] = value
|
||||
return value as Ref<T>
|
||||
}
|
||||
|
||||
export function useNuxtApp() {
|
||||
return {
|
||||
isHydrating: true,
|
||||
payload: { serverRendered: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function defineNuxtPlugin(plugin: (nuxtApp: NuxtApp) => void) {
|
||||
return {
|
||||
install(app) {
|
||||
plugin({ vueApp: app } as NuxtApp)
|
||||
}
|
||||
} satisfies VuePlugin
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { kebabCase } from 'scule'
|
||||
import { addTemplate, addTypeTemplate } from '@nuxt/kit'
|
||||
import type { Nuxt } from '@nuxt/schema'
|
||||
import type { Nuxt, NuxtTemplate, NuxtTypeTemplate } from '@nuxt/schema'
|
||||
import type { ModuleOptions } from './module'
|
||||
import * as theme from './theme'
|
||||
|
||||
export function addTemplates(options: ModuleOptions, nuxt: Nuxt) {
|
||||
export function getTemplates(options: ModuleOptions, uiConfig: Record<string, any>) {
|
||||
const templates: NuxtTemplate[] = []
|
||||
|
||||
for (const component in theme) {
|
||||
addTemplate({
|
||||
templates.push({
|
||||
filename: `ui/${kebabCase(component)}.ts`,
|
||||
write: true,
|
||||
getContents: async () => {
|
||||
@@ -41,20 +43,20 @@ export function addTemplates(options: ModuleOptions, nuxt: Nuxt) {
|
||||
})
|
||||
}
|
||||
|
||||
addTemplate({
|
||||
templates.push({
|
||||
filename: 'ui/index.ts',
|
||||
write: true,
|
||||
getContents: () => Object.keys(theme).map(component => `export { default as ${component} } from './${kebabCase(component)}'`).join('\n')
|
||||
})
|
||||
|
||||
// FIXME: `typeof colors[number]` should include all colors from the theme
|
||||
addTypeTemplate({
|
||||
templates.push({
|
||||
filename: 'types/ui.d.ts',
|
||||
getContents: () => `import * as ui from '#build/ui'
|
||||
import type { DeepPartial } from '#ui/types/utils'
|
||||
import colors from 'tailwindcss/colors'
|
||||
|
||||
const icons = ${JSON.stringify(nuxt.options.appConfig.ui.icons)};
|
||||
const icons = ${JSON.stringify(uiConfig.icons)};
|
||||
|
||||
type NeutralColor = 'slate' | 'gray' | 'zinc' | 'neutral' | 'stone'
|
||||
type Color = Exclude<keyof typeof colors, 'inherit' | 'current' | 'transparent' | 'black' | 'white' | NeutralColor>
|
||||
@@ -76,4 +78,17 @@ declare module '@nuxt/schema' {
|
||||
export {}
|
||||
`
|
||||
})
|
||||
|
||||
return templates
|
||||
}
|
||||
|
||||
export function addTemplates(options: ModuleOptions, nuxt: Nuxt) {
|
||||
const templates = getTemplates(options, nuxt.options.appConfig.ui)
|
||||
for (const template of templates) {
|
||||
if (template.filename!.endsWith('.d.ts')) {
|
||||
addTypeTemplate(template as NuxtTypeTemplate)
|
||||
} else {
|
||||
addTemplate(template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
itemTrailingKbds: 'hidden lg:inline-flex items-center shrink-0',
|
||||
itemTrailingKbdsSize: '',
|
||||
itemLabel: 'truncate',
|
||||
itemLabelExternalIcon: 'size-3 align-top text-[var(--ui-text-dimmed)]'
|
||||
itemLabelExternalIcon: 'inline-block size-3 align-top text-[var(--ui-text-dimmed)]'
|
||||
},
|
||||
variants: {
|
||||
active: {
|
||||
|
||||
@@ -16,7 +16,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
itemTrailingKbds: 'hidden lg:inline-flex items-center shrink-0',
|
||||
itemTrailingKbdsSize: '',
|
||||
itemLabel: 'truncate',
|
||||
itemLabelExternalIcon: 'size-3 align-top text-[var(--ui-text-dimmed)]'
|
||||
itemLabelExternalIcon: 'inline-block size-3 align-top text-[var(--ui-text-dimmed)]'
|
||||
},
|
||||
variants: {
|
||||
active: {
|
||||
|
||||
@@ -14,14 +14,14 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
linkTrailingBadgeSize: 'sm',
|
||||
linkTrailingIcon: 'size-5 transform shrink-0 group-data-[state=open]:rotate-180 transition-transform duration-200',
|
||||
linkLabel: 'truncate',
|
||||
linkLabelExternalIcon: 'size-3 align-top text-[var(--ui-text-dimmed)]',
|
||||
linkLabelExternalIcon: 'inline-block size-3 align-top text-[var(--ui-text-dimmed)]',
|
||||
childList: '',
|
||||
childItem: '',
|
||||
childLink: 'group size-full px-3 py-2 rounded-[calc(var(--ui-radius)*1.5)] flex items-start gap-2 text-left',
|
||||
childLinkWrapper: 'flex flex-col items-start',
|
||||
childLinkIcon: 'size-5 shrink-0',
|
||||
childLinkLabel: 'font-semibold text-sm relative inline-flex',
|
||||
childLinkLabelExternalIcon: 'size-3 align-top text-[var(--ui-text-dimmed)]',
|
||||
childLinkLabelExternalIcon: 'inline-block size-3 align-top text-[var(--ui-text-dimmed)]',
|
||||
childLinkDescription: 'text-sm text-[var(--ui-text-muted)]',
|
||||
separator: 'px-2 h-px bg-[var(--ui-border)]',
|
||||
viewportWrapper: 'absolute top-full inset-x-0 flex w-full',
|
||||
|
||||
63
src/unplugin.ts
Normal file
63
src/unplugin.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { join } from 'pathe'
|
||||
import { createUnplugin } from 'unplugin'
|
||||
import AutoImport from 'unplugin-auto-import'
|
||||
import { defu } from 'defu'
|
||||
import tailwind from '@tailwindcss/vite'
|
||||
import type colors from 'tailwindcss/colors'
|
||||
|
||||
import type * as ui from '#build/ui'
|
||||
|
||||
import { defaultOptions, getDefaultUiConfig, resolveColors } from './defaults'
|
||||
import type { ModuleOptions } from './module'
|
||||
import type icons from './theme/icons'
|
||||
|
||||
import TemplatePlugin from './plugins/templates'
|
||||
import PluginsPlugin from './plugins/plugins'
|
||||
import AppConfigPlugin from './plugins/app-config'
|
||||
import ComponentImportPlugin from './plugins/components'
|
||||
import NuxtEnvironmentPlugin from './plugins/nuxt-environment'
|
||||
|
||||
import type { DeepPartial } from './runtime/types/utils'
|
||||
|
||||
type NeutralColor = 'slate' | 'gray' | 'zinc' | 'neutral' | 'stone'
|
||||
type Color = Exclude<keyof typeof colors, 'inherit' | 'current' | 'transparent' | 'black' | 'white' | NeutralColor> | (string & {})
|
||||
|
||||
type AppConfigUI = {
|
||||
// TODO: add type hinting for colors from `options.theme.colors`
|
||||
colors?: Record<string, Color> & { neutral?: NeutralColor }
|
||||
icons?: Partial<typeof icons>
|
||||
} & DeepPartial<typeof ui, string>
|
||||
|
||||
export interface NuxtUIOptions extends Omit<ModuleOptions, 'fonts' | 'colorMode'> {
|
||||
/** Whether to generate declaration files for auto-imported components. */
|
||||
dts?: boolean
|
||||
ui?: AppConfigUI
|
||||
/**
|
||||
* Enable or disable `@vueuse/core` color-mode integration
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
colorMode?: boolean
|
||||
}
|
||||
|
||||
export const runtimeDir = fileURLToPath(new URL('./runtime', import.meta.url))
|
||||
|
||||
export const NuxtUIPlugin = createUnplugin<NuxtUIOptions | undefined>((_options = {}, meta) => {
|
||||
const options = defu(_options, { fonts: false }, defaultOptions)
|
||||
|
||||
options.theme = options.theme || {}
|
||||
options.theme.colors = resolveColors(options.theme.colors)
|
||||
|
||||
const appConfig = defu({ ui: options.ui }, { ui: getDefaultUiConfig(options.theme.colors) })
|
||||
|
||||
return [
|
||||
NuxtEnvironmentPlugin(),
|
||||
...ComponentImportPlugin(meta.framework, options),
|
||||
AutoImport[meta.framework]({ dts: options.dts ?? true, dirs: [join(runtimeDir, 'composables')] }),
|
||||
tailwind(),
|
||||
PluginsPlugin(options),
|
||||
TemplatePlugin(options, appConfig),
|
||||
AppConfigPlugin(options, appConfig)
|
||||
]
|
||||
})
|
||||
4
src/vite.ts
Normal file
4
src/vite.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { NuxtUIPlugin } from './unplugin'
|
||||
|
||||
export type { NuxtUIOptions } from './unplugin'
|
||||
export default NuxtUIPlugin.vite
|
||||
Reference in New Issue
Block a user