Skip to content

Commit 182b123

Browse files
authored
feat: add Placeholder component (#5974)
1 parent 6e8f832 commit 182b123

18 files changed

+534
-41
lines changed

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@
8484
"@babel/register": "^7.15.3",
8585
"@react-bootstrap/babel-preset": "^2.1.0",
8686
"@react-bootstrap/eslint-config": "^2.0.0",
87+
"@testing-library/dom": "^8.1.0",
88+
"@testing-library/react": "^12.0.0",
89+
"@testing-library/user-event": "^13.2.1",
8790
"@types/chai": "^4.2.21",
8891
"@types/mocha": "^9.0.0",
8992
"@types/sinon": "^10.0.2",

src/Col.tsx

+59-35
Original file line numberDiff line numberDiff line change
@@ -111,50 +111,74 @@ const propTypes = {
111111
xxl: column,
112112
};
113113

114+
export interface UseColMetadata {
115+
as?: React.ElementType;
116+
bsPrefix: string;
117+
spans: string[];
118+
}
119+
120+
export function useCol({
121+
as,
122+
bsPrefix,
123+
className,
124+
...props
125+
}: ColProps): [any, UseColMetadata] {
126+
bsPrefix = useBootstrapPrefix(bsPrefix, 'col');
127+
128+
const spans: string[] = [];
129+
const classes: string[] = [];
130+
131+
DEVICE_SIZES.forEach((brkPoint) => {
132+
const propValue = props[brkPoint];
133+
delete props[brkPoint];
134+
135+
let span: ColSize | undefined;
136+
let offset: NumberAttr | undefined;
137+
let order: ColOrder | undefined;
138+
139+
if (typeof propValue === 'object' && propValue != null) {
140+
({ span = true, offset, order } = propValue);
141+
} else {
142+
span = propValue;
143+
}
144+
145+
const infix = brkPoint !== 'xs' ? `-${brkPoint}` : '';
146+
147+
if (span)
148+
spans.push(
149+
span === true ? `${bsPrefix}${infix}` : `${bsPrefix}${infix}-${span}`,
150+
);
151+
152+
if (order != null) classes.push(`order${infix}-${order}`);
153+
if (offset != null) classes.push(`offset${infix}-${offset}`);
154+
});
155+
156+
return [
157+
{ ...props, className: classNames(className, ...classes, ...spans) },
158+
{
159+
as,
160+
bsPrefix,
161+
spans,
162+
},
163+
];
164+
}
165+
114166
const Col: BsPrefixRefForwardingComponent<'div', ColProps> = React.forwardRef<
115167
HTMLElement,
116168
ColProps
117169
>(
118170
// Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595
119-
({ bsPrefix, className, as: Component = 'div', ...props }, ref) => {
120-
const prefix = useBootstrapPrefix(bsPrefix, 'col');
121-
const spans: string[] = [];
122-
const classes: string[] = [];
123-
124-
DEVICE_SIZES.forEach((brkPoint) => {
125-
const propValue = props[brkPoint];
126-
delete props[brkPoint];
127-
128-
let span: ColSize | undefined;
129-
let offset: NumberAttr | undefined;
130-
let order: ColOrder | undefined;
131-
132-
if (typeof propValue === 'object' && propValue != null) {
133-
({ span = true, offset, order } = propValue);
134-
} else {
135-
span = propValue;
136-
}
137-
138-
const infix = brkPoint !== 'xs' ? `-${brkPoint}` : '';
139-
140-
if (span)
141-
spans.push(
142-
span === true ? `${prefix}${infix}` : `${prefix}${infix}-${span}`,
143-
);
144-
145-
if (order != null) classes.push(`order${infix}-${order}`);
146-
if (offset != null) classes.push(`offset${infix}-${offset}`);
147-
});
148-
149-
if (!spans.length) {
150-
spans.push(prefix); // plain 'col'
151-
}
171+
(props, ref) => {
172+
const [
173+
{ className, ...colProps },
174+
{ as: Component = 'div', bsPrefix, spans },
175+
] = useCol(props);
152176

153177
return (
154178
<Component
155-
{...props}
179+
{...colProps}
156180
ref={ref}
157-
className={classNames(className, ...spans, ...classes)}
181+
className={classNames(className, !spans.length && bsPrefix)}
158182
/>
159183
);
160184
},

src/Placeholder.tsx

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers';
4+
import usePlaceholder, { UsePlaceholderProps } from './usePlaceholder';
5+
import PlaceholderButton from './PlaceholderButton';
6+
7+
export interface PlaceholderProps extends UsePlaceholderProps, BsPrefixProps {}
8+
9+
const propTypes = {
10+
/**
11+
* @default 'placeholder'
12+
*/
13+
bsPrefix: PropTypes.string,
14+
15+
/**
16+
* Changes the animation of the placeholder.
17+
*
18+
* @type ('glow'|'wave')
19+
*/
20+
animation: PropTypes.string,
21+
22+
/**
23+
* Change the background color of the placeholder.
24+
*
25+
* @type {('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark')}
26+
*/
27+
bg: PropTypes.string,
28+
29+
/**
30+
* Component size variations.
31+
*
32+
* @type ('xs'|'sm'|'lg')
33+
*/
34+
size: PropTypes.string,
35+
};
36+
37+
const Placeholder: BsPrefixRefForwardingComponent<'span', PlaceholderProps> =
38+
React.forwardRef<HTMLElement, PlaceholderProps>(
39+
({ as: Component = 'span', ...props }, ref) => {
40+
const placeholderProps = usePlaceholder(props);
41+
42+
return <Component {...placeholderProps} ref={ref} />;
43+
},
44+
);
45+
46+
Placeholder.displayName = 'Placeholder';
47+
Placeholder.propTypes = propTypes;
48+
49+
export default Object.assign(Placeholder, {
50+
Button: PlaceholderButton,
51+
});

src/PlaceholderButton.tsx

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { BsPrefixRefForwardingComponent } from './helpers';
4+
import Button from './Button';
5+
import usePlaceholder, { UsePlaceholderProps } from './usePlaceholder';
6+
import { ButtonVariant } from './types';
7+
8+
export interface PlaceholderButtonProps extends UsePlaceholderProps {
9+
variant?: ButtonVariant;
10+
}
11+
12+
const propTypes = {
13+
/**
14+
* @default 'placeholder'
15+
*/
16+
bsPrefix: PropTypes.string,
17+
18+
/**
19+
* Changes the animation of the placeholder.
20+
*/
21+
animation: PropTypes.oneOf(['glow', 'wave']),
22+
23+
size: PropTypes.oneOf(['xs', 'sm', 'lg']),
24+
25+
/**
26+
* Button variant.
27+
*/
28+
variant: PropTypes.string,
29+
};
30+
31+
const PlaceholderButton: BsPrefixRefForwardingComponent<
32+
'button',
33+
PlaceholderButtonProps
34+
> = React.forwardRef<HTMLButtonElement, PlaceholderButtonProps>(
35+
(props, ref) => {
36+
const placeholderProps = usePlaceholder(props);
37+
38+
return <Button {...placeholderProps} ref={ref} disabled tabIndex={-1} />;
39+
},
40+
);
41+
42+
PlaceholderButton.displayName = 'PlaceholderButton';
43+
PlaceholderButton.propTypes = propTypes;
44+
45+
export default PlaceholderButton;

src/index.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ export type { PageItemProps } from './PageItem';
155155
export { default as Pagination } from './Pagination';
156156
export type { PaginationProps } from './Pagination';
157157

158+
export { default as Placeholder } from './Placeholder';
159+
export type { PlaceholderProps } from './Placeholder';
160+
export { default as PlaceholderButton } from './PlaceholderButton';
161+
export type { PlaceholderButtonProps } from './PlaceholderButton';
162+
158163
export { default as Popover } from './Popover';
159164
export type { PopoverProps } from './Popover';
160165

src/usePlaceholder.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import classNames from 'classnames';
2+
import { useBootstrapPrefix } from './ThemeProvider';
3+
import { useCol, ColProps } from './Col';
4+
import { Variant } from './types';
5+
6+
export type PlaceholderAnimation = 'glow' | 'wave';
7+
export type PlaceholderSize = 'xs' | 'sm' | 'lg';
8+
9+
export interface UsePlaceholderProps extends Omit<ColProps, 'as'> {
10+
animation?: PlaceholderAnimation;
11+
bg?: Variant;
12+
size?: PlaceholderSize;
13+
}
14+
15+
export default function usePlaceholder({
16+
animation,
17+
bg,
18+
bsPrefix,
19+
size,
20+
...props
21+
}: UsePlaceholderProps) {
22+
bsPrefix = useBootstrapPrefix(bsPrefix, 'placeholder');
23+
const [{ className, ...colProps }] = useCol(props);
24+
25+
return {
26+
...colProps,
27+
className: classNames(
28+
className,
29+
animation ? `${bsPrefix}-${animation}` : bsPrefix,
30+
size && `${bsPrefix}-${size}`,
31+
bg && `bg-${bg}`,
32+
),
33+
};
34+
}

test/PlaceholderButtonSpec.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { render } from '@testing-library/react';
2+
3+
import PlaceholderButton from '../src/PlaceholderButton';
4+
5+
describe('<PlaceholderButton>', () => {
6+
it('should render a placeholder', () => {
7+
const { container } = render(<PlaceholderButton />);
8+
container.firstElementChild!.className.should.contain('placeholder');
9+
});
10+
11+
it('should render size', () => {
12+
const { container } = render(<PlaceholderButton size="lg" />);
13+
container.firstElementChild!.className.should.contain('placeholder-lg');
14+
});
15+
16+
it('should render animation', () => {
17+
const { container } = render(<PlaceholderButton animation="glow" />);
18+
container.firstElementChild!.className.should.contain('placeholder-glow');
19+
});
20+
});

test/PlaceholderSpec.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { render } from '@testing-library/react';
2+
3+
import Placeholder from '../src/Placeholder';
4+
5+
describe('<Placeholder>', () => {
6+
it('should render a placeholder', () => {
7+
const { container } = render(<Placeholder />);
8+
container.firstElementChild!.className.should.contain('placeholder');
9+
});
10+
11+
it('should render size', () => {
12+
const { container } = render(<Placeholder size="lg" />);
13+
container.firstElementChild!.className.should.contain('placeholder-lg');
14+
});
15+
16+
it('should render animation', () => {
17+
const { container } = render(<Placeholder animation="glow" />);
18+
container.firstElementChild!.className.should.contain('placeholder-glow');
19+
});
20+
21+
it('should render bg', () => {
22+
const { container } = render(<Placeholder bg="primary" />);
23+
container.firstElementChild!.className.should.contain('bg-primary');
24+
});
25+
});

www/src/components/ReactPlayground.js

+11-5
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,23 @@ function Preview({ showCode, className }) {
119119
});
120120
}, [hjs, live.element]);
121121

