From 0910a21b7d35eb859ca9e160c4492ef41a33810e Mon Sep 17 00:00:00 2001 From: Kyle Tsang <6854874+kyletsang@users.noreply.github.com> Date: Thu, 3 Mar 2022 21:15:55 -0800 Subject: [PATCH] feat: support custom breakpoints (#6253) * feat: support custom breakpoints * docs: add docs --- src/Col.tsx | 7 +-- src/Container.tsx | 9 +--- src/ListGroup.tsx | 4 +- src/Modal.tsx | 1 + src/ModalDialog.tsx | 1 + src/Navbar.tsx | 5 +-- src/Row.tsx | 8 ++-- src/Stack.tsx | 4 +- src/ThemeProvider.tsx | 44 +++++++++++++++++-- src/createUtilityClasses.ts | 6 +-- src/types.tsx | 4 +- test/ColSpec.tsx | 10 +++++ test/ContainerSpec.tsx | 5 +++ test/DropdownMenuSpec.tsx | 11 +++++ test/ListGroupSpec.tsx | 2 +- test/ModalSpec.tsx | 11 +++++ test/NavbarSpec.tsx | 8 ++++ test/RowSpec.tsx | 10 +++++ test/createUtilityClassesSpec.ts | 12 ++++++ tests/simple-types-test.tsx | 34 ++++++++++++++- www/src/components/BreakpointTable.js | 61 +++++++++++++++++++++++++++ www/src/components/SideNav.js | 2 +- www/src/examples/CustomBreakpoints.js | 5 +++ www/src/pages/layout/breakpoints.mdx | 48 +++++++++++++++++++++ 24 files changed, 282 insertions(+), 30 deletions(-) create mode 100644 www/src/components/BreakpointTable.js create mode 100644 www/src/examples/CustomBreakpoints.js create mode 100644 www/src/pages/layout/breakpoints.mdx diff --git a/src/Col.tsx b/src/Col.tsx index fa1f069f0c..3edb24d57a 100644 --- a/src/Col.tsx +++ b/src/Col.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { useBootstrapPrefix } from './ThemeProvider'; +import { useBootstrapPrefix, useBootstrapBreakpoints } from './ThemeProvider'; import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers'; type NumberAttr = @@ -36,9 +36,9 @@ export interface ColProps lg?: ColSpec; xl?: ColSpec; xxl?: ColSpec; + [key: string]: any; } -const DEVICE_SIZES = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs'] as const; const colSize = PropTypes.oneOfType([ PropTypes.bool, PropTypes.number, @@ -124,11 +124,12 @@ export function useCol({ ...props }: ColProps): [any, UseColMetadata] { bsPrefix = useBootstrapPrefix(bsPrefix, 'col'); + const breakpoints = useBootstrapBreakpoints(); const spans: string[] = []; const classes: string[] = []; - DEVICE_SIZES.forEach((brkPoint) => { + breakpoints.forEach((brkPoint) => { const propValue = props[brkPoint]; delete props[brkPoint]; diff --git a/src/Container.tsx b/src/Container.tsx index 101eca6a3b..13e6482a5e 100644 --- a/src/Container.tsx +++ b/src/Container.tsx @@ -8,14 +8,9 @@ import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers'; export interface ContainerProps extends BsPrefixProps, React.HTMLAttributes { - fluid?: boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; + fluid?: boolean | string | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; } -const containerSizes = PropTypes.oneOfType([ - PropTypes.bool, - PropTypes.oneOf(['sm', 'md', 'lg', 'xl', 'xxl']), -]); - const propTypes = { /** * @default 'container' @@ -26,7 +21,7 @@ const propTypes = { * Allow the Container to fill all of its available horizontal space. * @type {(true|"sm"|"md"|"lg"|"xl"|"xxl")} */ - fluid: containerSizes, + fluid: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), /** * You can use a custom element for this component */ diff --git a/src/ListGroup.tsx b/src/ListGroup.tsx index 2ea1a277c4..dacfbd8cee 100644 --- a/src/ListGroup.tsx +++ b/src/ListGroup.tsx @@ -11,7 +11,7 @@ import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers'; export interface ListGroupProps extends BsPrefixProps, BaseNavProps { variant?: 'flush'; - horizontal?: boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; + horizontal?: boolean | string | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; defaultActiveKey?: EventKey; numbered?: boolean; } @@ -36,7 +36,7 @@ const propTypes = { * makes the list group horizontal starting at that breakpoint’s `min-width`. * @type {(true|'sm'|'md'|'lg'|'xl'|'xxl')} */ - horizontal: PropTypes.oneOf([true, 'sm', 'md', 'lg', 'xl', 'xxl']), + horizontal: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), /** * Generate numbered list items. diff --git a/src/Modal.tsx b/src/Modal.tsx index 705a67703b..3e29fee1a4 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -38,6 +38,7 @@ export interface ModalProps size?: 'sm' | 'lg' | 'xl'; fullscreen?: | true + | string | 'sm-down' | 'md-down' | 'lg-down' diff --git a/src/ModalDialog.tsx b/src/ModalDialog.tsx index 3350d4e46b..352560f341 100644 --- a/src/ModalDialog.tsx +++ b/src/ModalDialog.tsx @@ -12,6 +12,7 @@ export interface ModalDialogProps size?: 'sm' | 'lg' | 'xl'; fullscreen?: | true + | string | 'sm-down' | 'md-down' | 'lg-down' diff --git a/src/Navbar.tsx b/src/Navbar.tsx index ff95d9e7d2..d263c1c23b 100644 --- a/src/Navbar.tsx +++ b/src/Navbar.tsx @@ -23,7 +23,7 @@ export interface NavbarProps extends BsPrefixProps, Omit, 'onSelect'> { variant?: 'light' | 'dark'; - expand?: boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; + expand?: boolean | string | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; bg?: string; fixed?: 'top' | 'bottom'; sticky?: 'top'; @@ -50,8 +50,7 @@ const propTypes = { * The breakpoint, below which, the Navbar will collapse. * When `true` the Navbar will always be expanded regardless of screen size. */ - expand: PropTypes.oneOf([false, true, 'sm', 'md', 'lg', 'xl', 'xxl']) - .isRequired, + expand: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]).isRequired, /** * A convenience prop for adding `bg-*` utility classes since they are so commonly used here. diff --git a/src/Row.tsx b/src/Row.tsx index c392453114..ae2b65b7a2 100644 --- a/src/Row.tsx +++ b/src/Row.tsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import * as React from 'react'; -import { useBootstrapPrefix } from './ThemeProvider'; +import { useBootstrapPrefix, useBootstrapBreakpoints } from './ThemeProvider'; import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers'; type RowColWidth = @@ -32,9 +32,9 @@ export interface RowProps lg?: RowColumns; xl?: RowColumns; xxl?: RowColumns; + [key: string]: any; } -const DEVICE_SIZES = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs'] as const; const rowColWidth = PropTypes.oneOfType([PropTypes.number, PropTypes.string]); const rowColumns = PropTypes.oneOfType([ @@ -116,10 +116,12 @@ const Row: BsPrefixRefForwardingComponent<'div', RowProps> = React.forwardRef< ref, ) => { const decoratedBsPrefix = useBootstrapPrefix(bsPrefix, 'row'); + const breakpoints = useBootstrapBreakpoints(); + const sizePrefix = `${decoratedBsPrefix}-cols`; const classes: string[] = []; - DEVICE_SIZES.forEach((brkPoint) => { + breakpoints.forEach((brkPoint) => { const propValue = props[brkPoint]; delete props[brkPoint]; diff --git a/src/Stack.tsx b/src/Stack.tsx index eec64829f8..419ac6f48d 100644 --- a/src/Stack.tsx +++ b/src/Stack.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { useBootstrapPrefix } from './ThemeProvider'; +import { useBootstrapPrefix, useBootstrapBreakpoints } from './ThemeProvider'; import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers'; import { GapValue } from './types'; import createUtilityClassName, { @@ -46,6 +46,7 @@ const Stack: BsPrefixRefForwardingComponent<'span', StackProps> = bsPrefix, direction === 'horizontal' ? 'hstack' : 'vstack', ); + const breakpoints = useBootstrapBreakpoints(); return ( = bsPrefix, ...createUtilityClassName({ gap, + breakpoints, }), )} /> diff --git a/src/ThemeProvider.tsx b/src/ThemeProvider.tsx index c65ca4f1ed..d5ea888cbc 100644 --- a/src/ThemeProvider.tsx +++ b/src/ThemeProvider.tsx @@ -2,8 +2,11 @@ import PropTypes from 'prop-types'; import * as React from 'react'; import { useContext, useMemo } from 'react'; +export const DEFAULT_BREAKPOINTS = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs']; + export interface ThemeContextValue { prefixes: Record; + breakpoints: string[]; dir?: string; } @@ -11,23 +14,53 @@ export interface ThemeProviderProps extends Partial { children: React.ReactNode; } -const ThemeContext = React.createContext({ prefixes: {} }); +const ThemeContext = React.createContext({ + prefixes: {}, + breakpoints: DEFAULT_BREAKPOINTS, +}); const { Consumer, Provider } = ThemeContext; -function ThemeProvider({ prefixes = {}, dir, children }: ThemeProviderProps) { +function ThemeProvider({ + prefixes = {}, + breakpoints = DEFAULT_BREAKPOINTS, + dir, + children, +}: ThemeProviderProps) { const contextValue = useMemo( () => ({ prefixes: { ...prefixes }, + breakpoints, dir, }), - [prefixes, dir], + [prefixes, breakpoints, dir], ); return {children}; } ThemeProvider.propTypes = { + /** + * An object mapping of Bootstrap component classes that + * map to a custom class. + * + * **Note: Changing prefixes is an escape hatch and generally + * shouldn't be used.** + * + * For more information, see [here](/getting-started/theming/#prefixing-components). + */ prefixes: PropTypes.object, + + /** + * An array of breakpoints that your application supports. + * Defaults to the standard Bootstrap breakpoints. + */ + breakpoints: PropTypes.arrayOf(PropTypes.string), + + /** + * Indicates the directionality of the application's text. + * + * Use `rtl` to set text as "right to left". + */ dir: PropTypes.string, } as any; @@ -39,6 +72,11 @@ export function useBootstrapPrefix( return prefix || prefixes[defaultPrefix] || defaultPrefix; } +export function useBootstrapBreakpoints() { + const { breakpoints } = useContext(ThemeContext); + return breakpoints; +} + export function useIsRTL() { const { dir } = useContext(ThemeContext); return dir === 'rtl'; diff --git a/src/createUtilityClasses.ts b/src/createUtilityClasses.ts index 39b537b630..5e21327072 100644 --- a/src/createUtilityClasses.ts +++ b/src/createUtilityClasses.ts @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import { DEFAULT_BREAKPOINTS } from './ThemeProvider'; export type ResponsiveUtilityValue = | T @@ -25,16 +26,15 @@ export function responsivePropType(propType: any) { ]); } -export const DEVICE_SIZES = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs'] as const; - export default function createUtilityClassName( utilityValues: Record>, + breakpoints = DEFAULT_BREAKPOINTS, ) { const classes: string[] = []; Object.entries(utilityValues).forEach(([utilName, utilValue]) => { if (utilValue != null) { if (typeof utilValue === 'object') { - DEVICE_SIZES.forEach((brkPoint) => { + breakpoints.forEach((brkPoint) => { const bpValue = utilValue![brkPoint]; if (bpValue != null) { const infix = brkPoint !== 'xs' ? `-${brkPoint}` : ''; diff --git a/src/types.tsx b/src/types.tsx index 7d0bdec604..fba8bbcf4f 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -42,7 +42,8 @@ export type ResponsiveAlignProp = | { md: AlignDirection } | { lg: AlignDirection } | { xl: AlignDirection } - | { xxl: AlignDirection }; + | { xxl: AlignDirection } + | Record; export type AlignType = AlignDirection | ResponsiveAlignProp; @@ -55,6 +56,7 @@ export const alignPropType = PropTypes.oneOfType([ PropTypes.shape({ lg: alignDirection }), PropTypes.shape({ xl: alignDirection }), PropTypes.shape({ xxl: alignDirection }), + PropTypes.object, ]); export type RootCloseEvent = 'click' | 'mousedown'; diff --git a/test/ColSpec.tsx b/test/ColSpec.tsx index 72d4e0c5b9..04e121b404 100644 --- a/test/ColSpec.tsx +++ b/test/ColSpec.tsx @@ -1,4 +1,5 @@ import { render } from '@testing-library/react'; +import { ThemeProvider } from '../src'; import Col from '../src/Col'; @@ -83,4 +84,13 @@ describe('Col', () => { const { getByText } = render(Column); getByText('Column').tagName.toLowerCase().should.equal('div'); }); + + it('should allow custom breakpoints', () => { + const { getByText } = render( + + test + , + ); + getByText('test').classList.contains('col-custom-3').should.be.true; + }); }); diff --git a/test/ContainerSpec.tsx b/test/ContainerSpec.tsx index e43c8ec62f..83f3cde7e1 100644 --- a/test/ContainerSpec.tsx +++ b/test/ContainerSpec.tsx @@ -30,4 +30,9 @@ describe('', () => { const { getByText } = render(Container); getByText('Container').tagName.toLowerCase().should.equal('div'); }); + + it('should allow custom breakpoints', () => { + const { getByText } = render(test); + getByText('test').classList.contains('container-custom').should.be.true; + }); }); diff --git a/test/DropdownMenuSpec.tsx b/test/DropdownMenuSpec.tsx index 65811970c7..d46752d3a4 100644 --- a/test/DropdownMenuSpec.tsx +++ b/test/DropdownMenuSpec.tsx @@ -84,6 +84,17 @@ describe('', () => { container.querySelector('[data-bs-popper="static"]')!.should.exist; }); + it('allows custom responsive alignment classes', () => { + const { container } = render( + + Item + , + ); + + container.firstElementChild!.classList.contains('dropdown-menu-custom-end') + .should.be.true; + }); + it('should render variant', () => { const { container } = render( diff --git a/test/ListGroupSpec.tsx b/test/ListGroupSpec.tsx index bbc239093a..08ed0ab177 100644 --- a/test/ListGroupSpec.tsx +++ b/test/ListGroupSpec.tsx @@ -41,7 +41,7 @@ describe('', () => { listGroup.classList.contains('list-group-horizontal').should.be.true; }); - (['sm', 'md', 'lg', 'xl'] as const).forEach((breakpoint) => { + (['sm', 'md', 'lg', 'xl', 'xxl', 'custom'] as const).forEach((breakpoint) => { it(`accepts responsive horizontal ${breakpoint} breakpoint`, () => { const { getByTestId } = render( , diff --git a/test/ModalSpec.tsx b/test/ModalSpec.tsx index 70e84977ad..4ab90ae04d 100644 --- a/test/ModalSpec.tsx +++ b/test/ModalSpec.tsx @@ -183,6 +183,17 @@ describe('', () => { .be.true; }); + it('Should allow custom breakpoints for fullscreen', () => { + const { getByTestId } = render( + + Message + , + ); + + getByTestId('modal').classList.contains('modal-fullscreen-custom-down') + .should.be.true; + }); + it('Should pass centered to the dialog', () => { const { getByTestId } = render( diff --git a/test/NavbarSpec.tsx b/test/NavbarSpec.tsx index 5c7f17d703..ee8a0c2d32 100644 --- a/test/NavbarSpec.tsx +++ b/test/NavbarSpec.tsx @@ -277,6 +277,14 @@ describe('', () => { getByTestId('test').classList.contains('navbar-expand-sm').should.be.true; }); + it('should allow custom breakpoints for expand', () => { + const { getByTestId } = render( + , + ); + getByTestId('test').classList.contains('navbar-expand-custom').should.be + .true; + }); + it('Should render correctly when bg is set', () => { const { getByTestId } = render(); getByTestId('test').classList.contains('bg-light').should.be.true; diff --git a/test/RowSpec.tsx b/test/RowSpec.tsx index c2cb3852a7..1136ba5e8c 100644 --- a/test/RowSpec.tsx +++ b/test/RowSpec.tsx @@ -1,4 +1,5 @@ import { render } from '@testing-library/react'; +import { ThemeProvider } from '../src'; import Row from '../src/Row'; @@ -66,4 +67,13 @@ describe('Row', () => { getByText('Row').tagName.toLowerCase().should.equal('section'); getByText('Row').classList.contains('row').should.be.true; }); + + it('should allow custom breakpoints', () => { + const { getByText } = render( + + test + , + ); + getByText('test').classList.contains('row-cols-custom-3').should.be.true; + }); }); diff --git a/test/createUtilityClassesSpec.ts b/test/createUtilityClassesSpec.ts index 4f121c9f91..f41597d5e9 100644 --- a/test/createUtilityClassesSpec.ts +++ b/test/createUtilityClassesSpec.ts @@ -61,4 +61,16 @@ describe('createUtilityClassName', () => { 'text-xl-start', ]); }); + + it('should handle custom breakpoints', () => { + const classList = createUtilityClasses( + { + gap: { xs: 2, custom: 3 }, + }, + ['xs', 'custom'], + ); + + classList.length.should.equal(2); + classList.should.include.all.members(['gap-2', 'gap-custom-3']); + }); }); diff --git a/tests/simple-types-test.tsx b/tests/simple-types-test.tsx index 1505a55121..4e5314c8df 100644 --- a/tests/simple-types-test.tsx +++ b/tests/simple-types-test.tsx @@ -1,5 +1,6 @@ /* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/aria-role */ +/* eslint-disable @typescript-eslint/no-unused-vars */ import * as React from 'react'; import { @@ -20,6 +21,7 @@ import { Row, Dropdown, DropdownButton, + DropdownMenu, Fade, Figure, Form, @@ -43,9 +45,11 @@ import { Table, Tabs, Tab, + ThemeProvider, ToggleButtonGroup, ToggleButton, Toast, + ModalDialog, } from '../src'; import BootstrapModalManager from '../src/BootstrapModalManager'; @@ -55,7 +59,6 @@ const style: React.CSSProperties = { color: 'red', }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars const RefTest = () => { const carouselRef = React.useRef(); // eslint-disable-next-line @typescript-eslint/no-unused-expressions @@ -81,7 +84,6 @@ const FunctionComponent: React.FC = () =>
abc
; // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {}; -// eslint-disable-next-line @typescript-eslint/no-unused-vars const MegaComponent = () => ( <> @@ -1037,3 +1039,31 @@ const MegaComponent = () => ( */} ); + +const CustomBreakpoints = () => ( + + + + +
+ + +
+ + +
+ + + + + + + +
+ + +); diff --git a/www/src/components/BreakpointTable.js b/www/src/components/BreakpointTable.js new file mode 100644 index 0000000000..5fd335989b --- /dev/null +++ b/www/src/components/BreakpointTable.js @@ -0,0 +1,61 @@ +import Table from 'react-bootstrap/Table'; + +const BreakpointTable = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BreakpointClass infixDimensions
X-Small + None + <576px
Small + sm + ≥576px
Medium + md + ≥768px
Large + lg + ≥992px
Extra large + xl + ≥1200px
Extra extra large + xxl + ≥1400px
+ ); +}; + +export default BreakpointTable; diff --git a/www/src/components/SideNav.js b/www/src/components/SideNav.js index 8746e81d35..48f63d0b30 100644 --- a/www/src/components/SideNav.js +++ b/www/src/components/SideNav.js @@ -111,7 +111,7 @@ const gettingStarted = [ 'server-side-rendering', ]; -const layout = ['grid', 'stack']; +const layout = ['breakpoints', 'grid', 'stack']; const forms = [ 'overview', diff --git a/www/src/examples/CustomBreakpoints.js b/www/src/examples/CustomBreakpoints.js new file mode 100644 index 0000000000..d261a9c7a6 --- /dev/null +++ b/www/src/examples/CustomBreakpoints.js @@ -0,0 +1,5 @@ + +
Your app...
+
; diff --git a/www/src/pages/layout/breakpoints.mdx b/www/src/pages/layout/breakpoints.mdx new file mode 100644 index 0000000000..d445bd708a --- /dev/null +++ b/www/src/pages/layout/breakpoints.mdx @@ -0,0 +1,48 @@ +import { graphql } from 'gatsby'; + +import BreakpointTable from '../../components/BreakpointTable'; +import Callout from '../../components/Callout'; +import CodeBlock from '../../components/CodeBlock'; +import ComponentApi from '../../components/ComponentApi'; +import CustomBreakpoints from '../../examples/CustomBreakpoints'; +import DocLink from '../../components/DocLink'; + +# Breakpoints + +

+ Breakpoints are customizable widths that determine how your responsive layout + behaves across device or viewport sizes in Bootstrap. +

+ +## Available breakpoints + +Bootstrap includes six default breakpoints, sometimes referred to as _grid tiers_, +for building responsively. These breakpoints can be customized if you’re using our +source Sass files. + + + +## Custom breakpoints + +If you are looking to use custom breakpoints, you must wrap your application with +a theme provider and use the `breakpoints` prop to specify the breakpoints you +will use. This ensures that components such as `Row` or `Col` can parse the +correct custom breakpoint props. + + + +## More information + +For more information about breakpoints, see the Bootstrap documentation. + +## API + + + +export const query = graphql` + query ThemeProviderBreakpointsQuery { + ThemeProvider: componentMetadata(displayName: { eq: "ThemeProvider" }) { + ...ComponentApi_metadata + } + } +`;