This commit is contained in:
Benjamin Canac
2024-03-27 12:34:25 +01:00
155 changed files with 11236 additions and 3062 deletions

View File

@@ -18,9 +18,11 @@ module.exports = {
// Typescript
'@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/semi': ['error', 'never'],
// Vuejs
'vue/multi-word-component-names': 0,
'vue/require-default-prop': 0,
'vue/html-indent': ['error', 2],
'vue/comma-spacing': ['error', { before: false, after: true }],
'vue/script-indent': ['error', 2, { baseIndent: 0 }],

32
.gitignore vendored
View File

@@ -1,12 +1,24 @@
node_modules
*.log
.nuxt
nuxt.d.ts
# Nuxt dev/build outputs
.output
dist
.DS_Store
.history
.vercel
.idea
.env
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View File

@@ -1 +1 @@
typescript.includeWorkspace=true
modules[]=nuxt-ui-dev-module

50
cli/commands/init.mjs Normal file
View File

@@ -0,0 +1,50 @@
import { existsSync, promises as fsp } from 'node:fs'
import { resolve } from 'pathe'
import { defineCommand } from 'citty'
import { consola } from 'consola'
import { camelCase } from 'scule'
import templates from '../utils/templates.mjs'
export default defineCommand({
meta: {
name: 'init',
description: 'Init a new component.'
},
args: {
name: {
type: 'positional',
required: true,
description: 'Name of the component.'
}
},
async setup ({ args }) {
const name = args.name
if (!name) {
consola.error('name argument is missing!')
process.exit(1)
}
const path = resolve('.')
for (const template of Object.keys(templates)) {
const { filename, contents } = templates[template]({ name })
const filePath = resolve(path, filename)
if (existsSync(filePath)) {
consola.error(`🚨 ${filePath} already exists!`)
continue
}
await fsp.writeFile(filePath, contents.trim() + '\n')
consola.success(`🪄 Generated ${filePath}!`)
}
const themePath = resolve(path, 'src/theme/index.ts')
const theme = await fsp.readFile(themePath, 'utf-8')
const contents = `export { default as ${camelCase(name)} } from './${camelCase(name)}'`
if (!theme.includes(contents)) {
await fsp.writeFile(themePath, theme.trim() + '\n' + contents + '\n')
}
}
})

14
cli/index.mjs Normal file
View File

@@ -0,0 +1,14 @@
import { defineCommand, runMain } from 'citty'
import init from './commands/init.mjs'
const main = defineCommand({
meta: {
name: 'nuxtui',
description: 'Nuxt UI CLI'
},
subCommands: {
init
}
})
runMain(main)

12
cli/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "nuxt-ui-cli",
"exports": {
".": "./index.mjs"
},
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.2.3",
"pathe": "^1.1.2",
"scule": "^1.3.0"
}
}

120
cli/utils/templates.mjs Normal file
View File

@@ -0,0 +1,120 @@
import { splitByCase, upperFirst, camelCase, kebabCase } from 'scule'
const playground = ({ name }) => {
const upperName = splitByCase(name).map(p => upperFirst(p)).join('')
const kebabName = kebabCase(name)
return {
filename: `playground/pages/${kebabName}.vue`,
contents: `
<template>
<div>
<U${upperName} />
</div>
</template>
`
}
}
const component = ({ name }) => {
const upperName = splitByCase(name).map(p => upperFirst(p)).join('')
const camelName = camelCase(name)
return {
filename: `src/runtime/components/${upperName}.vue`,
contents: `
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { ${upperName}RootProps, ${upperName}RootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/${camelName}'
const appConfig = _appConfig as AppConfig & { ui: { ${camelName}: Partial<typeof theme> } }
const ${camelName} = tv({ extend: tv(theme), ...(appConfig.ui?.${camelName} || {}) })
type ${upperName}Variants = VariantProps<typeof ${camelName}>
export interface ${upperName}Props extends Omit<${upperName}RootProps, 'asChild'> {
class?: any
ui?: Partial<typeof ${camelName}.slots>
}
export interface ${upperName}Emits extends ${upperName}RootEmits {}
export interface ${upperName}Slots {}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { ${upperName}Root, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
const props = defineProps<${upperName}Props>()
const emits = defineEmits<${upperName}Emits>()
const slots = defineSlots<${upperName}Slots>()
const rootProps = useForwardPropsEmits(reactivePick(props), emits)
const ui = computed(() => tv({ extend: ${camelName}, slots: props.ui })())
</script>
<template>
<${upperName}Root v-bind="rootProps" :class="ui.root({ class: props.class })" />
</template>
`
}
}
const theme = ({ name }) => {
const camelName = camelCase(name)
return {
filename: `src/theme/${camelName}.ts`,
contents: `
export default (config: { colors: string[] }) => ({
slots: {
root: ''
},
variants: {
},
defaultVariants: {
}
})
`
}
}
const test = ({ name }) => {
const upperName = splitByCase(name).map(p => upperFirst(p)).join('')
return {
filename: `test/components/${upperName}.spec.ts`,
contents: `
import { describe, it, expect } from 'vitest'
import ${upperName}, { type ${upperName}Props } from '../../src/runtime/components/${upperName}.vue'
import ComponentRender from '../component-render'
describe('${upperName}', () => {
it.each([
['basic case', {}],
['with class', { props: { class: '' } }],
['with ui', { props: { ui: {} } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: ${upperName}Props, slots?: any }) => {
const html = await ComponentRender(nameOrHtml, options, ${upperName})
expect(html).toMatchSnapshot()
})
})
`
}
}
export default {
playground,
component,
theme,
test
}

22
modules/dev/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createResolver, defineNuxtModule, useNuxt } from '@nuxt/kit'
import { watch } from 'chokidar'
import { debounce } from 'perfect-debounce'
/**
* This is an internal module aiming to make the DX of developing Nuxt UI better.
*/
export default defineNuxtModule({
meta: {
name: 'nuxt-ui-dev-module'
},
setup () {
const nuxt = useNuxt()
const resolver = createResolver(import.meta.url)
const watcher = watch(resolver.resolve('../../src/theme'))
const generateApp = debounce(() => nuxt.hooks.callHook('builder:generateApp'))
watcher.on('all', generateApp)
nuxt.hook('close', () => watcher.close())
}
})

11
modules/dev/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "nuxt-ui-dev-module",
"exports": {
".": "./index.ts"
},
"dependencies": {
"@nuxt/kit": "latest",
"chokidar": "^3.6.0",
"perfect-debounce": "^1.0.0"
}
}

View File

@@ -7,6 +7,7 @@
"license": "MIT",
"exports": {
".": {
"types": "./dist/types.d.ts",
"import": "./dist/module.mjs",
"require": "./dist/module.cjs"
}
@@ -20,56 +21,46 @@
"node": ">=v16.20.2"
},
"scripts": {
"build": "nuxt-module-build build",
"prepack": "pnpm build",
"dev": "nuxi dev docs",
"play": "nuxi dev playground",
"build:docs": "nuxi generate docs",
"prepack": "nuxt-module-build build",
"dev": "nuxi dev playground",
"dev:build": "nuxi build playground",
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
"docs": "nuxi dev docs",
"docs:build": "nuxi generate docs",
"cli": "node ./cli/index.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "vue-tsc --noEmit && nuxi typecheck docs",
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare docs",
"release": "release-it",
"test": "vitest"
"typecheck": "vue-tsc --noEmit",
"test": "vitest",
"release": "release-it"
},
"dependencies": {
"@egoist/tailwindcss-icons": "^1.7.4",
"@headlessui/tailwindcss": "^0.2.0",
"@headlessui/vue": "^1.7.19",
"@iconify-json/heroicons": "^1.1.20",
"@nuxt/kit": "^3.11.1",
"@nuxtjs/color-mode": "^3.3.3",
"@nuxtjs/tailwindcss": "^6.11.4",
"@popperjs/core": "^2.11.8",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@tailwindcss/postcss": "4.0.0-alpha.10",
"@tailwindcss/vite": "4.0.0-alpha.10",
"@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.9.0",
"@vueuse/math": "^10.9.0",
"defu": "^6.1.4",
"fuse.js": "^6.6.2",
"nuxt-icon": "^0.6.10",
"nuxt-icon": "^0.6.9",
"ohash": "^1.1.3",
"pathe": "^1.1.2",
"scule": "^1.3.0",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.1"
"radix-vue": "^1.5.3",
"tailwind-variants": "^0.2.1",
"tailwindcss": "4.0.0-alpha.10"
},
"devDependencies": {
"@types/node": "^20.11.29",
"@nuxt/eslint-config": "^0.2.0",
"@nuxt/module-builder": "^0.5.5",
"@nuxt/schema": "^3.11.1",
"@nuxt/test-utils": "^3.12.0",
"@release-it/conventional-changelog": "^8.0.1",
"@vue/test-utils": "^2.4.5",
"eslint": "^8.57.0",
"happy-dom": "^14.3.6",
"happy-dom": "^14.1.0",
"joi": "^17.12.2",
"nuxt": "^3.11.1",
"nuxt-ui-dev-module": "workspace:*",
"release-it": "^17.1.1",
"typescript": "^5.4.3",
"unbuild": "^2.0.0",
"valibot": "^0.30.0",
"vitest": "^1.4.0",
"vitest-environment-nuxt": "^1.0.0",
@@ -78,10 +69,6 @@
"zod": "^3.22.4"
},
"resolutions": {
"@nuxt/kit": "^3.11.1",
"@nuxt/schema": "3.11.1",
"tailwindcss": "3.4.1",
"@headlessui/vue": "1.7.19",
"vue": "3.4.21"
"@nuxt/ui": "workspace:*"
}
}

View File

@@ -1,6 +1,6 @@
export default defineAppConfig({
ui: {
primary: 'green',
primary: 'sky',
gray: 'cool'
}
})

View File

@@ -1,23 +1,50 @@
<template>
<UContainer class="min-h-screen flex items-center">
<UCard class="flex-1" :ui="{ background: 'bg-gray-50 dark:bg-gray-800/50', ring: 'ring-1 ring-gray-300 dark:ring-gray-700', divide: 'divide-y divide-gray-300 dark:divide-gray-700', header: { base: 'font-bold' } }">
<template #header>
Welcome to the playground!
</template>
<script setup lang="ts">
import { splitByCase, upperFirst } from 'scule'
<p class="text-gray-500 dark:text-gray-400">
Try your components here!
</p>
</UCard>
</UContainer>
</template>
useHead({
bodyAttrs: {
class: 'antialiased font-sans text-gray-900 dark:text-white bg-white dark:bg-gray-900'
}
})
<script setup>
const components = [
'accordion',
'avatar',
'badge',
'button',
'card',
'checkbox',
'chip',
'collapsible',
'form',
'form-field',
'input',
'kbd',
'link',
'modal',
'navigation-menu',
'popover',
'skeleton',
'slideover',
'switch',
'tabs',
'textarea',
'tooltip'
]
function upperName (name: string) {
return splitByCase(name).map(p => upperFirst(p)).join('')
}
</script>
<style>
body {
@apply antialiased font-sans text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-900;
}
</style>
<template>
<UProvider>
<UContainer class="min-h-screen flex flex-col gap-4 items-center justify-center overflow-y-auto">
<UNavigationMenu :links="components.map(component => ({ label: upperName(component), to: `/${component}` }))" class="border-b border-gray-200 dark:border-gray-800 overflow-x-auto" />
<div class="flex-1 flex flex-col justify-center pb-12">
<NuxtPage />
</div>
</UContainer>
</UProvider>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types/form'
const schema = z.object({
email: z.string().min(2),
password: z.string().min(8)
})
type Schema = z.output<typeof schema>
const nestedSchema = z.object({
phone: z.string().length(10)
})
type NestedSchema = z.output<typeof nestedSchema>
const state = reactive<Partial<Schema & { nested: Partial<NestedSchema> }>>({
nested: {}
})
const checked = ref(false)
function onSubmit (event: FormSubmitEvent<Schema>) {
console.log('Success', event.data)
}
function onError (event: any) {
console.log('Error', event)
}
</script>
<template>
<UForm
:state="state"
:schema="schema"
class="gap-4 flex flex-col w-60"
@submit="(event) => onSubmit(event)"
@error="(event) => onError(event)"
>
<UFormField label="Email" name="email">
<UInput v-model="state.email" placeholder="john@lennon.com" />
</UFormField>
<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormField>
<div>
<UCheckbox v-model="checked" name="check" label="Check me" @change="state.nested = {}" />
</div>
<UForm v-if="checked && state.nested" :state="state.nested" :schema="nestedSchema">
<UFormField label="Phone" name="phone">
<UInput v-model="state.nested.phone" />
</UFormField>
</UForm>
<div>
<UButton color="gray" type="submit">
Submit
</UButton>
</div>
</UForm>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types/form'
const schema = z.object({
email: z.string().min(2),
password: z.string().min(8)
})
type Schema = z.output<typeof schema>
const itemSchema = z.object({
name: z.string().min(1),
price: z.string().min(1)
})
type ItemSchema = z.output<typeof itemSchema>
const state = reactive<Partial<Schema & { items: Partial<ItemSchema>[] }>>({})
function addItem () {
if (!state.items) {
state.items = []
}
state.items.push({})
}
function removeItem () {
if (state.items) {
state.items.pop()
}
}
const formItemRef = ref()
function onSubmit (event: FormSubmitEvent<Schema>) {
console.log('Success', event.data)
}
function onError (event: any) {
console.log('Error', event)
}
</script>
<template>
<UForm
ref="formItemRef"
:state="state"
:schema="schema"
class="gap-4 flex flex-col w-60"
@submit="onSubmit"
@error="onError"
>
<UFormField label="Email" name="email">
<UInput v-model="state.email" placeholder="john@lennon.com" />
</UFormField>
<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormField>
<UForm v-for="item, count in state.items" :key="count" :state="item" :schema="itemSchema" class="flex gap-2">
<UFormField label="Name" name="name">
<UInput v-model="item.name" />
</UFormField>
<UFormField label="Price" name="price">
<UInput v-model="item.price" />
</UFormField>
</UForm>
<div class="flex gap-2">
<UButton color="black" @click="addItem()">
Add Item
</UButton>
<UButton color="black" variant="ghost" @click="removeItem()">
Remove Item
</UButton>
</div>
<div>
<UButton color="gray" type="submit">
Submit
</UButton>
</div>
</UForm>
</template>

View File

@@ -0,0 +1,21 @@
<template>
<div class="relative overflow-hidden rounded border border-dashed border-gray-400 dark:border-gray-500 opacity-75 px-4 flex items-center justify-center">
<svg class="absolute inset-0 h-full w-full stroke-gray-900/10 dark:stroke-white/10" fill="none">
<defs>
<pattern
id="pattern-5c1e4f0e-62d5-498b-8ff0-cf77bb448c8e"
x="0"
y="0"
width="10"
height="10"
patternUnits="userSpaceOnUse"
>
<path d="M-3 13 15-5M-5 5l18-18M-1 21 17 3" />
</pattern>
</defs>
<rect stroke="none" fill="url(#pattern-5c1e4f0e-62d5-498b-8ff0-cf77bb448c8e)" width="100%" height="100%" />
</svg>
<slot />
</div>
</template>

View File

@@ -1,5 +1,7 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
'../src/module'
]
modules: ['../src/module'],
ui: {
colors: ['primary', 'red']
}
})

16
playground/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"private": true,
"name": "nuxt-ui-playground",
"type": "module",
"scripts": {
"dev": "nuxi dev",
"build": "nuxi build",
"generate": "nuxi generate"
},
"dependencies": {
"@nuxt/ui": "latest",
"nuxt": "latest",
"vue": "latest",
"vue-router": "latest"
}
}

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
const items = [{
label: 'Getting Started',
icon: 'i-heroicons-information-circle',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.'
}, {
label: 'Installation',
icon: 'i-heroicons-arrow-down-tray',
disabled: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.'
}, {
label: 'Theming',
icon: 'i-heroicons-eye-dropper',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.'
}, {
label: 'Layouts',
icon: 'i-heroicons-rectangle-group',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.'
}, {
label: 'Components',
icon: 'i-heroicons-square-3-stack-3d',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.'
}, {
label: 'Utilities',
slot: 'toto',
icon: 'i-heroicons-wrench-screwdriver',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.'
}]
</script>
<template>
<UCard :ui="{ body: 'p-0 sm:p-0' }">
<UAccordion :items="items" class="w-96" :ui="{ trigger: 'px-3.5', content: 'px-3.5' }" />
</UCard>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import theme from '#build/ui/avatar'
const sizes = Object.keys(theme.variants.size)
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5">
<UAvatar v-for="size in sizes" :key="size" src="https://avatars.githubusercontent.com/u/739984?v=4" alt="Benjamin Canac" :size="(size as any)" />
</div>
<div class="flex items-center gap-1.5">
<UAvatar v-for="size in sizes" :key="size" icon="i-heroicons-photo" :size="(size as any)" />
</div>
<div class="flex items-center gap-1.5">
<UAvatar v-for="size in sizes" :key="size" alt="Benjamin Canac" :size="(size as any)" />
</div>
<div class="flex items-center gap-1.5">
<UAvatar v-for="size in sizes" :key="size" :text="size" :size="(size as any)" />
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import theme from '#build/ui/badge'
const sizes = Object.keys(theme.variants.size)
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<UBadge class="font-bold">
Badge
</UBadge>
</div>
<div class="flex items-center gap-2">
<UBadge label="Badge" />
<UBadge label="Badge" variant="outline" />
<UBadge label="Badge" variant="soft" />
<UBadge label="Badge" variant="subtle" />
</div>
<div class="flex items-center gap-2">
<UBadge label="Badge" color="white" />
</div>
<div class="flex items-center gap-2">
<UBadge label="Badge" color="gray" />
</div>
<div class="flex items-center gap-2">
<UBadge label="Badge" color="black" />
</div>
<div class="flex items-center gap-2 ml-[-56px]">
<UBadge v-for="size in sizes" :key="size" label="Badge" :size="(size as any)" />
</div>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import theme from '#build/ui/button'
const sizes = Object.keys(theme.variants.size)
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<UButton class="font-bold">
Button
</UButton>
</div>
<div class="flex items-center gap-2">
<UButton disabled>
Disabled
</UButton>
</div>
<div class="flex items-center gap-2">
<UButton loading>
Loading
</UButton>
<UButton loading leading-icon="i-heroicons-rocket-launch">
Loading
</UButton>
</div>
<div class="flex items-center gap-2">
<UButton loading trailing>
Loading
</UButton>
<UButton loading trailing-icon="i-heroicons-paper-airplane">
Loading
</UButton>
</div>
<div class="flex items-center gap-2">
<UButton truncate class="w-16">
Truncate
</UButton>
</div>
<div class="flex items-center gap-2">
<UButton icon="i-heroicons-rocket-launch" label="Button" />
<UButton icon="i-heroicons-rocket-launch" label="Button" variant="outline" />
<UButton icon="i-heroicons-rocket-launch" label="Button" variant="soft" />
<UButton icon="i-heroicons-rocket-launch" label="Button" variant="ghost" />
<UButton icon="i-heroicons-rocket-launch" label="Button" variant="link" />
</div>
<div class="flex items-center gap-2">
<UButton icon="i-heroicons-rocket-launch" label="Button" color="white" />
<UButton icon="i-heroicons-rocket-launch" label="Button" color="white" variant="ghost" />
</div>
<div class="flex items-center gap-2">
<UButton icon="i-heroicons-rocket-launch" label="Button" color="gray" />
<UButton icon="i-heroicons-rocket-launch" label="Button" color="gray" variant="ghost" />
<UButton icon="i-heroicons-rocket-launch" label="Button" color="gray" variant="link" />
</div>
<div class="flex items-center gap-2">
<UButton icon="i-heroicons-rocket-launch" label="Button" color="black" />
<UButton icon="i-heroicons-rocket-launch" label="Button" color="black" variant="link" />
</div>
<div class="flex items-center gap-2 ml-[-129px]">
<UButton v-for="size in sizes" :key="size" label="Button" :size="(size as any)" />
</div>
<div class="flex items-center gap-2 ml-[-171px]">
<UButton v-for="size in sizes" :key="size" icon="i-heroicons-rocket-launch" label="Button" :size="(size as any)" />
</div>
<div class="flex items-center gap-2 ml-[-159px]">
<UButton
v-for="size in sizes"
:key="size"
icon="i-heroicons-rocket-launch"
label="Square"
square
:size="(size as any)"
/>
</div>
<div class="flex items-center gap-2 ml-[-67px]">
<UButton v-for="size in sizes" :key="size" icon="i-heroicons-rocket-launch" :size="(size as any)" />
</div>
<div class="flex items-center gap-2">
<UButton icon="i-heroicons-rocket-launch" trailing-icon="i-heroicons-chevron-down-20-solid" label="Block" loading block />
</div>
<div class="flex items-center gap-2">
<UButton icon="i-heroicons-cloud-arrow-down" label="Button" class="group" :ui="{ leadingIcon: 'group-hover:animate-pulse' }" />
</div>
</div>
</template>

