Skip to content

Commit

Permalink
feat: add Placeholder component (#5974)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyletsang committed Aug 19, 2021
1 parent 6e8f832 commit 182b123
Show file tree
Hide file tree
Showing 18 changed files with 534 additions and 41 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@
"@babel/register": "^7.15.3",
"@react-bootstrap/babel-preset": "^2.1.0",
"@react-bootstrap/eslint-config": "^2.0.0",
"@testing-library/dom": "^8.1.0",
"@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/chai": "^4.2.21",
"@types/mocha": "^9.0.0",
"@types/sinon": "^10.0.2",
Expand Down
94 changes: 59 additions & 35 deletions src/Col.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,50 +111,74 @@ const propTypes = {
xxl: column,
};

export interface UseColMetadata {
as?: React.ElementType;
bsPrefix: string;
spans: string[];
}

export function useCol({
as,
bsPrefix,
className,
...props
}: ColProps): [any, UseColMetadata] {
bsPrefix = useBootstrapPrefix(bsPrefix, 'col');

const spans: string[] = [];
const classes: string[] = [];

DEVICE_SIZES.forEach((brkPoint) => {
const propValue = props[brkPoint];
delete props[brkPoint];

let span: ColSize | undefined;
let offset: NumberAttr | undefined;
let order: ColOrder | undefined;

if (typeof propValue === 'object' && propValue != null) {
({ span = true, offset, order } = propValue);
} else {
span = propValue;
}

const infix = brkPoint !== 'xs' ? `-${brkPoint}` : '';

if (span)
spans.push(
span === true ? `${bsPrefix}${infix}` : `${bsPrefix}${infix}-${span}`,
);

if (order != null) classes.push(`order${infix}-${order}`);
if (offset != null) classes.push(`offset${infix}-${offset}`);
});

return [
{ ...props, className: classNames(className, ...classes, ...spans) },
{
as,
bsPrefix,
spans,
},
];
}

const Col: BsPrefixRefForwardingComponent<'div', ColProps> = React.forwardRef<
HTMLElement,
ColProps
>(
// Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595
({ bsPrefix, className, as: Component = 'div', ...props }, ref) => {
const prefix = useBootstrapPrefix(bsPrefix, 'col');
const spans: string[] = [];
const classes: string[] = [];

DEVICE_SIZES.forEach((brkPoint) => {
const propValue = props[brkPoint];
delete props[brkPoint];

let span: ColSize | undefined;
let offset: NumberAttr | undefined;
let order: ColOrder | undefined;

if (typeof propValue === 'object' && propValue != null) {
({ span = true, offset, order } = propValue);
} else {
span = propValue;
}

const infix = brkPoint !== 'xs' ? `-${brkPoint}` : '';

if (span)
spans.push(
span === true ? `${prefix}${infix}` : `${prefix}${infix}-${span}`,
);

if (order != null) classes.push(`order${infix}-${order}`);
if (offset != null) classes.push(`offset${infix}-${offset}`);
});

if (!spans.length) {
spans.push(prefix); // plain 'col'
}
(props, ref) => {
const [
{ className, ...colProps },
{ as: Component = 'div', bsPrefix, spans },
] = useCol(props);

return (
<Component
{...props}
{...colProps}
ref={ref}
className={classNames(className, ...spans, ...classes)}
className={classNames(className, !spans.length && bsPrefix)}
/>
);
},
Expand Down
51 changes: 51 additions & 0 deletions src/Placeholder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers';
import usePlaceholder, { UsePlaceholderProps } from './usePlaceholder';
import PlaceholderButton from './PlaceholderButton';

export interface PlaceholderProps extends UsePlaceholderProps, BsPrefixProps {}

const propTypes = {
/**
* @default 'placeholder'
*/
bsPrefix: PropTypes.string,

/**
* Changes the animation of the placeholder.
*
* @type ('glow'|'wave')
*/
animation: PropTypes.string,

/**
* Change the background color of the placeholder.
*
* @type {('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark')}
*/
bg: PropTypes.string,

/**
* Component size variations.
*
* @type ('xs'|'sm'|'lg')
*/
size: PropTypes.string,
};

const Placeholder: BsPrefixRefForwardingComponent<'span', PlaceholderProps> =
React.forwardRef<HTMLElement, PlaceholderProps>(
({ as: Component = 'span', ...props }, ref) => {
const placeholderProps = usePlaceholder(props);

return <Component {...placeholderProps} ref={ref} />;
},
);

Placeholder.displayName = 'Placeholder';
Placeholder.propTypes = propTypes;

export default Object.assign(Placeholder, {
Button: PlaceholderButton,
});
45 changes: 45 additions & 0 deletions src/PlaceholderButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { BsPrefixRefForwardingComponent } from './helpers';
import Button from './Button';
import usePlaceholder, { UsePlaceholderProps } from './usePlaceholder';
import { ButtonVariant } from './types';

export interface PlaceholderButtonProps extends UsePlaceholderProps {
variant?: ButtonVariant;
}

const propTypes = {
/**
* @default 'placeholder'
*/
bsPrefix: PropTypes.string,

/**
* Changes the animation of the placeholder.
*/
animation: PropTypes.oneOf(['glow', 'wave']),

size: PropTypes.oneOf(['xs', 'sm', 'lg']),

/**
* Button variant.
*/
variant: PropTypes.string,
};

const PlaceholderButton: BsPrefixRefForwardingComponent<
'button',
PlaceholderButtonProps
> = React.forwardRef<HTMLButtonElement, PlaceholderButtonProps>(
(props, ref) => {
const placeholderProps = usePlaceholder(props);

return <Button {...placeholderProps} ref={ref} disabled tabIndex={-1} />;
},
);

PlaceholderButton.displayName = 'PlaceholderButton';
PlaceholderButton.propTypes = propTypes;

export default PlaceholderButton;
5 changes: 5 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ export type { PageItemProps } from './PageItem';
export { default as Pagination } from './Pagination';
export type { PaginationProps } from './Pagination';

export { default as Placeholder } from './Placeholder';
export type { PlaceholderProps } from './Placeholder';
export { default as PlaceholderButton } from './PlaceholderButton';
export type { PlaceholderButtonProps } from './PlaceholderButton';

export { default as Popover } from './Popover';
export type { PopoverProps } from './Popover';

Expand Down
34 changes: 34 additions & 0 deletions src/usePlaceholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import classNames from 'classnames';
import { useBootstrapPrefix } from './ThemeProvider';
import { useCol, ColProps } from './Col';
import { Variant } from './types';

export type PlaceholderAnimation = 'glow' | 'wave';
export type PlaceholderSize = 'xs' | 'sm' | 'lg';

export interface UsePlaceholderProps extends Omit<ColProps, 'as'> {
animation?: PlaceholderAnimation;
bg?: Variant;
size?: PlaceholderSize;
}

export default function usePlaceholder({
animation,
bg,
bsPrefix,
size,
...props
}: UsePlaceholderProps) {
bsPrefix = useBootstrapPrefix(bsPrefix, 'placeholder');
const [{ className, ...colProps }] = useCol(props);

return {
...colProps,
className: classNames(
className,
animation ? `${bsPrefix}-${animation}` : bsPrefix,
size && `${bsPrefix}-${size}`,
bg && `bg-${bg}`,
),
};
}
20 changes: 20 additions & 0 deletions test/PlaceholderButtonSpec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { render } from '@testing-library/react';

import PlaceholderButton from '../src/PlaceholderButton';

describe('<PlaceholderButton>', () => {
it('should render a placeholder', () => {
const { container } = render(<PlaceholderButton />);
container.firstElementChild!.className.should.contain('placeholder');
});

it('should render size', () => {
const { container } = render(<PlaceholderButton size="lg" />);
container.firstElementChild!.className.should.contain('placeholder-lg');
});

it('should render animation', () => {
const { container } = render(<PlaceholderButton animation="glow" />);
container.firstElementChild!.className.should.contain('placeholder-glow');
});
});
25 changes: 25 additions & 0 deletions test/PlaceholderSpec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { render } from '@testing-library/react';

import Placeholder from '../src/Placeholder';

describe('<Placeholder>', () => {
it('should render a placeholder', () => {
const { container } = render(<Placeholder />);
container.firstElementChild!.className.should.contain('placeholder');
});

it('should render size', () => {
const { container } = render(<Placeholder size="lg" />);
container.firstElementChild!.className.should.contain('placeholder-lg');
});

it('should render animation', () => {
const { container } = render(<Placeholder animation="glow" />);
container.firstElementChild!.className.should.contain('placeholder-glow');
});

it('should render bg', () => {
const { container } = render(<Placeholder bg="primary" />);
container.firstElementChild!.className.should.contain('bg-primary');
});
});
16 changes: 11 additions & 5 deletions www/src/components/ReactPlayground.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,23 @@ function Preview({ showCode, className }) {
});
}, [hjs, live.element]);

