diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js
index 9c6b33253..26ba8e295 100644
--- a/src/lib/setupContextUtils.js
+++ b/src/lib/setupContextUtils.js
@@ -499,7 +499,7 @@ function registerPlugins(plugins, context) {
pluginItem(pluginApi)
}
} else {
- plugin(pluginApi)
+ plugin?.(pluginApi)
}
}
diff --git a/tests/custom-plugins.test.js b/tests/custom-plugins.test.js
new file mode 100644
index 000000000..1256ff8b4
--- /dev/null
+++ b/tests/custom-plugins.test.js
@@ -0,0 +1,1894 @@
+import createPlugin from './../plugin'
+import { run, html, css } from './util/run'
+
+test('plugins can create utilities with object syntax', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
`,
+ },
+ ],
+ plugins: [
+ function ({ addUtilities }) {
+ addUtilities({
+ '.custom-object-fill': {
+ 'object-fit': 'fill',
+ },
+ '.custom-object-contain': {
+ 'object-fit': 'contain',
+ },
+ '.custom-object-cover': {
+ 'object-fit': 'cover',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-object-fill {
+ object-fit: fill;
+ }
+ .custom-object-contain {
+ object-fit: contain;
+ }
+ .custom-object-cover {
+ object-fit: cover;
+ }
+ `)
+ })
+})
+
+test('plugins can create utilities with arrays of objects', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ function ({ addUtilities }) {
+ addUtilities([
+ {
+ '.custom-object-fill': {
+ 'object-fit': 'fill',
+ },
+ '.custom-object-contain': {
+ 'object-fit': 'contain',
+ },
+ '.custom-object-cover': {
+ 'object-fit': 'cover',
+ },
+ },
+ ])
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-object-fill {
+ object-fit: fill;
+ }
+ .custom-object-contain {
+ object-fit: contain;
+ }
+ .custom-object-cover {
+ object-fit: cover;
+ }
+ `)
+ })
+})
+
+test('plugins can create utilities with raw PostCSS nodes', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ function ({ addUtilities, postcss }) {
+ addUtilities([
+ postcss.rule({ selector: '.custom-object-fill' }).append([
+ postcss.decl({
+ prop: 'object-fit',
+ value: 'fill',
+ }),
+ ]),
+ postcss.rule({ selector: '.custom-object-contain' }).append([
+ postcss.decl({
+ prop: 'object-fit',
+ value: 'contain',
+ }),
+ ]),
+ postcss.rule({ selector: '.custom-object-cover' }).append([
+ postcss.decl({
+ prop: 'object-fit',
+ value: 'cover',
+ }),
+ ]),
+ ])
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-object-fill {
+ object-fit: fill;
+ }
+ .custom-object-contain {
+ object-fit: contain;
+ }
+ .custom-object-cover {
+ object-fit: cover;
+ }
+ `)
+ })
+})
+
+test('plugins can create utilities with mixed object styles and PostCSS nodes', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ function ({ addUtilities, postcss }) {
+ addUtilities([
+ {
+ '.custom-object-fill': {
+ objectFit: 'fill',
+ },
+ },
+ postcss.rule({ selector: '.custom-object-contain' }).append([
+ postcss.decl({
+ prop: 'object-fit',
+ value: 'contain',
+ }),
+ ]),
+ postcss.rule({ selector: '.custom-object-cover' }).append([
+ postcss.decl({
+ prop: 'object-fit',
+ value: 'cover',
+ }),
+ ]),
+ ])
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-object-fill {
+ object-fit: fill;
+ }
+ .custom-object-contain {
+ object-fit: contain;
+ }
+ .custom-object-cover {
+ object-fit: cover;
+ }
+ `)
+ })
+})
+
+test('plugins can create components with object syntax', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ function ({ addComponents }) {
+ addComponents({
+ '.btn-blue': {
+ backgroundColor: 'blue',
+ color: 'white',
+ padding: '.5rem 1rem',
+ borderRadius: '.25rem',
+ },
+ '.btn-blue:hover': {
+ backgroundColor: 'darkblue',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .btn-blue {
+ background-color: blue;
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 0.25rem;
+ }
+ .btn-blue:hover {
+ background-color: darkblue;
+ }
+ `)
+ })
+})
+
+test('plugins can add base styles with object syntax', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
`,
+ },
+ ],
+ plugins: [
+ function ({ addBase }) {
+ addBase({
+ img: {
+ maxWidth: '100%',
+ },
+ button: {
+ fontFamily: 'inherit',
+ },
+ })
+ },
+ ],
+ corePlugins: { preflight: false },
+ }
+
+ return run('@tailwind base', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ img {
+ max-width: 100%;
+ }
+ button {
+ font-family: inherit;
+ }
+ `)
+ })
+})
+
+test('plugins can add base styles with raw PostCSS nodes', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
`,
+ },
+ ],
+ plugins: [
+ function ({ addBase, postcss }) {
+ addBase([
+ postcss.rule({ selector: 'img' }).append([
+ postcss.decl({
+ prop: 'max-width',
+ value: '100%',
+ }),
+ ]),
+ postcss.rule({ selector: 'button' }).append([
+ postcss.decl({
+ prop: 'font-family',
+ value: 'inherit',
+ }),
+ ]),
+ ])
+ },
+ ],
+ corePlugins: { preflight: false },
+ }
+
+ return run('@tailwind base', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ img {
+ max-width: 100%;
+ }
+ button {
+ font-family: inherit;
+ }
+ `)
+ })
+})
+
+test('plugins can create components with raw PostCSS nodes', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ function ({ addComponents, postcss }) {
+ addComponents([
+ postcss.rule({ selector: '.btn-blue' }).append([
+ postcss.decl({
+ prop: 'background-color',
+ value: 'blue',
+ }),
+ postcss.decl({
+ prop: 'color',
+ value: 'white',
+ }),
+ postcss.decl({
+ prop: 'padding',
+ value: '.5rem 1rem',
+ }),
+ postcss.decl({
+ prop: 'border-radius',
+ value: '.25rem',
+ }),
+ ]),
+ postcss.rule({ selector: '.btn-blue:hover' }).append([
+ postcss.decl({
+ prop: 'background-color',
+ value: 'darkblue',
+ }),
+ ]),
+ ])
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .btn-blue {
+ background-color: blue;
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 0.25rem;
+ }
+ .btn-blue:hover {
+ background-color: darkblue;
+ }
+ `)
+ })
+})
+
+test('plugins can create components with mixed object styles and raw PostCSS nodes', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ function ({ addComponents, postcss }) {
+ addComponents([
+ postcss.rule({ selector: '.btn-blue' }).append([
+ postcss.decl({
+ prop: 'background-color',
+ value: 'blue',
+ }),
+ postcss.decl({
+ prop: 'color',
+ value: 'white',
+ }),
+ postcss.decl({
+ prop: 'padding',
+ value: '.5rem 1rem',
+ }),
+ postcss.decl({
+ prop: 'border-radius',
+ value: '.25rem',
+ }),
+ ]),
+ {
+ '.btn-blue:hover': {
+ backgroundColor: 'darkblue',
+ },
+ },
+ ])
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .btn-blue {
+ background-color: blue;
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 0.25rem;
+ }
+ .btn-blue:hover {
+ background-color: darkblue;
+ }
+ `)
+ })
+})
+
+test('plugins can create components with media queries with object syntax', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ function ({ addComponents }) {
+ addComponents({
+ '.custom-container': {
+ width: '100%',
+ },
+ '@media (min-width: 100px)': {
+ '.custom-container': {
+ maxWidth: '100px',
+ },
+ },
+ '@media (min-width: 200px)': {
+ '.custom-container': {
+ maxWidth: '200px',
+ },
+ },
+ '@media (min-width: 300px)': {
+ '.custom-container': {
+ maxWidth: '300px',
+ },
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-container {
+ width: 100%;
+ }
+ @media (min-width: 100px) {
+ .custom-container {
+ max-width: 100px;
+ }
+ }
+ @media (min-width: 200px) {
+ .custom-container {
+ max-width: 200px;
+ }
+ }
+ @media (min-width: 300px) {
+ .custom-container {
+ max-width: 300px;
+ }
+ }
+ `)
+ })
+})
+
+test('media queries can be defined multiple times using objects-in-array syntax', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+ `,
+ },
+ ],
+ plugins: [
+ function ({ addComponents }) {
+ addComponents([
+ {
+ '.custom-container': {
+ width: '100%',
+ },
+ '@media (min-width: 100px)': {
+ '.custom-container': {
+ maxWidth: '100px',
+ },
+ },
+ },
+ {
+ '.btn': {
+ padding: '1rem .5rem',
+ display: 'block',
+ },
+ '@media (min-width: 100px)': {
+ '.btn': {
+ display: 'inline-block',
+ },
+ },
+ },
+ ])
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-container {
+ width: 100%;
+ }
+ @media (min-width: 100px) {
+ .custom-container {
+ max-width: 100px;
+ }
+ }
+ .btn {
+ padding: 1rem 0.5rem;
+ display: block;
+ }
+ @media (min-width: 100px) {
+ .btn {
+ display: inline-block;
+ }
+ }
+ `)
+ })
+})
+
+test('plugins can create nested rules', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addComponents }) {
+ addComponents({
+ '.btn-blue': {
+ backgroundColor: 'blue',
+ color: 'white',
+ padding: '.5rem 1rem',
+ borderRadius: '.25rem',
+ '&:hover': {
+ backgroundColor: 'darkblue',
+ },
+ '@media (min-width: 500px)': {
+ '&:hover': {
+ backgroundColor: 'orange',
+ },
+ },
+ '> a': {
+ color: 'red',
+ },
+ 'h1 &': {
+ color: 'purple',
+ },
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .btn-blue {
+ background-color: blue;
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 0.25rem;
+ }
+ .btn-blue:hover {
+ background-color: darkblue;
+ }
+ @media (min-width: 500px) {
+ .btn-blue:hover {
+ background-color: orange;
+ }
+ }
+ .btn-blue > a {
+ color: red;
+ }
+ h1 .btn-blue {
+ color: purple;
+ }
+ `)
+ })
+})
+
+test('plugins can create rules with escaped selectors', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ e, addUtilities }) {
+ addUtilities({
+ [`.${e('custom-top-1/4')}`]: {
+ top: '25%',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-top-1\\/4 {
+ top: 25%;
+ }
+ `)
+ })
+})
+
+test('plugins can access the current config', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addComponents, config }) {
+ let containerClasses = [
+ {
+ '.custom-container': {
+ width: '100%',
+ },
+ },
+ ]
+
+ for (let maxWidth of Object.values(config('theme.customScreens'))) {
+ containerClasses.push({
+ [`@media (min-width: ${maxWidth})`]: {
+ '.custom-container': { maxWidth },
+ },
+ })
+ }
+
+ addComponents(containerClasses)
+ },
+ ],
+ theme: {
+ customScreens: {
+ sm: '576px',
+ md: '768px',
+ lg: '992px',
+ xl: '1200px',
+ },
+ },
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-container {
+ width: 100%;
+ }
+ @media (min-width: 576px) {
+ .custom-container {
+ max-width: 576px;
+ }
+ }
+ @media (min-width: 768px) {
+ .custom-container {
+ max-width: 768px;
+ }
+ }
+ @media (min-width: 992px) {
+ .custom-container {
+ max-width: 992px;
+ }
+ }
+ @media (min-width: 1200px) {
+ .custom-container {
+ max-width: 1200px;
+ }
+ }
+ `)
+ })
+})
+
+test('plugins can check if corePlugins are enabled', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addUtilities, corePlugins }) {
+ addUtilities({
+ '.test': {
+ 'text-color': corePlugins('textColor') ? 'true' : 'false',
+ opacity: corePlugins('opacity') ? 'true' : 'false',
+ },
+ })
+ },
+ ],
+ corePlugins: { textColor: false },
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .test {
+ text-color: false;
+ opacity: true;
+ }
+ `)
+ })
+})
+
+test('plugins can check if corePlugins are enabled when using array white-listing', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addUtilities, corePlugins }) {
+ addUtilities({
+ '.test': {
+ 'text-color': corePlugins('textColor') ? 'true' : 'false',
+ opacity: corePlugins('opacity') ? 'true' : 'false',
+ },
+ })
+ },
+ ],
+ corePlugins: ['textColor'],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .test {
+ text-color: true;
+ opacity: false;
+ }
+ `)
+ })
+})
+
+test('plugins can provide fallbacks to keys missing from the config', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addComponents, config }) {
+ addComponents({
+ '.btn': {
+ borderRadius: config('borderRadius.default', '.25rem'),
+ },
+ })
+ },
+ ],
+ theme: {
+ borderRadius: {
+ 1: '1px',
+ 2: '2px',
+ 4: '4px',
+ 8: '8px',
+ },
+ },
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .btn {
+ border-radius: 0.25rem;
+ }
+ `)
+ })
+})
+
+test('plugins can add multiple sets of utilities and components', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+ `,
+ },
+ ],
+ plugins: [
+ function ({ addUtilities, addComponents }) {
+ addComponents({
+ '.card': {
+ padding: '1rem',
+ borderRadius: '.25rem',
+ },
+ })
+
+ addUtilities({
+ '.custom-skew-12deg': {
+ transform: 'skewY(-12deg)',
+ },
+ })
+
+ addComponents({
+ '.btn': {
+ padding: '1rem .5rem',
+ display: 'inline-block',
+ },
+ })
+
+ addUtilities({
+ '.custom-border-collapse': {
+ borderCollapse: 'collapse',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components; @tailwind utilities;', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .card {
+ padding: 1rem;
+ border-radius: 0.25rem;
+ }
+ .btn {
+ padding: 1rem 0.5rem;
+ display: inline-block;
+ }
+ .custom-skew-12deg {
+ transform: skewY(-12deg);
+ }
+ .custom-border-collapse {
+ border-collapse: collapse;
+ }
+ `)
+ })
+})
+
+test('plugins respect prefix and important options by default when adding utilities', () => {
+ let config = {
+ prefix: 'tw-',
+ important: true,
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addUtilities }) {
+ addUtilities({
+ '.custom-rotate-90': {
+ transform: 'rotate(90deg)',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .tw-custom-rotate-90 {
+ transform: rotate(90deg) !important;
+ }
+ `)
+ })
+})
+
+test('when important is a selector it is used to scope utilities instead of adding !important', () => {
+ let config = {
+ prefix: 'tw-',
+ important: '#app',
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addUtilities }) {
+ addUtilities({
+ '.custom-rotate-90': {
+ transform: 'rotate(90deg)',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ #app .tw-custom-rotate-90 {
+ transform: rotate(90deg);
+ }
+ `)
+ })
+})
+
+test('when important is a selector it scopes all selectors in a rule, even though defining utilities like this is stupid', () => {
+ let config = {
+ important: '#app',
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addUtilities }) {
+ addUtilities({
+ '.custom-rotate-90, .custom-rotate-1\\/4': {
+ transform: 'rotate(90deg)',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ #app .custom-rotate-90,
+ #app .custom-rotate-1\\/4 {
+ transform: rotate(90deg);
+ transform: rotate(90deg);
+ }
+ `)
+ })
+})
+
+test('important utilities are not made double important when important option is used', () => {
+ let config = {
+ important: true,
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addUtilities }) {
+ addUtilities({
+ '.custom-rotate-90': {
+ transform: 'rotate(90deg) !important',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-rotate-90 {
+ transform: rotate(90deg) !important;
+ }
+ `)
+ })
+})
+
+test("component declarations respect the 'prefix' option by default", () => {
+ let config = {
+ prefix: 'tw-',
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addComponents }) {
+ addComponents({
+ '.btn-blue': {
+ backgroundColor: 'blue',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .tw-btn-blue {
+ background-color: blue;
+ }
+ `)
+ })
+})
+
+test('all selectors in a rule are prefixed', () => {
+ let config = {
+ prefix: 'tw-',
+ content: [
+ {
+ raw: html`
+ `,
+ },
+ ],
+ plugins: [
+ function ({ addUtilities, addComponents }) {
+ addUtilities({
+ '.custom-rotate-90, .custom-rotate-1\\/4': {
+ transform: 'rotate(90deg)',
+ },
+ })
+ addComponents({
+ '.btn-blue, .btn-red': {
+ padding: '10px',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .tw-btn-blue,
+ .tw-btn-red {
+ padding: 10px;
+ padding: 10px;
+ }
+ `)
+ })
+})
+
+test("component declarations can optionally ignore 'prefix' option", () => {
+ let config = {
+ prefix: 'tw-',
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addComponents }) {
+ addComponents(
+ {
+ '.btn-blue': {
+ backgroundColor: 'blue',
+ },
+ },
+ { respectPrefix: false }
+ )
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .btn-blue {
+ background-color: blue;
+ }
+ `)
+ })
+})
+
+test("component declarations are not affected by the 'important' option", () => {
+ let config = {
+ important: true,
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addComponents }) {
+ addComponents({
+ '.btn-blue': {
+ backgroundColor: 'blue',
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .btn-blue {
+ background-color: blue;
+ }
+ `)
+ })
+})
+
+test("plugins can apply the user's chosen prefix to components manually", () => {
+ let config = {
+ prefix: 'tw-',
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addComponents, prefix }) {
+ addComponents(
+ {
+ [prefix('.btn-blue')]: {
+ backgroundColor: 'blue',
+ },
+ },
+ { respectPrefix: false }
+ )
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .tw-btn-blue {
+ background-color: blue;
+ }
+ `)
+ })
+})
+
+test('prefix can optionally be ignored for utilities', () => {
+ let config = {
+ prefix: 'tw-',
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addUtilities }) {
+ addUtilities(
+ {
+ '.custom-rotate-90': {
+ transform: 'rotate(90deg)',
+ },
+ },
+ { respectPrefix: false }
+ )
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-rotate-90 {
+ transform: rotate(90deg);
+ }
+ `)
+ })
+})
+
+test('important can optionally be ignored for utilities', () => {
+ let config = {
+ important: true,
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addUtilities }) {
+ addUtilities(
+ {
+ '.custom-rotate-90': {
+ transform: 'rotate(90deg)',
+ },
+ },
+ { respectImportant: false }
+ )
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-rotate-90 {
+ transform: rotate(90deg);
+ }
+ `)
+ })
+})
+
+test('prefix will prefix all classes in a selector', () => {
+ let config = {
+ prefix: 'tw-',
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ addComponents, prefix }) {
+ addComponents(
+ {
+ [prefix('.btn-blue .w-1\\/4 > h1.text-xl + a .bar')]: {
+ backgroundColor: 'blue',
+ },
+ },
+ { respectPrefix: false }
+ )
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .tw-btn-blue .tw-w-1\\/4 > h1.tw-text-xl + a .tw-bar {
+ background-color: blue;
+ }
+ `)
+ })
+})
+
+test('plugins can be provided as an object with a handler function', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ {
+ handler({ addUtilities }) {
+ addUtilities({
+ '.custom-object-fill': {
+ 'object-fit': 'fill',
+ },
+ '.custom-object-contain': {
+ 'object-fit': 'contain',
+ },
+ '.custom-object-cover': {
+ 'object-fit': 'cover',
+ },
+ })
+ },
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .custom-object-fill {
+ object-fit: fill;
+ }
+ .custom-object-contain {
+ object-fit: contain;
+ }
+ .custom-object-cover {
+ object-fit: cover;
+ }
+ `)
+ })
+})
+
+test('plugins can provide a config but no handler', () => {
+ let config = {
+ content: [
+ {
+ raw: html``,
+ },
+ ],
+ plugins: [
+ {
+ config: {
+ prefix: 'tw-',
+ },
+ },
+ {
+ handler({ addUtilities }) {
+ addUtilities({
+ '.custom-object-fill': {
+ 'object-fit': 'fill',
+ },
+ '.custom-object-contain': {
+ 'object-fit': 'contain',
+ },
+ '.custom-object-cover': {
+ 'object-fit': 'cover',
+ },
+ })
+ },
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .tw-custom-object-fill {
+ object-fit: fill;
+ }
+ .tw-custom-object-contain {
+ object-fit: contain;
+ }
+ .tw-custom-object-cover {
+ object-fit: cover;
+ }
+ `)
+ })
+})
+
+test('plugins can be created using the `createPlugin` function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ screens: {
+ sm: '400px',
+ },
+ },
+ plugins: [
+ createPlugin(
+ function ({ addUtilities, theme, variants }) {
+ addUtilities(
+ Object.fromEntries(
+ Object.entries(theme('testPlugin')).map(([k, v]) => [
+ `.test-${k}`,
+ { testProperty: v },
+ ])
+ ),
+ variants('testPlugin')
+ )
+ },
+ {
+ theme: {
+ testPlugin: {
+ sm: '1rem',
+ md: '2rem',
+ lg: '3rem',
+ },
+ },
+ }
+ ),
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .test-sm {
+ test-property: 1rem;
+ }
+ .test-md {
+ test-property: 2rem;
+ }
+ .test-lg {
+ test-property: 3rem;
+ }
+ .hover\\:test-sm:hover {
+ test-property: 1rem;
+ }
+ @media (min-width: 400px) {
+ .sm\\:test-sm {
+ test-property: 1rem;
+ }
+ }
+ `)
+ })
+})
+
+test('plugins with extra options can be created using the `createPlugin.withOptions` function', () => {
+ let plugin = createPlugin.withOptions(
+ function ({ className }) {
+ return function ({ addUtilities, theme }) {
+ addUtilities(
+ Object.fromEntries(
+ Object.entries(theme('testPlugin')).map(([k, v]) => [
+ `.${className}-${k}`,
+ { testProperty: v },
+ ])
+ )
+ )
+ }
+ },
+ function () {
+ return {
+ theme: {
+ testPlugin: {
+ sm: '1rem',
+ md: '2rem',
+ lg: '3rem',
+ },
+ },
+ }
+ }
+ )
+
+ let config = {
+ content: [
+ { raw: html`` },
+ ],
+ corePlugins: [],
+ theme: {
+ screens: {
+ sm: '400px',
+ },
+ },
+ plugins: [plugin({ className: 'banana' })],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .banana-sm {
+ test-property: 1rem;
+ }
+ .banana-md {
+ test-property: 2rem;
+ }
+ .banana-lg {
+ test-property: 3rem;
+ }
+ .hover\\:banana-sm:hover {
+ test-property: 1rem;
+ }
+ @media (min-width: 400px) {
+ .sm\\:banana-sm {
+ test-property: 1rem;
+ }
+ }
+ `)
+ })
+})
+
+test('plugins should cache correctly', () => {
+ let plugin = createPlugin.withOptions(({ className = 'banana' } = {}) => ({ addComponents }) => {
+ addComponents({ [`.${className}`]: { position: 'absolute' } })
+ })
+
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ screens: {
+ sm: '400px',
+ },
+ },
+ }
+
+ function internalRun(options = {}) {
+ return run('@tailwind components', {
+ ...config,
+ plugins: [plugin(options)],
+ })
+ }
+
+ return Promise.all([internalRun(), internalRun({ className: 'apple' })]).then(
+ ([result1, result2]) => {
+ let expected1 = css`
+ .banana {
+ position: absolute;
+ }
+
+ @media (min-width: 400px) {
+ .sm\\:banana {
+ position: absolute;
+ }
+ }
+ `
+
+ let expected2 = css`
+ .apple {
+ position: absolute;
+ }
+
+ @media (min-width: 400px) {
+ .sm\\:apple {
+ position: absolute;
+ }
+ }
+ `
+
+ expect(result1.css).toMatchCss(expected1)
+ expect(result2.css).toMatchCss(expected2)
+ }
+ )
+})
+
+test('plugins created using `createPlugin.withOptions` do not need to be invoked if the user wants to use the default options', () => {
+ let plugin = createPlugin.withOptions(
+ function ({ className } = { className: 'banana' }) {
+ return function ({ addUtilities, theme }) {
+ addUtilities(
+ Object.fromEntries(
+ Object.entries(theme('testPlugin')).map(([k, v]) => [
+ `.${className}-${k}`,
+ { testProperty: v },
+ ])
+ )
+ )
+ }
+ },
+ function () {
+ return {
+ theme: {
+ testPlugin: {
+ sm: '1rem',
+ md: '2rem',
+ lg: '3rem',
+ },
+ },
+ variants: {
+ testPlugin: ['responsive', 'hover'],
+ },
+ }
+ }
+ )
+
+ let config = {
+ content: [
+ { raw: html`` },
+ ],
+ corePlugins: [],
+ theme: {
+ screens: {
+ sm: '400px',
+ },
+ },
+ plugins: [plugin],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .banana-sm {
+ test-property: 1rem;
+ }
+ .banana-md {
+ test-property: 2rem;
+ }
+ .banana-lg {
+ test-property: 3rem;
+ }
+ .hover\\:banana-sm:hover {
+ test-property: 1rem;
+ }
+ @media (min-width: 400px) {
+ .sm\\:banana-sm {
+ test-property: 1rem;
+ }
+ }
+ `)
+ })
+})
+
+test('the configFunction parameter is optional when using the `createPlugin.withOptions` function', () => {
+ let plugin = createPlugin.withOptions(function ({ className }) {
+ return function ({ addUtilities, theme }) {
+ addUtilities(
+ Object.fromEntries(
+ Object.entries(theme('testPlugin')).map(([k, v]) => [
+ `.${className}-${k}`,
+ { testProperty: v },
+ ])
+ )
+ )
+ }
+ })
+
+ let config = {
+ content: [
+ { raw: html`` },
+ ],
+ corePlugins: [],
+ theme: {
+ screens: {
+ sm: '400px',
+ },
+ testPlugin: {
+ sm: '1px',
+ md: '2px',
+ lg: '3px',
+ },
+ },
+ plugins: [plugin({ className: 'banana' })],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .banana-sm {
+ test-property: 1px;
+ }
+ .banana-md {
+ test-property: 2px;
+ }
+ .banana-lg {
+ test-property: 3px;
+ }
+ .hover\\:banana-sm:hover {
+ test-property: 1px;
+ }
+ @media (min-width: 400px) {
+ .sm\\:banana-sm {
+ test-property: 1px;
+ }
+ }
+ `)
+ })
+})
+
+test('keyframes are not escaped', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ plugins: [
+ function ({ matchUtilities }) {
+ matchUtilities({
+ foo: (value, { includeRules }) => {
+ includeRules(
+ [
+ {
+ [`@keyframes ${value}`]: {
+ '25.001%': {
+ color: 'black',
+ },
+ },
+ },
+ ],
+ { respectImportant: false }
+ )
+
+ return {
+ animation: `${value} 1s infinite`,
+ }
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind utilities', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ @keyframes abc {
+ 25.001% {
+ color: black;
+ }
+ }
+ .foo-\\[abc\\] {
+ animation: abc 1s infinite;
+ }
+ `)
+ })
+})
+
+test('font sizes are retrieved without default line-heights or letter-spacing using theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ fontSize: {
+ sm: ['14px', '20px'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ fontSize: theme('fontSize.sm'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ font-size: 14px;
+ }
+ `)
+ })
+})
+
+test('outlines are retrieved without outline-offset using theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ outline: {
+ black: ['2px dotted black', '4px'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ outline: theme('outline.black'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ outline: 2px dotted black;
+ }
+ `)
+ })
+})
+
+test('box-shadow values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ boxShadow: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ boxShadow: theme('boxShadow.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ box-shadow: width, height;
+ }
+ `)
+ })
+})
+
+test('transition-property values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ transitionProperty: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ transitionProperty: theme('transitionProperty.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ transition-property: width, height;
+ }
+ `)
+ })
+})
+
+test('transition-duration values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ transitionDuration: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ transitionDuration: theme('transitionDuration.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ transition-duration: width, height;
+ }
+ `)
+ })
+})
+
+test('transition-delay values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ transitionDuration: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ transitionDuration: theme('transitionDuration.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ transition-duration: width, height;
+ }
+ `)
+ })
+})
+
+test('transition-timing-function values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ transitionTimingFunction: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ transitionTimingFunction: theme('transitionTimingFunction.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ transition-timing-function: width, height;
+ }
+ `)
+ })
+})
+
+test('background-image values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ backgroundImage: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ backgroundImage: theme('backgroundImage.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ background-image: width, height;
+ }
+ `)
+ })
+})
+
+test('background-size values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ backgroundSize: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ backgroundSize: theme('backgroundSize.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ background-size: width, height;
+ }
+ `)
+ })
+})
+
+test('background-color values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ backgroundColor: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ backgroundColor: theme('backgroundColor.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ background-color: width, height;
+ }
+ `)
+ })
+})
+
+test('cursor values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ cursor: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ cursor: theme('cursor.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ cursor: width, height;
+ }
+ `)
+ })
+})
+
+test('animation values are joined when retrieved using the theme function', () => {
+ let config = {
+ content: [{ raw: html`` }],
+ corePlugins: [],
+ theme: {
+ animation: {
+ lol: ['width', 'height'],
+ },
+ },
+ plugins: [
+ function ({ addComponents, theme }) {
+ addComponents({
+ '.foo': {
+ animation: theme('animation.lol'),
+ },
+ })
+ },
+ ],
+ }
+
+ return run('@tailwind components', config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo {
+ animation: width, height;
+ }
+ `)
+ })
+})