diff --git a/client/src/utils/__fixtures/curriculum-helpers-css.js b/client/src/utils/__fixtures/curriculum-helpers-css.js index e34371664f..8ac1bbc3f9 100644 --- a/client/src/utils/__fixtures/curriculum-helpers-css.js +++ b/client/src/utils/__fixtures/curriculum-helpers-css.js @@ -37,6 +37,313 @@ a { } }`; +export const cssString = `:root { + --building-color1: #aa80ff; + --building-color2: #66cc99; + --building-color3: #cc6699; + --building-color4: #538cc6; + --window-color1: #bb99ff; + --window-color2: #8cd9b3; + --window-color3: #d98cb3; + --window-color4: #8cb3d9; +} + +* { + box-sizing: border-box; +} + +body { + height: 100vh; + margin: 0; + overflow: hidden; +} + +.background-buildings, +.foreground-buildings { + width: 100%; + height: 100%; + display: flex; + align-items: flex-end; + justify-content: space-evenly; + position: absolute; + top: 0; +} + +.building-wrap { + display: flex; + flex-direction: column; + align-items: center; +} + +.window-wrap { + display: flex; + align-items: center; + justify-content: space-evenly; +} + +.sky { + background: radial-gradient( + closest-corner circle at 15% 15%, + #ffcf33, + #ffcf33 20%, + #ffff66 21%, + #bbeeff 100% + ); +} + +/* BACKGROUND BUILDINGS - "bb" stands for "background building" */ +.bb1 { + width: 10%; + height: 70%; +} + +.bb1a { + width: 70%; +} + +.bb1b { + width: 80%; +} + +.bb1c { + width: 90%; +} + +.bb1d { + width: 100%; + height: 70%; + background: linear-gradient(var(--building-color1) 50%, var(--window-color1)); +} + +.bb1-window { + height: 10%; + background: linear-gradient(var(--building-color1), var(--window-color1)); +} + +.bb2 { + width: 10%; + height: 50%; +} + +.bb2a { + border-bottom: 5vh solid var(--building-color2); + border-left: 5vw solid transparent; + border-right: 5vw solid transparent; +} + +.bb2b { + width: 100%; + height: 100%; + background: repeating-linear-gradient( + var(--building-color2), + var(--building-color2) 6%, + var(--window-color2) 6%, + var(--window-color2) 9% + ); +} + +.bb3 { + width: 10%; + height: 55%; + background: repeating-linear-gradient( + 90deg, + var(--building-color3), + var(--building-color3), + var(--window-color3) 15% + ); +} + +.bb4 { + width: 11%; + height: 58%; +} + +.bb4a { + width: 3%; + height: 10%; + background-color: var(--building-color4); +} + +.bb4b { + width: 80%; + height: 5%; + background-color: var(--building-color4); +} + +.bb4c { + width: 100%; + height: 85%; + background-color: var(--building-color4); +} + +.bb4-window { + width: 18%; + height: 90%; + background-color: var(--window-color4); +} + +/* FOREGROUND BUILDINGS - "fb" stands for "foreground building" */ +.fb1 { + width: 10%; + height: 60%; +} + +.fb1a { + border-bottom: 7vh solid var(--building-color4); + border-left: 2vw solid transparent; + border-right: 2vw solid transparent; +} + +.fb1b { + width: 60%; + height: 10%; + background-color: var(--building-color4); +} + +.fb1c { + width: 100%; + height: 80%; + background: repeating-linear-gradient( + 90deg, + var(--building-color4), + var(--building-color4) 10%, + transparent 10%, + transparent 15% + ), + repeating-linear-gradient( + var(--building-color4), + var(--building-color4) 10%, + var(--window-color4) 10%, + var(--window-color4) 90% + ); +} + +.fb2 { + width: 10%; + height: 40%; +} + +.fb2a { + width: 100%; + border-bottom: 10vh solid var(--building-color3); + border-left: 1vw solid transparent; + border-right: 1vw solid transparent; +} + +.fb2b { + width: 100%; + height: 75%; + background-color: var(--building-color3); +} + +.fb2-window { + width: 22%; + height: 100%; + background-color: var(--window-color3); +} + +.fb3 { + width: 10%; + height: 35%; +} + +.fb3a { + width: 80%; + height: 15%; + background-color: var(--building-color1); +} + +.fb3b { + width: 100%; + height: 35%; + background-color: var(--building-color1); +} + +.fb3-window { + width: 25%; + height: 80%; + background-color: var(--window-color1); +} + +.fb4 { + width: 8%; + height: 45%; + position: relative; + left: 10%; +} + +.fb4a { + border-top: 5vh solid transparent; + border-left: 8vw solid var(--building-color1); +} + +.fb4b { + width: 100%; + height: 89%; + background-color: var(--building-color1); + display: flex; + flex-wrap: wrap; +} + +.fb4-window { + width: 30%; + height: 10%; + border-radius: 50%; + background-color: var(--window-color1); + margin: 10%; +} + +.fb5 { + width: 10%; + height: 33%; + position: relative; + right: 10%; + background: repeating-linear-gradient( + var(--building-color2), + var(--building-color2) 5%, + transparent 5%, + transparent 10% + ), + repeating-linear-gradient( + 90deg, + var(--building-color2), + var(--building-color2) 12%, + var(--window-color2) 12%, + var(--window-color2) 44% + ); +} + +.fb6 { + width: 9%; + height: 38%; + background: repeating-linear-gradient( + 90deg, + var(--building-color3), + var(--building-color3) 10%, + transparent 10%, + transparent 30% + ), + repeating-linear-gradient( + var(--building-color3), + var(--building-color3) 10%, + var(--window-color3) 10%, + var(--window-color3) 30% + ); +} + +@media (max-width: 1000px) { + .sky { + background: radial-gradient( + closest-corner circle at 15% 15%, + #ffcf33, + #ffcf33 20%, + #ffff66 21%, + #bbeeff 100% + ); + } +} +`; + const testValues = { cssFullExample, cssCodeWithCommentsRemoved diff --git a/client/src/utils/css-help.test.ts b/client/src/utils/css-help.test.ts new file mode 100644 index 0000000000..041b19de28 --- /dev/null +++ b/client/src/utils/css-help.test.ts @@ -0,0 +1,95 @@ +/* global describe it expect */ +import { cssString } from './__fixtures/curriculum-helpers-css'; +import CSSHelp from './css-help'; + +describe('css-help', () => { + const doc = document; + let t: CSSHelp; + beforeEach(() => { + const style = doc.createElement('style'); + style.innerHTML = cssString as string; + doc.head.appendChild(style); + t = new CSSHelp(doc); + // JSDOM does not implement latest CSSOM spec. As such, + // conditionText property needs to be manually added. + // REF: https://github.com/freeCodeCamp/freeCodeCamp/pull/42148#issuecomment-847291137 + const mediaRule = t.getCSSRules('media')?.[0] as CSSMediaRule; + const conditionText = mediaRule.media[0]; + mediaRule.conditionText = conditionText; + }); + describe('getStyleDeclaration', () => { + it('should return a CSSStyleDeclartion array of length 1', () => { + expect(t.getStyleDeclarations('*')?.length).toEqual(1); + }); + it('should return a non-empty CSSStyleDeclaration array', () => { + expect(t.getStyleDeclaration('.bb1')).toBeTruthy(); + }); + }); + describe('isPropertyUsed', () => { + it('should return true on existing properties', () => { + expect(t.isPropertyUsed('height')).toBeTruthy(); + }); + it('should return true on existing custom properties', () => { + expect(t.isPropertyUsed('--building-color1')).toBeTruthy(); + }); + }); + describe('isDeclaredAfter', () => { + it('should return true if existing style is declared after another', () => { + expect(t.getStyleRule('.bb1a')?.isDeclaredAfter('.bb1')).toBeTruthy(); + }); + }); + describe('getPropertyValue', () => { + it('should return custom property value needing trim', () => { + expect( + t + .getStyleDeclaration(':root') + ?.getPropertyValue('--building-color1') + .trim() + ).toEqual('#aa80ff'); + }); + it('should return value to existing property', () => { + expect( + t.getStyleDeclaration('.bb4a')?.getPropertyValue('background-color') + ).toBeTruthy(); + }); + it('should return property value without evaluating result', () => { + expect( + t.getStyleDeclaration('.bb4a')?.getPropertyValue('background-color') + ).toEqual('var(--building-color4)'); + }); + }); + describe('getCSSRules', () => { + it('should return a CSSRules array of length 1', () => { + expect(t.getCSSRules('media')?.length).toEqual(1); + }); + }); + describe('getRuleListsWithinMedia', () => { + it('should return a CSSMediaRule array with a selectable CSSStyleRule', () => { + expect( + t + .getRuleListsWithinMedia('(max-width: 1000px)') + .find(x => x.selectorText === '.sky') + ).toBeTruthy(); + }); + it('should return CSSStyleDeclaration property with complex value', () => { + // NOTE: JSDOM causes value to have tabbed characters, DOM has single-line values. + expect( + t + .getRuleListsWithinMedia('(max-width: 1000px)') + .find(x => x.selectorText === '.sky')?.style?.background + ).toEqual( + `radial-gradient( + closest-corner circle at 15% 15%, + #ffcf33, + #ffcf33 20%, + #ffff66 21%, + #bbeeff 100% + )` + ); + }); + }); + afterEach(() => { + document.body.innerHTML = ''; + document.head.innerHTML = ''; + }); +}); diff --git a/client/src/utils/css-help.ts b/client/src/utils/css-help.ts new file mode 100644 index 0000000000..8c76eb778c --- /dev/null +++ b/client/src/utils/css-help.ts @@ -0,0 +1,105 @@ +export interface ExtendedStyleRule extends CSSStyleRule { + isDeclaredAfter: (selector: string) => boolean; +} + +const getIsDeclaredAfter = (styleRule: CSSStyleRule) => (selector: string) => { + const cssStyleRules = Array.from( + styleRule.parentStyleSheet?.cssRules || [] + )?.filter(ele => ele.type === CSSRule.STYLE_RULE) as CSSStyleRule[]; + const previousStyleRule = cssStyleRules.find( + ele => ele?.selectorText === selector + ); + if (!previousStyleRule) return false; + const currPosition = Array.from( + styleRule.parentStyleSheet?.cssRules || [] + ).indexOf(styleRule); + const prevPosition = Array.from( + previousStyleRule?.parentStyleSheet?.cssRules || [] + ).indexOf(previousStyleRule); + return currPosition > prevPosition; +}; + +class CSSHelp { + doc: HTMLDocument; + constructor(doc: HTMLDocument) { + this.doc = doc; + } + private _getStyleRules() { + const styleSheet = this.getStyleSheet(); + return this.styleSheetToCssRulesArray(styleSheet).filter( + ele => ele.type === CSSRule.STYLE_RULE + ) as CSSStyleRule[]; + } + + getStyleDeclarations(selector: string): CSSStyleDeclaration[] { + return this._getStyleRules() + ?.filter(ele => ele?.selectorText === selector) + .map(x => x.style); + } + getStyleDeclaration(selector: string): CSSStyleDeclaration | undefined { + return this._getStyleRules()?.find(ele => ele?.selectorText === selector) + ?.style; + } + getStyleRule(selector: string): ExtendedStyleRule | null { + const styleRule = this._getStyleRules()?.find( + ele => ele?.selectorText === selector + ); + if (styleRule) { + return { + ...styleRule, + isDeclaredAfter: (selector: string) => + getIsDeclaredAfter(styleRule)(selector) + }; + } else { + return null; + } + } + getCSSRules(element?: string): CSSRule[] { + const styleSheet = this.getStyleSheet(); + const cssRules = this.styleSheetToCssRulesArray(styleSheet); + switch (element) { + case 'media': + return cssRules.filter(ele => ele.type === CSSRule.MEDIA_RULE); + case 'fontface': + return cssRules.filter(ele => ele.type === CSSRule.FONT_FACE_RULE); + case 'import': + return cssRules.filter(ele => ele.type === CSSRule.IMPORT_RULE); + case 'keyframes': + return cssRules.filter(ele => ele.type === CSSRule.KEYFRAMES_RULE); + default: + return cssRules; + } + } + isPropertyUsed(property: string): boolean { + return this._getStyleRules().some(ele => + ele.style?.getPropertyValue(property) + ); + } + getRuleListsWithinMedia(conditionText: string): CSSStyleRule[] { + const medias = this.getCSSRules('media') as CSSMediaRule[]; + const cond = medias?.find(x => x.conditionText === conditionText); + const cssRules = cond?.cssRules; + return Array.from(cssRules || []) as CSSStyleRule[]; + } + getStyleSheet(): CSSStyleSheet | null { + // TODO: Change selector to match exactly 'styles.css' + const link: HTMLLinkElement | null = this.doc?.querySelector( + "link[href*='styles']" + ); + const style: HTMLStyleElement | null = this.doc?.querySelector('style'); + if (link) { + return link.sheet; + } else if (style) { + return style.sheet; + } else { + return null; + } + } + styleSheetToCssRulesArray( + styleSheet: ReturnType + ): CSSRule[] { + return Array.from(styleSheet?.cssRules || []); + } +} + +export default CSSHelp; diff --git a/client/src/utils/curriculum-helpers.ts b/client/src/utils/curriculum-helpers.ts index 6bb55f6ae7..d7a5c1db3b 100644 --- a/client/src/utils/curriculum-helpers.ts +++ b/client/src/utils/curriculum-helpers.ts @@ -1,5 +1,6 @@ import { parse } from '@babel/parser'; import generate from '@babel/generator'; +import CSSHelp from './css-help'; const removeHtmlComments = (str: string): string => str.replace(/|$)/g, ''); @@ -18,6 +19,7 @@ export const removeJSComments = (codeStr: string): string => { }; try { const ast = parse(codeStr); + // TODO: Sort out type error on ast const { code } = generate(ast, options, codeStr); return code; } catch (err) { @@ -32,7 +34,8 @@ const removeWhiteSpace = (str = ''): string => { const curriculumHelpers = { removeHtmlComments, removeCssComments, - removeWhiteSpace + removeWhiteSpace, + CSSHelp }; export default curriculumHelpers; diff --git a/curriculum/challenges/english/01-responsive-web-design/responsive-web-design-principles/create-a-media-query.md b/curriculum/challenges/english/01-responsive-web-design/responsive-web-design-principles/create-a-media-query.md index f4069660d4..40ef0ec505 100644 --- a/curriculum/challenges/english/01-responsive-web-design/responsive-web-design-principles/create-a-media-query.md +++ b/curriculum/challenges/english/01-responsive-web-design/responsive-web-design-principles/create-a-media-query.md @@ -13,13 +13,13 @@ Media Queries are a new technique introduced in CSS3 that change the presentatio Media Queries consist of a media type, and if that media type matches the type of device the document is displayed on, the styles are applied. You can have as many selectors and styles inside your media query as you want. -Here's an example of a media query that returns the content when the device's width is less than or equal to 100px: +Here's an example of a media query that returns the content when the device's width is less than or equal to `100px`: ```css @media (max-width: 100px) { /* CSS Rules */ } ``` -and the following media query returns the content when the device's height is more than or equal to 350px: +and the following media query returns the content when the device's height is more than or equal to `350px`: ```css @media (min-height: 350px) { /* CSS Rules */ } @@ -33,38 +33,25 @@ Add a media query, so that the `p` tag has a `font-size` of `10px` when the devi # --hints-- -You should declare a `@media` query for devices with a `height` less than or equal to 800px. +You should declare a `@media` query for devices with a `height` less than or equal to `800px`. ```js -assert( - $('style') - .text() - .replace(/\s/g, '') - .match(/@media\(max-height:800px\)/g) -); +const media = new __helpers.CSSHelp(document).getCSSRules('media'); +assert(media.some(x => x.conditionText?.includes('(max-height: 800px)'))); ``` -Your `p` element should have a `font-size` of 10px when the device `height` is less than or equal to 800px. +Your `p` element should have a `font-size` of `10px` when the device `height` is less than or equal to `800px`. ```js -assert( - $('style') - .text() - .replace(/\s/g, '') - .match(/@media\(max-height:800px\){p{font-size:10px;?}}/g) -); +const rules = new __helpers.CSSHelp(document).getRuleListsWithinMedia('(max-height: 800px)'); +assert(rules?.find(x => x.selectorText === 'p')?.style.fontSize === "10px"); ``` -Your `p` element should have an initial `font-size` of 20px when the device `height` is more than 800px. +Your `p` element should have an initial `font-size` of `20px` when the device `height` is more than `800px`. ```js -assert( - $('style') - .text() - .replace(/\s/g, '') - .replace(/@media.*}/g, '') - .match(/p{font-size:20px;?}/g) -); +const ifPFirst = new __helpers.CSSHelp(document).getCSSRules()?.find(x => x?.selectorText === 'p' || x?.media); +assert(ifPFirst?.style?.fontSize === '20px'); ``` # --seed--