diff --git a/.circleci/config.yml b/.circleci/config.yml index a792a9e266cadf..a78d4ecfec98e0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,7 +31,6 @@ commands: command: | sudo apt-get update sudo apt-get install -y --force-yes gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget - jobs: checkout: @@ -156,7 +155,7 @@ jobs: name: Can we generate the documentation? command: yarn docs:api - run: - name: '`yarn docs:api` changes commited?' + name: '`yarn docs:api` changes committed?' command: git diff --exit-code - run: name: Can we generate the @material-ui/core build? diff --git a/docs/src/pages/customization/themes/ResponsiveFontSizes.js b/docs/src/pages/customization/themes/ResponsiveFontSizes.js new file mode 100644 index 00000000000000..ca17c0db0756e4 --- /dev/null +++ b/docs/src/pages/customization/themes/ResponsiveFontSizes.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { createMuiTheme, responsiveFontSizes } from '@material-ui/core/styles'; +import { ThemeProvider } from '@material-ui/styles'; +import Typography from '@material-ui/core/Typography'; + +let theme = createMuiTheme(); +theme = responsiveFontSizes(theme); + +export default function ResponsiveFontSizes() { + return ( + + Responsive h3 + + ); +} diff --git a/docs/src/pages/customization/themes/themes.md b/docs/src/pages/customization/themes/themes.md index 8432891c4a0381..4093770c1a4171 100644 --- a/docs/src/pages/customization/themes/themes.md +++ b/docs/src/pages/customization/themes/themes.md @@ -345,6 +345,35 @@ html { {{"demo": "pages/customization/themes/FontSizeTheme.js"}} +### Responsive font sizes + +The typography variants properties map directly to the generated CSS. +You can use [media queries](/layout/breakpoints/#api) inside them: + +```js +const theme = createMuiTheme(); + +theme.typography.h1 = { + fontSize: '3rem', + '@media (min-width:600px)': { + fontSize: '4.5rem', + }, + [theme.breakpoints.up('md')]: { + fontSize: '6rem', + }, +}; +``` + +To automate this setup, you can use the [`responsiveFontSizes()`](#responsivefontsizes-theme-options-theme) helper to make Typography font sizes in the theme responsive. + +You can see this in action in the example below. adjust your browser's window size, and notice how the font size changes as the width crosses the different [breakpoints](/layout/breakpoints/): + +{{"demo": "pages/customization/themes/ResponsiveFontSizes.js"}} + +### Fluid font sizes + +To be done: [#15251](https://github.com/mui-org/material-ui/issues/15251). + ## Spacing We encourage you to use the `theme.spacing()` helper to create consistent spacing between the elements of your UI. @@ -512,3 +541,33 @@ const theme = createMuiTheme({ }, }); ``` + +### `responsiveFontSizes(theme, options) => theme` + +Generate responsive typography settings based on the options received. + +#### Arguments + +1. `theme` (*Object*): The theme object to enhance. +2. `options` (*Object* [optional]): + + - `breakpoints` (*Array* [optional]): Default to `['sm', 'md', 'lg']`. Array of [breakpoints](/layout/breakpoints/) (identifiers). + - `disableAlign` (*Boolean* [optional]): Default to `false`. Whether font sizes change slightly so line + heights are preserved and align to Material Design's 4px line height grid. + This requires a unitless line height in the theme's styles. + - `factor` (*Number* [optional]): Default to `2`. This value determines the strength of font size resizing. The higher the value, the less difference there is between font sizes on small screens. + The lower the value, the bigger font sizes for small screens. The value must me greater than 1. + - `variants` (*Array* [optional]): Default to all. The typography variants to handle. + +#### Returns + +`theme` (*Object*): The new theme with a responsive typography. + +#### Examples + +```js +import { createMuiTheme, responsiveFontSizes } from '@material-ui/core/styles'; + +let theme = createMuiTheme(); +theme = responsiveFontSizes(theme); +``` diff --git a/packages/material-ui/package.json b/packages/material-ui/package.json index 3b1baf6ca6cc24..0a54696b0fccf6 100644 --- a/packages/material-ui/package.json +++ b/packages/material-ui/package.json @@ -43,6 +43,7 @@ "@types/react-transition-group": "^2.0.16", "clsx": "^1.0.2", "csstype": "^2.5.2", + "convert-css-length": "^1.0.2", "debounce": "^1.1.0", "deepmerge": "^3.0.0", "hoist-non-react-statics": "^3.2.1", diff --git a/packages/material-ui/src/styles/createTypography.js b/packages/material-ui/src/styles/createTypography.js index 02e3b2f78050d8..eac9d2474a95d9 100644 --- a/packages/material-ui/src/styles/createTypography.js +++ b/packages/material-ui/src/styles/createTypography.js @@ -65,6 +65,7 @@ export default function createTypography(palette, typography) { return deepmerge( { + htmlFontSize, pxToRem, round, fontFamily, diff --git a/packages/material-ui/src/styles/cssUtils.js b/packages/material-ui/src/styles/cssUtils.js new file mode 100644 index 00000000000000..a1bfda8971dec0 --- /dev/null +++ b/packages/material-ui/src/styles/cssUtils.js @@ -0,0 +1,73 @@ +export function alignProperty({ size, grid }) { + const sizeBelow = size - (size % grid); + const sizeAbove = sizeBelow + grid; + + return size - sizeBelow < sizeAbove - size ? sizeBelow : sizeAbove; +} + +// fontGrid finds a minimal grid (in rem) for the fontSize values so that the +// lineHeight falls under a x pixels grid, 4px in the case of Material Design, +// without changing the relative line height +export function fontGrid({ lineHeight, pixels, htmlFontSize }) { + return pixels / (lineHeight * htmlFontSize); +} + +/** + * generate a responsive version of a given CSS property + * @example + * responsiveProperty({ + * cssProperty: 'fontSize', + * min: 15, + * max: 20, + * unit: 'px', + * breakpoints: [300, 600], + * }) + * + * // this returns + * + * { + * fontSize: '15px', + * '@media (min-width:300px)': { + * fontSize: '17.5px', + * }, + * '@media (min-width:600px)': { + * fontSize: '20px', + * }, + * } + * + * @param {Object} params + * @param {string} params.cssProperty - The CSS property to be made responsive + * @param {number} params.min - The smallest value of the CSS property + * @param {number} params.max - The largest value of the CSS property + * @param {number} [params.unit] - The unit to be used for the CSS property + * @param {Array.number} [params.breakpoints] - An array of breakpoints + * @param {number} [params.alignStep] - Round scaled value to fall under this grid + * @returns {Object} responsive styles for {params.cssProperty} + */ +export function responsiveProperty({ + cssProperty, + min, + max, + unit = 'rem', + breakpoints = [600, 960, 1280], + transform = null, +}) { + const output = { + [cssProperty]: `${min}${unit}`, + }; + + const factor = (max - min) / breakpoints[breakpoints.length - 1]; + breakpoints.forEach(breakpoint => { + let value = min + factor * breakpoint; + + if (transform !== null) { + value = transform(value); + } + + output[`@media (min-width:${breakpoint}px)`] = { + [cssProperty]: `${Math.round(value * 10000) / 10000}${unit}`, + }; + }); + + return output; +} diff --git a/packages/material-ui/src/styles/cssUtils.test.js b/packages/material-ui/src/styles/cssUtils.test.js new file mode 100644 index 00000000000000..c4ec0692b3396f --- /dev/null +++ b/packages/material-ui/src/styles/cssUtils.test.js @@ -0,0 +1,99 @@ +import { assert } from 'chai'; +import { alignProperty, fontGrid, responsiveProperty } from './cssUtils'; + +describe('cssUtils', () => { + describe('alignProperty', () => { + const tests = [ + { args: { size: 8, grid: 4 }, expected: 8 }, + { args: { size: 8, grid: 1 }, expected: 8 }, + { args: { size: 8, grid: 9 }, expected: 9 }, + { args: { size: 8, grid: 7 }, expected: 7 }, + { args: { size: 8, grid: 17 }, expected: 0 }, + ]; + + tests.forEach(test => { + const { + args: { size, grid }, + expected, + } = test; + + it(`aligns ${size} on grid ${grid} to ${expected}`, () => { + const sizeAligned = alignProperty({ size, grid }); + assert.strictEqual(sizeAligned, expected); + }); + }); + }); + + describe('fontGrid', () => { + const tests = [ + { lineHeight: 1.3, pixels: 4, htmlFontSize: 16 }, + { lineHeight: 1.6, pixels: 9, htmlFontSize: 15 }, + { lineHeight: 1.0, pixels: 3, htmlFontSize: 14 }, + ]; + + tests.forEach(test => { + const { lineHeight, pixels, htmlFontSize } = test; + + describe(`when ${lineHeight} lineHeight, ${pixels} pixels, + ${htmlFontSize} htmlFontSize`, () => { + const grid = fontGrid({ lineHeight, pixels, htmlFontSize }); + + it(`should return a font grid such that the relative lineHeight is aligned`, () => { + const absoluteLineHeight = grid * lineHeight * htmlFontSize; + assert.strictEqual(Math.round((absoluteLineHeight % pixels) * 100000) / 100000, 0); + }); + }); + + it(`with ${lineHeight} lineHeight, ${pixels} pixels, + ${htmlFontSize} htmlFontSize, the font grid is such that + there is no smaller font aligning the lineHeight`, () => { + const grid = fontGrid({ lineHeight, pixels, htmlFontSize }); + const absoluteLineHeight = grid * lineHeight * htmlFontSize; + assert.strictEqual(Math.floor(absoluteLineHeight / pixels), 1); + }); + }); + }); + + describe('responsiveProperty', () => { + describe('when providing two breakpoints and pixel units', () => { + it('should respond with three styles in pixels', () => { + const result = responsiveProperty({ + cssProperty: 'fontSize', + min: 15, + max: 20, + unit: 'px', + breakpoints: [300, 600], + }); + + assert.deepEqual(result, { + fontSize: '15px', + '@media (min-width:300px)': { + fontSize: '17.5px', + }, + '@media (min-width:600px)': { + fontSize: '20px', + }, + }); + }); + }); + + describe('when providing one breakpoint and requesting rem units', () => { + it('should respond with two styles in rem', () => { + const result = responsiveProperty({ + cssProperty: 'fontSize', + min: 0.875, + max: 1, + unit: 'rem', + breakpoints: [500], + }); + + assert.deepEqual(result, { + fontSize: '0.875rem', + '@media (min-width:500px)': { + fontSize: '1rem', + }, + }); + }); + }); + }); +}); diff --git a/packages/material-ui/src/styles/index.js b/packages/material-ui/src/styles/index.js index 426aacd6007aaa..7c1db851b0f155 100644 --- a/packages/material-ui/src/styles/index.js +++ b/packages/material-ui/src/styles/index.js @@ -3,6 +3,7 @@ export { default as createMuiTheme } from './createMuiTheme'; export { default as createStyles } from './createStyles'; export { default as makeStyles } from './makeStyles'; export { default as MuiThemeProvider } from './MuiThemeProvider'; +export { default as responsiveFontSizes } from './responsiveFontSizes'; export { default as styled } from './styled'; export * from './transitions'; export { default as useTheme } from './useTheme'; diff --git a/packages/material-ui/src/styles/responsiveFontSizes.js b/packages/material-ui/src/styles/responsiveFontSizes.js new file mode 100644 index 00000000000000..25e972f00021c6 --- /dev/null +++ b/packages/material-ui/src/styles/responsiveFontSizes.js @@ -0,0 +1,90 @@ +import convertLength from 'convert-css-length'; +import { responsiveProperty, alignProperty, fontGrid } from './cssUtils'; + +function isUnitless(value) { + return String(parseFloat(value)).length === String(value).length; +} + +export default function responsiveFontSizes(themeInput, options = {}) { + const { + breakpoints = ['sm', 'md', 'lg'], + disableAlign = false, + factor = 2, + variants = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'subtitle1', + 'subtitle2', + 'body1', + 'body2', + 'caption', + 'button', + 'overline', + ], + } = options; + + const theme = { ...themeInput }; + theme.typography = { ...theme.typography }; + const typography = theme.typography; + + // Convert between css lengths e.g. em->px or px->rem + // Set the baseFontSize for your project. Defaults to 16px (also the browser default). + const convert = convertLength(typography.htmlFontSize); + const breakpointValues = breakpoints.map(x => theme.breakpoints.values[x]); + + variants.forEach(variant => { + const style = typography[variant]; + const remFontSize = parseFloat(convert(style.fontSize, 'rem')); + + if (remFontSize <= 1) { + return; + } + + const maxFontSize = remFontSize; + const minFontSize = 1 + (maxFontSize - 1) / factor; + + let { lineHeight } = style; + + if (!isUnitless(lineHeight) && !disableAlign) { + throw new Error( + [ + `Material-UI: unsupported non-unitless line height with grid alignment.`, + 'Use unitless line heights instead.', + ].join('\n'), + ); + } + + if (!isUnitless(lineHeight)) { + // make it unitless + lineHeight = parseFloat(convert(lineHeight, 'rem')) / parseFloat(remFontSize); + } + + let transform = null; + + if (!disableAlign) { + transform = value => + alignProperty({ + size: value, + grid: fontGrid({ pixels: 4, lineHeight, htmlFontSize: typography.htmlFontSize }), + }); + } + + typography[variant] = { + ...style, + ...responsiveProperty({ + cssProperty: 'fontSize', + min: minFontSize, + max: maxFontSize, + unit: 'rem', + breakpoints: breakpointValues, + transform, + }), + }; + }); + + return theme; +} diff --git a/packages/material-ui/src/styles/responsiveFontSizes.test.js b/packages/material-ui/src/styles/responsiveFontSizes.test.js new file mode 100644 index 00000000000000..4a5b4642949309 --- /dev/null +++ b/packages/material-ui/src/styles/responsiveFontSizes.test.js @@ -0,0 +1,72 @@ +import { assert } from 'chai'; +import responsiveFontSizes from './responsiveFontSizes'; +import { createMuiTheme } from '@material-ui/core/styles'; + +describe('responsiveFontSizes', () => { + it('should support unitless line height', () => { + const defaultVariant = { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + fontSize: '6rem', + fontWeight: 300, + letterSpacing: '-0.01562em', + lineHeight: 1, + }; + + const theme = createMuiTheme({ + typography: { + h1: defaultVariant, + }, + }); + const { typography } = responsiveFontSizes(theme); + assert.deepEqual(typography.h1, { + ...defaultVariant, + fontSize: '3.5rem', + '@media (min-width:600px)': { fontSize: '4.75rem' }, + '@media (min-width:960px)': { fontSize: '5.5rem' }, + '@media (min-width:1280px)': { fontSize: defaultVariant.fontSize }, + }); + }); + + it('should disable vertical alignment', () => { + const defaultVariant = { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + fontSize: '6rem', + fontWeight: 300, + letterSpacing: '-0.01562em', + lineHeight: '6rem', + }; + + const theme = createMuiTheme({ + typography: { + h1: defaultVariant, + }, + }); + const { typography } = responsiveFontSizes(theme, { + disableAlign: true, + }); + + assert.deepEqual(typography.h1, { + ...defaultVariant, + fontSize: '3.5rem', + '@media (min-width:600px)': { fontSize: '4.6719rem' }, + '@media (min-width:960px)': { fontSize: '5.375rem' }, + '@media (min-width:1280px)': { fontSize: defaultVariant.fontSize }, + }); + }); + + describe('when requesting a responsive typography with non unitless line height and alignment', () => { + it('should throw an error, as this is not supported', () => { + const theme = createMuiTheme({ + typography: { + h1: { + lineHeight: '6rem', + }, + }, + }); + + assert.throw(() => { + responsiveFontSizes(theme); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index b8902127c804be..411f34f3de766c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1721,14 +1721,15 @@ write-file-atomic "^2.3.0" "@material-ui/core@^3.9.3": - version "4.0.0-alpha.8" + version "4.0.0-beta.0" dependencies: "@babel/runtime" "^7.2.0" - "@material-ui/styles" "^4.0.0-alpha.8" - "@material-ui/system" "^4.0.0-alpha.8" - "@material-ui/utils" "^4.0.0-alpha.8" + "@material-ui/styles" "^4.0.0-beta.0" + "@material-ui/system" "^4.0.0-beta.0" + "@material-ui/utils" "^4.0.0-beta.0" "@types/react-transition-group" "^2.0.16" clsx "^1.0.2" + convert-css-length "^1.0.2" csstype "^2.5.2" debounce "^1.1.0" deepmerge "^3.0.0" @@ -4076,6 +4077,11 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +console-polyfill@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/console-polyfill/-/console-polyfill-0.1.2.tgz#96cfed51caf78189f699572e6f18271dc37c0e30" + integrity sha1-ls/tUcr3gYn2mVcubxgnHcN8DjA= + constants-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" @@ -4179,6 +4185,14 @@ conventional-recommended-bump@^4.0.4: meow "^4.0.0" q "^1.5.1" +convert-css-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/convert-css-length/-/convert-css-length-1.0.2.tgz#32f38a8ac55d78372ff43562532564366c871ccc" + integrity sha512-ecV7j3hXyXN1X2XfJBzhMR0o1Obv0v3nHmn0UiS3ACENrzbxE/EknkiunS/fCwQva0U62X1GChi8GaPh4oTlLg== + dependencies: + console-polyfill "^0.1.2" + parse-unit "^1.0.1" + convert-source-map@1.6.0, convert-source-map@^1.1.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1, convert-source-map@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" @@ -10148,6 +10162,11 @@ parse-path@^4.0.0: is-ssh "^1.3.0" protocols "^1.4.0" +parse-unit@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-unit/-/parse-unit-1.0.1.tgz#7e1bb6d5bef3874c28e392526a2541170291eecf" + integrity sha1-fhu21b7zh0wo45JSaiVBFwKR7s8= + parse-url@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-5.0.1.tgz#99c4084fc11be14141efa41b3d117a96fcb9527f"