diff --git a/CHANGELOG.md b/CHANGELOG.md index b14521253c84..480d77632a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correctly parse shadow lengths without a leading zero ([#7289](https://github.com/tailwindlabs/tailwindcss/pull/7289)) - Don't crash when scanning extremely long class candidates ([#7331](https://github.com/tailwindlabs/tailwindcss/pull/7331)) - Use less hacky fix for urls detected as custom properties ([#7275](https://github.com/tailwindlabs/tailwindcss/pull/7275)) +- Correctly generate negative utilities when dash is before the prefix ([#7295](https://github.com/tailwindlabs/tailwindcss/pull/7295)) +- Detect prefixed negative utilities in the safelist ([#7295](https://github.com/tailwindlabs/tailwindcss/pull/7295)) ## [3.0.18] - 2022-01-28 diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 3f6e2a013ea6..63caa54398c7 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -63,9 +63,23 @@ function applyPrefix(matches, context) { let [meta] = match if (meta.options.respectPrefix) { let container = postcss.root({ nodes: [match[1].clone()] }) + let classCandidate = match[1].raws.tailwind.classCandidate + container.walkRules((r) => { - r.selector = prefixSelector(context.tailwindConfig.prefix, r.selector) + // If this is a negative utility with a dash *before* the prefix we + // have to ensure that the generated selector matches the candidate + + // Not doing this will cause `-tw-top-1` to generate the class `.tw--top-1` + // The disconnect between candidate <-> class can cause @apply to hard crash. + let shouldPrependNegative = classCandidate.startsWith('-') + + r.selector = prefixSelector( + context.tailwindConfig.prefix, + r.selector, + shouldPrependNegative + ) }) + match[1] = container.nodes[0] } } @@ -371,6 +385,14 @@ function splitWithSeparator(input, separator) { return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g')) } +function* recordCandidates(matches, classCandidate) { + for (const match of matches) { + match[1].raws.tailwind = { classCandidate } + + yield match + } +} + function* resolveMatches(candidate, context) { let separator = context.tailwindConfig.separator let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse() @@ -482,7 +504,9 @@ function* resolveMatches(candidate, context) { continue } - matches = applyPrefix(matches.flat(), context) + matches = matches.flat() + matches = Array.from(recordCandidates(matches, classCandidate)) + matches = applyPrefix(matches, context) if (important) { matches = applyImportant(matches, context) diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index b41df0b930ff..9461b76fa512 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -666,17 +666,30 @@ function registerPlugins(plugins, context) { if (checks.length > 0) { let patternMatchingCount = new Map() + let prefixLength = context.tailwindConfig.prefix.length for (let util of classList) { let utils = Array.isArray(util) ? (() => { let [utilName, options] = util - let classes = Object.keys(options?.values ?? {}).map((value) => - formatClass(utilName, value) - ) + let values = Object.keys(options?.values ?? {}) + let classes = values.map((value) => formatClass(utilName, value)) if (options?.supportsNegativeValues) { + // This is the normal negated version + // e.g. `-inset-1` or `-tw-inset-1` classes = [...classes, ...classes.map((cls) => '-' + cls)] + + // This is the negated version *after* the prefix + // e.g. `tw--inset-1` + // The prefix is already attached to util name + // So we add the negative after the prefix + classes = [ + ...classes, + ...classes.map( + (cls) => cls.slice(0, prefixLength) + '-' + cls.slice(prefixLength) + ), + ] } return classes diff --git a/src/util/prefixSelector.js b/src/util/prefixSelector.js index cf056bcabbfa..34ce7d57c413 100644 --- a/src/util/prefixSelector.js +++ b/src/util/prefixSelector.js @@ -1,12 +1,14 @@ import parser from 'postcss-selector-parser' -import { tap } from './tap' -export default function (prefix, selector) { +export default function (prefix, selector, prependNegative = false) { return parser((selectors) => { selectors.walkClasses((classSelector) => { - tap(classSelector.value, (baseClass) => { - classSelector.value = `${prefix}${baseClass}` - }) + let baseClass = classSelector.value + let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-') + + classSelector.value = shouldPlaceNegativeBeforePrefix + ? `-${prefix}${baseClass.slice(1)}` + : `${prefix}${baseClass}` }) }).processSync(selector) } diff --git a/tests/getClassList.test.js b/tests/getClassList.test.js index 9289ed98c066..a4c0b47e364a 100644 --- a/tests/getClassList.test.js +++ b/tests/getClassList.test.js @@ -5,22 +5,57 @@ it('should generate every possible class, without variants', () => { let config = {} let context = createContext(resolveConfig(config)) - expect(context.getClassList()).toBeInstanceOf(Array) + let classes = context.getClassList() + expect(classes).toBeInstanceOf(Array) // Verify we have a `container` for the 'components' section. - expect(context.getClassList()).toContain('container') + expect(classes).toContain('container') // Verify we handle the DEFAULT case correctly - expect(context.getClassList()).toContain('border') + expect(classes).toContain('border') // Verify we handle negative values correctly - expect(context.getClassList()).toContain('-inset-1/4') - expect(context.getClassList()).toContain('-m-0') - expect(context.getClassList()).not.toContain('-uppercase') - expect(context.getClassList()).not.toContain('-opacity-50') - expect( - createContext( - resolveConfig({ theme: { extend: { margin: { DEFAULT: '5px' } } } }) - ).getClassList() - ).not.toContain('-m-DEFAULT') + expect(classes).toContain('-inset-1/4') + expect(classes).toContain('-m-0') + expect(classes).not.toContain('-uppercase') + expect(classes).not.toContain('-opacity-50') + + config = { theme: { extend: { margin: { DEFAULT: '5px' } } } } + context = createContext(resolveConfig(config)) + classes = context.getClassList() + + expect(classes).not.toContain('-m-DEFAULT') +}) + +it('should generate every possible class while handling negatives and prefixes', () => { + let config = { prefix: 'tw-' } + let context = createContext(resolveConfig(config)) + let classes = context.getClassList() + expect(classes).toBeInstanceOf(Array) + + // Verify we have a `container` for the 'components' section. + expect(classes).toContain('tw-container') + + // Verify we handle the DEFAULT case correctly + expect(classes).toContain('tw-border') + + // Verify we handle negative values correctly + expect(classes).toContain('-tw-inset-1/4') + expect(classes).toContain('-tw-m-0') + expect(classes).not.toContain('-tw-uppercase') + expect(classes).not.toContain('-tw-opacity-50') + + // These utilities do work but there's no reason to generate + // them alongside the `-{prefix}-{utility}` versions + expect(classes).not.toContain('tw--inset-1/4') + expect(classes).not.toContain('tw--m-0') + + config = { + prefix: 'tw-', + theme: { extend: { margin: { DEFAULT: '5px' } } }, + } + context = createContext(resolveConfig(config)) + classes = context.getClassList() + + expect(classes).not.toContain('-tw-m-DEFAULT') }) diff --git a/tests/prefix.test.js b/tests/prefix.test.js index 20fb279adbef..9124fd5b64c0 100644 --- a/tests/prefix.test.js +++ b/tests/prefix.test.js @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' -import { run, css } from './util/run' +import { run, html, css } from './util/run' test('prefix', () => { let config = { @@ -73,3 +73,288 @@ test('prefix', () => { expect(result.css).toMatchFormattedCss(expected) }) }) + +it('negative values: marker before prefix', async () => { + let config = { + prefix: 'tw-', + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .-tw-top-1 { + top: -0.25rem; + } + `) +}) + +it('negative values: marker after prefix', async () => { + let config = { + prefix: 'tw-', + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .tw--top-1 { + top: -0.25rem; + } + `) +}) + +it('negative values: marker before prefix and arbitrary value', async () => { + let config = { + prefix: 'tw-', + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .-tw-top-\[1px\] { + top: -1px; + } + `) +}) + +it('negative values: marker after prefix and arbitrary value', async () => { + let config = { + prefix: 'tw-', + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .tw--top-\[1px\] { + top: -1px; + } + `) +}) + +it('negative values: no marker and arbitrary value', async () => { + let config = { + prefix: 'tw-', + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .tw-top-\[-1px\] { + top: -1px; + } + `) +}) + +it('negative values: variant versions', async () => { + let config = { + prefix: 'tw-', + content: [ + { + raw: html` +
+
+
+ + +
+ `, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .hover\:-tw-top-1:hover { + top: -0.25rem; + } + .hover\:tw--top-1:hover { + top: -0.25rem; + } + .hover\:-tw-top-\[1px\]:hover { + top: -1px; + } + .hover\:tw--top-\[1px\]:hover { + top: -1px; + } + .hover\:tw-top-\[-1px\]:hover { + top: -1px; + } + `) +}) + +it('negative values: prefix and apply', async () => { + let config = { + prefix: 'tw-', + content: [{ raw: html`` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + + .a { + @apply hover:tw--top-1; + } + .b { + @apply hover:-tw-top-1; + } + .c { + @apply hover:-tw-top-[1px]; + } + .d { + @apply hover:tw--top-[1px]; + } + .e { + @apply hover:tw-top-[-1px]; + } + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .a:hover { + top: -0.25rem; + } + .b:hover { + top: -0.25rem; + } + .c:hover { + top: -1px; + } + .d:hover { + top: -1px; + } + .e:hover { + top: -1px; + } + `) +}) + +it('negative values: prefix in the safelist', async () => { + let config = { + prefix: 'tw-', + safelist: [{ pattern: /-tw-top-1/g }, { pattern: /tw--top-1/g }], + theme: { + inset: { + 1: '0.25rem', + }, + }, + content: [{ raw: html`` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .-tw-top-1 { + top: -0.25rem; + } + .tw--top-1 { + top: -0.25rem; + } + `) +}) + +it('prefix with negative values and variants in the safelist', async () => { + let config = { + prefix: 'tw-', + safelist: [ + { pattern: /-tw-top-1/, variants: ['hover', 'sm:hover'] }, + { pattern: /tw--top-1/, variants: ['hover', 'sm:hover'] }, + ], + theme: { + inset: { + 1: '0.25rem', + }, + }, + content: [{ raw: html`` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + await run(input, config) + + const result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .-tw-top-1 { + top: -0.25rem; + } + .tw--top-1 { + top: -0.25rem; + } + .hover\:-tw-top-1:hover { + top: -0.25rem; + } + + .hover\:tw--top-1:hover { + top: -0.25rem; + } + @media (min-width: 640px) { + .sm\:hover\:-tw-top-1:hover { + top: -0.25rem; + } + .sm\:hover\:tw--top-1:hover { + top: -0.25rem; + } + } + `) +})