Skip to content

Commit

Permalink
feat: support custom breakpoints (#6253)
Browse files Browse the repository at this point in the history
* feat: support custom breakpoints

* docs: add docs
  • Loading branch information
kyletsang committed Mar 4, 2022
1 parent 0eb6a2d commit 0910a21
Show file tree
Hide file tree
Showing 24 changed files with 282 additions and 30 deletions.
7 changes: 4 additions & 3 deletions src/Col.tsx
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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];

Expand Down
9 changes: 2 additions & 7 deletions src/Container.tsx
Expand Up @@ -8,14 +8,9 @@ import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers';
export interface ContainerProps
extends BsPrefixProps,
React.HTMLAttributes<HTMLElement> {
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'
Expand All @@ -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
*/
Expand Down
4 changes: 2 additions & 2 deletions src/ListGroup.tsx
Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/Modal.tsx
Expand Up @@ -38,6 +38,7 @@ export interface ModalProps
size?: 'sm' | 'lg' | 'xl';
fullscreen?:
| true
| string
| 'sm-down'
| 'md-down'
| 'lg-down'
Expand Down
1 change: 1 addition & 0 deletions src/ModalDialog.tsx
Expand Up @@ -12,6 +12,7 @@ export interface ModalDialogProps
size?: 'sm' | 'lg' | 'xl';
fullscreen?:
| true
| string
| 'sm-down'
| 'md-down'
| 'lg-down'
Expand Down
5 changes: 2 additions & 3 deletions src/Navbar.tsx
Expand Up @@ -23,7 +23,7 @@ export interface NavbarProps
extends BsPrefixProps,
Omit<React.HTMLAttributes<HTMLElement>, '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';
Expand All @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions src/Row.tsx
Expand Up @@ -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 =
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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];

Expand Down
4 changes: 3 additions & 1 deletion 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, {
Expand Down Expand Up @@ -46,6 +46,7 @@ const Stack: BsPrefixRefForwardingComponent<'span', StackProps> =
bsPrefix,
direction === 'horizontal' ? 'hstack' : 'vstack',
);
const breakpoints = useBootstrapBreakpoints();

return (
<Component
Expand All @@ -56,6 +57,7 @@ const Stack: BsPrefixRefForwardingComponent<'span', StackProps> =
bsPrefix,
...createUtilityClassName({
gap,
breakpoints,
}),
)}
/>
Expand Down
44 changes: 41 additions & 3 deletions src/ThemeProvider.tsx
Expand Up @@ -2,32 +2,65 @@ 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<string, string>;
breakpoints: string[];
dir?: string;
}

export interface ThemeProviderProps extends Partial<ThemeContextValue> {
children: React.ReactNode;
}

const ThemeContext = React.createContext<ThemeContextValue>({ prefixes: {} });
const ThemeContext = React.createContext<ThemeContextValue>({
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 <Provider value={contextValue}>{children}</Provider>;
}

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;

Expand All @@ -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';
Expand Down
6 changes: 3 additions & 3 deletions src/createUtilityClasses.ts
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
import { DEFAULT_BREAKPOINTS } from './ThemeProvider';

export type ResponsiveUtilityValue<T> =
| T
Expand All @@ -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<string, ResponsiveUtilityValue<unknown>>,
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}` : '';
Expand Down
4 changes: 3 additions & 1 deletion src/types.tsx
Expand Up @@ -42,7 +42,8 @@ export type ResponsiveAlignProp =
| { md: AlignDirection }
| { lg: AlignDirection }
| { xl: AlignDirection }
| { xxl: AlignDirection };
| { xxl: AlignDirection }
| Record<string, AlignDirection>;

export type AlignType = AlignDirection | ResponsiveAlignProp;

Expand All @@ -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';
Expand Down
10 changes: 10 additions & 0 deletions test/ColSpec.tsx
@@ -1,4 +1,5 @@
import { render } from '@testing-library/react';
import { ThemeProvider } from '../src';

import Col from '../src/Col';

Expand Down Expand Up @@ -83,4 +84,13 @@ describe('Col', () => {
const { getByText } = render(<Col>Column</Col>);
getByText('Column').tagName.toLowerCase().should.equal('div');
});

it('should allow custom breakpoints', () => {
const { getByText } = render(
<ThemeProvider breakpoints={['custom']}>
<Col custom="3">test</Col>
</ThemeProvider>,
);
getByText('test').classList.contains('col-custom-3').should.be.true;
});
});
5 changes: 5 additions & 0 deletions test/ContainerSpec.tsx
Expand Up @@ -30,4 +30,9 @@ describe('<Container>', () => {
const { getByText } = render(<Container>Container</Container>);
getByText('Container').tagName.toLowerCase().should.equal('div');
});

it('should allow custom breakpoints', () => {
const { getByText } = render(<Container fluid="custom">test</Container>);
getByText('test').classList.contains('container-custom').should.be.true;
});
});
11 changes: 11 additions & 0 deletions test/DropdownMenuSpec.tsx
Expand Up @@ -84,6 +84,17 @@ describe('<Dropdown.Menu>', () => {
container.querySelector('[data-bs-popper="static"]')!.should.exist;
});

it('allows custom responsive alignment classes', () => {
const { container } = render(
<DropdownMenu show align={{ custom: 'end' }}>
<DropdownItem>Item</DropdownItem>
</DropdownMenu>,
);

container.firstElementChild!.classList.contains('dropdown-menu-custom-end')
.should.be.true;
});

it('should render variant', () => {
const { container } = render(
<DropdownMenu show variant="dark">
Expand Down
2 changes: 1 addition & 1 deletion test/ListGroupSpec.tsx
Expand Up @@ -41,7 +41,7 @@ describe('<ListGroup>', () => {
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(
<ListGroup horizontal={breakpoint} data-testid="test" />,
Expand Down
11 changes: 11 additions & 0 deletions test/ModalSpec.tsx
Expand Up @@ -183,6 +183,17 @@ describe('<Modal>', () => {
.be.true;
});

it('Should allow custom breakpoints for fullscreen', () => {
const { getByTestId } = render(
<Modal show fullscreen="custom-down" data-testid="modal">
<strong>Message</strong>
</Modal>,
);

getByTestId('modal').classList.contains('modal-fullscreen-custom-down')
.should.be.true;
});

it('Should pass centered to the dialog', () => {
const { getByTestId } = render(
<Modal show centered data-testid="modal">
Expand Down
8 changes: 8 additions & 0 deletions test/NavbarSpec.tsx
Expand Up @@ -277,6 +277,14 @@ describe('<Navbar>', () => {
getByTestId('test').classList.contains('navbar-expand-sm').should.be.true;
});

it('should allow custom breakpoints for expand', () => {
const { getByTestId } = render(
<Navbar expand="custom" data-testid="test" />,
);
getByTestId('test').classList.contains('navbar-expand-custom').should.be
.true;
});

it('Should render correctly when bg is set', () => {
const { getByTestId } = render(<Navbar bg="light" data-testid="test" />);
getByTestId('test').classList.contains('bg-light').should.be.true;
Expand Down

0 comments on commit 0910a21

Please sign in to comment.