Skip to content

Commit

Permalink
feat(module): smart safelisting (#268)
Browse files Browse the repository at this point in the history
Co-authored-by: Sébastien Chopin <seb@nuxtjs.com>
  • Loading branch information
benjamincanac and atinux authored Jun 13, 2023
1 parent e12e974 commit 20fa4d2
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 120 deletions.
4 changes: 2 additions & 2 deletions docs/components/ThemeSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/content/1.getting-started/2.installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 32 additions & 2 deletions docs/content/1.getting-started/3.theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<UButton />`. 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

Expand Down
4 changes: 3 additions & 1 deletion docs/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ui from '../src/module'
import colors from 'tailwindcss/colors'

export default defineNuxtConfig({
// @ts-ignore
Expand All @@ -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,
Expand Down
151 changes: 151 additions & 0 deletions src/colors.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading

1 comment on commit 20fa4d2

@vercel
Copy link

@vercel vercel bot commented on 20fa4d2 Jun 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

ui – ./

ui.nuxtlabs.com
ui-git-dev-nuxtlabs.vercel.app
ui-nuxtlabs.vercel.app

Please sign in to comment.