122-
useMutationObserver(exampleRef.current, {
123-
childList: true, subtree: true },
122+
useMutationObserver(
123+
exampleRef.current,
124+
{
125+
childList: true,
126+
subtree: true,
127+
},
124128
(mutations) => {
125129
mutations.forEach((mutation) => {
126-
if (mutation.addedNodes.length > 0) {
130+
if (hjs && mutation.addedNodes.length > 0) {
127131
hjs.run({
128132
theme: 'gray',
129133
images: qsa(exampleRef.current, 'img'),
130134
});
131135
}
132136
});
133-
});
137+
},
138+
);
134139

135140
const handleClick = useCallback((e) => {
136141
if (e.target.tagName === 'A') {
@@ -248,7 +253,8 @@ function Editor() {
248253
);
249254
}
250255

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

253259
const propTypes = {
254260
codeText: PropTypes.string.isRequired,

www/src/components/SideNav.js

+1
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ const components = [
131131
'offcanvas',
132132
'overlays',
133133
'pagination',
134+
'placeholder',
134135
'popovers',
135136
'progress',
136137
'spinners',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<>
2+
<Placeholder as="p" animation="glow">
3+
<Placeholder xs={12} />
4+
</Placeholder>
5+
<Placeholder as="p" animation="wave">
6+
<Placeholder xs={12} />
7+
</Placeholder>
8+
</>;

www/src/examples/Placeholder/Card.js

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<div className="d-flex justify-content-around">
2+
<Card style={{ width: '18rem' }}>
3+
<Card.Img variant="top" src="holder.js/100px180" />
4+
<Card.Body>
5+
<Card.Title>Card Title</Card.Title>
6+
<Card.Text>
7+
Some quick example text to build on the card title and make up the bulk
8+
of the card's content.
9+
</Card.Text>
10+
<Button variant="primary">Go somewhere</Button>
11+
</Card.Body>
12+
</Card>
13+
14+
<Card style={{ width: '18rem' }}>
15+
<Card.Img variant="top" src="holder.js/100px180" />
16+
<Card.Body>
17+
<Placeholder as={Card.Title} animation="glow">
18+
<Placeholder xs={6} />
19+
</Placeholder>
20+
<Placeholder as={Card.Text} animation="glow">
21+
<Placeholder xs={7} /> <Placeholder xs={4} /> <Placeholder xs={4} />{' '}
22+
<Placeholder xs={6} /> <Placeholder xs={8} />
23+
</Placeholder>
24+
<Placeholder.Button variant="primary" xs={6} />
25+
</Card.Body>
26+
</Card>
27+
</div>;

0 commit comments

Comments
 (0)