Skip to content

Commit 57be243

Browse files
authored
feat: add Stack component (react-bootstrap#5987)
1 parent ea724b2 commit 57be243

15 files changed

+317
-1
lines changed

src/Stack.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import classNames from 'classnames';
2+
import * as React from 'react';
3+
import PropTypes from 'prop-types';
4+
import { useBootstrapPrefix } from './ThemeProvider';
5+
import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers';
6+
import { GapValue } from './types';
7+
import createUtilityClassName, {
8+
ResponsiveUtilityValue,
9+
responsivePropType,
10+
} from './createUtilityClasses';
11+
12+
export type StackDirection = 'horizontal' | 'vertical';
13+
14+
export interface StackProps
15+
extends BsPrefixProps,
16+
React.HTMLAttributes<HTMLElement> {
17+
direction?: StackDirection;
18+
gap?: ResponsiveUtilityValue<GapValue>;
19+
}
20+
21+
const propTypes = {
22+
/**
23+
* Change the underlying component CSS base class name and modifier class names prefix.
24+
* **This is an escape hatch** for working with heavily customized bootstrap css.
25+
*
26+
* Defaults to `hstack` if direction is `horizontal` or `vstack` if direction
27+
* is `vertical`.
28+
*
29+
* @default 'hstack | vstack'
30+
*/
31+
bsPrefix: PropTypes.string,
32+
33+
/**
34+
* Sets the spacing between each item. Valid values are `0-5`.
35+
*/
36+
gap: responsivePropType(PropTypes.number),
37+
};
38+
39+
const Stack: BsPrefixRefForwardingComponent<'span', StackProps> =
40+
React.forwardRef<HTMLElement, StackProps>(
41+
(
42+
{ as: Component = 'div', bsPrefix, className, direction, gap, ...props },
43+
ref,
44+
) => {
45+
bsPrefix = useBootstrapPrefix(
46+
bsPrefix,
47+
direction === 'horizontal' ? 'hstack' : 'vstack',
48+
);
49+
50+
return (
51+
<Component
52+
{...props}
53+
ref={ref}
54+
className={classNames(
55+
className,
56+
bsPrefix,
57+
...createUtilityClassName({
58+
gap,
59+
}),
60+
)}
61+
/>
62+
);
63+
},
64+
);
65+
66+
Stack.displayName = 'Stack';
67+
Stack.propTypes = propTypes;
68+
69+
export default Stack;

src/createUtilityClasses.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import PropTypes from 'prop-types';
2+
3+
export type ResponsiveUtilityValue<T> =
4+
| T
5+
| {
6+
xs?: T;
7+
sm?: T;
8+
md?: T;
9+
lg?: T;
10+
xl?: T;
11+
xxl?: T;
12+
};
13+
14+
export function responsivePropType(propType: any) {
15+
return PropTypes.oneOfType([
16+
propType,
17+
PropTypes.shape({
18+
xs: propType,
19+
sm: propType,
20+
md: propType,
21+
lg: propType,
22+
xl: propType,
23+
xxl: propType,
24+
}),
25+
]);
26+
}
27+
28+
export const DEVICE_SIZES = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs'] as const;
29+
30+
export default function createUtilityClassName(
31+
utilityValues: Record<string, ResponsiveUtilityValue<unknown>>,
32+
) {
33+
const classes: string[] = [];
34+
Object.entries(utilityValues).forEach(([utilName, utilValue]) => {
35+
if (utilValue != null) {
36+
if (typeof utilValue === 'object') {
37+
DEVICE_SIZES.forEach((brkPoint) => {
38+
const bpValue = utilValue![brkPoint];
39+
if (bpValue != null) {
40+
const infix = brkPoint !== 'xs' ? `-${brkPoint}` : '';
41+
classes.push(`${utilName}${infix}-${bpValue}`);
42+
}
43+
});
44+
} else {
45+
classes.push(`${utilName}-${utilValue}`);
46+
}
47+
}
48+
});
49+
50+
return classes;
51+
}

src/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ export type { SplitButtonProps } from './SplitButton';
181181
export { default as SSRProvider } from './SSRProvider';
182182
export type { SSRProviderProps } from './SSRProvider';
183183

184+
export { default as Stack } from './Stack';
185+
export type { StackProps } from './Stack';
186+
184187
export { default as Tab } from './Tab';
185188
export type { TabProps } from './Tab';
186189

src/types.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,5 @@ export const alignPropType = PropTypes.oneOfType([
5858
]);
5959

6060
export type RootCloseEvent = 'click' | 'mousedown';
61+
62+
export type GapValue = 0 | 1 | 2 | 3 | 4 | 5;

test/StackSpec.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { render } from '@testing-library/react';
2+
3+
import Stack from '../src/Stack';
4+
5+
describe('<Stack>', () => {
6+
it('should render a vertical stack by default', () => {
7+
const { container } = render(<Stack />);
8+
container.firstElementChild!.className.should.contain('vstack');
9+
});
10+
11+
it('should render direction', () => {
12+
const { container } = render(<Stack direction="horizontal" />);
13+
container.firstElementChild!.className.should.contain('hstack');
14+
});
15+
16+
it('should render gap', () => {
17+
const { container } = render(<Stack gap={2} />);
18+
container.firstElementChild!.classList.contains('gap-2').should.be.true;
19+
});
20+
21+
it('should render responsive gap', () => {
22+
const { container } = render(<Stack gap={{ md: 2 }} />);
23+
container.firstElementChild!.classList.contains('gap-md-2').should.be.true;
24+
});
25+
});

test/createUtilityClassesSpec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import createUtilityClasses from '../src/createUtilityClasses';
2+
3+
describe('createUtilityClassName', () => {
4+
it('should not create a class when value is not defined', () => {
5+
const classList = createUtilityClasses({
6+
gap: undefined,
7+
});
8+
9+
classList.length.should.equal(0);
10+
});
11+
12+
it('should handle falsy values', () => {
13+
const classList = createUtilityClasses({
14+
gap: 0,
15+
});
16+
17+
classList.length.should.equal(1);
18+
classList.should.include.all.members(['gap-0']);
19+
});
20+
21+
it('should handle responsive falsy values', () => {
22+
const classList = createUtilityClasses({
23+
gap: { xs: 0, md: 0 },
24+
});
25+
26+
classList.length.should.equal(2);
27+
classList.should.include.all.members(['gap-0', 'gap-md-0']);
28+
});
29+
30+
it('should return `utilityName-value` when value is a primitive', () => {
31+
const classList = createUtilityClasses({
32+
gap: 2,
33+
});
34+
35+
classList.length.should.equal(1);
36+
classList.should.include.all.members(['gap-2']);
37+
});
38+
39+
it('should return responsive class when value is a responsive type', () => {
40+
const classList = createUtilityClasses({
41+
gap: { xs: 2, lg: 3, xxl: 4 },
42+
});
43+
44+
classList.length.should.equal(3);
45+
classList.should.include.all.members(['gap-2', 'gap-lg-3', 'gap-xxl-4']);
46+
});
47+
48+
it('should return multiple classes', () => {
49+
const classList = createUtilityClasses({
50+
gap: { xs: 2, lg: 3, xxl: 4 },
51+
text: { xs: 'start', md: 'end', xl: 'start' },
52+
});
53+
54+
classList.length.should.equal(6);
55+
classList.should.include.all.members([
56+
'gap-2',
57+
'gap-lg-3',
58+
'gap-xxl-4',
59+
'text-start',
60+
'text-md-end',
61+
'text-xl-start',
62+
]);
63+
});
64+
});

tests/simple-types-test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
ProgressBar,
4040
Spinner,
4141
SplitButton,
42+
Stack,
4243
Table,
4344
Tabs,
4445
Tab,
@@ -1011,6 +1012,11 @@ const MegaComponent = () => (
10111012
<ToggleButton value={2}>Radio 2</ToggleButton>
10121013
<ToggleButton value={3}>Radio 3</ToggleButton>
10131014
</ToggleButtonGroup>
1015+
<Stack direction="horizontal" gap={1} />
1016+
<Stack
1017+
direction="vertical"
1018+
gap={{ xs: 2, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}
1019+
/>
10141020
{/* // As = ComponentClass // TODO: Reinstate these? What _is_ ExpectError? */}
10151021
{/*
10161022
<Tabs invalidProp="2" />; // $ExpectError

www/src/components/SideNav.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ const gettingStarted = [
108108
'server-side-rendering',
109109
];
110110

111-
const layout = ['grid'];
111+
const layout = ['grid', 'stack'];
112112

113113
const components = [
114114
'alerts',

www/src/examples/Stack/Buttons.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<Stack gap={2} className="col-md-5 mx-auto">
2+
<Button variant="secondary">Save changes</Button>
3+
<Button variant="outline-secondary">Cancel</Button>
4+
</Stack>;

www/src/examples/Stack/Form.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<Stack direction="horizontal" gap={3}>
2+
<Form.Control className="me-auto" placeholder="Add your item here..." />
3+
<Button variant="secondary">Submit</Button>
4+
<div className="vr" />
5+
<Button variant="outline-danger">Reset</Button>
6+
</Stack>;

www/src/examples/Stack/Horizontal.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<Stack direction="horizontal" gap={3}>
2+
<div className="bg-light border">First item</div>
3+
<div className="bg-light border">Second item</div>
4+
<div className="bg-light border">Third item</div>
5+
</Stack>;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<Stack direction="horizontal" gap={3}>
2+
<div className="bg-light border">First item</div>
3+
<div className="bg-light border ms-auto">Second item</div>
4+
<div className="bg-light border">Third item</div>
5+
</Stack>;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<Stack direction="horizontal" gap={3}>
2+
<div className="bg-light border">First item</div>
3+
<div className="bg-light border ms-auto">Second item</div>
4+
<div className="vr" />
5+
<div className="bg-light border">Third item</div>
6+
</Stack>;

www/src/examples/Stack/Vertical.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<Stack gap={3}>
2+
<div className="bg-light border">First item</div>
3+
<div className="bg-light border">Second item</div>
4+
<div className="bg-light border">Third item</div>
5+
</Stack>;

www/src/pages/layout/stack.mdx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { graphql } from 'gatsby';
2+
3+
import Callout from '../../components/Callout';
4+
import ComponentApi from '../../components/ComponentApi';
5+
import ReactPlayground from '../../components/ReactPlayground';
6+
7+
import StackButtons from '../../examples/Stack/Buttons'
8+
import StackForm from '../../examples/Stack/Form'
9+
import StackHorizontal from '../../examples/Stack/Horizontal';
10+
import StackHorizontalMarginStart from '../../examples/Stack/HorizontalMarginStart';
11+
import StackHorizontalVerticalRules from '../../examples/Stack/HorizontalVerticalRules';
12+
import StackVertical from '../../examples/Stack/Vertical';
13+
14+
# Stacks
15+
16+
<p className="lead">
17+
Shorthand helpers that build on top of our flexbox utilities to make component
18+
layout faster and easier than ever.
19+
</p>
20+
21+
## Vertical
22+
23+
Stacks are vertical by default and stacked items are full-width by default. Use the `gap`
24+
prop to add space between items.
25+
26+
<ReactPlayground codeText={StackVertical} />
27+
28+
## Horizontal
29+
30+
Use `direction="horizontal"` for horizontal layouts. Stacked items are vertically centered
31+
by default and only take up their necessary width. Use the `gap` prop to add space between
32+
items.
33+
34+
<ReactPlayground codeText={StackHorizontal} />
35+
36+
Using horizontal margin utilities like `.ms-auto` as spacers:
37+
38+
<ReactPlayground codeText={StackHorizontalMarginStart} />
39+
40+
And with vertical rules:
41+
42+
<ReactPlayground codeText={StackHorizontalVerticalRules} />
43+
44+
## Examples
45+
46+
Use a vertical `Stack` to stack buttons and other elements:
47+
48+
<ReactPlayground codeText={StackButtons} />
49+
50+
Create an inline form with a horizontal `Stack`:
51+
52+
<ReactPlayground codeText={StackForm} />
53+
54+
## API
55+
56+
<ComponentApi metadata={props.data.Stack} />
57+
58+
export const query = graphql`
59+
query Stack {
60+
Stack: componentMetadata(displayName: { eq: "Stack" }) {
61+
displayName
62+
...ComponentApi_metadata
63+
}
64+
}
65+
`;

0 commit comments

Comments
 (0)