useMutationObserver(exampleRef.current, {
childList: true, subtree: true },
useMutationObserver(
exampleRef.current,
{
childList: true,
subtree: true,
},
(mutations) => {
mutations.forEach((mutation) => {
if (mutation.addedNodes.length > 0) {
if (hjs && mutation.addedNodes.length > 0) {
hjs.run({
theme: 'gray',
images: qsa(exampleRef.current, 'img'),
});
}
});
});
},
);

const handleClick = useCallback((e) => {
if (e.target.tagName === 'A') {
Expand Down Expand Up @@ -248,7 +253,8 @@ function Editor() {
);
}

const PRETTIER_IGNORE_REGEX = /({\s*\/\*\s+prettier-ignore\s+\*\/\s*})|(\/\/\s+prettier-ignore)/gim;
const PRETTIER_IGNORE_REGEX =
/({\s*\/\*\s+prettier-ignore\s+\*\/\s*})|(\/\/\s+prettier-ignore)/gim;

const propTypes = {
codeText: PropTypes.string.isRequired,
Expand Down
1 change: 1 addition & 0 deletions www/src/components/SideNav.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ const components = [
'offcanvas',
'overlays',
'pagination',
'placeholder',
'popovers',
'progress',
'spinners',
Expand Down
8 changes: 8 additions & 0 deletions www/src/examples/Placeholder/Animation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<>
<Placeholder as="p" animation="glow">
<Placeholder xs={12} />
</Placeholder>
<Placeholder as="p" animation="wave">
<Placeholder xs={12} />
</Placeholder>
</>;
27 changes: 27 additions & 0 deletions www/src/examples/Placeholder/Card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<div className="d-flex justify-content-around">
<Card style={{ width: '18rem' }}>
<Card.Img variant="top" src="holder.js/100px180" />
<Card.Body>
<Card.Title>Card Title</Card.Title>
<Card.Text>
Some quick example text to build on the card title and make up the bulk
of the card's content.
</Card.Text>
<Button variant="primary">Go somewhere</Button>
</Card.Body>
</Card>

<Card style={{ width: '18rem' }}>
<Card.Img variant="top" src="holder.js/100px180" />
<Card.Body>
<Placeholder as={Card.Title} animation="glow">
<Placeholder xs={6} />
</Placeholder>
<Placeholder as={Card.Text} animation="glow">
<Placeholder xs={7} /> <Placeholder xs={4} /> <Placeholder xs={4} />{' '}
<Placeholder xs={6} /> <Placeholder xs={8} />
</Placeholder>
<Placeholder.Button variant="primary" xs={6} />
</Card.Body>
</Card>
</div>;
Loading

0 comments on commit 182b123

Please sign in to comment.