15
playground/pages/card.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<div class="flex flex-col gap-4">
<UCard class="w-96">
<template #header>
<Placeholder class="h-8" />
</template>
<Placeholder class="h-32" />
<template #footer>
<Placeholder class="h-8" />
</template>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import theme from '#build/ui/checkbox'
const sizes = Object.keys(theme.variants.size)
</script>
<template>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col gap-4 ml-[-272px]">
<UCheckbox label="Normal" />
<UCheckbox label="Checked" :model-value="true" />
<UCheckbox label="Indeterminate" indeterminate />
<UCheckbox label="Default checked" default-checked />
<UCheckbox label="Required" required />
<UCheckbox label="Disabled" disabled />
<UCheckbox label="Custom icon" color="red" icon="i-heroicons-heart-solid" :model-value="true" />
</div>
<div class="flex items-center gap-4 ml-[-156px]">
<UCheckbox v-for="size in sizes" :key="size" label="Check me" :size="(size as any)" />
</div>
<div class="flex items-center gap-4">
<UCheckbox v-for="size in sizes" :key="size" label="Check me" description="This is a description" :size="(size as any)" />
</div>
</div>
</template>

38
playground/pages/chip.vue Normal file
View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import theme from '#build/ui/chip'
const sizes = Object.keys(theme.variants.size)
const positions = Object.keys(theme.variants.position)
const items = [{
name: 'messages',
icon: 'i-heroicons-chat-bubble-oval-left',
count: 3
}, {
name: 'notifications',
icon: 'i-heroicons-bell',
count: 0
}]
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<UChip v-for="position in positions" :key="position" :position="(position as any)">
<UButton icon="i-heroicons-inbox" color="gray" />
</UChip>
</div>
<div class="flex items-center gap-2">
<UChip v-for="{ name, icon, count } in items" :key="name" :text="count" :show="count > 0" size="lg">
<UButton :icon="icon" color="gray" />
</UChip>
</div>
<div class="flex items-center gap-2 ml-[-84px]">
<UChip v-for="size in sizes" :key="size" :size="(size as any)" inset text="1">
<UAvatar src="https://avatars.githubusercontent.com/u/739984?v=4" :size="(size as any)" />
</UChip>
</div>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<UCollapsible class="space-y-2 w-48">
<UButton
class="group"
trailing-icon="i-heroicons-chevron-right-20-solid"
color="gray"
label="Open"
block
:ui="{ trailingIcon: 'group-data-[state=open]:rotate-90 transition-transform duration-200' }"
/>
<template #content>
<Placeholder class="h-24 w-full" />
</template>
</UCollapsible>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import theme from '#build/ui/formField'
const sizes = Object.keys(theme.variants.size)
const feedbacks = [
{ description: 'This is a description' },
{ error: 'This is an error' },
{ hint: 'This is a hint' },
{ help: 'Help! I need somebody!' },
{ required: true }
]
</script>
<template>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col gap-4 ml-[-258px]">
<div v-for="(feedback, count) in feedbacks" :key="count" class="flex items-center">
<UFormField v-bind="feedback" label="Email" name="email">
<UInput placeholder="john@lennon.com" />
</UFormField>
</div>
</div>
<div class="flex items-center gap-4">
<UFormField
v-for="size in sizes"
:key="size"
:size="(size as any)"
label="Email"
name="email"
>
<UInput placeholder="john@lennon.com" />
</UFormField>
</div>
<div class="flex items-center gap-4">
<UFormField
v-for="size in sizes"
:key="size"
:size="(size as any)"
label="Email"
description="This is a description"
name="email"
>
<UInput placeholder="john@lennon.com" />
</UFormField>
</div>
</div>
</template>

