diff --git a/docs/components/ThemeSelect.vue b/docs/components/ThemeSelect.vue
index e68171cd..a2442023 100644
--- a/docs/components/ThemeSelect.vue
+++ b/docs/components/ThemeSelect.vue
@@ -70,7 +70,7 @@ watch(grayCookie, (gray) => {
const primaryOptions = computed(() => useWithout(appConfig.ui.colors, 'primary').map(color => ({ value: color, text: color, hex: colors[color][colorMode.value === 'dark' ? 400 : 500] })))
const primary = computed({
get () {
- return primaryOptions.value.find(option => option.value === primaryCookie.value)
+ return primaryOptions.value.find(option => option.value === primaryCookie.value) || primaryOptions.value.find(option => option.value === 'green')
},
set (option) {
primaryCookie.value = option.value
@@ -80,7 +80,7 @@ const primary = computed({
const grayOptions = computed(() => ['slate', 'cool', 'zinc', 'neutral', 'stone'].map(color => ({ value: color, text: color, hex: colors[color][colorMode.value === 'dark' ? 400 : 500] })))
const gray = computed({
get () {
- return grayOptions.value.find(option => option.value === grayCookie.value)
+ return grayOptions.value.find(option => option.value === grayCookie.value) || grayOptions.value.find(option => option.value === 'cool')
},
set (option) {
grayCookie.value = option.value
diff --git a/docs/content/1.getting-started/2.installation.md b/docs/content/1.getting-started/2.installation.md
index 381d8792..35dd9331 100644
--- a/docs/content/1.getting-started/2.installation.md
+++ b/docs/content/1.getting-started/2.installation.md
@@ -45,6 +45,7 @@ As this module installs [@nuxtjs/tailwindcss](https://tailwindcss.nuxtjs.org/) a
| `prefix` | `u` | Define the prefix of the imported components. |
| `global` | `false` | Expose components globally. |
| `icons` | `['heroicons']` | Icon collections to load. |
+| `safelistColors` | `['primary']` | Force safelisting of colors. |
## Edge
diff --git a/docs/content/1.getting-started/3.theming.md b/docs/content/1.getting-started/3.theming.md
index f37fbacd..b899c914 100644
--- a/docs/content/1.getting-started/3.theming.md
+++ b/docs/content/1.getting-started/3.theming.md
@@ -33,13 +33,43 @@ Likewise, you can't define a `primary` color in your `tailwind.config.ts` as it
We'd advise you to use those colors in your components and pages, e.g. `text-primary-500 dark:text-primary-400`, `bg-gray-100 dark:bg-gray-900`, etc. so your app automatically adapts when changing your `app.config.ts`.
::
-Components that have a `color` prop like [Avatar](/elements/avatar#chip), [Badge](/elements/badge#style), [Button](/elements/button#style) and [Notification](/overlays/notification#timeout) will use the `primary` color by default but will handle all the colors defined in your `tailwind.config.ts` or the default Tailwind CSS colors.
+Components having a `color` prop like [Avatar](/elements/avatar#chip), [Badge](/elements/badge#style), [Button](/elements/button#style), [Input](/elements/input#style) (inherited in [Select](/forms/select) and [SelectMenu](/forms/select-menu)) and [Notification](/overlays/notification#timeout) will use the `primary` color by default but will handle all the colors defined in your `tailwind.config.ts` or the default Tailwind CSS colors.
+
+Variant classes of those components are defined with a syntax like `bg-{color}-500 dark:bg-{color}-400` so they can be used with any color. However, this means that Tailwind will not find those classes and therefore will not generate the corresponding CSS.
+
+The module uses the [Tailwind CSS safelist](https://tailwindcss.com/docs/content-configuration#safelisting-classes) feature to force the generation of all the classes for the `primary` color **only** as it is the default color for all the components.
+
+Then, the module will automatically detect when you use one of those components with a color and will safelist it for you. This means that if you use a `red` color for a Button component, the `red` color classes will be safelisted for the Button component only. This will allow to keep the CSS bundle size as small as possible.
+
+There is one case where you would want to force the safelisting of a color. For example, if you've set the default color of the Button component to `orange` in your `app.config.ts`.
+
+```ts [app.config.ts]
+export default defineAppConfig({
+ ui: {
+ button: {
+ default: {
+ color: 'orange'
+ }
+ }
+ }
+})
+```
+
+This will apply the orange color when using a default ``. You'll need to safelist this color manually in your `nuxt.config.ts` ui options as we won't be able to detect it automatically. You can do so through the `safelistColors` option.
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ ui: {
+ safelistColors: ['orange']
+ }
+})
+```
## Dark mode
All the components are styled with dark mode in mind.
-Thanks to [Tailwind CSS dark mode](https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually) `class` strategy and the [@nuxtjs/color-mode](https://github.com/nuxt-modules/color-mode) module, you literally have nothing to do.
+Thanks to [Tailwind CSS dark mode](https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually) class strategy and the [@nuxtjs/color-mode](https://github.com/nuxt-modules/color-mode) module, you literally have nothing to do.
## Components
diff --git a/docs/nuxt.config.ts b/docs/nuxt.config.ts
index e1e85858..381c87aa 100644
--- a/docs/nuxt.config.ts
+++ b/docs/nuxt.config.ts
@@ -1,4 +1,5 @@
import ui from '../src/module'
+import colors from 'tailwindcss/colors'
export default defineNuxtConfig({
// @ts-ignore
@@ -25,7 +26,8 @@ export default defineNuxtConfig({
},
ui: {
global: true,
- icons: ['heroicons', 'simple-icons']
+ icons: ['heroicons', 'simple-icons'],
+ safelistColors: Object.keys(colors)
},
typescript: {
strict: false,
diff --git a/src/colors.ts b/src/colors.ts
new file mode 100644
index 00000000..610d6e0f
--- /dev/null
+++ b/src/colors.ts
@@ -0,0 +1,151 @@
+const colorsToExclude = [
+ 'inherit',
+ 'transparent',
+ 'current',
+ 'white',
+ 'black',
+ 'slate',
+ 'gray',
+ 'zinc',
+ 'neutral',
+ 'stone',
+ 'cool'
+]
+
+const omit = (obj: object, keys: string[]) => {
+ return Object.fromEntries(
+ Object.entries(obj).filter(([key]) => !keys.includes(key))
+ )
+}
+
+const kebabCase = (str: string) => {
+ return str
+ ?.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
+ ?.map(x => x.toLowerCase())
+ ?.join('-')
+}
+
+const safelistByComponent = {
+ avatar: (colorsAsRegex) => [{
+ pattern: new RegExp(`bg-(${colorsAsRegex}|gray)-500`)
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex}|gray)-400`),
+ variants: ['dark']
+ }],
+ badge: (colorsAsRegex) => [{
+ pattern: new RegExp(`bg-(${colorsAsRegex})-50`)
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
+ variants: ['dark']
+ }, {
+ pattern: new RegExp(`text-(${colorsAsRegex})-500`)
+ }, {
+ pattern: new RegExp(`text-(${colorsAsRegex})-400`),
+ variants: ['dark']
+ }, {
+ pattern: new RegExp(`ring-(${colorsAsRegex})-500`)
+ }, {
+ pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
+ variants: ['dark']
+ }],
+ button: (colorsAsRegex) => [{
+ pattern: new RegExp(`bg-(${colorsAsRegex})-50`),
+ variants: ['hover']
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex})-100`),
+ variants: ['hover']
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
+ variants: ['dark', 'dark:disabled']
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex})-500`),
+ variants: ['disabled', 'dark:hover']
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex})-600`),
+ variants: ['hover']
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex})-900`),
+ variants: ['dark:hover']
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex})-950`),
+ variants: ['dark', 'dark:hover']
+ }, {
+ pattern: new RegExp(`text-(${colorsAsRegex})-400`),
+ variants: ['dark']
+ }, {
+ pattern: new RegExp(`text-(${colorsAsRegex})-500`),
+ variants: ['dark:hover']
+ }, {
+ pattern: new RegExp(`text-(${colorsAsRegex})-600`),
+ variants: ['hover']
+ }, {
+ pattern: new RegExp(`outline-(${colorsAsRegex})-400`),
+ variants: ['dark:focus-visible']
+ }, {
+ pattern: new RegExp(`outline-(${colorsAsRegex})-500`),
+ variants: ['focus-visible']
+ }, {
+ pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
+ variants: ['dark:focus-visible']
+ }, {
+ pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
+ variants: ['focus-visible']
+ }],
+ input: (colorsAsRegex) => [{
+ pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
+ variants: ['dark', 'dark:focus']
+ }, {
+ pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
+ variants: ['focus']
+ }],
+ notification: (colorsAsRegex) => [{
+ pattern: new RegExp(`bg-(${colorsAsRegex}|gray)-500`)
+ }, {
+ pattern: new RegExp(`bg-(${colorsAsRegex}|gray)-400`),
+ variants: ['dark']
+ }, {
+ pattern: new RegExp(`text-(${colorsAsRegex}|gray)-500`)
+ }, {
+ pattern: new RegExp(`text-(${colorsAsRegex}|gray)-400`),
+ variants: ['dark']
+ }]
+}
+
+const colorsAsRegex = (colors: string[]): string => colors.join('|')
+
+export const excludeColors = (colors: object) => Object.keys(omit(colors, colorsToExclude)).map(color => kebabCase(color)) as string[]
+
+export const generateSafelist = (colors: string[]) => ['avatar', 'badge', 'button', 'input', 'notification'].flatMap(component => safelistByComponent[component](colorsAsRegex(colors)))
+
+export const customSafelistExtractor = (prefix, content: string) => {
+ const classes = []
+ const regex = /<(\w+)\s+[^>]*color=["']([^"']+)["'][^>]*>/gs
+ const matches = [...content.matchAll(regex)]
+
+ for (const match of matches) {
+ const [, component, color] = match
+
+ if (colorsToExclude.includes(color)) {
+ continue
+ }
+
+ if (Object.keys(safelistByComponent).map(component => `${prefix}${component.charAt(0).toUpperCase() + component.slice(1)}`).includes(component)) {
+ const name = component.replace(prefix, '').toLowerCase()
+
+ const matchClasses = safelistByComponent[name](color).flatMap(group => {
+ return ['', ...(group.variants || [])].flatMap(variant => {
+ const matches = group.pattern.source.match(/\(([^)]+)\)/g)
+
+ return matches.map(match => {
+ const colorOptions = match.substring(1, match.length - 1).split('|')
+ return colorOptions.map(color => `${variant ? variant + ':' : ''}` + group.pattern.source.replace(match, color))
+ }).flat()
+ })
+ })
+
+ classes.push(...matchClasses)
+ }
+ }
+
+ return classes
+}
diff --git a/src/module.ts b/src/module.ts
index aa29fd28..8405e5d9 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -1,22 +1,20 @@
import { defineNuxtModule, installModule, addComponentsDir, addImportsDir, createResolver, addPlugin, resolvePath } from '@nuxt/kit'
-import colors from 'tailwindcss/colors.js'
+import defaultColors from 'tailwindcss/colors.js'
+import { defaultExtractor as createDefaultExtractor } from 'tailwindcss/lib/lib/defaultExtractor.js'
import { iconsPlugin, getIconCollections } from '@egoist/tailwindcss-icons'
import { name, version } from '../package.json'
-import { colorsAsRegex, excludeColors } from './runtime/utils/colors'
-
+import { generateSafelist, excludeColors, customSafelistExtractor } from './colors'
import appConfig from './runtime/app.config'
+
type DeepPartial = Partial<{ [P in keyof T]: DeepPartial | { [key: string]: string } }>
-// @ts-ignore
-delete colors.lightBlue
-// @ts-ignore
-delete colors.warmGray
-// @ts-ignore
-delete colors.trueGray
-// @ts-ignore
-delete colors.coolGray
-// @ts-ignore
-delete colors.blueGray
+const defaultExtractor = createDefaultExtractor({ tailwindConfig: { separator: ':' } })
+
+delete defaultColors.lightBlue
+delete defaultColors.warmGray
+delete defaultColors.trueGray
+delete defaultColors.coolGray
+delete defaultColors.blueGray
declare module 'nuxt/schema' {
interface AppConfigInput {
@@ -40,6 +38,8 @@ export interface ModuleOptions {
global?: boolean
icons: string[] | string
+
+ safelistColors?: string[]
}
export default defineNuxtModule({
@@ -52,8 +52,9 @@ export default defineNuxtModule({
}
},
defaults: {
- prefix: 'u',
- icons: ['heroicons']
+ prefix: 'U',
+ icons: ['heroicons'],
+ safelistColors: ['primary']
},
async setup (options, nuxt) {
const { resolve } = createResolver(import.meta.url)
@@ -70,14 +71,14 @@ export default defineNuxtModule({
app.configs.push(appConfigFile)
})
- // @ts-ignore
- nuxt.hook('tailwindcss:config', function (tailwindConfig: TailwindConfig) {
- const globalColors = {
- ...(tailwindConfig.theme.colors || colors),
+ nuxt.hook('tailwindcss:config', function (tailwindConfig) {
+ const globalColors: any = {
+ ...(tailwindConfig.theme.colors || defaultColors),
...tailwindConfig.theme.extend?.colors
}
tailwindConfig.theme.extend.colors = tailwindConfig.theme.extend.colors || {}
+ // @ts-ignore
globalColors.primary = tailwindConfig.theme.extend.colors.primary = {
50: 'rgb(var(--color-primary-50) / )',
100: 'rgb(var(--color-primary-100) / )',
@@ -93,9 +94,11 @@ export default defineNuxtModule({
}
if (globalColors.gray) {
- globalColors.cool = tailwindConfig.theme.extend.colors.cool = colors.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) / )',
100: 'rgb(var(--color-gray-100) / )',
@@ -110,65 +113,24 @@ export default defineNuxtModule({
950: 'rgb(var(--color-gray-950) / )'
}
- const variantColors = excludeColors(globalColors)
- const safeColorsAsRegex = colorsAsRegex(variantColors)
+ const colors = excludeColors(globalColors)
nuxt.options.appConfig.ui = {
...nuxt.options.appConfig.ui,
primary: 'green',
gray: 'cool',
- colors: variantColors
+ colors
}
tailwindConfig.safelist = tailwindConfig.safelist || []
- tailwindConfig.safelist.push(...[
- 'bg-gray-500',
- 'dark:bg-gray-400',
- {
- pattern: new RegExp(`bg-(${safeColorsAsRegex})-(50|400|500)`)
- }, {
- pattern: new RegExp(`bg-(${safeColorsAsRegex})-500`),
- variants: ['disabled']
- }, {
- pattern: new RegExp(`bg-(${safeColorsAsRegex})-(400|950)`),
- variants: ['dark']
- }, {
- pattern: new RegExp(`bg-(${safeColorsAsRegex})-(500|900|950)`),
- variants: ['dark:hover']
- }, {
- pattern: new RegExp(`bg-(${safeColorsAsRegex})-400`),
- variants: ['dark:disabled']
- }, {
- pattern: new RegExp(`bg-(${safeColorsAsRegex})-(50|100|600)`),
- variants: ['hover']
- }, {
- pattern: new RegExp(`outline-(${safeColorsAsRegex})-500`),
- variants: ['focus-visible']
- }, {
- pattern: new RegExp(`outline-(${safeColorsAsRegex})-400`),
- variants: ['dark:focus-visible']
- }, {
- pattern: new RegExp(`ring-(${safeColorsAsRegex})-500`),
- variants: ['focus', 'focus-visible']
- }, {
- pattern: new RegExp(`ring-(${safeColorsAsRegex})-400`),
- variants: ['dark', 'dark:focus', 'dark:focus-visible']
- }, {
- pattern: new RegExp(`text-(${safeColorsAsRegex})-400`),
- variants: ['dark']
- }, {
- pattern: new RegExp(`text-(${safeColorsAsRegex})-500`),
- variants: ['dark:hover']
- }, {
- pattern: new RegExp(`text-(${safeColorsAsRegex})-600`),
- variants: ['hover']
- }
- ])
+ tailwindConfig.safelist.push(...generateSafelist(options.safelistColors))
tailwindConfig.plugins = tailwindConfig.plugins || []
tailwindConfig.plugins.push(iconsPlugin({ collections: getIconCollections(options.icons as any[]) }))
})
+ // Modules
+
await installModule('@nuxtjs/color-mode', { classSuffix: '' })
await installModule('@nuxtjs/tailwindcss', {
viewer: false,
@@ -181,17 +143,31 @@ export default defineNuxtModule({
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries')
],
- content: [
- resolve(runtimeDir, 'components/**/*.{vue,mjs,ts}'),
- resolve(runtimeDir, '*.{mjs,js,ts}')
- ]
+ content: {
+ files: [
+ resolve(runtimeDir, 'components/**/*.{vue,mjs,ts}'),
+ resolve(runtimeDir, '*.{mjs,js,ts}')
+ ],
+ extract: {
+ vue: (content) => {
+ return [
+ ...defaultExtractor(content),
+ ...customSafelistExtractor(options.prefix, content)
+ ]
+ }
+ }
+ }
}
})
+ // Plugins
+
addPlugin({
src: resolve(runtimeDir, 'plugins', 'colors')
})
+ // Components
+
addComponentsDir({
path: resolve(runtimeDir, 'components', 'elements'),
prefix: options.prefix,
@@ -229,6 +205,8 @@ export default defineNuxtModule({
watch: false
})
+ // Composables
+
addImportsDir(resolve(runtimeDir, 'composables'))
}
})
diff --git a/src/runtime/components/elements/Dropdown.vue b/src/runtime/components/elements/Dropdown.vue
index ec65ac75..9f1eab97 100644
--- a/src/runtime/components/elements/Dropdown.vue
+++ b/src/runtime/components/elements/Dropdown.vue
@@ -50,11 +50,11 @@ import type { PropType } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { defineComponent, ref, computed, onMounted } from 'vue'
import { defu } from 'defu'
+import { omit } from 'lodash-es'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import UKbd from '../elements/Kbd.vue'
import ULinkCustom from '../elements/LinkCustom.vue'
-import { omit } from '../../utils'
import { usePopper } from '../../composables/usePopper'
import type { Avatar } from '../../types/avatar'
import type { PopperOptions } from '../../types'
diff --git a/src/runtime/components/navigation/VerticalNavigation.vue b/src/runtime/components/navigation/VerticalNavigation.vue
index f2d232c0..c12ae4fc 100644
--- a/src/runtime/components/navigation/VerticalNavigation.vue
+++ b/src/runtime/components/navigation/VerticalNavigation.vue
@@ -42,10 +42,10 @@ import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { defu } from 'defu'
+import { omit } from 'lodash-es'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import ULinkCustom from '../elements/LinkCustom.vue'
-import { omit } from '../../utils'
import type { Avatar } from '../../types/avatar'
import { useAppConfig } from '#imports'
// TODO: Remove
diff --git a/src/runtime/plugins/colors.ts b/src/runtime/plugins/colors.ts
index de18ca0f..208438f6 100644
--- a/src/runtime/plugins/colors.ts
+++ b/src/runtime/plugins/colors.ts
@@ -1,5 +1,5 @@
import { computed } from 'vue'
-import { hexToRgb } from '../utils/colors'
+import { hexToRgb } from '../utils'
import { defineNuxtPlugin, useHead, useAppConfig, useNuxtApp } from '#imports'
import colors from '#tailwind-config/theme/colors'
diff --git a/src/runtime/utils/colors.ts b/src/runtime/utils/colors.ts
deleted file mode 100644
index b65101a7..00000000
--- a/src/runtime/utils/colors.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { omit, kebabCase } from './index'
-
-export const colorsToExclude = [
- 'inherit',
- 'transparent',
- 'current',
- 'white',
- 'black',
- 'slate',
- 'gray',
- 'zinc',
- 'neutral',
- 'stone',
- 'cool'
-]
-
-export const excludeColors = (colors: object) => Object.keys(omit(colors, colorsToExclude)).map(color => kebabCase(color)) as string[]
-
-export const colorsAsRegex = (colors: string[]): string => colors.join('|')
-
-export const hexToRgb = (hex) => {
- // 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
-}
diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts
index 24ec4ded..f4c3d87e 100644
--- a/src/runtime/utils/index.ts
+++ b/src/runtime/utils/index.ts
@@ -2,17 +2,17 @@ export function classNames (...classes: any[string]) {
return classes.filter(Boolean).join(' ')
}
-export const kebabCase = (str: string) => {
- return str
- ?.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
- ?.map(x => x.toLowerCase())
- ?.join('-')
-}
+export const hexToRgb = (hex) => {
+ // 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
+ })
-export const omit = (obj: object, keys: string[]) => {
- return Object.fromEntries(
- Object.entries(obj).filter(([key]) => !keys.includes(key))
- )
+ 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 const getSlotsChildren = (slots: any) => {