├── .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 | [](https://travis-ci.com/galacemiguel/fluid-system)    
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 |
--------------------------------------------------------------------------------