121
playground/pages/form.vue Normal file
View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { z } from 'zod'
import type { Form, FormSubmitEvent } from '#ui/types/form'
type User = {
email: string
password: string
tos: boolean
}
const state = reactive<Partial<User>>({})
const state2 = reactive<Partial<User>>({})
const state3 = reactive<Partial<User>>({})
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
tos: z.literal(true)
})
const disabledForm = ref<Form<User>>()
function onSubmit (event: FormSubmitEvent<User>) {
console.log(event.data)
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex gap-4">
<UForm
:state="state"
:schema="schema"
class="gap-4 flex flex-col w-60"
@submit="(event) => onSubmit(event)"
>
<UFormField label="Email" name="email">
<UInput v-model="state.email" placeholder="john@lennon.com" />
</UFormField>
<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormField>
<UFormField name="tos">
<UCheckbox v-model="state.tos" label="I accept the terms and conditions" />
</UFormField>
<div>
<UButton color="gray" type="submit">
Submit
</UButton>
</div>
</UForm>
<UForm
:state="state2"
:schema="schema"
class="gap-4 flex flex-col w-60"
:validate-on-input-delay="2000"
@submit="(event) => onSubmit(event)"
>
<UFormField label="Email" name="email">
<UInput v-model="state2.email" placeholder="john@lennon.com" />
</UFormField>
<UFormField
label="Password"
name="password"
:validate-on-input-delay="50"
eager-validation
>
<UInput v-model="state2.password" type="password" />
</UFormField>
<div>
<UButton color="gray" type="submit">
Submit
</UButton>
</div>
</UForm>
<UForm
ref="disabledForm"
:state="state3"
:schema="schema"
class="gap-4 flex flex-col w-60"
disabled
@submit="(event) => onSubmit(event)"
>
<UFormField label="Email" name="email">
<UInput v-model="state3.email" placeholder="john@lennon.com" />
</UFormField>
<UFormField
label="Password"
name="password"
:validate-on-input-delay="50"
eager-validation
>
<UInput v-model="state3.password" type="password" />
</UFormField>
<UFormField name="tos">
<UCheckbox v-model="state3.tos" label="I accept the terms and conditions" />
</UFormField>
<div>
<UButton color="gray" type="submit" :disabled="disabledForm?.disabled">
Submit
</UButton>
</div>
</UForm>
</div>
<div class="flex gap-4">
<FormNestedExample />
<FormNestedListExample />
</div>
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<div />
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import theme from '#build/ui/input'
const sizes = Object.keys(theme.variants.size)
</script>
<template>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col gap-4 ml-[-120px]">
<UInput placeholder="Search..." autofocus />
<UInput placeholder="Search..." color="gray" />
<UInput placeholder="Search..." color="primary" />
<UInput placeholder="Search..." disabled />
<UInput placeholder="Search..." type="number" />
<UInput icon="i-heroicons-folder" placeholder="Search..." type="file" />
<UInput icon="i-heroicons-calendar" placeholder="Search..." type="date" />
<UInput icon="i-heroicons-lock-closed" placeholder="Search..." type="password" value="password" />
<UInput loading placeholder="Search..." />
<UInput loading leading-icon="i-heroicons-magnifying-glass" placeholder="Search..." />
<UInput loading trailing placeholder="Search..." />
<UInput loading trailing-icon="i-heroicons-magnifying-glass" placeholder="Search..." />
</div>
<div class="flex items-center gap-4">
<UInput
v-for="size in sizes"
:key="size"
placeholder="Search..."
:size="(size as any)"
/>
</div>
<div class="flex items-center gap-4">
<UInput
v-for="size in sizes"
:key="size"
icon="i-heroicons-magnifying-glass"
placeholder="Search..."
:size="(size as any)"
/>
</div>
<div class="flex items-center gap-4">
<UInput
v-for="size in sizes"
:key="size"
icon="i-heroicons-magnifying-glass"
trailing
placeholder="Search..."
:size="(size as any)"
/>
</div>
</div>
</template>

6
playground/pages/kbd.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<div class="flex items-center gap-1">
<UKbd value="⌘" />
<UKbd value="K" />
</div>
</template>

55
playground/pages/link.vue Normal file
View File

@@ -0,0 +1,55 @@
<template>
<div class="flex items-center gap-4">
<div class="flex flex-col items-start gap-2 text-sm">
<ULink raw>
Button raw
</ULink>
<ULink active>
Button active
</ULink>
<ULink active class="font-medium" active-class="text-gray-900 dark:text-white">
Button active with class
</ULink>
<ULink active disabled>
Button active disabled
</ULink>
<ULink>
Button inactive
</ULink>
<ULink class="font-medium" inactive-class="hover:text-primary-500 dark:hover:text-primary-400">
Button inactive with class
</ULink>
<ULink disabled>
Button inactive disabled
</ULink>
</div>
<div class="flex flex-col items-start gap-2 text-sm">
<ULink to="/link" raw>
Link raw
</ULink>
<ULink to="/link">
Link active
</ULink>
<ULink to="/link" class="font-medium" active-class="text-gray-900 dark:text-white">
Link active with class
</ULink>
<ULink to="/link" disabled>
Link active disabled
</ULink>
<ULink to="/button">
Link inactive
</ULink>
<ULink to="/button" class="font-medium" inactive-class="hover:text-primary-500 dark:hover:text-primary-400">
Link inactive with class
</ULink>
<ULink to="/button" disabled>
Link inactive disabled
</ULink>
</div>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
const open = ref(false)
</script>
<template>
<div class="flex flex-col gap-2">
<UModal title="First modal">
<UButton color="white" label="Open with nested" />
<template #footer>
<UModal title="Second modal">
<UButton label="Open second" />
</UModal>
</template>
</UModal>
<UModal v-model:open="open" title="Modal with v-model" description="This can be useful to control the state of the modal yourself." />
<UButton label="Open with v-model" color="gray" @click="open = true" />
<UModal title="Modal without overlay" description="This modal has `overlay: false` prop." :overlay="false">
<UButton label="Open without overlay" color="white" />
</UModal>
<UModal title="Modal without modal & overlay" description="This modal has `modal: false` and `overlay: false` to interact with outside content." :overlay="false" :modal="false">
<UButton label="Open without modal" color="gray" />
</UModal>
<UModal title="Modal without transition" description="This modal has `transition: false` prop." :transition="false">
<UButton label="Open without transition" color="white" />
</UModal>
<UModal title="Modal without portal" description="This modal has `portal: false` prop." :portal="false">
<UButton label="Open without portal" color="gray" />
</UModal>
<UModal title="Modal fullscreen" description="This modal has `fullscreen: true` prop." fullscreen>
<UButton label="Open fullscreen" color="white" />
</UModal>
<UModal title="Modal prevent close" description="This modal has `prevent-close: true` prop so it won't close when clicking outside." prevent-close>
<UButton label="Open unclosable" color="gray" />
</UModal>
<UModal title="Modal without close button" description="This modal has `close: null` prop." :close="null">
<UButton label="Open without close button" color="white" />
</UModal>
<UModal title="Modal with custom close button" description="The `close` prop inherits from the Button props." :close="{ color: 'primary', variant: 'solid', size: 'xs' }" :ui="{ close: 'top-3.5 rounded-full' }">
<UButton label="Open with custom close button" color="gray" />
</UModal>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
const links = [
[{
label: 'Profile',
active: true,
avatar: {
src: 'https://avatars.githubusercontent.com/u/739984?v=4'
},
badge: 100,
click () {
console.log('Profile clicked')
}
}, {
label: 'Modal',
icon: 'i-heroicons-home',
to: '/modal'
}, {
label: 'NavigationMenu',
icon: 'i-heroicons-chart-bar',
to: '/navigation-menu'
}, {
label: 'Popover',
icon: 'i-heroicons-command-line',
to: '/popover'
}], [{
label: 'Examples',
icon: 'i-heroicons-light-bulb',
to: 'https://ui.nuxt.com',
target: '_blank'
}, {
label: 'Help',
icon: 'i-heroicons-question-mark-circle'
}]
]
</script>
<template>
<div class="flex flex-col gap-12 w-4xl">
<UNavigationMenu :links="links" class="border-b border-gray-200 dark:border-gray-800" />
<UNavigationMenu :links="links" orientation="vertical" class="w-48" />
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
const open = ref(false)
const loading = ref(false)
function send () {
loading.value = true
setTimeout(() => {
loading.value = false
open.value = false
}, 1000)
}
</script>
<template>
<div class="text-center">
<UPopover v-model:open="open" arrow>
<UButton label="Click me" color="white" />
<template #content>
<div class="flex justify-center gap-2 p-4 w-48">
<UButton label="Close" color="gray" @click="open = false" />
<UButton label="Send" color="black" trailing-icon="i-heroicons-paper-airplane" :loading="loading" @click="send" />
</div>
</template>
</UPopover>
<div class="mt-24">
<UPopover mode="hover" :content="{ side: 'top' }">
<UButton label="Hover me top" color="white" />
<template #content>
<div class="w-48 h-16" />
</template>
</UPopover>
<div class="flex items-center gap-2 my-2">
<UPopover mode="hover" :content="{ side: 'left' }">
<UButton label="Hover me left" color="white" />
<template #content>
<div class="w-48 h-16" />
</template>
</UPopover>
<UPopover mode="hover" :content="{ side: 'right' }">
<UButton label="Hover me right" color="white" />
<template #content>
<div class="w-48 h-16" />
</template>
</UPopover>
</div>
<UPopover mode="hover">
<UButton label="Hover me bottom" color="white" />
<template #content>
<div class="w-48 h-16" />
</template>
</UPopover>
</div>
</div>
</template>

View File

@@ -0,0 +1,10 @@
<template>
<div class="flex items-center gap-4">
<USkeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<USkeleton class="h-4 w-[250px]" />
<USkeleton class="h-4 w-[200px]" />
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
const open = ref(false)
</script>
<template>
<div class="flex flex-col gap-2">
<USlideover title="First slideover">
<UButton color="white" label="Open with nested" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
<template #footer>
<USlideover title="Second slideover">
<UButton label="Open second" />
</USlideover>
</template>
</USlideover>
<USlideover title="Slideover on left side" description="This slideover has `side: 'left'` prop." side="left">
<UButton label="Open on left" color="gray" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
<USlideover title="Slideover on top side" description="This slideover has `side: 'top'` prop." side="top">
<UButton label="Open on top" color="white" />
<template #body>
<Placeholder class="h-48 w-full" />
</template>
</USlideover>
<USlideover title="Slideover on bottom side" description="This slideover has `side: 'bottom'` prop." side="bottom">
<UButton label="Open on bottom" color="gray" />
<template #body>
<Placeholder class="h-48 w-full" />
</template>
</USlideover>
<USlideover v-model:open="open" title="Slideover with v-model" description="This can be useful to control the state of the slideover yourself.">
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
<UButton label="Open with v-model" color="white" @click="open = true" />
<USlideover title="Slideover without overlay" description="This slideover has `overlay: false` prop." :overlay="false">
<UButton label="Open without overlay" color="gray" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
<USlideover title="Slideover without modal & overlay" description="This slideover has `modal: false` and `overlay: false` to interact with outside content." :overlay="false" :modal="false">
<UButton label="Open without modal" color="white" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
<USlideover title="Slideover without transition" description="This slideover has `transition: false` prop." :transition="false">
<UButton label="Open without transition" color="gray" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
<USlideover title="Slideover without portal" description="This slideover has `portal: false` prop." :portal="false">
<UButton label="Open without portal" color="white" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
<USlideover title="Slideover prevent close" description="This slideover has `prevent-close: true` prop so it won't close when clicking outside." prevent-close>
<UButton label="Open unclosable" color="gray" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
<USlideover title="Slideover without close button" description="This slideover has `close: null` prop." :close="null">
<UButton label="Open without close button" color="white" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
<USlideover title="Slideover with custom close button" description="The `close` prop inherits from the Button props." :close="{ color: 'primary', variant: 'solid', size: 'xs' }" :ui="{ close: 'top-3.5 rounded-full' }">
<UButton label="Open with custom close button" color="gray" />
<template #body>
<Placeholder class="h-full w-full" />
</template>
</USlideover>
</div>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import theme from '#build/ui/switch'
const sizes = Object.keys(theme.variants.size)
const checked = ref(false)
</script>
<template>
<div class="flex flex-col gap-2">
<div>
<USwitch v-model:checked="checked" />
</div>
<div>
<USwitch v-model:checked="checked" disabled />
</div>
<div class="flex items-center gap-2 ml-[-64px]">
<USwitch v-for="size in sizes" :key="size" v-model:checked="checked" :size="(size as any)" />
</div>
<div class="flex items-center gap-2 ml-[-64px]">
<USwitch
v-for="size in sizes"
:key="size"
v-model:checked="checked"
:size="(size as any)"
unchecked-icon="i-heroicons-x-mark-20-solid"
checked-icon="i-heroicons-check-20-solid"
/>
</div>
<div class="flex items-center gap-2 ml-[-64px]">
<USwitch
v-for="size in sizes"
:key="size"
v-model:checked="checked"
:size="(size as any)"
unchecked-icon="i-heroicons-x-mark-20-solid"
checked-icon="i-heroicons-check-20-solid"
loading
/>
</div>
</div>
</template>

16
playground/pages/tabs.vue Normal file
View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [{
label: 'Tab1',
content: 'This is the content shown for Tab1'
}, {
label: 'Tab2',
content: 'And, this is the content for Tab2'
}, {
label: 'Tab3',
content: 'Finally, this is the content for Tab3'
}]
</script>
<template>
<UTabs :items="items" />
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import theme from '#build/ui/textarea'
const sizes = Object.keys(theme.variants.size)
</script>
<template>
<div class="flex flex-col items-center gap-4">
<div class="flex gap-4">
<UTextarea />
</div>
<div class="flex gap-4">
<UTextarea placeholder="Search..." autofocus />
<UTextarea placeholder="Search..." color="gray" />
<UTextarea placeholder="Search..." color="primary" />
<UTextarea placeholder="Search..." disabled />
</div>
<div class="flex items-center gap-4">
<UTextarea
v-for="size in sizes"
:key="size"
placeholder="Search..."
:size="(size as any)"
/>
</div>
<div class="flex gap-4">
<UTextarea autofocus />
<UTextarea autofocus :autofocus-delay="500" />
<UTextarea autoresize />
<UTextarea autoresize :maxrows="5" :rows="1" />
</div>
<div class="flex gap-4">
<UTextarea variant="none" placeholder="You can't see me" />
</div>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<template>
<div class="flex flex-col">
<UTooltip text="Top" :shortcuts="['⌘', 'T']" :content="{ side: 'top' }" arrow>
<UAvatar text="T" />
</UTooltip>
<div class="flex items-center gap-2 ml-[-20px]">
<UTooltip text="Left" :shortcuts="['⌘', 'L']" :content="{ side: 'left' }" arrow>
<UAvatar text="L" />
</UTooltip>
<UTooltip text="Right" :shortcuts="['⌘', 'R']" :content="{ side: 'right' }" arrow>
<UAvatar text="R" />
</UTooltip>
</div>
<UTooltip text="Bottom" :shortcuts="['⌘', 'B']" arrow>
<UAvatar text="B" />
</UTooltip>
</div>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

4191
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
packages:
- "docs"
- "./"
- "cli"
- "docs"
- "playground"
- "modules/*"

View File

@@ -1,68 +1,17 @@
import { createRequire } from 'node:module'
import { defineNuxtModule, installModule, addComponentsDir, addImportsDir, createResolver, addPlugin } from '@nuxt/kit'
import { defaultExtractor as createDefaultExtractor } from 'tailwindcss/lib/lib/defaultExtractor.js'
import { iconsPlugin, getIconCollections, type CollectionNames, type IconsPluginOptions } from '@egoist/tailwindcss-icons'
import { name, version } from '../package.json'
import createTemplates from './templates'
import { generateSafelist, excludeColors, customSafelistExtractor } from './colors'
import * as config from './runtime/ui.config'
import type { DeepPartial, Strategy } from './runtime/types/utils'
const defaultExtractor = createDefaultExtractor({ tailwindConfig: { separator: ':' } })
const _require = createRequire(import.meta.url)
const defaultColors = _require('tailwindcss/colors.js')
delete defaultColors.lightBlue
delete defaultColors.warmGray
delete defaultColors.trueGray
delete defaultColors.coolGray
delete defaultColors.blueGray
type UI = {
primary?: string
gray?: string
colors?: string[]
strategy?: Strategy
[key: string]: any
} & DeepPartial<typeof config>
declare module 'nuxt/schema' {
interface AppConfigInput {
// @ts-ignore
ui?: UI
}
}
declare module '@nuxt/schema' {
interface AppConfigInput {
// @ts-ignore
ui?: UI
}
}
import { defu } from 'defu'
import { createResolver, defineNuxtModule, addComponentsDir, addImportsDir, addVitePlugin, addPlugin, installModule } from '@nuxt/kit'
import tailwindcss from '@tailwindcss/vite'
import addTemplates from './templates'
import icons from './theme/icons'
export interface ModuleOptions {
/**
* @default 'u'
*/
prefix?: string
/**
* @default false
*/
global?: boolean
icons: CollectionNames[] | 'all' | IconsPluginOptions
safelistColors?: string[]
/**
* Disables the global css styles added by the module.
*/
disableGlobalStyles?: boolean
colors?: string[]
}
export default defineNuxtModule<ModuleOptions>({
meta: {
name,
version,
name: 'ui',
configKey: 'ui',
compatibility: {
nuxt: '^3.10.0'
@@ -70,181 +19,42 @@ export default defineNuxtModule<ModuleOptions>({
},
defaults: {
prefix: 'U',
icons: ['heroicons'],
safelistColors: ['primary'],
disableGlobalStyles: false
colors: undefined
},
async setup (options, nuxt) {
const { resolve } = createResolver(import.meta.url)
// Transpile runtime
const runtimeDir = resolve('./runtime')
nuxt.options.build.transpile.push(runtimeDir)
nuxt.options.build.transpile.push('@popperjs/core', '@headlessui/vue')
options.colors = options.colors || ['primary', 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchia', 'pink', 'rose']
nuxt.options.alias['#ui'] = runtimeDir
nuxt.options.alias['#ui'] = resolve('./runtime')
if (!options.disableGlobalStyles) {
nuxt.options.css.push(resolve(runtimeDir, 'ui.css'))
}
// @ts-ignore
nuxt.hook('tailwindcss:config', function (tailwindConfig) {
tailwindConfig.theme = tailwindConfig.theme || {}
tailwindConfig.theme.extend = tailwindConfig.theme.extend || {}
tailwindConfig.theme.extend.colors = tailwindConfig.theme.extend.colors || {}
const globalColors: any = {
...(tailwindConfig.theme.colors || defaultColors),
...tailwindConfig.theme.extend?.colors
}
// @ts-ignore
globalColors.primary = tailwindConfig.theme.extend.colors.primary = {
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
200: 'rgb(var(--color-primary-200) / <alpha-value>)',
300: 'rgb(var(--color-primary-300) / <alpha-value>)',
400: 'rgb(var(--color-primary-400) / <alpha-value>)',
500: 'rgb(var(--color-primary-500) / <alpha-value>)',
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
700: 'rgb(var(--color-primary-700) / <alpha-value>)',
800: 'rgb(var(--color-primary-800) / <alpha-value>)',
900: 'rgb(var(--color-primary-900) / <alpha-value>)',
950: 'rgb(var(--color-primary-950) / <alpha-value>)',
DEFAULT: 'rgb(var(--color-primary-DEFAULT) / <alpha-value>)'
}
if (globalColors.gray) {
// @ts-ignore
globalColors.cool = tailwindConfig.theme.extend.colors.cool = defaultColors.gray
}
// @ts-ignore
globalColors.gray = tailwindConfig.theme.extend.colors.gray = {
50: 'rgb(var(--color-gray-50) / <alpha-value>)',
100: 'rgb(var(--color-gray-100) / <alpha-value>)',
200: 'rgb(var(--color-gray-200) / <alpha-value>)',
300: 'rgb(var(--color-gray-300) / <alpha-value>)',
400: 'rgb(var(--color-gray-400) / <alpha-value>)',
500: 'rgb(var(--color-gray-500) / <alpha-value>)',
600: 'rgb(var(--color-gray-600) / <alpha-value>)',
700: 'rgb(var(--color-gray-700) / <alpha-value>)',
800: 'rgb(var(--color-gray-800) / <alpha-value>)',
900: 'rgb(var(--color-gray-900) / <alpha-value>)',
950: 'rgb(var(--color-gray-950) / <alpha-value>)'
}
const colors = excludeColors(globalColors)
// @ts-ignore
nuxt.options.appConfig.ui = {
primary: 'green',
gray: 'cool',
colors,
strategy: 'merge'
}
tailwindConfig.safelist = tailwindConfig.safelist || []
tailwindConfig.safelist.push(...generateSafelist(options.safelistColors || [], colors))
nuxt.options.appConfig.ui = defu(nuxt.options.appConfig.ui || {}, {
primary: 'green',
gray: 'cool',
icons
})
createTemplates(nuxt)
// nuxt.options.postcss = nuxt.options.postcss || {}
// nuxt.options.postcss.plugins = nuxt.options.postcss.plugins || {}
// nuxt.options.postcss.plugins['@tailwindcss/postcss'] = {}
// Modules
addVitePlugin(tailwindcss)
await installModule('nuxt-icon')
await installModule('@nuxtjs/color-mode', { classSuffix: '' })
await installModule('@nuxtjs/tailwindcss', {
exposeConfig: true,
config: {
darkMode: 'class',
plugins: [
require('@tailwindcss/forms')({ strategy: 'class' }),
require('@tailwindcss/aspect-ratio'),
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries'),
require('@headlessui/tailwindcss'),
iconsPlugin(Array.isArray(options.icons) || options.icons === 'all' ? { collections: getIconCollections(options.icons) } : typeof options.icons === 'object' ? options.icons as IconsPluginOptions : {})
],
content: {
files: [
resolve(runtimeDir, 'components/**/*.{vue,mjs,ts}'),
resolve(runtimeDir, 'ui.config/**/*.{mjs,js,ts}')
],
transform: {
vue: (content) => {
return content.replaceAll(/(?:\r\n|\r|\n)/g, ' ')
}
},
extract: {
vue: (content) => {
return [
...defaultExtractor(content),
// @ts-ignore
...customSafelistExtractor(options.prefix, content, nuxt.options.appConfig.ui.colors, options.safelistColors)
]
}
}
}
}
})
// Plugins
// await installModule('@nuxtjs/color-mode', { classSuffix: '' })
addPlugin({
src: resolve(runtimeDir, 'plugins', 'colors')
src: resolve('./runtime/plugins/index')
})
addPlugin({
src: resolve(runtimeDir, 'plugins', 'modals')
addComponentsDir({
path: resolve('./runtime/components'),
prefix: options.prefix,
pathPrefix: false
})
addPlugin({
src: resolve(runtimeDir, 'plugins', 'slideovers')
})
addImportsDir(resolve('./runtime/composables'))
// Components
addComponentsDir({
path: resolve(runtimeDir, 'components', 'elements'),
prefix: options.prefix,
global: options.global,
watch: false
})
addComponentsDir({
path: resolve(runtimeDir, 'components', 'forms'),
prefix: options.prefix,
global: options.global,
watch: false
})
addComponentsDir({
path: resolve(runtimeDir, 'components', 'data'),
prefix: options.prefix,
global: options.global,
watch: false
})
addComponentsDir({
path: resolve(runtimeDir, 'components', 'layout'),
prefix: options.prefix,
global: options.global,
watch: false
})
addComponentsDir({
path: resolve(runtimeDir, 'components', 'navigation'),
prefix: options.prefix,
global: options.global,
watch: false
})
addComponentsDir({
path: resolve(runtimeDir, 'components', 'overlays'),
prefix: options.prefix,
global: options.global,
watch: false
})
// Composables
addImportsDir(resolve(runtimeDir, 'composables'))
addTemplates(options, nuxt)
}
})

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { AccordionRootProps, AccordionRootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/accordion'
import type { IconProps } from '#ui/components/Icon.vue'
const appConfig = _appConfig as AppConfig & { ui: { accordion: Partial<typeof theme> } }
const accordion = tv({ extend: tv(theme), ...(appConfig.ui?.accordion || {}) })
export interface AccordionItem {
slot?: string
icon?: IconProps['name']
label?: string
value?: string
content?: string
disabled?: boolean
}
export interface AccordionProps<T extends AccordionItem> extends Omit<AccordionRootProps, 'asChild' | 'dir' | 'orientation'> {
items?: T[]
class?: any
ui?: Partial<typeof accordion.slots>
}
export interface AccordionEmits extends AccordionRootEmits {}
type SlotProps<T> = (props: { item: T, index: number }) => any
export type AccordionSlots<T extends AccordionItem> = {
leading: SlotProps<T>
default: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
} & {
[key in T['slot'] as string]?: SlotProps<T>
}
</script>
<script setup lang="ts" generic="T extends AccordionItem">
import { computed } from 'vue'
import { AccordionRoot, AccordionItem, AccordionHeader, AccordionTrigger, AccordionContent, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#app'
const props = withDefaults(defineProps<AccordionProps<T>>(), {
type: 'single',
collapsible: true,
defaultValue: '0'
})
const emits = defineEmits<AccordionEmits>()
defineSlots<AccordionSlots<T>>()
const appConfig = useAppConfig()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'collapsible', 'defaultValue', 'disabled', 'modelValue', 'type'), emits)
const ui = computed(() => tv({ extend: accordion, slots: props.ui })())
</script>
<template>
<AccordionRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
<AccordionItem v-for="(item, index) in items" :key="index" :value="item.value || String(index)" :disabled="item.disabled" :class="ui.item()">
<AccordionHeader :class="ui.header()">
<AccordionTrigger :class="ui.trigger()">
<slot name="leading" :item="item" :index="index">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon()" />
</slot>
<span v-if="item.label || $slots.default" :class="ui.label()">
<slot :item="item" :index="index">{{ item.label }}</slot>
</span>
<slot name="trailing" :item="item" :index="index">
<UIcon :name="appConfig.ui.icons.chevronDown" :class="ui.trailingIcon()" />
</slot>
</AccordionTrigger>
</AccordionHeader>
<AccordionContent v-if="item.content || $slots.content || (item.slot && $slots[item.slot])" :class="ui.content()" :value="item.value || String(index)">
<slot :name="item.slot || 'content'" :item="item" :index="index">
{{ item.content }}
</slot>
</AccordionContent>
</AccordionItem>
</AccordionRoot>
</template>
<style>
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { AvatarFallbackProps, AvatarRootProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/avatar'
import type { IconProps } from '#ui/components/Icon.vue'
const appConfig = _appConfig as AppConfig & { ui: { avatar: Partial<typeof theme> } }
const avatar = tv({ extend: tv(theme), ...(appConfig.ui?.avatar || {}) })
type AvatarVariants = VariantProps<typeof avatar>
export interface AvatarProps extends Omit<AvatarRootProps, 'asChild'>, Omit<AvatarFallbackProps, 'as' | 'asChild'> {
src?: string
alt?: string
icon?: IconProps['name']
text?: string
size?: AvatarVariants['size']
class?: any
ui?: Partial<typeof avatar.slots>
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { AvatarRoot, AvatarImage, AvatarFallback, useForwardProps } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import UIcon from '#ui/components/Icon.vue'
const props = defineProps<AvatarProps>()
const rootProps = useForwardProps(reactivePick(props, 'as'))
const fallbackProps = useForwardProps(reactivePick(props, 'delayMs'))
const fallback = computed(() => props.text || (props.alt || '').split(' ').map(word => word.charAt(0)).join('').substring(0, 2))
const ui = computed(() => tv({ extend: avatar, slots: props.ui })({ size: props.size }))
</script>
<template>
<AvatarRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
<AvatarImage v-if="src" :src="src" :alt="alt" :class="ui.image()" />
<AvatarFallback as-child v-bind="fallbackProps">
<UIcon v-if="icon" :name="icon" :class="ui.icon()" />
<span v-else :class="ui.fallback()">{{ fallback }}</span>
</AvatarFallback>
</AvatarRoot>
</template>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { PrimitiveProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/badge'
const appConfig = _appConfig as AppConfig & { ui: { badge: Partial<typeof theme> } }
const badge = tv({ extend: tv(theme), ...(appConfig.ui?.badge || {}) })
type BadgeVariants = VariantProps<typeof badge>
export interface BadgeProps extends Omit<PrimitiveProps, 'asChild'> {
label?: string | number
color?: BadgeVariants['color']
variant?: BadgeVariants['variant']
size?: BadgeVariants['size']
class?: any
}
export interface BadgeSlots {
default(): any
}
</script>
<script setup lang="ts">
import { Primitive } from 'radix-vue'
const props = withDefaults(defineProps<BadgeProps>(), { as: 'span' })
defineSlots<BadgeSlots>()
</script>
<template>
<Primitive :as="as" :class="badge({ color, variant, size, class: props.class })">
<slot>
{{ label }}
</slot>
</Primitive>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/button'
import type { LinkProps } from '#ui/components/Link.vue'
import type { UseComponentIconsProps } from '#ui/composables/useComponentIcons'
const appConfig = _appConfig as AppConfig & { ui: { button: Partial<typeof theme> } }
const button = tv({ extend: tv(theme), ...(appConfig.ui?.button || {}) })
type ButtonVariants = VariantProps<typeof button>
export interface ButtonProps extends UseComponentIconsProps, LinkProps {
label?: string
color?: ButtonVariants['color']
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
square?: boolean
block?: boolean
truncate?: boolean
class?: any
ui?: Partial<typeof button.slots>
}
export interface ButtonSlots {
leading(): any
default(): any
trailing(): any
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { useForwardProps } from 'radix-vue'
import { reactiveOmit } from '@vueuse/core'
import UIcon from '#ui/components/Icon.vue'
import { useComponentIcons } from '#ui/composables/useComponentIcons'
const props = defineProps<ButtonProps>()
const slots = defineSlots<ButtonSlots>()
const linkProps = useForwardProps(reactiveOmit(props, 'type', 'label', 'color', 'variant', 'size', 'icon', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'square', 'block', 'disabled', 'truncate', 'class', 'ui'))
// const { size, rounded } = useInjectButtonGroup({ ui, props })
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
const ui = computed(() => tv({ extend: button, slots: props.ui })({
color: props.color,
variant: props.variant,
size: props.size,
loading: props.loading,
truncate: props.truncate,
block: props.block,
square: props.square || (!slots.default && !props.label),
leading: isLeading.value,
trailing: isTrailing.value
}))
</script>
<template>
<ULink :type="type" :disabled="disabled || loading" :class="ui.base({ class: props.class })" v-bind="linkProps" raw>
<slot name="leading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon()" aria-hidden="true" />
</slot>
<span v-if="label || $slots.default" :class="ui.label()">
<slot>
{{ label }}
</slot>
</span>
<slot name="trailing">
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="ui.trailingIcon()" aria-hidden="true" />
</slot>
</ULink>
</template>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { PrimitiveProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/card'
const appConfig = _appConfig as AppConfig & { ui: { card: Partial<typeof theme> } }
const card = tv({ extend: tv(theme), ...(appConfig.ui?.card || {}) })
export interface CardProps extends Omit<PrimitiveProps, 'asChild'> {
class?: any
ui?: Partial<typeof card.slots>
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { Primitive } from 'radix-vue'
const props = withDefaults(defineProps<CardProps>(), { as: 'div' })
const ui = computed(() => tv({ extend: card, slots: props.ui })())
</script>
<template>
<Primitive :as="as" :class="ui.root({ class: props.class })">
<div v-if="$slots.header" :class="ui.header()">
<slot name="header" />
</div>
<div v-if="$slots.default" :class="ui.body()">
<slot />
</div>
<div v-if="$slots.footer" :class="ui.footer()">
<slot name="footer" />
</div>
</Primitive>
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { CheckboxRootProps, CheckboxRootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/checkbox'
import type { IconProps } from '#ui/components/Icon.vue'
const appConfig = _appConfig as AppConfig & { ui: { checkbox: Partial<typeof theme> } }
const checkbox = tv({ extend: tv(theme), ...(appConfig.ui?.checkbox || {}) })
type CheckboxVariants = VariantProps<typeof checkbox>
export interface CheckboxProps extends Omit<CheckboxRootProps, 'asChild'> {
id?: string
name?: string
description?: string
label?: string
color?: CheckboxVariants['color']
size?: CheckboxVariants['size']
icon?: IconProps['name']
indeterminateIcon?: IconProps['name']
indeterminate?: boolean
class?: any
ui?: Partial<typeof checkbox.slots>
}
export interface CheckboxEmits extends CheckboxRootEmits {}
export interface CheckboxSlots {
label(props: { label?: string }): any
description(props: { description?: string }): any
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { CheckboxRoot, CheckboxIndicator, Label, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useId } from '#imports'
import { useFormField } from '#ui/composables/useFormField'
import { useAppConfig } from '#app'
const props = defineProps<CheckboxProps>()
const emits = defineEmits<CheckboxEmits>()
defineSlots<CheckboxSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultChecked', 'disabled', 'required', 'name'), emits)
const appConfig = useAppConfig()
const { inputId: _inputId, emitFormChange, size, color, name, disabled } = useFormField<CheckboxProps>(props)
const inputId = _inputId.value ?? useId()
const modelValue = defineModel<boolean | undefined>({
default: undefined,
set (value) {
return value
}
})
const indeterminate = computed(() => (modelValue.value === undefined && props.indeterminate))
const checked = computed({
get () {
return indeterminate.value ? 'indeterminate' : modelValue.value
},
set (value) {
modelValue.value = value === 'indeterminate' ? undefined : value
}
})
function onChecked () {
emitFormChange()
}
const ui = computed(() => tv({ extend: checkbox, slots: props.ui })({
size: size.value,
color: color.value,
required: props.required,
disabled: disabled.value,
checked: modelValue.value ?? props.defaultChecked,
indeterminate: indeterminate.value
}))
</script>
<template>
<div :class="ui.root({ class: props.class })">
<div :class="ui.container()">
<CheckboxRoot
:id="inputId"
v-model:checked="checked"
v-bind="{ ...rootProps, name, disabled }"
:class="ui.base()"
@update:checked="onChecked"
>
<CheckboxIndicator :class="ui.indicator()">
<UIcon v-if="indeterminate" :name="indeterminateIcon || appConfig.ui.icons.minus" :class="ui.icon()" />
<UIcon v-else :name="icon || appConfig.ui.icons.check" :class="ui.icon()" />
</CheckboxIndicator>
</CheckboxRoot>
</div>
<div v-if="(label || $slots.label) || (description || $slots.description)" :class="ui.wrapper()">
<Label v-if="label || $slots.label" :for="inputId" :class="ui.label()">
<slot name="label" :label="label">
{{ label }}
</slot>
</Label>
<p v-if="description || $slots.description" :class="ui.description()">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { PrimitiveProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/chip'
const appConfig = _appConfig as AppConfig & { ui: { chip: Partial<typeof theme> } }
const chip = tv({ extend: tv(theme), ...(appConfig.ui?.chip || {}) })
type ChipVariants = VariantProps<typeof chip>
export interface ChipProps extends Omit<PrimitiveProps, 'asChild'> {
text?: string | number
inset?: boolean
color?: ChipVariants['color']
size?: ChipVariants['size']
position?: ChipVariants['position']
class?: any
ui?: Partial<typeof theme.slots>
}
export interface ChipSlots {
default(): any
content(): any
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { Primitive } from 'radix-vue'
const show = defineModel<boolean>('show', { default: true })
const props = withDefaults(defineProps<ChipProps>(), { as: 'div' })
defineSlots<ChipSlots>()
const ui = computed(() => tv({ extend: chip, slots: props.ui })({
color: props.color,
size: props.size,
position: props.position,
inset: props.inset
}))
</script>
<template>
<Primitive :as="as" :class="ui.root({ class: props.class })">
<slot />
<span v-if="show" :class="ui.base()">
<slot name="content">
{{ text }}
</slot>
</span>
</Primitive>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { CollapsibleRootProps, CollapsibleRootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/collapsible'
const appConfig = _appConfig as AppConfig & { ui: { collapsible: Partial<typeof theme> } }
const collapsible = tv({ extend: tv(theme), ...(appConfig.ui?.collapsible || {}) })
export interface CollapsibleProps extends Omit<CollapsibleRootProps, 'asChild'> {
class?: any
ui?: Partial<typeof collapsible.slots>
}
export interface CollapsibleEmits extends CollapsibleRootEmits {}
export interface CollapsibleSlots {
default(): any
content(): any
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
const props = defineProps<CollapsibleProps>()
const emits = defineEmits<CollapsibleEmits>()
defineSlots<CollapsibleSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'open', 'disabled'), emits)
const ui = computed(() => tv({ extend: collapsible, slots: props.ui })())
</script>
<template>
<CollapsibleRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
<CollapsibleTrigger v-if="$slots.default" as-child>
<slot />
</CollapsibleTrigger>
<CollapsibleContent :class="ui.content()">
<slot name="content" />
</CollapsibleContent>
</CollapsibleRoot>
</template>
<style>
@keyframes collapsible-down {
from {
height: 0;
}
to {
height: var(--radix-collapsible-content-height);
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
}
to {
height: 0;
}
}
</style>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { PrimitiveProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/container'
const appConfig = _appConfig as AppConfig & { ui: { container: Partial<typeof theme> } }
const container = tv({ extend: tv(theme), ...(appConfig.ui?.container || {}) })
export interface ContainerProps extends Omit<PrimitiveProps, 'asChild'> {
class?: any
}
</script>
<script setup lang="ts">
import { Primitive } from 'radix-vue'
const props = withDefaults(defineProps<ContainerProps>(), { as: 'div' })
</script>
<template>
<Primitive :as="as" :class="container({ class: props.class })">
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,247 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/form'
import { getYupErrors, isYupSchema, getValibotError, isValibotSchema, getZodErrors, isZodSchema, getJoiErrors, isJoiSchema } from '#ui/utils/form'
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, FormInjectedOptions, Form, FormErrorWithId } from '#ui/types/form'
const appConfig = _appConfig as AppConfig & { ui: { form: Partial<typeof theme> } }
const form = tv({ extend: tv(theme), ...(appConfig.ui?.form || {}) })
export interface FormProps<T extends object> {
id?: string | number
schema?: FormSchema<T>
state: Partial<T>
validate?: (state: Partial<T>) => Promise<FormError[] | void>
validateOn?: FormInputEvents[]
disabled?: boolean
validateOnInputDelay?: number
class?: any
}
export interface FormEmits<T extends object> {
(e: 'submit', payload: FormSubmitEvent<T>): void
(e: 'error', payload: FormErrorEvent): void
}
export interface FormSlots {
default(): any
}
export class FormValidationException extends Error {
formId: string | number
errors: FormErrorWithId[]
childrens: FormValidationException[]
constructor (formId: string | number, errors: FormErrorWithId[], childErrors: FormValidationException[]) {
super('Form validation exception')
this.formId = formId
this.errors = errors
this.childrens = childErrors
Object.setPrototypeOf(this, FormValidationException.prototype)
}
}
</script>
<script lang="ts" setup generic="T extends object">
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed } from 'vue'
import { useEventBus, type UseEventBusReturn } from '@vueuse/core'
import { useId } from '#imports'
const props = withDefaults(defineProps<FormProps<T>>(), {
validateOn () {
return ['input', 'blur', 'change'] as FormInputEvents[]
},
validateOnInputDelay: 300
})
const emit = defineEmits<FormEmits<T>>()
defineSlots<FormSlots>()
const formId = props.id ?? useId()
const bus = useEventBus<FormEvent>(`form-${formId}`)
const parentBus = inject<UseEventBusReturn<FormEvent, string> | undefined>(
'form-events',
undefined
)
provide('form-events', bus)
const nestedForms = ref<Map<string | number, { validate: () => any }>>(new Map())
onMounted(async () => {
bus.on(async (event) => {
if (event.type === 'attach') {
nestedForms.value.set(event.formId, { validate: event.validate })
} else if (event.type === 'detach') {
nestedForms.value.delete(event.formId)
} else if (props.validateOn?.includes(event.type as FormInputEvents)) {
await _validate({ name: event.name, silent: true, nested: false })
}
})
})
onUnmounted(() => {
bus.reset()
})
onMounted(async () => {
if (parentBus) {
await nextTick()
parentBus.emit({ type: 'attach', validate: _validate, formId })
}
})
onUnmounted(() => {
if (parentBus) {
parentBus.emit({ type: 'detach', formId })
}
})
const options = {
disabled: computed(() => props.disabled),
validateOnInputDelay: computed(() => props.validateOnInputDelay)
}
provide<FormInjectedOptions>('form-options', options)
const errors = ref<FormErrorWithId[]>([])
provide('form-errors', errors)
const inputs = ref<Record<string, string>>({})
provide('form-inputs', inputs)
function resolveErrorIds (errs: FormError[]): FormErrorWithId[] {
return errs.map((err) => ({
...err,
id: inputs.value[err.name]
}))
}
async function getErrors (): Promise<FormErrorWithId[]> {
let errs = props.validate ? (await props.validate(props.state)) ?? [] : []
if (props.schema) {
if (isZodSchema(props.schema)) {
errs = errs.concat(await getZodErrors(props.state, props.schema))
} else if (isYupSchema(props.schema)) {
errs = errs.concat(await getYupErrors(props.state, props.schema))
} else if (isJoiSchema(props.schema)) {
errs = errs.concat(await getJoiErrors(props.state, props.schema))
} else if (isValibotSchema(props.schema)) {
errs = errs.concat(await getValibotError(props.state, props.schema))
} else {
throw new Error('Form validation failed: Unsupported form schema')
}
}
return resolveErrorIds(errs)
}
async function _validate (
opts: { name?: string | string[], silent?: boolean, nested?: boolean } = { silent: false, nested: true }
): Promise<T | false> {
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name
const nestedValidatePromises = !names && opts.nested ? Array.from(nestedForms.value.values()).map(
({ validate }) => validate().then(() => undefined).catch((error: Error) => {
if (!(error instanceof FormValidationException)) {
throw error
}
return error
})
) : []
if (names) {
const otherErrors = errors.value.filter(
(error) => !names!.includes(error.name)
)
const pathErrors = (await getErrors()).filter((error) =>
names!.includes(error.name)
)
errors.value = otherErrors.concat(pathErrors)
} else {
errors.value = await getErrors()
}
const childErrors = nestedValidatePromises ? await Promise.all(nestedValidatePromises) : []
if (errors.value.length + childErrors.length > 0) {
if (opts.silent) return false
throw new FormValidationException(formId, errors.value, childErrors)
}
return props.state as T
}
async function onSubmit (payload: Event) {
const event = payload as SubmitEvent
try {
await _validate({ nested: true })
const submitEvent: FormSubmitEvent<any> = {
...event,
data: props.state
}
emit('submit', submitEvent)
} catch (error) {
if (!(error instanceof FormValidationException)) {
throw error
}
const errorEvent: FormErrorEvent = {
...event,
errors: error.errors,
childrens: error.childrens
}
emit('error', errorEvent)
}
}
defineExpose<Form<T>>({
validate: _validate,
errors,
setErrors (errs: FormError[], name?: string) {
if (name) {
errors.value = errors.value
.filter((error) => error.name !== name)
.concat(resolveErrorIds(errs))
} else {
errors.value = resolveErrorIds(errs)
}
},
async submit () {
await onSubmit(new Event('submit'))
},
getErrors (name?: string) {
if (name) {
return errors.value.filter((err) => err.name === name)
}
return errors.value
},
clear (name?: string) {
if (name) {
errors.value = errors.value.filter((err) => err.name !== name)
} else {
errors.value = []
}
},
...options
})
</script>
<template>
<component
:is="parentBus ? 'div' : 'form'"
:id="formId"
:class="form({ class: props.class })"
@submit.prevent="onSubmit"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/formField'
const appConfig = _appConfig as AppConfig & { ui: { formField: Partial<typeof theme> } }
const formField = tv({ extend: tv(theme), ...(appConfig.ui?.formField || {}) })
type FormFieldVariants = VariantProps<typeof formField>
export interface FormFieldProps {
name?: string
label?: string
description?: string
help?: string
error?: string
hint?: string
size?: FormFieldVariants['size']
required?: boolean
eagerValidation?: boolean
validateOnInputDelay?: number
class?: any
ui?: Partial<typeof formField.slots>
}
export interface FormFieldSlots {
label(props: { label?: string }): any
hint(props: { hint?: string }): any
description(props: { description?: string }): any
error(props: { error?: string }): any
help(props: { help?: string }): any
default(props: { error?: string }): any
}
</script>
<script lang="ts" setup>
import { computed, ref, inject, provide, type Ref } from 'vue'
import { Label } from 'radix-vue'
import type { FormError, FormFieldInjectedOptions } from '#ui/types/form'
import { useId } from '#imports'
const props = defineProps<FormFieldProps>()
defineSlots<FormFieldSlots>()
const ui = computed(() => tv({ extend: formField, slots: props.ui })({
size: props.size,
required: props.required
}))
const formErrors = inject<Ref<FormError[]> | null>('form-errors', null)
const error = computed(() => {
return (props.error && typeof props.error === 'string') ||
typeof props.error === 'boolean'
? props.error
: formErrors?.value?.find((error) => error.name === props.name)?.message
})
const inputId = ref(useId())
provide<FormFieldInjectedOptions<FormFieldProps>>('form-field', {
error,
inputId,
name: computed(() => props.name),
size: computed(() => props.size),
eagerValidation: computed(() => props.eagerValidation),
validateOnInputDelay: computed(() => props.validateOnInputDelay)
})
</script>
<template>
<div :class="ui.root({ class: props.class })">
<div :class="ui.wrapper()">
<div v-if="label || $slots.label" :class="ui.labelWrapper()">
<Label :for="inputId" :class="ui.label()">
<slot name="label" :label="label">
{{ label }}
</slot>
</Label>
<span v-if="hint || $slots.hint" :class="ui.hint()">
<slot name="hint" :hint="hint">
{{ hint }}
</slot>
</span>
</div>
<p v-if="description || $slots.description" :class="ui.description()">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</div>
<div :class="label ? ui.container() : ''">
<slot :error="error" />
<p v-if="(typeof error === 'string' && error) || $slots.error" :class="ui.error()">
<slot name="error" :error="error">
{{ error }}
</slot>
</p>
<p v-else-if="help || $slots.help" :class="ui.help()">
<slot name="help" :help="help">
{{ help }}
</slot>
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
export interface IconProps {
name: string
}
</script>
<script setup lang="ts">
defineProps<IconProps>()
</script>
<template>
<Icon :name="name" />
</template>

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import type { InputHTMLAttributes } from 'vue'
import { tv, type VariantProps } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/input'
import { looseToNumber } from '#ui/utils'
import type { UseComponentIconsProps } from '#ui/composables/useComponentIcons'
const appConfig = _appConfig as AppConfig & { ui: { input: Partial<typeof theme> } }
const input = tv({ extend: tv(theme), ...(appConfig.ui?.input || {}) })
type InputVariants = VariantProps<typeof input>
export interface InputProps extends UseComponentIconsProps {
id?: string
name?: string
type?: InputHTMLAttributes['type']
placeholder?: string
color?: InputVariants['color']
variant?: InputVariants['variant']
size?: InputVariants['size']
required?: boolean
autofocus?: boolean
autofocusDelay?: number
disabled?: boolean
class?: any
ui?: Partial<typeof input.slots>
}
export interface InputEmits {
(e: 'blur', event: FocusEvent): void
}
export interface InputSlots {
leading(): any
default(): any
trailing(): any
}
</script>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useFormField } from '#ui/composables/useFormField'
import { useComponentIcons } from '#ui/composables/useComponentIcons'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputProps>(), {
type: 'text',
autofocusDelay: 100
})
const [modelValue, modelModifiers] = defineModel<string | number>()
const emit = defineEmits<InputEmits>()
defineSlots<InputSlots>()
const { emitFormBlur, emitFormInput, size, color, inputId, name, disabled } = useFormField<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
// const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
// const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
const ui = computed(() => tv({ extend: input, slots: props.ui })({
color: color.value,
variant: props.variant,
size: size?.value,
loading: props.loading,
leading: isLeading.value,
trailing: isTrailing.value
}))
const inputRef = ref<HTMLInputElement | null>(null)
function autoFocus () {
if (props.autofocus) {
inputRef.value?.focus()
}
}
// Custom function to handle the v-model properties
function updateInput (value: string) {
if (modelModifiers.trim) {
value = value.trim()
}
if (modelModifiers.number || props.type === 'number') {
value = looseToNumber(value)
}
modelValue.value = value
emitFormInput()
}
function onInput (event: Event) {
if (!modelModifiers.lazy) {
updateInput((event.target as HTMLInputElement).value)
}
}
function onChange (event: Event) {
const value = (event.target as HTMLInputElement).value
if (modelModifiers.lazy) {
updateInput(value)
}
// Update trimmed input so that it has same behavior as native input https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
if (modelModifiers.trim) {
(event.target as HTMLInputElement).value = value.trim()
}
}
function onBlur (event: FocusEvent) {
emitFormBlur()
emit('blur', event)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
</script>
<template>
<div :class="ui.root({ class: props.class })">
<input
:id="inputId"
ref="inputRef"
:type="type"
:value="modelValue"
:name="name"
:placeholder="placeholder"
:class="ui.base()"
:disabled="disabled"
v-bind="$attrs"
@input="onInput"
@blur="onBlur"
@change="onChange"
>
<slot />
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="ui.leading()">
<slot name="leading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon()" />
</slot>
</span>
<span v-if="(isTrailing && trailingIconName) || $slots.trailing" :class="ui.trailing()">
<slot name="trailing">
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="ui.trailingIcon()" />
</slot>
</span>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { PrimitiveProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/kbd'
const appConfig = _appConfig as AppConfig & { ui: { kbd: Partial<typeof theme> } }
const kbd = tv({ extend: tv(theme), ...(appConfig.ui?.kbd || {}) })
type KbdVariants = VariantProps<typeof kbd>
export interface KbdProps extends Omit<PrimitiveProps, 'asChild'> {
value?: string
size?: KbdVariants['size']
class?: any
}
export interface KbdSlots {
default(): any
}
</script>
<script setup lang="ts">
import { Primitive } from 'radix-vue'
const props = withDefaults(defineProps<KbdProps>(), { as: 'kbd' })
defineSlots<KbdSlots>()
</script>
<template>
<Primitive :as="as" :class="kbd({ size, class: props.class })">
<slot>
{{ value }}
</slot>
</Primitive>
</template>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import type { ButtonHTMLAttributes } from 'vue'
import { tv } from 'tailwind-variants'
import type { PrimitiveProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/link'
import type { NuxtLinkProps } from '#app'
const appConfig = _appConfig as AppConfig & { ui: { link: Partial<typeof theme> } }
const link = tv({ extend: tv(theme), ...(appConfig.ui?.link || {}) })
export interface LinkProps extends NuxtLinkProps, Omit<PrimitiveProps, 'asChild'> {
type?: ButtonHTMLAttributes['type']
disabled?: boolean
active?: boolean
exact?: boolean
exactQuery?: boolean
exactHash?: boolean
inactiveClass?: string
custom?: boolean
raw?: boolean
class?: 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 { useRoute } from '#imports'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<LinkProps>(), {
as: 'button',
type: 'button',
active: undefined,
activeClass: '',
inactiveClass: ''
})
const route = useRoute()
const nuxtLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'exactQuery', 'exactHash', 'activeClass', 'inactiveClass'))
const ui = computed(() => tv({
extend: link,
variants: {
active: {
true: props.activeClass,
false: props.inactiveClass
}
}
}))
function isLinkActive (slotProps: any) {
if (props.active !== undefined) {
return props.active
}
if (props.exactQuery && !isEqual(slotProps.route.query, route.query)) {
return false
}
if (props.exactHash && slotProps.route.hash !== route.hash) {
return false
}
if (props.exact && slotProps.isExactActive) {
return true
}
if (!props.exact && slotProps.isActive) {
return true
}
return false
}
function resolveLinkClass (slotProps: any) {
const active = isLinkActive(slotProps)
if (props.raw) {
return [props.class, active ? props.activeClass : props.inactiveClass]
}
return ui.value({ class: props.class, active, disabled: props.disabled })
}
</script>
<template>
<NuxtLink v-slot="slotProps" v-bind="nuxtLinkProps" custom>
<template v-if="custom">
<slot v-bind="{ ...$attrs, ...slotProps, as, type, disabled, active: isLinkActive(slotProps) }" />
</template>
<ULinkBase v-else v-bind="{ ...$attrs, ...slotProps, as, type, disabled }" :class="resolveLinkClass(slotProps)">
<slot v-bind="{ ...slotProps, as, type, disabled, active: isLinkActive(slotProps) }" />
</ULinkBase>
</NuxtLink>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { Primitive } from 'radix-vue'
const props = defineProps<{
as: string
type: string
disabled?: boolean
click?: (e: MouseEvent) => void
href?: string
navigate: (e: MouseEvent) => void
route?: object
rel?: string
target?: string
isExternal?: boolean
isActive: boolean
isExactActive: boolean
}>()
function onClick (e: MouseEvent) {
if (props.disabled) {
e.stopPropagation()
e.preventDefault()
return
}
if (props.click) {
props.click(e)
}
if (props.href && !props.isExternal) {
props.navigate(e)
}
}
</script>
<template>
<Primitive
v-bind="href ? {
as: 'a',
href: disabled ? undefined : href,
'aria-disabled': disabled ? 'true' : undefined,
role: disabled ? 'link' : undefined
} : {
as,
type,
disabled
}"
:rel="rel"
:target="target"
@click="onClick"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { DialogRootProps, DialogRootEmits, DialogContentProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/modal'
import type { ButtonProps } from '#ui/components/Button.vue'
const appConfig = _appConfig as AppConfig & { ui: { modal: Partial<typeof theme> } }
const modal = tv({ extend: tv(theme), ...(appConfig.ui?.modal || {}) })
export interface ModalProps extends DialogRootProps {
title?: string
description?: string
content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'>
overlay?: boolean
transition?: boolean
fullscreen?: boolean
preventClose?: boolean
portal?: boolean
close?: ButtonProps | null
class?: any
ui?: Partial<typeof modal.slots>
}
export interface ModalEmits extends DialogRootEmits {}
export interface ModalSlots {
default(): any
content(): any
header(): any
title(): any
description(): any
close(): any
body(): any
footer(): any
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#app'
import UButton from '#ui/components/Button.vue'
const props = withDefaults(defineProps<ModalProps>(), {
portal: true,
overlay: true,
transition: true
})
const emits = defineEmits<ModalEmits>()
defineSlots<ModalSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
if (props.preventClose) {
return {
'pointerDownOutside': (e: Event) => e.preventDefault(),
'interactOutside': (e: Event) => e.preventDefault()
}
}
return {}
})
const appConfig = useAppConfig()
const ui = computed(() => tv({ extend: modal, slots: props.ui })({
transition: props.transition,
fullscreen: props.fullscreen
}))
</script>
<template>
<DialogRoot v-bind="rootProps">
<DialogTrigger v-if="$slots.default" as-child>
<slot />
</DialogTrigger>
<DialogPortal :disabled="!portal">
<DialogOverlay v-if="overlay" :class="ui.overlay()" />
<DialogContent :class="ui.content({ class: props.class })" v-bind="contentProps" v-on="contentEvents">
<slot name="content">
<div :class="ui.header()">
<slot name="header">
<DialogTitle v-if="title || $slots.title" :class="ui.title()">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || $slots.description" :class="ui.description()">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
<DialogClose as-child>
<slot name="close" :class="ui.close()">
<UButton
v-if="close !== null"
:icon="appConfig.ui.icons.close"
size="sm"
color="gray"
variant="ghost"
aria-label="Close"
v-bind="close"
:class="ui.close()"
/>
</slot>
</DialogClose>
</slot>
</div>
<div v-if="$slots.body" :class="ui.body()">
<slot name="body" />
</div>
<div v-if="$slots.footer" :class="ui.footer()">
<slot name="footer" />
</div>
</slot>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<style>
@keyframes modal-overlay-open {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-overlay-closed {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes modal-content-open {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes modal-content-closed {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
</style>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { NavigationMenuRootProps, NavigationMenuRootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/navigationMenu'
import type { LinkProps } from '#ui/components/Link.vue'
import type { AvatarProps } from '#ui/components/Avatar.vue'
import type { BadgeProps } from '#ui/components/Badge.vue'
import type { IconProps } from '#ui/components/Icon.vue'
const appConfig = _appConfig as AppConfig & { ui: { navigationMenu: Partial<typeof theme> } }
const navigationMenu = tv({ extend: tv(theme), ...(appConfig.ui?.navigationMenu || {}) })
export interface NavigationMenuLink extends LinkProps {
label: string | number
icon?: IconProps['name']
avatar?: AvatarProps
badge?: string | number | BadgeProps
}
export interface NavigationMenuProps<T extends NavigationMenuLink> extends Omit<NavigationMenuRootProps, 'asChild' | 'dir'> {
links: T[][] | T[]
class?: any
ui?: Partial<typeof navigationMenu.slots>
}
export interface NavigationMenuEmits extends NavigationMenuRootEmits {}
type SlotProps<T> = (props: { link: T, active: boolean }) => any
export interface NavigationMenuSlots<T extends NavigationMenuLink> {
leading: SlotProps<T>
default: SlotProps<T>
trailing: SlotProps<T>
}
</script>
<script setup lang="ts" generic="T extends NavigationMenuLink">
import { computed } from 'vue'
import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuLink, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { UIcon, UAvatar, UBadge, ULink, ULinkBase } from '#components'
import { omit } from '#ui/utils'
const props = withDefaults(defineProps<NavigationMenuProps<T>>(), { orientation: 'horizontal' })
const emits = defineEmits<NavigationMenuEmits>()
defineSlots<NavigationMenuSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'delayDuration', 'skipDelayDuration', 'orientation'), emits)
const ui = computed(() => tv({ extend: navigationMenu, slots: props.ui })({ orientation: props.orientation }))
const lists = computed(() => props.links?.length ? (Array.isArray(props.links[0]) ? props.links : [props.links]) as T[][] : [])
</script>
<template>
<NavigationMenuRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
<NavigationMenuList v-for="(list, index) in lists" :key="`list-${index}`" :class="ui.list()">
<NavigationMenuItem v-for="(link, subIndex) in list" :key="`list-${index}-${subIndex}`" :class="ui.item()">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit(link, ['label', 'icon', 'avatar', 'badge'])" custom>
<NavigationMenuLink as-child :active="active">
<ULinkBase v-bind="slotProps" :class="ui.base({ active })">
<slot name="leading" :link="link" :active="active">
<UAvatar v-if="link.avatar" size="2xs" v-bind="link.avatar" :class="ui.avatar({ active })" />
<UIcon v-else-if="link.icon" :name="link.icon" :class="ui.icon({ active })" />
</slot>
<span v-if="link.label || $slots.default" :class="ui.label()">
<slot :link="link" :active="active">
{{ link.label }}
</slot>
</span>
<slot name="trailing" :link="link" :active="active">
<UBadge
v-if="link.badge"
color="gray"
variant="solid"
size="xs"
v-bind="(typeof link.badge === 'string' || typeof link.badge === 'number') ? { label: link.badge } : link.badge"
:class="ui.badge()"
/>
</slot>
</ULinkBase>
</NavigationMenuLink>
</ULink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenuRoot>
</template>

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { PopoverRootProps, HoverCardRootProps, PopoverRootEmits, PopoverContentProps, PopoverArrowProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/popover'
const appConfig = _appConfig as AppConfig & { ui: { popover: Partial<typeof theme> } }
const popover = tv({ extend: tv(theme), ...(appConfig.ui?.popover || {}) })
export interface PopoverProps extends PopoverRootProps, Pick<HoverCardRootProps, 'openDelay' | 'closeDelay'>{
/**
* The mode of the popover.
* @defaultValue "click"
*/
mode?: 'click' | 'hover'
content?: Omit<PopoverContentProps, 'as' | 'asChild' | 'forceMount'>
arrow?: boolean | Omit<PopoverArrowProps, 'as' | 'asChild'>
portal?: boolean
class?: any
ui?: Partial<typeof popover.slots>
}
export interface PopoverEmits extends PopoverRootEmits {}
export interface PopoverSlots {
default(): any
content(): any
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { defu } from 'defu'
import { useForwardPropsEmits } from 'radix-vue'
import { Popover, HoverCard } from 'radix-vue/namespaced'
import { reactivePick } from '@vueuse/core'
const props = withDefaults(defineProps<PopoverProps>(), {
mode: 'click',
openDelay: 0,
closeDelay: 0
})
const emits = defineEmits<PopoverEmits>()
defineSlots<PopoverSlots>()
const pick = props.mode === 'hover' ? reactivePick(props, 'defaultOpen', 'open', 'openDelay', 'closeDelay') : reactivePick(props, 'defaultOpen', 'open', 'modal')
const rootProps = useForwardPropsEmits(pick, emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8 }) as PopoverContentProps)
const arrowProps = toRef(() => props.arrow as PopoverArrowProps)
const ui = computed(() => tv({ extend: popover, slots: props.ui })())
const Component = computed(() => props.mode === 'hover' ? HoverCard : Popover)
</script>
<template>
<Component.Root v-bind="rootProps">
<Component.Trigger v-if="$slots.default" as-child>
<slot />
</Component.Trigger>
<Component.Portal :disabled="!portal">
<Component.Content v-bind="contentProps" :class="ui.content({ class: props.class })">
<slot name="content" />
<Component.Arrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow()" />
</Component.Content>
</Component.Portal>
</Component.Root>
</template>
<style>
@keyframes popover-down-open {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes popover-down-closed {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-0.25rem);
}
}
@keyframes popover-right-open {
from {
opacity: 0;
transform: translateX(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes popover-right-closed {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateX(-0.25rem);
}
}
@keyframes popover-up-open {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes popover-up-closed {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(0.25rem);
}
}
@keyframes popover-left-open {
from {
opacity: 0;
transform: translateX(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes popover-left-closed {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateX(0.25rem);
}
}
</style>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import type { ConfigProviderProps, ToastProviderProps, TooltipProviderProps } from 'radix-vue'
export interface ProviderProps extends ConfigProviderProps {
tooltip?: TooltipProviderProps
toast?: ToastProviderProps
}
</script>
<script setup lang="ts">
import { toRef } from 'vue'
import { ConfigProvider, ToastProvider, TooltipProvider, useForwardProps } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useId } from '#imports'
const props = withDefaults(defineProps<ProviderProps>(), {
useId: () => useId()
})
const configProps = useForwardProps(reactivePick(props, 'dir', 'scrollBody', 'useId'))
const tooltipProps = toRef(() => props.tooltip as TooltipProviderProps)
const toastProps = toRef(() => props.toast as ToastProviderProps)
</script>
<template>
<ConfigProvider v-bind="configProps">
<TooltipProvider v-bind="tooltipProps">
<ToastProvider v-bind="toastProps">
<slot />
</ToastProvider>
</TooltipProvider>
</ConfigProvider>
</template>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { PrimitiveProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/skeleton'
const appConfig = _appConfig as AppConfig & { ui: { skeleton: Partial<typeof theme> } }
const skeleton = tv({ extend: tv(theme), ...(appConfig.ui?.skeleton || {}) })
export interface SkeletonProps extends Omit<PrimitiveProps, 'asChild'> {
class?: any
}
</script>
<script setup lang="ts">
import { Primitive } from 'radix-vue'
const props = withDefaults(defineProps<SkeletonProps>(), { as: 'div' })
</script>
<template>
<Primitive :as="as" :class="skeleton({ class: props.class })">
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,214 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { DialogRootProps, DialogRootEmits, DialogContentProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/slideover'
import type { ButtonProps } from '#ui/components/Button.vue'
const appConfig = _appConfig as AppConfig & { ui: { slideover: Partial<typeof theme> } }
const slideover = tv({ extend: tv(theme), ...(appConfig.ui?.slideover || {}) })
export interface SlideoverProps extends DialogRootProps {
title?: string
description?: string
content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'>
overlay?: boolean
transition?: boolean
side?: 'left' | 'right' | 'top' | 'bottom'
preventClose?: boolean
portal?: boolean
close?: ButtonProps | null
class?: any
ui?: Partial<typeof slideover.slots>
}
export interface SlideoverEmits extends DialogRootEmits {}
export interface SlideoverSlots {
default(): any
content(): any
header(): any
title(): any
description(): any
close(): any
body(): any
footer(): any
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#app'
import UButton from '#ui/components/Button.vue'
const props = withDefaults(defineProps<SlideoverProps>(), {
portal: true,
overlay: true,
transition: true,
side: 'right'
})
const emits = defineEmits<SlideoverEmits>()
defineSlots<SlideoverSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
if (props.preventClose) {
return {
'pointerDownOutside': (e: Event) => e.preventDefault(),
'interactOutside': (e: Event) => e.preventDefault()
}
}
return {}
})
const appConfig = useAppConfig()
const ui = computed(() => tv({ extend: slideover, slots: props.ui })({
transition: props.transition,
side: props.side
}))
</script>
<template>
<DialogRoot v-bind="rootProps">
<DialogTrigger v-if="$slots.default" as-child>
<slot />
</DialogTrigger>
<DialogPortal :disabled="!portal">
<DialogOverlay v-if="overlay" :class="ui.overlay()" />
<DialogContent :data-side="side" :class="ui.content({ class: props.class })" v-bind="contentProps" v-on="contentEvents">
<slot name="content">
<div :class="ui.header()">
<slot name="header">
<DialogTitle v-if="title || $slots.title" :class="ui.title()">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || $slots.description" :class="ui.description()">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
<DialogClose as-child>
<slot name="close" :class="ui.close()">
<UButton
v-if="close !== null"
:icon="appConfig.ui.icons.close"
size="sm"
color="gray"
variant="ghost"
aria-label="Close"
v-bind="close"
:class="ui.close()"
/>
</slot>
</DialogClose>
</slot>
</div>
<div :class="ui.body()">
<slot name="body" />
</div>
<div v-if="$slots.footer" :class="ui.footer()">
<slot name="footer" />
</div>
</slot>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<style>
@keyframes slideover-overlay-open {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideover-overlay-closed {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideover-content-right-open {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideover-content-right-closed {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@keyframes slideover-content-left-open {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideover-content-left-closed {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
@keyframes slideover-content-top-open {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
@keyframes slideover-content-top-closed {
from {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
@keyframes slideover-content-bottom-open {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes slideover-content-bottom-closed {
from {
transform: translateY(0);
}
to {
transform: translateY(100%);
}
}
</style>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { SwitchRootProps, SwitchRootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/switch'
import type { IconProps } from '#ui/components/Icon.vue'
const appConfig = _appConfig as AppConfig & { ui: { switch: Partial<typeof theme> } }
const switchTv = tv({ extend: tv(theme), ...(appConfig.ui?.switch || {}) })
type SwitchVariants = VariantProps<typeof switchTv>
export interface SwitchProps extends Omit<SwitchRootProps, 'asChild'> {
color?: SwitchVariants['color']
size?: SwitchVariants['size']
loading?: boolean
loadingIcon?: IconProps['name']
checkedIcon?: IconProps['name']
uncheckedIcon?: IconProps['name']
class?: any
ui?: Partial<typeof switchTv.slots>
}
export interface SwitchEmits extends SwitchRootEmits {}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#app'
const props = defineProps<SwitchProps>()
const emits = defineEmits<SwitchEmits>()
const appConfig = useAppConfig()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultChecked', 'checked', 'required', 'name', 'id', 'value'), emits)
const ui = computed(() => tv({ extend: switchTv, slots: props.ui })({
color: props.color,
size: props.size,
loading: props.loading
}))
</script>
<template>
<SwitchRoot :disabled="disabled || loading" v-bind="rootProps" :class="ui.root({ class: props.class })">
<SwitchThumb :class="ui.thumb()">
<UIcon v-if="loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.icon({ checked: true, unchecked: true })" />
<template v-else>
<UIcon v-if="checkedIcon" :name="checkedIcon" :class="ui.icon({ checked: true })" />
<UIcon v-if="uncheckedIcon" :name="uncheckedIcon" :class="ui.icon({ unchecked: true })" />
</template>
</SwitchThumb>
</SwitchRoot>
</template>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { TabsRootProps, TabsRootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/tabs'
const appConfig = _appConfig as AppConfig & { ui: { tabs: Partial<typeof theme> } }
const tabs = tv({ extend: tv(theme), ...(appConfig.ui?.tabs || {}) })
export interface TabsItem {
label?: string
value?: string
slot?: string
disabled?: boolean
content?: string
}
export interface TabsProps<T extends TabsItem> extends Omit<TabsRootProps, 'asChild'> {
items: T[]
class?: any
ui?: Partial<typeof tabs.slots>
}
export interface TabsEmits extends TabsRootEmits {}
type SlotProps<T> = (props: { item: T, index: number }) => any
export type TabsSlots<T extends TabsItem> = {
default: SlotProps<T>
content: SlotProps<T>
} & {
[key in T['slot'] as string]?: SlotProps<T>
}
</script>
<script setup lang="ts" generic="T extends TabsItem">
import { computed } from 'vue'
import { TabsRoot, TabsList, TabsIndicator, TabsTrigger, TabsContent, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
const props = withDefaults(defineProps<TabsProps<T>>(), { defaultValue: '0' })
const emits = defineEmits<TabsEmits>()
defineSlots<TabsSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultValue', 'orientation', 'activationMode', 'modelValue'), emits)
const ui = computed(() => tv({ extend: tabs, slots: props.ui })())
</script>
<template>
<TabsRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
<TabsList :class="ui.list()">
<TabsIndicator :class="ui.indicator()" />
<TabsTrigger v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :disabled="item.disabled" :class="ui.trigger()">
<span v-if="item.label || $slots.default" :class="ui.label()">
<slot :item="item" :index="index">{{ item.label }}</slot>
</span>
</TabsTrigger>
</TabsList>
<TabsContent v-for="(item, index) of items" :key="index" force-mount :value="item.value || String(index)" :class="ui.content()">
<slot :name="item.slot || 'content'" :item="item" :index="index">
{{ item.content }}
</slot>
</TabsContent>
</TabsRoot>
</template>

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/textarea'
import { looseToNumber } from '#ui/utils'
const appConfig = _appConfig as AppConfig & { ui: { textarea: Partial<typeof theme> } }
const textarea = tv({ extend: tv(theme), ...(appConfig.ui?.textarea || {}) })
type TextareaVariants = VariantProps<typeof textarea>
export interface TextareaProps {
id?: string
name?: string
placeholder?: string
color?: TextareaVariants['color']
variant?: TextareaVariants['variant']
size?: TextareaVariants['size']
required?: boolean
autofocus?: boolean
autofocusDelay?: number
disabled?: boolean
class?: any
rows?: number
maxrows?: number
autoresize?: boolean
ui?: Partial<typeof textarea.slots>
}
export interface TextareaEmits {
(e: 'blur', event: FocusEvent): void
}
export interface TextareaSlots {
default(): any
}
</script>
<script lang="ts" setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useFormField } from '#ui/composables/useFormField'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<TextareaProps>(), {
rows: 3,
maxrows: 0,
autofocusDelay: 100
})
const emit = defineEmits<TextareaEmits>()
defineSlots<TextareaSlots>()
const [modelValue, modelModifiers] = defineModel<string | number>()
const { emitFormBlur, emitFormInput, size, color, inputId, name, disabled } = useFormField<TextareaProps>(props)
const ui = computed(() => tv({ extend: textarea, slots: props.ui })({
color: color.value,
variant: props.variant,
size: size?.value
}))
const textareaRef = ref<HTMLTextAreaElement | null>(null)
function autoFocus () {
if (props.autofocus) {
textareaRef.value?.focus()
}
}
// Custom function to handle the v-model properties
function updateInput (value: string) {
if (modelModifiers.trim) {
value = value.trim()
}
if (modelModifiers.number) {
value = looseToNumber(value)
}
modelValue.value = value
emitFormInput()
}
function onInput (event: Event) {
autoResize()
if (!modelModifiers.lazy) {
updateInput((event.target as HTMLInputElement).value)
}
}
function onChange (event: Event) {
const value = (event.target as HTMLInputElement).value
if (modelModifiers.lazy) {
updateInput(value)
}
// Update trimmed textarea so that it has same behavior as native textarea https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
if (modelModifiers.trim) {
(event.target as HTMLInputElement).value = value.trim()
}
}
function onBlur (event: FocusEvent) {
emitFormBlur()
emit('blur', event)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
function autoResize () {
if (props.autoresize) {
if (!textareaRef.value) {
return
}
textareaRef.value.rows = props.rows
const styles = window.getComputedStyle(textareaRef.value)
const paddingTop = parseInt(styles.paddingTop)
const paddingBottom = parseInt(styles.paddingBottom)
const padding = paddingTop + paddingBottom
const lineHeight = parseInt(styles.lineHeight)
const { scrollHeight } = textareaRef.value
const newRows = (scrollHeight - padding) / lineHeight
if (newRows > props.rows) {
textareaRef.value.rows = props.maxrows ? Math.min(newRows, props.maxrows) : newRows
}
}
}
watch(() => modelValue, () => {
nextTick(autoResize)
})
onMounted(() => {
setTimeout(() => {
autoResize()
}, 100)
})
</script>
<template>
<div :class="ui.root({ class: props.class })">
<textarea
:id="inputId"
ref="textareaRef"
:value="modelValue"
:name="name"
:rows="rows"
:placeholder="placeholder"
:class="ui.base()"
:disabled="disabled"
:required="required"
v-bind="$attrs"
@input="onInput"
@blur="onBlur"
@change="onChange"
/>
<slot />
</div>
</template>

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { TooltipRootProps, TooltipRootEmits, TooltipContentProps, TooltipArrowProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/tooltip'
import type { KbdProps } from '#ui/components/Kbd.vue'
const appConfig = _appConfig as AppConfig & { ui: { tooltip: Partial<typeof theme> } }
const tooltip = tv({ extend: tv(theme), ...(appConfig.ui?.tooltip || {}) })
export interface TooltipProps extends TooltipRootProps {
text?: string
shortcuts?: string[] | KbdProps[]
content?: Omit<TooltipContentProps, 'as' | 'asChild'>
arrow?: boolean | Omit<TooltipArrowProps, 'as' | 'asChild'>
portal?: boolean
class?: any
ui?: Partial<typeof tooltip.slots>
}
export interface TooltipEmits extends TooltipRootEmits {}
export interface TooltipSlots {
default(): any
content(): any
text(): any
shortcuts(): any
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { defu } from 'defu'
import { TooltipRoot, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import UKbd from '#ui/components/Kbd.vue'
const props = defineProps<TooltipProps>()
const emits = defineEmits<TooltipEmits>()
defineSlots<TooltipSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultOpen', 'open', 'delayDuration'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8 }) as TooltipContentProps)
const arrowProps = toRef(() => props.arrow as TooltipArrowProps)
const ui = computed(() => tv({ extend: tooltip, slots: props.ui })())
</script>
<template>
<TooltipRoot v-bind="rootProps">
<TooltipTrigger v-if="$slots.default" as-child>
<slot />
</TooltipTrigger>
<TooltipPortal :disabled="!portal">
<TooltipContent v-bind="contentProps" :class="ui.content({ class: props.class })">
<slot name="content">
<span v-if="text" :class="ui.text()">
<slot name="text">{{ text }}</slot>
</span>
<span v-if="shortcuts?.length" :class="ui.shortcuts()">
<slot name="shortcuts">
<UKbd v-for="(shortcut, index) in shortcuts" :key="index" size="xs" v-bind="typeof shortcut === 'string' ? { value: shortcut } : shortcut" />
</slot>
</span>
</slot>
<TooltipArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow()" />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</template>
<style>
@keyframes tooltip-down {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tooltip-right {
from {
opacity: 0;
transform: translateX(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tooltip-up {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tooltip-left {
from {
opacity: 0;
transform: translateX(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,42 @@
import { computed } from 'vue'
import { useAppConfig } from '#app'
import type { IconProps } from '#ui/components/Icon.vue'
export interface UseComponentIconsProps {
icon?: IconProps['name']
leading?: boolean
leadingIcon?: IconProps['name']
trailing?: boolean
trailingIcon?: IconProps['name']
loading?: boolean
loadingIcon?: IconProps['name']
}
export function useComponentIcons (props: UseComponentIconsProps) {
const appConfig = useAppConfig()
const isLeading = computed(() => (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing && !props.trailingIcon) || !!props.leadingIcon)
const isTrailing = computed(() => (props.icon && props.trailing) || (props.loading && props.trailing) || !!props.trailingIcon)
const leadingIconName = computed(() => {
if (props.loading) {
return props.loadingIcon || appConfig.ui.icons.loading
}
return props.leadingIcon || props.icon
})
const trailingIconName = computed(() => {
if (props.loading && !isLeading.value) {
return props.loadingIcon || appConfig.ui.icons.loading
}
return props.trailingIcon || props.icon
})
return {
isLeading,
isTrailing,
leadingIconName,
trailingIconName
}
}

View File

@@ -0,0 +1,70 @@
import { inject, ref, computed } from 'vue'
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
import type { FormEvent, FormInputEvents, FormFieldInjectedOptions, FormInjectedOptions } from '#ui/types/form'
type Props<T> = {
id?: string
name?: string
// @ts-ignore FIXME: TS doesn't like this
size?: T['size']
// @ts-ignore FIXME: TS doesn't like this
color?: T['color']
eagerValidation?: boolean
legend?: string
disabled?: boolean
}
export function useFormField <T> (inputProps?: Props<T>) {
const formOptions = inject<FormInjectedOptions | undefined>('form-options', undefined)
const formBus = inject<UseEventBusReturn<FormEvent, string> | undefined>('form-events', undefined)
const formField = inject<FormFieldInjectedOptions<T> | undefined>('form-field', undefined)
const formInputs = inject<any>('form-inputs', undefined)
if (formField) {
if (inputProps?.id) {
// Updates for="..." attribute on label if inputProps.id is provided
formField.inputId.value = inputProps?.id
}
if (formInputs && formField.name.value) {
formInputs.value[formField.name.value] = formField.inputId.value
}
}
const blurred = ref(false)
function emitFormEvent (type: FormInputEvents, name: string) {
if (formBus && formField) {
formBus.emit({ type, name })
}
}
function emitFormBlur () {
emitFormEvent('blur', formField?.name.value as string)
blurred.value = true
}
function emitFormChange () {
emitFormEvent('change', formField?.name.value as string)
}
const emitFormInput = useDebounceFn(
() => {
if (blurred.value || formField?.eagerValidation.value) {
emitFormEvent('input', formField?.name.value as string)
}
},
formField?.validateOnInputDelay.value ?? formOptions?.validateOnInputDelay?.value ?? 0
)
return {
inputId: computed(() => inputProps?.id ?? formField?.inputId.value),
name: computed(() => inputProps?.name ?? formField?.name.value),
size: computed(() => inputProps?.size ?? formField?.size?.value),
color: computed(() => formField?.error?.value ? 'red' : inputProps?.color),
disabled: computed(() => formOptions?.disabled?.value || inputProps?.disabled),
emitFormBlur,
emitFormInput,
emitFormChange
}
}

View File

@@ -0,0 +1,46 @@
import { computed } from 'vue'
import { defineNuxtPlugin, useAppConfig, useNuxtApp, useHead } from '#imports'
export default defineNuxtPlugin(() => {
const appConfig = useAppConfig()
const nuxtApp = useNuxtApp()
const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
const root = computed(() => {
return `:root {
${shades.map(shade => `--color-primary-${shade}: var(--color-${appConfig.ui.primary}-${shade});`).join('\n')}
--color-primary-DEFAULT: var(--color-primary-500);
${shades.map(shade => `--color-gray-${shade}: var(--color-${appConfig.ui.gray}-${shade});`).join('\n')}
}
.dark {
--color-primary-DEFAULT: var(--color-primary-400);
}
`
})
// Head
const headData: any = {
style: [{
innerHTML: () => root.value,
tagPriority: -2,
id: 'nuxt-ui-colors',
type: 'text/css'
}]
}
// SPA mode
if (import.meta.client && nuxtApp.isHydrating && !nuxtApp.payload.serverRendered) {
const style = document.createElement('style')
style.innerHTML = root.value
style.setAttribute('data-nuxt-ui-colors', '')
document.head.appendChild(style)
headData.script = [{
innerHTML: 'document.head.removeChild(document.querySelector(\'[data-nuxt-ui-colors]\'))'
}]
}
useHead(headData)
})

5
src/runtime/types/app.config.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '#build/app.config' {
import type { AppConfig } from '@nuxt/schema'
const _default: AppConfig
export default _default
}

View File

@@ -1,7 +1,25 @@
import type { Ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
export interface FormError<T extends string = string> {
path: T
export interface Form<T> {
validate (opts?: { name: string | string[], silent?: false, nested?: boolean }): Promise<T | false>
clear (path?: string): void
errors: Ref<FormError[]>
setErrors (errs: FormError[], path?: string): void
getErrors (path?: string): FormError[]
submit (): Promise<void>
disabled: ComputedRef<boolean>
}
export type FormSchema<T extends object> =
| ZodSchema
| YupObjectSchema<T>
| ValibotObjectSchema<T>
| JoiSchema<T>
export type FormInputEvents = 'input' | 'blur' | 'change'
export interface FormError<P extends string = string> {
name: P
message: string
}
@@ -9,30 +27,48 @@ export interface FormErrorWithId extends FormError {
id: string
}
export interface Form<T> {
validate(path?: string | string[], opts?: { silent?: true }): Promise<T | false>;
validate(path?: string | string[], opts?: { silent?: false }): Promise<T>;
clear(path?: string): void
errors: Ref<FormError[]>
setErrors(errs: FormError[], path?: string): void
getErrors(path?: string): FormError[]
submit(): Promise<void>
}
export type FormSubmitEvent<T> = SubmitEvent & { data: T }
export type FormErrorEvent = SubmitEvent & { errors: FormErrorWithId[] }
export type FormEventType = 'blur' | 'input' | 'change' | 'submit'
export type FormValidationError = {
errors: FormErrorWithId[]
childrens: FormValidationError[]
}
export interface FormEvent {
export type FormErrorEvent = SubmitEvent & FormValidationError
export type FormEventType = FormInputEvents
export type FormChildAttachEvent = {
type: 'attach'
formId: string | number
validate: Form<any>['validate']
}
export type FormChildDetachEvent = {
type: 'detach'
formId: string | number
}
export type FormInputEvent = {
type: FormEventType
path?: string
name?: string
}
export interface InjectedFormGroupValue {
inputId: Ref<string | undefined>
name: Ref<string>
size: Ref<string | number | symbol>
error: Ref<string | boolean | undefined>
eagerValidation: Ref<boolean>
export type FormEvent =
| FormInputEvent
| FormChildAttachEvent
| FormChildDetachEvent
export interface FormInjectedOptions {
disabled?: ComputedRef<boolean>
validateOnInputDelay?: ComputedRef<number>
}
export interface FormFieldInjectedOptions<T> {
inputId: Ref<string | undefined>
name: ComputedRef<string | undefined>
size: ComputedRef<T['size']>
error: ComputedRef<string | boolean | undefined>
eagerValidation: ComputedRef<boolean | undefined>
validateOnInputDelay: ComputedRef<number | undefined>
}

View File

@@ -1,31 +0,0 @@
export * from './accordion'
export * from './alert'
export * from './avatar'
export * from './badge'
export * from './breadcrumb'
export * from './button'
export * from './chip'
export * from './clipboard'
export * from './command-palette'
export * from './divider'
export * from './dropdown'
export * from './form-group'
export * from './form'
export * from './horizontal-navigation'
export * from './input'
export * from './kbd'
export * from './link'
export * from './meter'
export * from './modal'
export * from './slideover'
export * from './notification'
export * from './popper'
export * from './progress'
export * from './range'
export * from './select'
export * from './tabs'
export * from './textarea'
export * from './toggle'
export * from './tooltip'
export * from './vertical-navigation'
export * from './utils'

83
src/runtime/utils/form.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { ZodSchema } from 'zod'
import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi'
import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
import type { ObjectSchemaAsync as ValibotObjectSchema } from 'valibot'
import type { FormError } from '#ui/types/form'
export function isYupSchema (schema: any): schema is YupObjectSchema<any> {
return schema.validate && schema.__isYupSchema__
}
export function isYupError (error: any): error is YupError {
return error.inner !== undefined
}
export async function getYupErrors (state: any, schema: YupObjectSchema<any>): Promise<FormError[]> {
try {
await schema.validate(state, { abortEarly: false })
return []
} catch (error) {
if (isYupError(error)) {
return error.inner.map((issue) => ({
name: issue.path ?? '',
message: issue.message
}))
} else {
throw error
}
}
}
export function isZodSchema (schema: any): schema is ZodSchema {
return schema.parse !== undefined
}
export async function getZodErrors (state: any, schema: ZodSchema): Promise<FormError[]> {
const result = await schema.safeParseAsync(state)
if (result.success === false) {
return result.error.issues.map((issue) => ({
name: issue.path.join('.'),
message: issue.message
}))
}
return []
}
export function isJoiSchema (schema: any): schema is JoiSchema {
return schema.validateAsync !== undefined && schema.id !== undefined
}
export function isJoiError (error: any): error is JoiError {
return error.isJoi === true
}
export async function getJoiErrors (state: any, schema: JoiSchema): Promise<FormError[]> {
try {
await schema.validateAsync(state, { abortEarly: false })
return []
} catch (error) {
if (isJoiError(error)) {
return error.details.map((detail) => ({
name: detail.path.join('.'),
message: detail.message
}))
} else {
throw error
}
}
}
export function isValibotSchema (schema: any): schema is ValibotObjectSchema<any> {
return schema._parse !== undefined
}
export async function getValibotError (state: any, schema: ValibotObjectSchema<any>): Promise<FormError[]> {
const result = await schema._parse(state)
if (result.issues) {
return result.issues.map((issue) => ({
name: issue.path?.map((p) => p.key).join('.') || '',
message: issue.message
}))
}
return []
}

View File

@@ -1,86 +1,24 @@
import { defu, createDefu } from 'defu'
import { extendTailwindMerge } from 'tailwind-merge'
import type { Strategy } from '../types'
export function pick<Data extends object, Keys extends keyof Data> (data: Data, keys: Keys[]): Pick<Data, Keys> {
const result = {} as Pick<Data, Keys>
const customTwMerge = extendTailwindMerge<string, string>({
extend: {
classGroups: {
icons: [(classPart: string) => /^i-/.test(classPart)]
}
}
})
const defuTwMerge = createDefu((obj, key, value, namespace) => {
if (namespace === 'default' || namespace.startsWith('default.')) {
return false
}
if (namespace === 'popper' || namespace.startsWith('popper.')) {
return false
}
if (namespace.endsWith('avatar') && key === 'size') {
return false
}
if (namespace.endsWith('chip') && key === 'size') {
return false
}
if (namespace.endsWith('badge') && key === 'size' || key === 'color' || key === 'variant') {
return false
}
if (typeof obj[key] === 'string' && typeof value === 'string' && obj[key] && value) {
// @ts-ignore
obj[key] = customTwMerge(obj[key], value)
return true
}
})
export function mergeConfig<T> (strategy: Strategy, ...configs): T {
if (strategy === 'override') {
return defu({}, ...configs) as T
for (const key of keys) {
result[key] = data[key]
}
return defuTwMerge({}, ...configs) as T
}
export function hexToRgb (hex: string) {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
hex = hex.replace(shorthandRegex, function (_, r, g, b) {
return r + r + g + g + b + b
})
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}`
: null
}
export function getSlotsChildren (slots: any) {
let children = slots.default?.()
if (children?.length) {
children = children.flatMap(c => {
if (typeof c.type === 'symbol') {
if (typeof c.children === 'string') {
// `v-if="false"` or commented node
return
}
return c.children
} else if (c.type.name === 'ContentSlot') {
return c.ctx.slots.default?.()
}
return c
}).filter(Boolean)
export function omit<Data extends object, Keys extends keyof Data> (data: Data, keys: Keys[]): Omit<Data, Keys> {
const result = { ...data }
for (const key of keys) {
delete result[key]
}
return children || []
return result as Omit<Data, Keys>
}
/**
* "123-foo" will be parsed to 123
* This is used for the .number modifier in v-model
*/
export function looseToNumber (val: any): any {
const n = parseFloat(val)
return isNaN(n) ? val : n
}
export * from './lodash'
export * from './link'

View File

@@ -1,20 +1,99 @@
import { useNuxt, addTemplate } from '@nuxt/kit'
import { addTemplate, addTypeTemplate } from '@nuxt/kit'
import type { Nuxt } from '@nuxt/schema'
import type { ModuleOptions } from './module'
import * as theme from './theme'
export default function createTemplates (options: ModuleOptions, nuxt: Nuxt) {
const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
export default function createTemplates (nuxt = useNuxt()) {
const template = addTemplate({
filename: 'ui.colors.mjs',
getContents: () => `export default ${JSON.stringify(nuxt.options.appConfig.ui.colors)};`,
write: true
})
const typesTemplate = addTemplate({
filename: 'ui.colors.d.ts',
getContents: () => `declare module '#ui-colors' { const defaultExport: ${JSON.stringify(nuxt.options.appConfig.ui.colors)}; export default defaultExport; }`,
write: true
filename: 'tailwind.css',
write: true,
getContents: () => `@import "tailwindcss";
@layer base {
:root {
color-scheme: light dark;
}
}
@theme {
--color-gray-*: initial;
--color-cool-50: #f9fafb;
--color-cool-100: #f3f4f6;
--color-cool-200: #e5e7eb;
--color-cool-300: #d1d5db;
--color-cool-400: #9ca3af;
--color-cool-500: #6b7280;
--color-cool-600: #4b5563;
--color-cool-700: #374151;
--color-cool-800: #1f2937;
--color-cool-900: #111827;
--color-cool-950: #030712;
${shades.map(shade => `--color-primary-${shade}: var(--color-primary-${shade});`).join('\n')}
${shades.map(shade => `--color-gray-${shade}: var(--color-gray-${shade});`).join('\n')}
}
`
})
nuxt.options.alias['#ui-colors'] = template.dst
nuxt.options.css.push(template.dst)
nuxt.hook('prepare:types', (opts) => {
opts.references.push({ path: typesTemplate.dst })
for (const component in theme) {
addTemplate({
filename: `ui/${component}.ts`,
write: true,
getContents: async () => {
const template = (theme as any)[component]
const result = typeof template === 'function' ? template({ colors: options.colors }) : template
const variants = Object.keys(result.variants || {})
let json = JSON.stringify(result, null, 2)
for (const variant of variants) {
json = json.replaceAll(new RegExp(`("${variant}": "[0-9a-z-]+")`, 'g'), '$1 as const')
}
return `export default ${json}`
}
})
}
addTemplate({
filename: 'ui/index.ts',
write: true,
getContents: () => Object.keys(theme).map(component => `export { default as ${component} } from './${component}'`).join('\n')
})
// FIXME: `typeof colors[number]` should include all colors from the theme
addTypeTemplate({
filename: 'types/ui.d.ts',
getContents: () => `import * as ui from '#build/ui'
type DeepPartial<T> = Partial<{
[P in keyof T]: DeepPartial<T[P]> | { [key: string]: string | object }
}>
const colors = ${JSON.stringify(options.colors)} as const;
type UI = {
primary?: typeof colors[number]
gray?: 'slate' | 'cool' | 'zinc' | 'neutral' | 'stone'
[key: string]: any
} & DeepPartial<typeof ui>
declare module 'nuxt/schema' {
interface AppConfigInput {
ui?: UI
}
}
declare module '@nuxt/schema' {
interface AppConfigInput {
ui?: UI
}
}
export {}
`
})
}

12
src/theme/accordion.ts Normal file
View File

@@ -0,0 +1,12 @@
export default {
slots: {
root: 'w-full',
item: 'border-b border-gray-200 dark:border-gray-800 last:border-b-0',
header: 'flex',
trigger: 'group flex-1 flex items-center gap-1.5 font-medium text-sm hover:underline py-3.5 disabled:cursor-not-allowed disabled:opacity-75 disabled:hover:no-underline focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400 focus-visible:outline-0',
content: 'text-sm pb-3.5 data-[state=open]:animate-[accordion-down_200ms_ease-in-out] data-[state=closed]:animate-[accordion-up_200ms_ease-in-out] overflow-hidden focus:outline-none',
leadingIcon: 'shrink-0 w-5 h-5',
trailingIcon: 'ms-auto w-5 h-5 group-data-[state=open]:rotate-180 transition-transform duration-200',
label: 'truncate'
}
}

42
src/theme/avatar.ts Normal file
View File

@@ -0,0 +1,42 @@
export default {
slots: {
root: 'inline-flex items-center justify-center shrink-0 select-none overflow-hidden rounded-full align-middle bg-gray-100 dark:bg-gray-800',
image: 'h-full w-full rounded-[inherit] object-cover',
fallback: 'font-medium leading-none text-gray-500 dark:text-gray-400 truncate',
icon: 'text-gray-500 dark:text-gray-400 shrink-0'
},
variants: {
size: {
'3xs': {
root: 'size-4 text-[8px]'
},
'2xs': {
root: 'size-5 text-[10px]'
},
xs: {
root: 'size-6 text-xs'
},
sm: {
root: 'size-7 text-sm'
},
md: {
root: 'size-8 text-base'
},
lg: {
root: 'size-9 text-lg'
},
xl: {
root: 'size-10 text-xl'
},
'2xl': {
root: 'size-11 text-[22px]'
},
'3xl': {
root: 'size-12 text-2xl'
}
}
},
defaultVariants: {
size: 'sm'
}
}

57
src/theme/badge.ts Normal file
View File

@@ -0,0 +1,57 @@
export default (config: { colors: string[] }) => ({
base: 'rounded-md font-medium inline-flex items-center',
variants: {
color: {
...Object.fromEntries(config.colors.map((color: string) => [color, ''])),
white: '',
gray: '',
black: ''
},
variant: {
solid: '',
outline: '',
soft: '',
subtle: ''
},
size: {
xs: 'text-xs px-1.5 py-0.5',
sm: 'text-xs px-2 py-1',
md: 'text-sm px-2 py-1',
lg: 'text-sm px-2.5 py-1.5'
}
},
compoundVariants: [...config.colors.map((color: string) => ({
color,
variant: 'solid',
class: `bg-${color}-500 dark:bg-${color}-400 text-white dark:text-gray-900`
})), ...config.colors.map((color: string) => ({
color,
variant: 'outline',
class: `text-${color}-500 dark:text-${color}-400 ring ring-inset ring-${color}-500 dark:ring-${color}-400`
})), ...config.colors.map((color: string) => ({
color,
variant: 'soft',
class: `bg-${color}-50 dark:bg-${color}-400/10 text-${color}-500 dark:text-${color}-400`
})), ...config.colors.map((color: string) => ({
color,
variant: 'subtle',
class: `bg-${color}-50 dark:bg-${color}-400/10 text-${color}-500 dark:text-${color}-400 ring ring-inset ring-${color}-500/25 dark:ring-${color}-400/25`
})), {
color: 'white',
variant: 'solid',
class: 'ring ring-inset ring-gray-300 dark:ring-gray-700 text-gray-900 dark:text-white bg-white dark:bg-gray-900'
}, {
color: 'gray',
variant: 'solid',
class: 'ring ring-inset ring-gray-300 dark:ring-gray-700 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-800'
}, {
color: 'black',
variant: 'solid',
class: 'text-white dark:text-gray-900 bg-gray-900 dark:bg-white'
}],
defaultVariants: {
color: 'primary',
variant: 'solid',
size: 'sm'
}
})

169
src/theme/button.ts Normal file
View File

@@ -0,0 +1,169 @@
export default (config: { colors: string[] }) => ({
slots: {
base: 'rounded-md font-medium inline-flex items-center focus:outline-none focus-visible:outline-0 disabled:cursor-not-allowed disabled:opacity-75',
label: '',
leadingIcon: 'shrink-0',
trailingIcon: 'shrink-0'
},
variants: {
color: {
...Object.fromEntries(config.colors.map((color: string) => [color, ''])),
white: '',
gray: '',
black: ''
},
variant: {
solid: '',
outline: '',
soft: '',
ghost: '',
link: ''
},
size: {
'2xs': {
base: 'px-2 py-1 text-xs gap-x-1',
leadingIcon: 'size-4',
trailingIcon: 'size-4'
},
xs: {
base: 'px-2.5 py-1.5 text-xs gap-x-1.5',
leadingIcon: 'size-4',
trailingIcon: 'size-4'
},
sm: {
base: 'px-2.5 py-1.5 text-sm gap-x-1.5',
leadingIcon: 'size-5',
trailingIcon: 'size-5'
},
md: {
base: 'px-3 py-2 text-sm gap-x-2',
leadingIcon: 'size-5',
trailingIcon: 'size-5'
},
lg: {
base: 'px-3.5 py-2.5 text-sm gap-x-2.5',
leadingIcon: 'size-5',
trailingIcon: 'size-5'
},
xl: {
base: 'px-3.5 py-2.5 text-base gap-x-2.5',
leadingIcon: 'size-6',
trailingIcon: 'size-6'
}
},
truncate: {
true: {
label: 'truncate'
}
},
block: {
true: {
base: 'w-full',
trailingIcon: 'ms-auto'
}
},
square: {
true: ''
},
leading: {
true: ''
},
trailing: {
true: ''
},
loading: {
true: ''
}
},
compoundVariants: [...config.colors.map((color: string) => ({
color,
variant: 'solid',
class: `shadow-sm text-white dark:text-gray-900 bg-${color}-500 hover:bg-${color}-600 disabled:bg-${color}-500 dark:bg-${color}-400 dark:hover:bg-${color}-500 dark:disabled:bg-${color}-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-${color}-500 dark:focus-visible:outline-${color}-400`
})), ...config.colors.map((color: string) => ({
color,
variant: 'outline',
class: `ring ring-inset ring-current text-${color}-500 dark:text-${color}-400 hover:bg-${color}-50 disabled:bg-transparent dark:hover:bg-${color}-950 dark:disabled:bg-transparent focus-visible:ring-2 focus-visible:ring-${color}-500 dark:focus-visible:ring-${color}-400`
})), ...config.colors.map((color: string) => ({
color,
variant: 'soft',
class: `text-${color}-500 dark:text-${color}-400 bg-${color}-50 hover:bg-${color}-100 disabled:bg-${color}-50 dark:bg-${color}-950 dark:hover:bg-${color}-900 dark:disabled:bg-${color}-950 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-${color}-500 dark:focus-visible:ring-${color}-400`
})), ...config.colors.map((color: string) => ({
color,
variant: 'ghost',
class: `text-${color}-500 dark:text-${color}-400 hover:bg-${color}-50 disabled:bg-transparent dark:hover:bg-${color}-950 dark:disabled:bg-transparent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-${color}-500 dark:focus-visible:ring-${color}-400`
})), ...config.colors.map((color: string) => ({
color,
variant: 'link',
class: `text-${color}-500 hover:text-${color}-600 disabled:text-${color}-500 dark:text-${color}-400 dark:hover:text-${color}-500 dark:disabled:text-${color}-400 underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-${color}-500 dark:focus-visible:ring-${color}-400`
})), {
color: 'white',
variant: 'solid',
class: 'shadow-sm ring ring-inset ring-gray-300 dark:ring-gray-700 text-gray-900 dark:text-white bg-white hover:bg-gray-50 disabled:bg-white dark:bg-gray-900 dark:hover:bg-gray-800/50 dark:disabled:bg-gray-900 focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400'
}, {
color: 'white',
variant: 'ghost',
class: 'text-gray-900 dark:text-white hover:bg-white dark:hover:bg-gray-900 focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400'
}, {
color: 'gray',
variant: 'solid',
class: 'shadow-sm ring ring-inset ring-gray-300 dark:ring-gray-700 text-gray-700 dark:text-gray-200 bg-gray-50 hover:bg-gray-100 disabled:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50 dark:disabled:bg-gray-800 focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400'
}, {
color: 'gray',
variant: 'ghost',
class: 'text-gray-700 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400'
}, {
color: 'gray',
variant: 'link',
class: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 underline-offset-4 hover:underline focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400'
}, {
color: 'black',
variant: 'solid',
class: 'shadow-sm text-white dark:text-gray-900 bg-gray-900 hover:bg-gray-800 disabled:bg-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:disabled:bg-white focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400'
}, {
color: 'black',
variant: 'link',
class: 'text-gray-900 dark:text-white underline-offset-4 hover:underline focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400'
}, {
size: '2xs',
square: true,
class: 'p-1'
}, {
size: 'xs',
square: true,
class: 'p-1.5'
}, {
size: 'sm',
square: true,
class: 'p-1.5'
}, {
size: 'md',
square: true,
class: 'p-2'
}, {
size: 'lg',
square: true,
class: 'p-2.5'
}, {
size: 'xl',
square: true,
class: 'p-2.5'
}, {
loading: true,
leading: true,
class: {
leadingIcon: 'animate-spin'
}
}, {
loading: true,
leading: false,
trailing: true,
class: {
trailingIcon: 'animate-spin'
}
}],
defaultVariants: {
color: 'primary',
variant: 'solid',
size: 'sm'
}
})

8
src/theme/card.ts Normal file
View File

@@ -0,0 +1,8 @@
export default {
slots: {
root: 'bg-white dark:bg-gray-900 ring ring-gray-200 dark:ring-gray-800 divide-y divide-gray-200 dark:divide-gray-800 rounded-lg shadow',
header: 'p-4 sm:px-6',
body: 'p-4 sm:p-6',
footer: 'p-4 sm:px-6'
}
}

79
src/theme/checkbox.ts Normal file
View File

@@ -0,0 +1,79 @@
export default (config: { colors: string[] }) => ({
slots: {
root: 'relative flex items-start',
base: 'shrink-0 text-white dark:text-gray-900 rounded ring ring-inset ring-gray-300 dark:ring-gray-700 focus:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900',
container: 'flex items-center',
wrapper: 'ms-2',
indicator: 'flex',
icon: 'size-full',
label: 'font-medium text-gray-700 dark:text-gray-200',
description: 'text-gray-500 dark:text-gray-400'
},
variants: {
color: Object.fromEntries(config.colors.map((color: string) => [color, `focus-visible:ring-${color}-500 dark:focus-visible:ring-${color}-400`])),
size: {
'2xs': {
base: 'size-3',
container: 'h-4',
wrapper: 'text-xs'
},
xs: {
base: 'size-3.5',
container: 'h-4',
wrapper: 'text-xs'
},
sm: {
base: 'size-4',
container: 'h-5',
wrapper: 'text-sm'
},
md: {
base: 'size-[18px]',
container: 'h-5',
wrapper: 'text-sm'
},
lg: {
base: 'size-5',
container: 'h-6',
wrapper: 'text-base'
},
xl: {
base: 'size-[22px]',
container: 'h-6',
wrapper: 'text-base'
}
},
required: {
true: {
label: 'after:content-[\'*\'] after:ms-0.5 after:text-red-500 dark:after:text-red-400'
}
},
disabled: {
true: {
base: 'cursor-not-allowed opacity-75',
label: 'cursor-not-allowed opacity-75',
description: 'cursor-not-allowed opacity-75'
}
},
checked: {
true: ''
},
indeterminate: {
true: ''
}
},
compoundVariants: config.colors.flatMap((color) => ([{
color,
checked: true,
class: `ring-2 ring-inset ring-${color}-500 dark:ring-${color}-400 bg-${color}-500 dark:bg-${color}-400`
}, {
color,
indeterminate: true,
class: `ring-2 ring-inset ring-${color}-500 dark:ring-${color}-400 bg-${color}-500 dark:bg-${color}-400`
}
])),
defaultVariants: {
size: 'sm',
color: 'primary'
}
})

56
src/theme/chip.ts Normal file
View File

@@ -0,0 +1,56 @@
export default (config: { colors: string[] }) => ({
slots: {
root: 'relative inline-flex items-center justify-center shrink-0',
base: 'absolute rounded-full ring ring-white dark:ring-gray-900 flex items-center justify-center text-white dark:text-gray-900 font-medium whitespace-nowrap'
},
variants: {
color: {
...Object.fromEntries(config.colors.map((color: string) => [color, `bg-${color}-500 dark:bg-${color}-400`])),
gray: 'bg-gray-500 dark:bg-gray-400',
white: 'bg-white dark:bg-gray-900',
black: 'bg-gray-900 dark:bg-white'
},
size: {
'3xs': 'h-[4px] min-w-[4px] text-[4px]',
'2xs': 'h-[5px] min-w-[5px] text-[5px]',
xs: 'h-[6px] min-w-[6px] text-[6px]',
sm: 'h-[7px] min-w-[7px] text-[7px]',
md: 'h-[8px] min-w-[8px] text-[8px]',
lg: 'h-[9px] min-w-[9px] text-[9px]',
xl: 'h-[10px] min-w-[10px] text-[10px]',
'2xl': 'h-[11px] min-w-[11px] text-[11px]',
'3xl': 'h-[12px] min-w-[12px] text-[12px]'
},
position: {
'top-right': 'top-0 right-0',
'bottom-right': 'bottom-0 right-0',
'top-left': 'top-0 left-0',
'bottom-left': 'bottom-0 left-0'
},
inset: {
false: ''
}
},
compoundVariants: [{
position: 'top-right',
inset: false,
class: '-translate-y-1/2 translate-x-1/2 transform'
}, {
position: 'bottom-right',
inset: false,
class: 'translate-y-1/2 translate-x-1/2 transform'
}, {
position: 'top-left',
inset: false,
class: '-translate-y-1/2 -translate-x-1/2 transform'
}, {
position: 'bottom-left',
inset: false,
class: 'translate-y-1/2 -translate-x-1/2 transform'
}],
defaultVariants: {
size: 'sm',
color: 'primary',
position: 'top-right'
}
})

6
src/theme/collapsible.ts Normal file
View File

@@ -0,0 +1,6 @@
export default {
slots: {
root: '',
content: 'data-[state=open]:animate-[collapsible-down_200ms_ease-in-out] data-[state=closed]:animate-[collapsible-up_200ms_ease-in-out] overflow-hidden'
}
}

3
src/theme/container.ts Normal file
View File

@@ -0,0 +1,3 @@
export default {
base: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'
}

3
src/theme/form.ts Normal file
View File

@@ -0,0 +1,3 @@
export default {
base: ''
}

32
src/theme/formField.ts Normal file
View File

@@ -0,0 +1,32 @@
export default {
slots: {
root: '',
wrapper: '',
labelWrapper: 'flex content-center items-center justify-between',
label: 'block font-medium text-gray-700 dark:text-gray-200',
container: 'mt-1 relative',
description: 'text-gray-500 dark:text-gray-400',
error: 'mt-2 text-red-500 dark:text-red-400',
hint: 'text-gray-500 dark:text-gray-400',
help: 'mt-2 text-gray-500 dark:text-gray-400'
},
variants: {
size: {
'2xs': { root: 'text-xs' },
xs: { root: 'text-xs' },
sm: { root: 'text-sm' },
md: { root: 'text-sm' },
lg: { root: 'text-base' },
xl: { root: 'text-base' }
},
required: {
true: {
// eslint-disable-next-line quotes
label: `after:content-['*'] after:ms-0.5 after:text-red-500 dark:after:text-red-400`
}
}
},
defaultVariants: {
size: 'sm'
}
}

11
src/theme/icons.ts Normal file
View File

@@ -0,0 +1,11 @@
export default {
chevronDown: 'i-heroicons-chevron-down-20-solid',
chevronLeft: 'i-heroicons-chevron-left-20-solid',
chevronRight: 'i-heroicons-chevron-right-20-solid',
check: 'i-heroicons-check-20-solid',
close: 'i-heroicons-x-mark-20-solid',
empty: 'i-heroicons-circle-stack-20-solid',
loading: 'i-heroicons-arrow-path-20-solid',
minus: 'i-heroicons-minus-20-solid',
search: 'i-heroicons-magnifying-glass-20-solid'
}

24
src/theme/index.ts Normal file
View File

@@ -0,0 +1,24 @@
export { default as accordion } from './accordion'
export { default as avatar } from './avatar'
export { default as badge } from './badge'
export { default as button } from './button'
export { default as card } from './card'
export { default as checkbox } from './checkbox'
export { default as chip } from './chip'
export { default as collapsible } from './collapsible'
export { default as container } from './container'
export { default as form } from './form'
export { default as formField } from './formField'
export { default as icons } from './icons'
export { default as input } from './input'
export { default as kbd } from './kbd'
export { default as link } from './link'
export { default as modal } from './modal'
export { default as navigationMenu } from './navigationMenu'
export { default as popover } from './popover'
export { default as skeleton } from './skeleton'
export { default as slideover } from './slideover'
export { default as switch } from './switch'
export { default as tabs } from './tabs'
export { default as tooltip } from './tooltip'
export { default as textarea } from './textarea'

155
src/theme/input.ts Normal file
View File

@@ -0,0 +1,155 @@
export default (config: { colors: string[] }) => {
return {
slots: {
root: 'relative',
base: 'relative block w-full disabled:cursor-not-allowed disabled:opacity-75 focus:outline-none border-0 rounded-md placeholder-gray-400 dark:placeholder-gray-500',
leading: 'absolute inset-y-0 start-0 flex items-center',
leadingIcon: 'shrink-0 text-gray-400 dark:text-gray-500',
trailing: 'absolute inset-y-0 end-0 flex items-center',
trailingIcon: 'shrink-0 text-gray-400 dark:text-gray-500'
},
variants: {
size: {
'2xs': {
base: 'text-xs gap-x-1 px-2 py-1',
leading: 'px-2',
trailing: 'px-2',
leadingIcon: 'size-4',
trailingIcon: 'size-4'
},
xs: {
base: 'text-sm gap-x-1.5 px-2.5 py-1',
leading: 'px-2.5',
trailing: 'px-2.5',
leadingIcon: 'size-4',
trailingIcon: 'size-4'
},
sm: {
base: 'text-sm gap-x-1.5 px-2.5 py-1.5',
leading: 'px-2.5',
trailing: 'px-2.5',
leadingIcon: 'size-5',
trailingIcon: 'size-5'
},
md: {
base: 'text-sm gap-x-1.5 px-3 py-2',
leading: 'px-3',
trailing: 'px-3',
leadingIcon: 'size-5',
trailingIcon: 'size-5'
},
lg: {
base: 'text-sm gap-x-2.5 px-3.5 py-2.5',
leading: 'px-3.5',
trailing: 'px-3.5',
leadingIcon: 'size-5',
trailingIcon: 'size-5'
},
xl: {
base: 'text-base gap-x-2.5 px-3.5 py-2.5',
leading: 'px-3.5',
trailing: 'px-3.5',
leadingIcon: 'size-6',
trailingIcon: 'size-6'
}
},
variant: {
outline: '',
none: 'bg-transparent focus:ring-0 focus:shadow-none'
},
color: {
...Object.fromEntries(config.colors.map((color: string) => [color, ''])),
white: '',
gray: ''
},
leading: {
true: ''
},
trailing: {
true: ''
},
loading: {
true: ''
}
},
compoundVariants: [...config.colors.map((color: string) => ({
color,
variant: 'outline',
class: `shadow-sm bg-transparent text-gray-900 dark:text-white ring ring-inset ring-${color}-500 dark:ring-${color}-400 focus:ring-2 focus:ring-${color}-500 dark:focus:ring-${color}-400`
})), {
color: 'white',
variant: 'outline',
class: 'shadow-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-white ring ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400'
}, {
color: 'gray',
variant: 'outline',
class: 'shadow-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white ring ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400'
}, {
leading: true,
size: '2xs',
class: 'ps-7'
}, {
leading: true,
size: 'xs',
class: 'ps-8'
}, {
leading: true,
size: 'sm',
class: 'ps-9'
}, {
leading: true,
size: 'md',
class: 'ps-10'
}, {
leading: true,
size: 'lg',
class: 'ps-11'
}, {
leading: true,
size: 'xl',
class: 'ps-12'
}, {
trailing: true,
size: '2xs',
class: 'pe-7'
}, {
trailing: true,
size: 'xs',
class: 'pe-8'
}, {
trailing: true,
size: 'sm',
class: 'pe-9'
}, {
trailing: true,
size: 'md',
class: 'pe-10'
}, {
trailing: true,
size: 'lg',
class: 'pe-11'
}, {
trailing: true,
size: 'xl',
class: 'pe-12'
}, {
loading: true,
leading: true,
class: {
leadingIcon: 'animate-spin'
}
}, {
loading: true,
leading: false,
trailing: true,
class: {
trailingIcon: 'animate-spin'
}
}],
defaultVariants: {
size: 'sm',
color: 'white',
variant: 'outline'
}
}
}

13
src/theme/kbd.ts Normal file
View File

@@ -0,0 +1,13 @@
export default {
base: 'inline-flex items-center justify-center text-gray-900 dark:text-white px-1 rounded font-medium font-sans bg-gray-50 dark:bg-gray-800 ring ring-gray-300 dark:ring-gray-700 ring-inset',
variants: {
size: {
xs: 'h-4 min-w-[16px] text-[10px]',
sm: 'h-5 min-w-[20px] text-[11px]',
md: 'h-6 min-w-[24px] text-[12px]'
}
},
defaultVariants: {
size: 'sm'
}
}

12
src/theme/link.ts Normal file
View File

@@ -0,0 +1,12 @@
export default {
base: 'focus-visible:outline-primary-500 dark:focus-visible:outline-primary-400',
variants: {
active: {
true: 'text-primary-500 dark:text-primary-400',
false: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
},
disabled: {
true: 'cursor-not-allowed opacity-75'
}
}
}

28
src/theme/modal.ts Normal file
View File

@@ -0,0 +1,28 @@
export default {
slots: {
overlay: 'fixed inset-0 z-30 bg-gray-200/75 dark:bg-gray-800/75',
content: 'fixed z-50 w-full h-dvh bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800 flex flex-col focus:outline-none',
header: 'px-4 py-5 sm:px-6',
body: 'flex-1 p-4 sm:p-6',
footer: 'flex items-center gap-x-1.5 p-4 sm:px-6',
title: 'text-gray-900 dark:text-white font-semibold',
description: 'mt-1 text-gray-500 dark:text-gray-400 text-sm',
close: 'absolute top-4 right-4'
},
variants: {
transition: {
true: {
overlay: 'data-[state=open]:animate-[modal-overlay-open_200ms_ease-out] data-[state=closed]:animate-[modal-overlay-closed_200ms_ease-in]',
content: 'data-[state=open]:animate-[modal-content-open_200ms_ease-out] data-[state=closed]:animate-[modal-content-closed_200ms_ease-in]'
}
},
fullscreen: {
true: {
content: 'inset-0'
},
false: {
content: 'top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] sm:max-w-lg sm:h-auto sm:my-8 sm:rounded-lg sm:shadow-lg sm:ring ring-gray-200 dark:ring-gray-800'
}
}
}
}

View File

@@ -0,0 +1,49 @@
export default {
slots: {
root: 'relative',
list: '',
item: '',
base: 'group relative w-full flex items-center gap-1.5 font-medium text-sm before:absolute before:rounded-md focus:outline-none focus-visible:outline-none dark:focus-visible:outline-none focus-visible:before:ring-inset focus-visible:before:ring-2 focus-visible:before:ring-primary-500 dark:focus-visible:before:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-75',
icon: 'shrink-0 w-5 h-5 relative',
avatar: 'shrink-0 relative',
label: 'truncate relative',
badge: 'shrink-0 ms-auto relative rounded'
},
variants: {
orientation: {
horizontal: {
root: 'w-full flex items-center justify-between',
list: 'flex items-center min-w-0',
item: 'min-w-0',
base: 'px-2.5 py-3.5 before:inset-x-0 before:inset-y-2 hover:before:bg-gray-50 dark:hover:before:bg-gray-800/50 after:absolute after:bottom-0 after:inset-x-2.5 after:block after:h-[2px] after:mt-2 after:rounded-full'
},
vertical: {
root: 'flex flex-col *:py-1.5 first:*:pt-0 last:*:pb-0 divide-y divide-gray-200 dark:divide-gray-800',
base: 'px-2.5 py-1.5 before:inset-px'
}
},
active: {
true: {
base: 'text-gray-900 dark:text-white',
icon: 'text-gray-700 dark:text-gray-200'
},
false: {
base: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
icon: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-700 dark:group-hover:text-gray-200'
}
}
},
compoundVariants: [{
orientation: 'horizontal',
active: true,
class: 'after:bg-primary-500 dark:after:bg-primary-400'
}, {
orientation: 'vertical',
active: true,
class: 'before:bg-gray-100 dark:before:bg-gray-800'
}, {
orientation: 'vertical',
active: false,
class: 'hover:before:bg-gray-50 dark:hover:before:bg-gray-800/50'
}]
}

6
src/theme/popover.ts Normal file
View File

@@ -0,0 +1,6 @@
export default {
slots: {
content: 'bg-white dark:bg-gray-900 shadow-lg rounded-md ring ring-gray-200 dark:ring-gray-800 will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-[popover-down-open_200ms_ease-out] data-[state=closed]:data-[side=top]:animate-[popover-down-closed_200ms_ease-in] data-[state=open]:data-[side=right]:animate-[popover-left-open_200ms_ease-out] data-[state=closed]:data-[side=right]:animate-[popover-left-closed_200ms_ease-in] data-[state=open]:data-[side=left]:animate-[popover-right-open_200ms_ease-out] data-[state=closed]:data-[side=left]:animate-[popover-right-closed_200ms_ease-in] data-[state=open]:data-[side=bottom]:animate-[popover-up-open_200ms_ease-out] data-[state=closed]:data-[side=bottom]:animate-[popover-up-closed_200ms_ease-in]',
arrow: 'fill-gray-200 dark:fill-gray-800'
}
}

3
src/theme/skeleton.ts Normal file
View File

@@ -0,0 +1,3 @@
export default {
base: 'animate-pulse rounded-md bg-gray-100 dark:bg-gray-800'
}

34
src/theme/slideover.ts Normal file
View File

@@ -0,0 +1,34 @@
export default {
slots: {
overlay: 'fixed inset-0 z-30 bg-gray-200/75 dark:bg-gray-800/75',
content: 'fixed z-50 bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800 sm:ring ring-gray-200 dark:ring-gray-800 sm:shadow-lg flex flex-col focus:outline-none',
header: 'px-4 py-5 sm:px-6',
body: 'flex-1 overflow-y-auto p-4 sm:p-6',
footer: 'flex items-center gap-x-1.5 p-4 sm:px-6',
title: 'text-gray-900 dark:text-white font-semibold',
description: 'mt-1 text-gray-500 dark:text-gray-400 text-sm',
close: 'absolute top-4 right-4'
},
variants: {
side: {
left: {
content: 'left-0 inset-y-0 w-full max-w-md'
},
right: {
content: 'right-0 inset-y-0 w-full max-w-md'
},
top: {
content: 'inset-x-0 top-0'
},
bottom: {
content: 'inset-x-0 bottom-0'
}
},
transition: {
true: {
overlay: 'data-[state=open]:animate-[slideover-overlay-open_200ms_ease-out] data-[state=closed]:animate-[slideover-overlay-closed_200ms_ease-in]',
content: 'data-[state=open]:data-[side=left]:animate-[slideover-content-left-open_200ms_ease-in-out] data-[state=closed]:data-[side=left]:animate-[slideover-content-left-closed_200ms_ease-in-out] data-[state=open]:data-[side=right]:animate-[slideover-content-right-open_200ms_ease-in-out] data-[state=closed]:data-[side=right]:animate-[slideover-content-right-closed_200ms_ease-in-out] data-[state=open]:data-[side=top]:animate-[slideover-content-top-open_200ms_ease-in-out] data-[state=closed]:data-[side=top]:animate-[slideover-content-top-closed_200ms_ease-in-out] data-[state=open]:data-[side=bottom]:animate-[slideover-content-bottom-open_200ms_ease-in-out] data-[state=closed]:data-[side=bottom]:animate-[slideover-content-bottom-closed_200ms_ease-in-out]'
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More