diff --git a/README.md b/README.md index b441707..2a470f9 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,20 @@ # Fish Tacos - ## What - This module complements the **react** and **styled-components** libraries to create an API that removes the cognitive load and arduous boilerplate associated with responsive scenarios. +[![depends on styled-components](https://user-images.githubusercontent.com/15273233/40872099-ab686562-669c-11e8-8b7f-0d70f87280cb.png)](https://www.styled-components.com/) -[![depends on_ styled-components](https://user-images.githubusercontent.com/15273233/40872099-ab686562-669c-11e8-8b7f-0d70f87280cb.png)](https://www.styled-components.com/) - -[![depends on_ react](https://user-images.githubusercontent.com/15273233/40872100-ae1d736a-669c-11e8-965a-3ce06fbd872d.png)](https://reactjs.org/) +[![depends on react](https://user-images.githubusercontent.com/15273233/40872100-ae1d736a-669c-11e8-965a-3ce06fbd872d.png)](https://reactjs.org/) [![typescript](https://user-images.githubusercontent.com/15273233/40872275-a61d4660-669f-11e8-8edf-860f1947759f.png)](https://www.typescriptlang.org/) -[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) +[![commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) - + +[![code style prettier](https://img.shields.io/badge/code_style-prettier-FF69A4.svg)](https://prettier.io/) ## Why @@ -26,18 +24,16 @@ Unfortunately `min-*` and `max-*` declarations are not supported natively in **C **This module addresses the scenarios where:** -+ Users can experience jarring layout reflows as various breakpoints are triggered forcing abrupt changes to **CSS** values. - -+ Percentage based values _(referencing things like **viewport** or an **element container**)_ can encounter cases where values scale to exaggerated limits _(both **big** and **small**)_ due to the absence of *Minimum* and *Maximum* thresholds. +* Users can experience jarring layout reflows as various breakpoints are triggered forcing abrupt changes to **CSS** values. +* Percentage based values _(referencing things like **viewport** or an **element container**)_ can encounter cases where values scale to exaggerated limits _(both **big** and **small**)_ due to the absence of _Minimum_ and _Maximum_ thresholds. ## Demo -[This demo](https://codepen.io/DevonChurch/project/live/DeJWLQ/) retro fits several **Bootstrap** components with the fluid resizing system this module offers. Resize the window to see the responsive measurement declarations scale *up* / *down* while staying within the limits of their thresholds. +[This demo](https://codepen.io/DevonChurch/project/live/DeJWLQ/) retro fits several **Bootstrap** components with the fluid resizing system this module offers. Resize the window to see the responsive measurement declarations scale _up_ / _down_ while staying within the limits of their thresholds. ![fish-tacos](https://user-images.githubusercontent.com/15273233/40871976-3008e8d0-669a-11e8-99b1-66955a994286.gif) - ## Installation Install the module from **NPM** . @@ -49,25 +45,24 @@ npm install --save fish-tacos Import the module into your project. ```javascript -import ft from 'fish-tacos'; +import ft from "fish-tacos"; ``` - ## Usage -The API is very simple. Specify the **CSS property** that you want to change and supply a *Minimum* and *Maximum* threshold to restrict scaling. +The API is very simple. Specify the **CSS property** that you want to change and supply a _Minimum_ and _Maximum_ threshold to restrict scaling. -Because designers like to supply their measurements in **pixel** based units our API uses pixels as the base target and converts them into **REM**'s in the final output. This gives styles an enhanced level of accessibility *(with dynamic font scaling)* while making **design** and **development** collaboration easier. +Because designers like to supply their measurements in **pixel** based units our API uses pixels as the base target and converts them into **REM**'s in the final output. This gives styles an enhanced level of accessibility _(with dynamic font scaling)_ while making **design** and **development** collaboration easier. The result is pure, static **CSS**. This means the fluid scaling is based on native browser functionality and therefore performant. ### Basic ```javascript -ft('font-size', [20, 32]) +ft("font-size", [20, 32]); ``` -*The above declaration will create the following vanilla **CSS**:* +_The above declaration will create the following vanilla **CSS**:_ ```css font-size: 1.25rem; @@ -86,10 +81,10 @@ font-size: 1.25rem; If you want to target a property that uses `top`, `right`, `bottom` and `left` references for more granularity you can use the more verbose permutation below. ```javascript -ft('margin', { top: [30, 60], bottom: [10, 30] }) +ft("margin", { top: [30, 60], bottom: [10, 30] }); ``` -*The above declaration will create the following vanilla **CSS**:* +_The above declaration will create the following vanilla **CSS**:_ ```css margin-top: 1.875rem; @@ -101,7 +96,7 @@ margin-top: 1.875rem; @media (min-width: 960px) { margin-top: 3.75rem; } - + margin-bottom: 0.625rem; @media (min-width: 480px) { @@ -113,29 +108,29 @@ margin-bottom: 0.625rem; } ``` - ### Example -Integrating this module into your existing workflow is as easy as swapping out a standard **CSS** *property* / *value* declaration for the new API. +Integrating this module into your existing workflow is as easy as swapping out a standard **CSS** _property_ / _value_ declaration for the new API. ```javascript -import React from 'react'; -import ReactDOM from 'react-dom'; -import styled from 'styled-components'; -import ft from 'fish-tacos'; +import React from "react"; +import ReactDOM from "react-dom"; +import styled from "styled-components"; +import ft from "fish-tacos"; const Heading1 = styled.h1` - ${ft('font-size', [30, 50])} - ${ft('margin', { top: [30, 60], bottom: [10, 30] })} + ${ft("font-size", [30, 50])} ${ft("margin", { + top: [30, 60], + bottom: [10, 30] + })}; `; ReactDOM.render( Hello World, - document.getElementById('app') + document.getElementById("app") ); ``` - ## License MIT diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index da9d58b..85cc40e 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,3 +1,107 @@ -test("adds 1 + 2 to equal 3", () => { - expect(1 + 1).toBe(3); +import ft from '../src/'; + +const setBodyFontSize = value => (document.body.style.fontSize = value); +const removeBodyStyles = () => (document.body.style = {}); + +beforeEach(() => { + setBodyFontSize('16px'); +}); + +test('requires "base font" on ', () => { + removeBodyStyles(); + + expect(() => { + ft('padding', [10, 20]); + }).toThrowError('not found'); +}); + +test('requires "base font" of 16px', () => { + setBodyFontSize('10px'); + + expect(() => { + ft('padding', [10, 20]); + }).toThrowError('font-size is not 16px'); +}); + +test('requires "unit" to be defined', () => { + expect(() => { + ft(undefined, [10, 20]); + }).toThrowError('is not defined'); +}); + +test('requires "unit" to be a "string"', () => { + expect(() => { + ft(100, [10, 20]); + }).toThrowError('is not of type "string"'); +}); + +[ + { reference: 'min', sizes: [undefined, 20] }, + { reference: 'max', sizes: [10, undefined] }, +].forEach(({ reference, sizes }) => { + test(`requires "${reference}" to be defined`, () => { + expect(() => { + ft('padding', sizes); + }).toThrowError('is not defined'); + }); +}); + +[{ reference: 'min', sizes: ['10', 20] }, { reference: 'max', sizes: [10, '20'] }].forEach( + ({ reference, sizes }) => { + test(`requires "${reference}" to be a "number"`, () => { + expect(() => { + ft('padding', sizes); + }).toThrowError('not of type "number"'); + }); + } +); + +[{ reference: 'min', sizes: [10 / 0, 20] }, { reference: 'max', sizes: [10, 20 / 0] }].forEach( + ({ reference, sizes }) => { + test(`requires "${reference}" to be "finite"`, () => { + expect(() => { + ft('padding', sizes); + }).toThrowError('is an "infinite" value'); + }); + } +); + +test('should create a single declaration in the correct format', () => { + const result = ft('padding', [10, 20]); + expect(result).toMatch(` +padding: 0.625rem; + +@media (min-width: 30rem) { + padding: 2.0833333333333335vw; +} + +@media (min-width: 60rem) { + padding: 1.25rem; +} +`); +}); + +test('should create a multi declaration in the correct format', () => { + const result = ft('padding', { top: [10, 20], bottom: [15, 25] }); + expect(result).toMatch(` +padding-top: 0.625rem; + +@media (min-width: 30rem) { + padding-top: 2.0833333333333335vw; +} + +@media (min-width: 60rem) { + padding-top: 1.25rem; +} + +padding-bottom: 0.9375rem; + +@media (min-width: 30rem) { + padding-bottom: 3.125vw; +} + +@media (min-width: 50rem) { + padding-bottom: 1.5625rem; +} +`); }); diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 0000000..8a14b79 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,11 @@ +module.exports = { + printWidth: 100, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: true, + trailingComma: 'es5', + bracketSpacing: true, + arrowParens: 'avoid', + parser: 'typescript', +}; diff --git a/src/index.ts b/src/index.ts index 8a0595f..1f44e01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,171 @@ -console.log("Hello World!"); +// Formula (A): +// 。 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 。 +// ¦ ¦ +// ¦ Current Breakpoint (px) ¦ +// ¦ ----------------------- = Pixels Per Viewport (vw : px) ¦ +// ¦ 100 ¦ +// ¦ ¦ +// ° -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- ° +// +// Formula (B): +// 。 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 。 +// ¦ ¦ +// ¦ Element Value (px) ¦ +// ¦ ------------------------ = Viewport Units (vw) ¦ +// ¦ Pixels Per Viewport Unit ¦ +// ¦ ¦ +// ° -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- ° +// +// +// @exmaple +// +// We have a minimum 'font-size' value of 10px and a maximum of 30px. +// +// We need to find... +// +// + The number of pixels a single viewpoint unit represents at the 'small' +// breakpoint. +// +// + The total viewport units (vw) to assign at the 'small' breakpoint which will +// represent the minimum 'px' value at that breakpoint and begin the scale to +// the maximum value as the browser width increases. +// +// + The breakpoint in which the maximum value will be reached (so that we can +// STOP scaling the value against the browser width). +// +// Formula (A): +// 600 / 100 = 6 // 6px for every 1vw unit at a breakpoint of 600px. +// +// Formula (B): +// 10 / 6 = 1.6 // 1.6vw represents 10px (minimum font-size) at a breakpoint of +// // 600px. +// +// Formula (B) - rearranged: +// 30 / 1.6 = 18 // 18px represents the number of pixels per 1 viewpoint unit (vw) +// // when the maximum font-size value is reached. +// +// Formula (A) - rearranged: +// 100 * 18 = 1800 // 1800px represents the breakpoint width that would reach the +// // maximum font-size so that we can reassign it to a static +// // value to stop scaling against the browser width. +// +// Result (to be injected into a styled-component): +// 。 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 。 +// ¦ ¦ +// ¦ font-size: 0.625rem; // 10px ¦ +// ¦ ¦ +// ¦ @media all and (min-width: 37.5rem) { // 600px ¦ +// ¦ font-size: 1.6vw; ¦ +// ¦ } ¦ +// ¦ ¦ +// ¦ @media all and (min-width: 106.25rem) { // 1700px ¦ +// ¦ font-size: 1.875rem; // 30px ¦ +// ¦ } ¦ +// ¦ ¦ +// ° -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- ° -const foo: string = "FOO"; +interface MultiMinMax { + [key: string]: [number, number]; +} + +const BASE_FONT = 16; + +const logPrefix = '[fish-tacos]'; + +const testBaseFontSize = (): number | boolean => { + const bodyNode = document.querySelector('body'); + const { fontSize } = window.getComputedStyle(bodyNode); + const rawPixels = parseInt(fontSize, 10); + const isNotValue = !fontSize && isNaN(rawPixels); + const isNot16px = rawPixels !== BASE_FONT; + + if (isNotValue) throw `${logPrefix} font-size on not found`; + else if (isNot16px) + throw `${logPrefix} font-size is not 16px (all calculations are based on this assumption)`; + + return !(isNotValue && isNot16px); +}; + +const createRem = (pixels: number): string => `${pixels / BASE_FONT}rem`; + +const createDynamicValues = (unit: string, [min, max]: [number, number]): string => { + const breakpointMin = 480; + const breakpointRatio = 100; + const viewportRatioMin = breakpointMin / breakpointRatio; + const viewportWidth = min / viewportRatioMin; + const viewportRatioMax = max / viewportWidth; + const breakpointMax = breakpointRatio * viewportRatioMax; + + // NOTE: Make sure the template literal is pushed up flush to the margin so that + // there is no ambiguity regarding spacing when evaluating against our Jest tests. + return ` +${unit}: ${createRem(min)}; + +@media (min-width: ${createRem(breakpointMin)}) { + ${unit}: ${viewportWidth}vw; +} + +@media (min-width: ${createRem(breakpointMax)}) { + ${unit}: ${createRem(max)}; +} +`; +}; + +const testIsUnitRelevant = (unit: string): boolean => { + const isNotUnit = !unit; + const isNotString = unit && !(typeof unit === 'string'); + + if (isNotUnit) throw `${logPrefix} "unit" parameter is not defined`; + else if (isNotString) throw `${logPrefix} "unit" parameter is not of type "string"`; + + return !(isNotUnit && isNotString); +}; + +const testIsValueRelevant = (value: number, reference: string): boolean => { + const { isNaN, isFinite } = Number; + const isNotValue = !value; + const isNotNumber = value && !(typeof value === 'number'); + const isNotFinite = !isNotNumber && !isFinite(value); + + if (isNotValue) throw `${logPrefix} "${reference}" parameter is not defined`; + else if (isNotNumber) throw `${logPrefix} "${reference}" parameter is not of type "number"`; + else if (isNotFinite) throw `${logPrefix} "${reference}" parameter is an "infinite" value`; + + return !(isNotValue && isNotNumber && isNotFinite); +}; + +const testDeclarationParameters = (unit: string, [min, max]: [number, number]): boolean => { + const isUnitRelevant = testIsUnitRelevant(unit); + const isMinRelevant = testIsValueRelevant(min, 'min'); + const isMaxRelevant = testIsValueRelevant(max, 'max'); + + return isUnitRelevant && isMinRelevant && isMaxRelevant; +}; + +const createSingleDeclaration = (unit: string, [min, max]: [number, number]): string => + testDeclarationParameters(unit, [min, max]) ? createDynamicValues(unit, [min, max]) : ''; + +const createMultipleDeclaretions = (unit: string, sizes: MultiMinMax): string => { + const keys = Object.keys(sizes); + + return keys.reduce( + (acc, key) => `${acc}${createSingleDeclaration(`${unit}-${key}`, sizes[key])}`, + '' + ); +}; + +const init = (unit: string, sizes: any): string => { + const isBaseFontSize = testBaseFontSize(); + const isSingleDeclaration = Array.isArray(sizes); + + switch (true) { + case !isBaseFontSize: + return ''; + case isSingleDeclaration: + return createSingleDeclaration(unit, sizes); + default: + return createMultipleDeclaretions(unit, sizes); + } +}; + +export default init;