├── .eslintrc.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmignore ├── .release-it.json ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── changelog-template.hbs ├── package.json ├── src ├── index.js ├── preflightChecks.js └── utils.js ├── test └── index.test.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018, 15 | "sourceType": "module" 16 | }, 17 | "rules": {} 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Check release type 12 | id: is-minor-release 13 | uses: shioyang/check-pr-labels-on-push-action@v1.0.3 14 | with: 15 | github-token: ${{ secrets.GITHUB_TOKEN }} 16 | labels: '["minor"]' 17 | - name: git config 18 | run: | 19 | git config user.name "${GITHUB_ACTOR}" 20 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 21 | - run: yarn install 22 | - run: npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }} 23 | - run: yarn release patch --npm.skipChecks 24 | if: steps.is-minor-release.outputs.result == 'false' 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | - run: yarn release minor --npm.skipChecks 28 | if: steps.is-minor-release.outputs.result == 'true' 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | sandbox 4 | coverage 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc.json 2 | .github 3 | .release-it.json 4 | .travis.yml 5 | babel.config.js 6 | changelog-template.hbs 7 | coverage 8 | sandbox 9 | src 10 | test 11 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "before:init": "yarn test", 4 | "after:bump": "yarn auto-changelog --starting-date 2021-08-24 --package --commit-limit false --template changelog-template.hbs" 5 | }, 6 | "git": { 7 | "changelog": null, 8 | "addUntrackedFiles": true, 9 | "commit": true, 10 | "tag": true, 11 | "push": true 12 | }, 13 | "github": { "release": false }, 14 | "npm": { 15 | "publish": true, 16 | "publishArgs": ["--registry=https://registry.npmjs.org"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | script: 5 | - yarn test --coverage 6 | after_success: 7 | - npx codecov 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.3 - 2021-10-03 4 | 5 | - Bump tmpl from 1.0.4 to 1.0.5 [`#21`](https://github.com/galacemiguel/fluid-system/pull/21) 6 | 7 | 8 | 9 | ## [v1.1.0](https://github.com/galacemiguel/fluid-system/compare/v1.0.13...v1.1.0) - 2021-08-22 10 | 11 | ### Additions 12 | 13 | - Automate releases with [release-it](https://github.com/release-it/release-it) 14 | 15 | ### Fixes 16 | 17 | - Bump ws from 7.4.4 to 7.4.6 ([`df2da2f`](https://github.com/galacemiguel/fluid-system/commit/df2da2f9564b5f095d08ec2af6e238d49c07354f)) 18 | - Bump browserslist from 4.9.1 to 4.16.6 ([`5766541`](https://github.com/galacemiguel/fluid-system/commit/5766541912adfd8eff53f379e64b9d79d53da304)) 19 | 20 | ## 1.0.12 - 2021/05/23 21 | 22 | - Upgrade vulnerable dependencies 23 | 24 | ## 1.0.11 - 2021/03/20 25 | 26 | - Upgrade vulnerable dependencies 27 | 28 | ## 1.0.10 - 2020/09/12 29 | 30 | - Update dependencies 31 | 32 | ## 1.0.9 - 2020/03/15 33 | 34 | - Update dependencies 35 | 36 | ## 1.0.8 - 2020/02/06 37 | 38 | - Fixed errors with shorthand style declarations ([#8](https://github.com/galacemiguel/fluid-system/issues/8)) 39 | - Fixed errors with irregular fluidStart values ([#6](https://github.com/galacemiguel/fluid-system/issues/6)) 40 | - Fixed error in README code example 41 | - Added comment to README code example describing object breakpoints support 42 | 43 | ## 1.0.7 - 2019/12/15 44 | 45 | - Miscellaneous README fixes 46 | - Fix npm–repository link 47 | 48 | ## 1.0.5 - 2019/11/30 49 | 50 | - Replace lookbehind regex for better cross-browser support ([#3](https://github.com/galacemiguel/fluid-system/issues/3)) 51 | 52 | ## 1.0.3 - 2019/10/16 53 | 54 | - Removed non-production files from package tarball 55 | 56 | ## 1.0.2 - 2019/10/16 57 | 58 | - Update phrasing in some sections of the README 59 | 60 | ## 1.0.1 - 2019/10/13 61 | 62 | - Exclude generating of fluid styles for styles that do not meet the same-unit requirements 63 | - Added integration test for same-unit requirements 64 | - New README section on same-unit requirements 65 | 66 | ## 1.0.0 - 2019/10/12 67 | 68 | ### Changes 69 | 70 | - The default `fluid` export now transforms existing style prop functions (e.g., `typography` and `space` from `styled-system`) to make their output styles fluid where appropriate 71 | - `_fluidSystem.startingWidth` is now defined via a `fluidStart` alias on the theme `breakpoints` array 72 | - Skipping breakpoints is done now with `null` instead of `"-"` as before to align with the Styled System syntaax 73 | 74 | ### Additions 75 | 76 | - A default `fluidStart` value is set at `320px` (or `20em/rem` depending on what units your `breakpoints` are defined in) 77 | - There is now also `breakpoints` default of `["40em", "52em", "64em"]` 78 | - Object `breakpoints` support 79 | - Integration tests 80 | - Continuous integration via `TravisCI` 81 | - Test coverage reports via `Codecov` 82 | - Code linting via `eslint` 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Miguel N. Galace 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💧 Fluid System 2 | 3 | [![Build Status](https://travis-ci.com/galacemiguel/fluid-system.svg?branch=master)](https://travis-ci.com/galacemiguel/fluid-system) ![Codecov](https://img.shields.io/codecov/c/github/galacemiguel/fluid-system) ![npm](https://img.shields.io/npm/v/fluid-system?label=npm) ![Downloads](https://img.shields.io/npm/dt/fluid-system) ![GitHub](https://img.shields.io/github/license/galacemiguel/fluid-system?color=00c2ff) 4 | 5 | Fluid System is a style props function transformer for generating fluid styles. 6 | 7 | It is designed to be used with libraries built upon [the System UI specification](https://system-ui.com/) like [Styled System](https://styled-system.com/) or [Rebass](https://rebassjs.org/), and a CSS-in-JS library such as [styled-components](https://styled-components.com/) or [Emotion](https://emotion.sh/). 8 | 9 | ## What is Fluid Design? ⛲️ 10 | 11 | > Fluid design is to responsive design what responsive design was to fixed layouts. 12 | 13 | There is a greater need now, more than ever, for websites to adapt their designs to the plethora of devices existing in the market today. And to do so, most websites might follow a type size specification like below: 14 | 15 | | | Phone | Tablet | Desktop | 16 | | ------------ | ------: | ------: | -------: | 17 | | Screen width | ≥ 320px | ≥ 768px | ≥ 1024px | 18 | | Font size | 16px | 19px | 23px | 19 | 20 | The approach to implementing this with _responsive_ design has been to create a breakpoint at each point of transition. But such an approach alienates a likely majority of your users whose screen sizes do not align exactly with those sweet spots. 21 | 22 | Take for example, a device having a viewport width of 767px. With a responsive design approach, it would be served a font size of 16px when—being just 1 pixel away from our 768px breakpoint—it should be getting something much closer to 19px. 23 | 24 | Fluid design aims to bridge that gap by interpolating between those defined size measurements based on the width of your user's screen. 25 | 26 | The graph below (red line) illustrates a fluid design implementation for the type size specification above: 27 | 28 |

29 | 30 |

31 | 32 | This technique extends beyond just font sizes and can be used with any [CSS lengths](https://css-tricks.com/the-lengths-of-css/) scale in your design, like a space scale for margin and padding, or even a scale for sizes to control width and height. 33 | 34 | You can read more about the technique [here](https://css-tricks.com/between-the-lines/). 35 | 36 | ## Quick Start 🏊‍♀️ 37 | 38 | Convinced? Install Fluid System on your project. 39 | 40 | ``` 41 | npm install fluid-system 42 | ``` 43 | 44 | Make sure your `breakpoints` and the scales you wish to use in your design are defined in your [theme object](https://github.com/system-ui/theme-specification). Then, define a `fluidStart` alias on your `breakpoints`. 45 | 46 | ```javascript 47 | // theme.js 48 | const theme = { 49 | breakpoints: ["768px", "1024px"], 50 | fontSizes: [13, 16, 19, 23, 27, 33, 39, 47] 51 | }; 52 | 53 | theme.breakpoints.fluidStart = "320px"; 54 | 55 | /* You can also define theme.breakpoints as an object. 56 | theme.breakpoints = { 57 | fluidStart: '320px', 58 | 0: '768px', 59 | 1: '1024px' 60 | }; 61 | */ 62 | 63 | export default theme; 64 | ``` 65 | 66 | This will define the viewport width at which your styles will begin to become fluid. Below that, they will remain fixed at the smallest sizes they have defined. 67 | 68 | > `fluidStart` is set to `320px` by default (or `20em`/`rem` depending on what unit your `breakpoints` are defined in). 69 | 70 | Then, make sure your theme is available to your component tree via a `ThemeProvider`, otherwise Styled System will not be able to pick up on your theme values. 71 | 72 | ```jsx 73 | import React from "react"; 74 | import { ThemeProvider } from "styled-components"; 75 | import theme from "./theme"; 76 | 77 | const App = () => ( 78 | {/* Your component tree */} 79 | ); 80 | 81 | export default App; 82 | ``` 83 | 84 | Now, in your base components, wrap the Styled System functions (or your custom style prop functions) that you want to make fluid with the `fluid` function. 85 | 86 | `fluid` transforms style prop functions to make all the responsive styles they have defined in CSS lengths fluid. 87 | 88 | ```jsx 89 | import React from "react"; 90 | import styled from "styled-components"; 91 | import { typography } from "styled-system"; 92 | import fluid from "fluid-system"; 93 | 94 | const FluidText = styled("p")(fluid(typography)); 95 | 96 | const MyComponent = () => ( 97 | Hello, world! I'm fluid! 98 | ); 99 | ``` 100 | 101 | `FluidText` in `MyComponent` will now fluidly scale between `16px`, `19px`, and `23px` in line with your theme's defined `fontSizes` and `breakpoints`. 102 | 103 | | | < `320px`\* | ≥ `320px`\* | ≥ `768px` | ≥ `1024px` | 104 | | ------------------- | ----------: | ----------: | ----------: | ---------: | 105 | | `typography` | `16px` | `16px` | `19px` | `23px` | 106 | | `fluid(typography)` | `16px` | `16`–`19px` | `19`–`23px` | `23px` | 107 | 108 | \* `theme.breakpoints.fluidStart` 109 | 110 | ## Requirements ☔️ 111 | 112 | Because of the way linear interpolation calculations work, all measurements at play—your theme's `breakpoints`, and all the sizes you wish to transition between—will need to be defined in the same unit. 113 | 114 | For example, if your `breakpoints` are defined in `px`, you will need to use `px` measurements in your styles for Fluid System to work its magic. Styles defined in different units will not have fluid styles generated. 115 | 116 | ## Interpolating Across Breakpoints 🚣‍♀️ 117 | 118 | Fluid System follows the Styled System syntax for skipping breakpoints. For fluid styles, it can also be used to interpolate styles _across_ breakpoints. Just set `null` between two values in your array and Fluid System will skip over defining a new size at that breakpoint and instead smoothly scale between the endpoints as if the middle breakpoint had not been defined. 119 | 120 | ```jsx 121 | 122 | ``` 123 | 124 | | | < `320px`\* | ≥ `320px`\* | ≥ `768px` | ≥ `1024px` | 125 | | ------------------- | ----------: | ----------: | --------: | ---------: | 126 | | `typography` | `16px` | `16px` | | `19px` | 127 | | `fluid(typography)` | `16px` | `16`–`19px` | | `19px` | 128 | 129 | \* `theme.breakpoints.fluidStart` 130 | 131 | ## Usage with Rebass 🤽‍♂️ 132 | 133 | Fluid System works just the same with Rebass! Just note that you may need to install Styled System separately, or some of its base style prop functions, if you haven't already. 134 | 135 | ``` 136 | npm install @styled-system/typography 137 | ``` 138 | 139 | ```jsx 140 | import styled from "@emotion/styled"; 141 | import { Text } from "rebass"; 142 | import typography from "@styled-system/typography"; 143 | import fluid from "fluid-system"; 144 | 145 | const FluidText = styled(Text)(fluid(typography)); 146 | ``` 147 | 148 | ## Prior Art 🌊 149 | 150 | - [Responsive And Fluid Typography With vh And vw Units](https://www.smashingmagazine.com/2016/05/fluid-typography/) 151 | - [Fluid Responsive Typography With CSS Poly Fluid Sizing](https://www.smashingmagazine.com/2017/05/fluid-responsive-typography-css-poly-fluid-sizing/) 152 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const presets = [["@babel/preset-env"], ["minify"]]; 2 | 3 | module.exports = { presets }; 4 | -------------------------------------------------------------------------------- /changelog-template.hbs: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | {{#each releases}} 4 | {{#if href}} 5 | ## [{{title}}]({{href}}) - {{isoDate}} 6 | {{else}} 7 | ## {{title}} - {{isoDate}} 8 | {{/if}} 9 | 10 | {{#each merges}} 11 | - {{{message}}}{{#if href}} [`#{{id}}`]({{href}}){{/if}} 12 | {{/each}} 13 | 14 | {{/each}} 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluid-system", 3 | "description": "Fluid System is a style props function transformer for generating fluid styles.", 4 | "version": "1.1.3", 5 | "main": "lib/index.js", 6 | "repository": "github:galacemiguel/fluid-system", 7 | "author": "Miguel N. Galace (https://galacemiguel.com)", 8 | "license": "MIT", 9 | "scripts": { 10 | "test": "jest", 11 | "bundle": "rollup src/index.js --file lib/index.js --format umd --name 'fluidSystem'", 12 | "transpile": "babel lib/index.js -o lib/index.js", 13 | "package": "yarn bundle && yarn transpile", 14 | "prepublish": "yarn test && yarn package", 15 | "release": "release-it" 16 | }, 17 | "keywords": [ 18 | "fluid", 19 | "design system", 20 | "typography", 21 | "system ui", 22 | "styled-system", 23 | "styled-components", 24 | "emotion" 25 | ], 26 | "devDependencies": { 27 | "@babel/cli": "^7.11.6", 28 | "@babel/core": "^7.11.6", 29 | "@babel/preset-env": "^7.11.5", 30 | "@styled-system/space": "^5.1.2", 31 | "@styled-system/typography": "^5.1.2", 32 | "auto-changelog": "^2.3.0", 33 | "babel-preset-minify": "^0.5.1", 34 | "eslint": "^6.5.1", 35 | "jest": "^26.6.3", 36 | "release-it": "^14.11.3", 37 | "rollup": "^1.23.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | setDefaultBreakpoints, 3 | convertBreakpointsObject, 4 | checkBreakpointUnits, 5 | setDefaultFluidStart, 6 | checkFluidStartUnit 7 | } from "./preflightChecks"; 8 | import { 9 | buildMediaQuery, 10 | isMeasurement, 11 | pipe, 12 | stripUnit, 13 | getUnit 14 | } from "./utils"; 15 | 16 | const main = ([stylePropFn, props]) => { 17 | const { breakpoints } = props.theme; 18 | 19 | const styleObject = stylePropFn(props); 20 | const allInterpolatableValues = parseInterpolatableValues( 21 | getUnit(breakpoints[0]), 22 | styleObject 23 | ); 24 | const fluidBreakpoints = [breakpoints.fluidStart, ...breakpoints].sort( 25 | (a, b) => stripUnit(a) - stripUnit(b) 26 | ); 27 | fluidBreakpoints.fluidStart = breakpoints.fluidStart; 28 | 29 | const allTransitionGroups = buildTransitionGroups( 30 | allInterpolatableValues, 31 | fluidBreakpoints 32 | ); 33 | const fluidStyleObject = buildFluidStyleObject( 34 | allTransitionGroups, 35 | fluidBreakpoints 36 | ); 37 | const mergedStyleObject = mergeResponsiveStyles( 38 | styleObject, 39 | fluidStyleObject 40 | ); 41 | 42 | return mergedStyleObject; 43 | }; 44 | 45 | const parseInterpolatableValues = (breakpointUnit, styleObject) => { 46 | const responsiveCssProps = Object.entries(styleObject) 47 | .filter( 48 | ([cssProp, cssValue]) => 49 | !cssProp.startsWith("@media") && isMeasurement(cssValue) 50 | ) 51 | .map(([cssProp]) => cssProp); 52 | const mediaQueries = Object.keys(styleObject).filter(cssProp => 53 | cssProp.startsWith("@media") 54 | ); 55 | 56 | const allResponsiveValues = responsiveCssProps.map(cssProp => { 57 | const mediaQueryValues = mediaQueries.map( 58 | mediaQuery => styleObject[mediaQuery][cssProp] 59 | ); 60 | const valuesWithUnits = [styleObject[cssProp], ...mediaQueryValues].map( 61 | value => (typeof value === "number" ? value + "px" : value) 62 | ); 63 | 64 | return { 65 | property: cssProp, 66 | values: valuesWithUnits 67 | }; 68 | }); 69 | 70 | const allInterpolatableValues = allResponsiveValues.filter( 71 | responsiveValues => { 72 | const responsiveValuesUnits = responsiveValues.values 73 | .map(getUnit) 74 | .filter(Boolean); 75 | const singleUnit = new Set(responsiveValuesUnits).size === 1; 76 | const sameAsBreakpointUnit = responsiveValuesUnits[0] === breakpointUnit; 77 | 78 | return singleUnit && sameAsBreakpointUnit; 79 | } 80 | ); 81 | 82 | return allInterpolatableValues; 83 | }; 84 | 85 | const buildTransitionGroups = (allInterpolatableValues, breakpoints) => 86 | allInterpolatableValues.reduce( 87 | (allTransitionGroups, interpolatableValues) => { 88 | const interpolatableIndicesStart = breakpoints.findIndex( 89 | breakpoint => breakpoint === breakpoints.fluidStart 90 | ); 91 | const interpolatableIndices = interpolatableValues.values 92 | .map((value, i) => (value !== undefined ? i : null)) 93 | .filter(Number.isInteger); 94 | 95 | let transitionGroup = []; 96 | 97 | for ( 98 | let i = interpolatableIndicesStart; 99 | i < interpolatableIndices.length - 1; 100 | i++ 101 | ) { 102 | const interpolatableIndex = interpolatableIndices[i]; 103 | const nextInterpolatableIndex = interpolatableIndices[i + 1]; 104 | 105 | transitionGroup.push({ 106 | values: [ 107 | interpolatableValues.values[interpolatableIndex], 108 | interpolatableValues.values[nextInterpolatableIndex] 109 | ], 110 | breakpoints: [ 111 | breakpoints[interpolatableIndex], 112 | breakpoints[nextInterpolatableIndex] 113 | ] 114 | }); 115 | } 116 | 117 | return { 118 | ...allTransitionGroups, 119 | [interpolatableValues.property]: transitionGroup 120 | }; 121 | }, 122 | {} 123 | ); 124 | 125 | const buildFluidStyleObject = (allTransitionGroups, breakpoints) => { 126 | const fluidStyles = Object.entries(allTransitionGroups).map( 127 | ([property, transitionGroups]) => 128 | buildFluidStyle(property, transitionGroups) 129 | ); 130 | const mediaQueries = breakpoints.map(breakpoint => 131 | buildMediaQuery(breakpoint) 132 | ); 133 | 134 | const fluidStyleObject = mediaQueries.reduce( 135 | (fluidStyleObject, mediaQuery) => ({ 136 | ...fluidStyleObject, 137 | [mediaQuery]: fluidStyles 138 | .map(fluidStyle => fluidStyle[mediaQuery]) 139 | .reduce( 140 | (breakpointStyles, fluidStyle) => ({ 141 | ...breakpointStyles, 142 | ...fluidStyle 143 | }), 144 | {} 145 | ) 146 | }), 147 | {} 148 | ); 149 | 150 | return fluidStyleObject; 151 | }; 152 | 153 | const buildFluidStyle = (property, transitionGroups) => 154 | transitionGroups.reduce( 155 | ( 156 | fluidStyle, 157 | { 158 | values: [minProp, maxProp], 159 | breakpoints: [minBreakpoint, maxBreakpoint] 160 | } 161 | ) => ({ 162 | ...fluidStyle, 163 | [buildMediaQuery(minBreakpoint)]: { 164 | [property]: buildLerpCalc( 165 | [minProp, maxProp], 166 | [minBreakpoint, maxBreakpoint] 167 | ) 168 | } 169 | }), 170 | {} 171 | ); 172 | 173 | const buildLerpCalc = ([minProp, maxProp], [minBreakpoint, maxBreakpoint]) => 174 | `calc(${minProp} + (${stripUnit(maxProp)} - ${stripUnit( 175 | minProp 176 | )})*(100vw - ${minBreakpoint})/(${stripUnit(maxBreakpoint)} - ${stripUnit( 177 | minBreakpoint 178 | )}))`; 179 | 180 | const mergeResponsiveStyles = (styleObject, fluidStyleObject) => { 181 | const baseStyles = Object.keys(styleObject) 182 | .filter(style => !style.startsWith("@media")) 183 | .reduce( 184 | (baseStyles, style) => ({ 185 | ...baseStyles, 186 | [style]: styleObject[style] 187 | }), 188 | {} 189 | ); 190 | const mediaQueries = [ 191 | ...Object.keys(styleObject), 192 | ...Object.keys(fluidStyleObject) 193 | ] 194 | .filter(key => key.startsWith("@media")) 195 | .filter((mediaQuery, i, self) => self.indexOf(mediaQuery) === i); 196 | const sortedMediaQueries = mediaQueries.sort( 197 | (a, b) => 198 | parseFloat(a.match(/@media screen and \(min-width: (.*)\)/)[1]) - 199 | parseFloat(b.match(/@media screen and \(min-width: (.*)\)/)[1]) 200 | ); 201 | 202 | const mergedStyleObject = sortedMediaQueries.reduce( 203 | (mergedStyleObject, mediaQuery) => { 204 | const mergedBreakpointStyles = { 205 | ...styleObject[mediaQuery], 206 | ...fluidStyleObject[mediaQuery] 207 | }; 208 | 209 | if (!Object.entries(mergedBreakpointStyles).length) { 210 | return mergedStyleObject; 211 | } 212 | 213 | return { 214 | ...mergedStyleObject, 215 | [mediaQuery]: mergedBreakpointStyles 216 | }; 217 | }, 218 | { ...baseStyles } 219 | ); 220 | 221 | return mergedStyleObject; 222 | }; 223 | 224 | const fluid = stylePropFn => 225 | pipe( 226 | props => [stylePropFn, props], 227 | setDefaultBreakpoints, 228 | convertBreakpointsObject, 229 | checkBreakpointUnits, 230 | setDefaultFluidStart, 231 | checkFluidStartUnit, 232 | main 233 | ); 234 | 235 | export default fluid; 236 | -------------------------------------------------------------------------------- /src/preflightChecks.js: -------------------------------------------------------------------------------- 1 | import { getUnit, stripUnit } from "./utils"; 2 | 3 | export const setDefaultBreakpoints = ([stylePropFn, props]) => { 4 | if (!props.theme.breakpoints) { 5 | props.theme.breakpoints = ["40em", "52em", "64em"]; 6 | } 7 | 8 | return [stylePropFn, props]; 9 | }; 10 | 11 | export const convertBreakpointsObject = ([stylePropFn, props]) => { 12 | const breakpoints = props.theme.breakpoints; 13 | 14 | if (breakpoints.constructor === Object) { 15 | const breakpointsArray = Object.entries(breakpoints) 16 | .filter(([key]) => key !== "fluidStart") 17 | .map(([, value]) => value) 18 | .sort((a, b) => stripUnit(a) - stripUnit(b)); 19 | const fluidStart = breakpoints.fluidStart; 20 | 21 | props.theme.breakpoints = breakpointsArray; 22 | props.theme.breakpoints.fluidStart = fluidStart; 23 | } 24 | 25 | return [stylePropFn, props]; 26 | }; 27 | 28 | export const checkBreakpointUnits = ([stylePropFn, props]) => { 29 | const breakpoints = props.theme.breakpoints; 30 | const breakpointUnits = breakpoints.map(getUnit); 31 | 32 | if (new Set(breakpointUnits).size > 1) { 33 | throw new TypeError( 34 | `Cannot interpolate between dissimilar units in the theme breakpoints: [${breakpoints 35 | .map(breakpoint => `"${breakpoint}"`) 36 | .join(", ")}]` 37 | ); 38 | } 39 | 40 | return [stylePropFn, props]; 41 | }; 42 | 43 | export const setDefaultFluidStart = ([stylePropFn, props]) => { 44 | if (!props.theme.breakpoints.fluidStart) { 45 | const breakpointUnit = getUnit(props.theme.breakpoints[0]); 46 | 47 | switch (breakpointUnit) { 48 | case "em": 49 | props.theme.breakpoints.fluidStart = "20em"; 50 | break; 51 | case "rem": 52 | props.theme.breakpoints.fluidStart = "20rem"; 53 | break; 54 | case "px": 55 | props.theme.breakpoints.fluidStart = "320px"; 56 | break; 57 | default: 58 | throw new TypeError( 59 | `Cannot define a default fluid starting width for "${breakpointUnit}" unit breakpoints; manually set the alias on the theme object instead` 60 | ); 61 | } 62 | } 63 | 64 | return [stylePropFn, props]; 65 | }; 66 | 67 | export const checkFluidStartUnit = ([stylePropFn, props]) => { 68 | const fluidStartUnit = getUnit(props.theme.breakpoints.fluidStart); 69 | const breakpointUnit = props.theme.breakpoints.length 70 | ? getUnit(props.theme.breakpoints[0]) 71 | : null; 72 | 73 | if (breakpointUnit && fluidStartUnit !== breakpointUnit) 74 | throw new TypeError( 75 | `"The fluid starting width must be defined in the same unit as the theme breakpoints"` 76 | ); 77 | 78 | return [stylePropFn, props]; 79 | }; 80 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const _pipe = (f, g) => (...args) => g(f(...args)); 2 | export const pipe = (...fns) => fns.reduce(_pipe); 3 | 4 | export const isMeasurement = measurement => { 5 | return !isNaN(parseInt(measurement)); 6 | }; 7 | 8 | export const getUnit = measurement => { 9 | if (!measurement) { 10 | return null; 11 | } 12 | 13 | if (typeof measurement === "number") { 14 | return "px"; 15 | } 16 | 17 | const matchedMeasurement = measurement.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z]*|%)$/); 18 | return matchedMeasurement ? matchedMeasurement[2] : null; 19 | }; 20 | 21 | export const stripUnit = measurement => parseFloat(measurement); 22 | 23 | export const buildMediaQuery = breakpoint => 24 | `@media screen and (min-width: ${breakpoint})`; 25 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import space from "@styled-system/space"; 2 | import typography from "@styled-system/typography"; 3 | 4 | import fluid from "../src"; 5 | import { buildMediaQuery } from "../src/utils"; 6 | 7 | const themeFactory = theme => ({ 8 | disableStyledSystemCache: true, 9 | breakpoints: ["40em", "52em", "64em"], 10 | ...theme 11 | }); 12 | 13 | describe("main", () => { 14 | test("preserves non-responsive styles", () => { 15 | expect( 16 | fluid(typography)({ 17 | theme: themeFactory(), 18 | fontFamily: "sans-serif" 19 | }) 20 | ).toEqual({ fontFamily: "sans-serif" }); 21 | }); 22 | 23 | test("preserves non-interpolatable styles", () => { 24 | expect( 25 | fluid(typography)({ 26 | theme: themeFactory({ 27 | breakpoints: ["40em"] 28 | }), 29 | textAlign: ["left", "center"] 30 | }) 31 | ).toEqual({ 32 | textAlign: "left", 33 | [buildMediaQuery("40em")]: { 34 | textAlign: "center" 35 | } 36 | }); 37 | }); 38 | 39 | test("preserves styles with shorthand declarations", () => { 40 | expect( 41 | fluid(space)({ 42 | theme: themeFactory({ 43 | breakpoints: ["40em"] 44 | }), 45 | margin: ["1em 2em", "2em 4em"] 46 | }) 47 | ).toEqual({ 48 | margin: "1em 2em", 49 | [buildMediaQuery("40em")]: { 50 | margin: "2em 4em" 51 | } 52 | }); 53 | }); 54 | 55 | test("generates fluid styles for px units", () => { 56 | expect( 57 | fluid(typography)({ 58 | theme: themeFactory({ 59 | breakpoints: { 60 | 0: "40em", 61 | fluidStart: "20em" 62 | } 63 | }), 64 | fontSize: ["1em", "1.33em"] 65 | }) 66 | ).toEqual({ 67 | fontSize: "1em", 68 | [buildMediaQuery("20em")]: { 69 | fontSize: "calc(1em + (1.33 - 1)*(100vw - 20em)/(40 - 20))" 70 | }, 71 | [buildMediaQuery("40em")]: { 72 | fontSize: "1.33em" 73 | } 74 | }); 75 | }); 76 | 77 | test("generates fluid styles for em units", () => { 78 | expect( 79 | fluid(typography)({ 80 | theme: themeFactory({ 81 | breakpoints: { 82 | 0: "40em", 83 | fluidStart: "20em" 84 | } 85 | }), 86 | fontSize: ["1em", "1.33em"] 87 | }) 88 | ).toEqual({ 89 | fontSize: "1em", 90 | [buildMediaQuery("20em")]: { 91 | fontSize: "calc(1em + (1.33 - 1)*(100vw - 20em)/(40 - 20))" 92 | }, 93 | [buildMediaQuery("40em")]: { 94 | fontSize: "1.33em" 95 | } 96 | }); 97 | }); 98 | 99 | test("generates fluid styles and preserves other styles", () => { 100 | expect( 101 | fluid(typography)({ 102 | theme: themeFactory({ 103 | breakpoints: { 104 | 0: "40em", 105 | fluidStart: "20em" 106 | } 107 | }), 108 | fontSize: ["1em", "1.33em"], 109 | fontFamily: "sans-serif", 110 | textAlign: ["left", "center"] 111 | }) 112 | ).toEqual({ 113 | fontSize: "1em", 114 | fontFamily: "sans-serif", 115 | textAlign: "left", 116 | [buildMediaQuery("20em")]: { 117 | fontSize: "calc(1em + (1.33 - 1)*(100vw - 20em)/(40 - 20))" 118 | }, 119 | [buildMediaQuery("40em")]: { 120 | fontSize: "1.33em", 121 | textAlign: "center" 122 | } 123 | }); 124 | }); 125 | 126 | test("skips generating fluid styles for responsive styles defined in different units", () => { 127 | expect( 128 | fluid(typography)({ 129 | theme: themeFactory({ 130 | breakpoints: ["40em"] 131 | }), 132 | fontSize: ["1em", "21px"] 133 | }) 134 | ).toEqual({ 135 | fontSize: "1em", 136 | [buildMediaQuery("40em")]: { 137 | fontSize: "21px" 138 | } 139 | }); 140 | }); 141 | 142 | test("skips generating fluid styles for responsive styles in a different unit than the breakpoints", () => { 143 | expect( 144 | fluid(typography)({ 145 | theme: themeFactory({ 146 | breakpoints: ["40em"] 147 | }), 148 | fontSize: ["16px", "21px"] 149 | }) 150 | ).toEqual({ 151 | fontSize: "16px", 152 | [buildMediaQuery("40em")]: { 153 | fontSize: "21px" 154 | } 155 | }); 156 | }); 157 | 158 | test("accepts a custom fluidStart", () => { 159 | const styleObject = fluid(typography)({ 160 | theme: themeFactory({ breakpoints: { 0: "40em", fluidStart: "27em" } }), 161 | fontSize: ["1em", "1.33em"] 162 | }); 163 | const firstMediaQuery = Object.keys(styleObject).find(key => 164 | key.startsWith("@media") 165 | ); 166 | 167 | expect(firstMediaQuery).toMatch(/27em/); 168 | }); 169 | 170 | test("skips breakpoints for null values", () => { 171 | expect( 172 | fluid(typography)({ 173 | theme: themeFactory({ 174 | breakpoints: { 175 | 0: "40em", 176 | 1: "52em", 177 | fluidStart: "20em" 178 | } 179 | }), 180 | fontSize: ["1em", null, "1.33em"] 181 | }) 182 | ).toEqual({ 183 | fontSize: "1em", 184 | [buildMediaQuery("20em")]: { 185 | fontSize: "calc(1em + (1.33 - 1)*(100vw - 20em)/(52 - 20))" 186 | }, 187 | [buildMediaQuery("52em")]: { 188 | fontSize: "1.33em" 189 | } 190 | }); 191 | }); 192 | 193 | test("generates correct styles when fluidStart is not the least value in theme.breakpoints", () => { 194 | expect( 195 | fluid(typography)({ 196 | theme: themeFactory({ 197 | breakpoints: { 198 | 0: "20em", 199 | 1: "30em", 200 | 2: "40em", 201 | fluidStart: "25em" 202 | } 203 | }), 204 | fontSize: ["1em", "1.33em", "1.77em", "2.17em"] 205 | }) 206 | ).toEqual({ 207 | fontSize: "1em", 208 | [buildMediaQuery("20em")]: { 209 | fontSize: "1.33em" 210 | }, 211 | [buildMediaQuery("25em")]: { 212 | fontSize: "calc(1.33em + (1.77 - 1.33)*(100vw - 25em)/(30 - 25))" 213 | }, 214 | [buildMediaQuery("30em")]: { 215 | fontSize: "calc(1.77em + (2.17 - 1.77)*(100vw - 30em)/(40 - 30))" 216 | }, 217 | [buildMediaQuery("40em")]: { 218 | fontSize: "2.17em" 219 | } 220 | }); 221 | }); 222 | 223 | test("generates correct styles when fluidStart matches a value in theme.breakpoints", () => { 224 | expect( 225 | fluid(typography)({ 226 | theme: themeFactory({ 227 | breakpoints: { 228 | 0: "20em", 229 | 1: "30em", 230 | 2: "40em", 231 | fluidStart: "30em" 232 | } 233 | }), 234 | fontSize: ["1em", "1.33em", "1.77em", "2.17em"] 235 | }) 236 | ).toEqual({ 237 | fontSize: "1em", 238 | [buildMediaQuery("20em")]: { 239 | fontSize: "1.33em" 240 | }, 241 | [buildMediaQuery("30em")]: { 242 | fontSize: "calc(1.77em + (2.17 - 1.77)*(100vw - 30em)/(40 - 30))" 243 | }, 244 | [buildMediaQuery("40em")]: { 245 | fontSize: "2.17em" 246 | } 247 | }); 248 | }); 249 | }); 250 | 251 | describe("preflight checks", () => { 252 | test("sets default breakpoints when none are given", () => { 253 | const styleObject = fluid(typography)({ 254 | theme: themeFactory({ breakpoints: undefined }), 255 | fontSize: ["1em", "1.33em"] 256 | }); 257 | const hasMediaQuery = Object.keys(styleObject).some(key => 258 | key.startsWith("@media") 259 | ); 260 | 261 | expect(hasMediaQuery).toBe(true); 262 | }); 263 | 264 | test("handles object breakpoints", () => { 265 | const styleObject = fluid(typography)({ 266 | theme: themeFactory({ breakpoints: { sm: "40em", fluidStart: "20em" } }), 267 | fontSize: ["1em", "1.33em"] 268 | }); 269 | const hasFluidStartMediaQuery = Object.keys(styleObject).some( 270 | key => key.startsWith("@media") && key.includes("20em") 271 | ); 272 | const hasSmMediaQuery = Object.keys(styleObject).some( 273 | key => key.startsWith("@media") && key.includes("40em") 274 | ); 275 | 276 | expect(hasFluidStartMediaQuery && hasSmMediaQuery).toBe(true); 277 | }); 278 | 279 | test("throws an error if breakpoints are defined in different units", () => { 280 | expect(() => 281 | fluid(typography)({ 282 | theme: themeFactory({ breakpoints: ["640px", "52em", "64rem"] }) 283 | }) 284 | ).toThrow(TypeError); 285 | }); 286 | 287 | describe("given no fluidStart", () => { 288 | test('sets an "em" unit fluidStart for "em" unit breakpoints', () => { 289 | const styleObject = fluid(typography)({ 290 | theme: themeFactory({ breakpoints: ["40em"] }), 291 | fontSize: ["1em", "1.33em"] 292 | }); 293 | const fluidStartMediaQuery = Object.keys(styleObject).find( 294 | key => key.startsWith("@media") && !key.includes("40em") 295 | ); 296 | 297 | expect(fluidStartMediaQuery).toMatch(/[0-9]+em/); 298 | }); 299 | 300 | test('sets a "rem" unit fluidStart for "rem" unit breakpoints', () => { 301 | const styleObject = fluid(typography)({ 302 | theme: themeFactory({ breakpoints: ["40rem"] }), 303 | fontSize: ["1rem", "1.33rem"] 304 | }); 305 | const fluidStartMediaQuery = Object.keys(styleObject).find( 306 | key => key.startsWith("@media") && !key.includes("40rem") 307 | ); 308 | 309 | expect(fluidStartMediaQuery).toMatch(/[0-9]+rem/); 310 | }); 311 | 312 | test('sets a "px" unit fluidStart for "px" unit breakpoints', () => { 313 | const styleObject = fluid(typography)({ 314 | theme: themeFactory({ breakpoints: ["640px"] }), 315 | fontSize: ["16px", "21px"] 316 | }); 317 | const fluidStartMediaQuery = Object.keys(styleObject).find( 318 | key => key.startsWith("@media") && !key.includes("640px") 319 | ); 320 | 321 | expect(fluidStartMediaQuery).toMatch(/[0-9]+px/); 322 | }); 323 | 324 | test("throws an error for breakpoints declared with other units", () => { 325 | expect(() => 326 | fluid(typography)({ 327 | theme: themeFactory({ breakpoints: ["640pt"] }), 328 | fontSize: ["1em", "1.33em"] 329 | }) 330 | ).toThrow(TypeError); 331 | }); 332 | }); 333 | 334 | test("throws an error if fluidStart is defined in a different unit than breakpoints", () => { 335 | expect(() => 336 | fluid(typography)({ 337 | theme: themeFactory({ breakpoints: { 0: "40em", fluidStart: "320px" } }) 338 | }) 339 | ).toThrow(TypeError); 340 | }); 341 | }); 342 | --------------------------------------------------------------------------------