From 8333d46caeb4a40b647b54777c3f1acb56cae9b8 Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Wed, 20 Jun 2018 12:21:14 -0400 Subject: [PATCH] Add variant prefix to last class in a selector, not the first --- __tests__/responsiveAtRule.test.js | 245 +++++++++++++++++++++++++ src/lib/substituteResponsiveAtRules.js | 6 +- src/lib/substituteVariantsAtRules.js | 10 +- src/util/buildClassVariant.js | 5 - src/util/buildSelectorVariant.js | 16 ++ 5 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 __tests__/responsiveAtRule.test.js delete mode 100644 src/util/buildClassVariant.js create mode 100644 src/util/buildSelectorVariant.js diff --git a/__tests__/responsiveAtRule.test.js b/__tests__/responsiveAtRule.test.js new file mode 100644 index 000000000..783fbf79b --- /dev/null +++ b/__tests__/responsiveAtRule.test.js @@ -0,0 +1,245 @@ +import postcss from 'postcss' +import plugin from '../src/lib/substituteResponsiveAtRules' +import config from '../defaultConfig.stub.js' + +function run(input, opts = () => config) { + return postcss([plugin(opts)]).process(input, { from: undefined }) +} + +test('it can generate responsive variants', () => { + const input = ` + @responsive { + .banana { color: yellow; } + .chocolate { color: brown; } + } + ` + + const output = ` + .banana { color: yellow; } + .chocolate { color: brown; } + @media (min-width: 500px) { + .sm\\:banana { color: yellow; } + .sm\\:chocolate { color: brown; } + } + @media (min-width: 750px) { + .md\\:banana { color: yellow; } + .md\\:chocolate { color: brown; } + } + @media (min-width: 1000px) { + .lg\\:banana { color: yellow; } + .lg\\:chocolate { color: brown; } + } + ` + + return run(input, () => ({ + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + options: { + separator: ':', + }, + })).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('it can generate responsive variants with a custom separator', () => { + const input = ` + @responsive { + .banana { color: yellow; } + .chocolate { color: brown; } + } + ` + + const output = ` + .banana { color: yellow; } + .chocolate { color: brown; } + @media (min-width: 500px) { + .sm__banana { color: yellow; } + .sm__chocolate { color: brown; } + } + @media (min-width: 750px) { + .md__banana { color: yellow; } + .md__chocolate { color: brown; } + } + @media (min-width: 1000px) { + .lg__banana { color: yellow; } + .lg__chocolate { color: brown; } + } + ` + + return run(input, () => ({ + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + options: { + separator: '__', + }, + })).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('responsive variants are grouped', () => { + const input = ` + @responsive { + .banana { color: yellow; } + } + + .apple { color: red; } + + @responsive { + .chocolate { color: brown; } + } + ` + + const output = ` + .banana { color: yellow; } + .apple { color: red; } + .chocolate { color: brown; } + @media (min-width: 500px) { + .sm\\:banana { color: yellow; } + .sm\\:chocolate { color: brown; } + } + @media (min-width: 750px) { + .md\\:banana { color: yellow; } + .md\\:chocolate { color: brown; } + } + @media (min-width: 1000px) { + .lg\\:banana { color: yellow; } + .lg\\:chocolate { color: brown; } + } + ` + + return run(input, () => ({ + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + options: { + separator: ':', + }, + })).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('screen prefix is only applied to the last class in a selector', () => { + const input = ` + @responsive { + .banana li * .sandwich #foo > div { color: yellow; } + } + ` + + const output = ` + .banana li * .sandwich #foo > div { color: yellow; } + @media (min-width: 500px) { + .banana li * .sm\\:sandwich #foo > div { color: yellow; } + } + @media (min-width: 750px) { + .banana li * .md\\:sandwich #foo > div { color: yellow; } + } + @media (min-width: 1000px) { + .banana li * .lg\\:sandwich #foo > div { color: yellow; } + } + ` + + return run(input, () => ({ + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + options: { + separator: ':', + }, + })).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('responsive variants are generated for all selectors in a rule', () => { + const input = ` + @responsive { + .foo, .bar { color: yellow; } + } + ` + + const output = ` + .foo, .bar { color: yellow; } + @media (min-width: 500px) { + .sm\\:foo, .sm\\:bar { color: yellow; } + } + @media (min-width: 750px) { + .md\\:foo, .md\\:bar { color: yellow; } + } + @media (min-width: 1000px) { + .lg\\:foo, .lg\\:bar { color: yellow; } + } + ` + + return run(input, () => ({ + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + options: { + separator: ':', + }, + })).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('selectors with no classes cannot be made responsive', () => { + const input = ` + @responsive { + div { color: yellow; } + } + ` + expect.assertions(1) + return run(input, () => ({ + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + options: { + separator: ':', + }, + })).catch(e => { + expect(e).toMatchObject({ name: 'CssSyntaxError' }) + }) +}) + +test('all selectors in a rule must contain classes', () => { + const input = ` + @responsive { + .foo, div { color: yellow; } + } + ` + expect.assertions(1) + return run(input, () => ({ + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + options: { + separator: ':', + }, + })).catch(e => { + expect(e).toMatchObject({ name: 'CssSyntaxError' }) + }) +}) diff --git a/src/lib/substituteResponsiveAtRules.js b/src/lib/substituteResponsiveAtRules.js index 9adf8200d..bc0b911b5 100644 --- a/src/lib/substituteResponsiveAtRules.js +++ b/src/lib/substituteResponsiveAtRules.js @@ -2,7 +2,7 @@ import _ from 'lodash' import postcss from 'postcss' import cloneNodes from '../util/cloneNodes' import buildMediaQuery from '../util/buildMediaQuery' -import buildClassVariant from '../util/buildClassVariant' +import buildSelectorVariant from '../util/buildSelectorVariant' export default function(config) { return function(css) { @@ -28,7 +28,9 @@ export default function(config) { responsiveRules.map(rule => { const cloned = rule.clone() cloned.selectors = _.map(rule.selectors, selector => - buildClassVariant(selector, screen, separator) + buildSelectorVariant(selector, screen, separator, message => { + throw rule.error(message) + }) ) return cloned }) diff --git a/src/lib/substituteVariantsAtRules.js b/src/lib/substituteVariantsAtRules.js index 7d42537f1..5ddaa8ea5 100644 --- a/src/lib/substituteVariantsAtRules.js +++ b/src/lib/substituteVariantsAtRules.js @@ -1,9 +1,9 @@ import _ from 'lodash' import postcss from 'postcss' -import buildClassVariant from '../util/buildClassVariant' +import buildSelectorVariant from '../util/buildSelectorVariant' function buildPseudoClassVariant(selector, pseudoClass, separator) { - return `${buildClassVariant(selector, pseudoClass, separator)}:${pseudoClass}` + return `${buildSelectorVariant(selector, pseudoClass, separator)}:${pseudoClass}` } function generatePseudoClassVariant(pseudoClass) { @@ -23,7 +23,11 @@ const variantGenerators = { const cloned = container.clone() cloned.walkRules(rule => { - rule.selector = `.group:hover ${buildClassVariant(rule.selector, 'group-hover', separator)}` + rule.selector = `.group:hover ${buildSelectorVariant( + rule.selector, + 'group-hover', + separator + )}` }) container.before(cloned.nodes) diff --git a/src/util/buildClassVariant.js b/src/util/buildClassVariant.js deleted file mode 100644 index a9d1377ec..000000000 --- a/src/util/buildClassVariant.js +++ /dev/null @@ -1,5 +0,0 @@ -import escapeClassName from './escapeClassName' - -export default function buildClassVariant(className, variantName, separator) { - return `.${variantName}${escapeClassName(separator)}${className.slice(1)}` -} diff --git a/src/util/buildSelectorVariant.js b/src/util/buildSelectorVariant.js new file mode 100644 index 000000000..5fbb0e9d0 --- /dev/null +++ b/src/util/buildSelectorVariant.js @@ -0,0 +1,16 @@ +import escapeClassName from './escapeClassName' +import parser from 'postcss-selector-parser' +import tap from 'lodash/tap' + +export default function buildSelectorVariant(selector, variantName, separator, onError = () => {}) { + return parser(selectors => { + tap(selectors.first.filter(({ type }) => type === 'class').pop(), classSelector => { + if (classSelector === undefined) { + onError('Variant cannot be generated because selector contains no classes.') + return + } + + classSelector.value = `${variantName}${escapeClassName(separator)}${classSelector.value}` + }) + }).processSync(selector) +}