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) => {