Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 69 additions & 20 deletions src/lib/spacing.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Children, HTMLAttributes, HTMLProps, ReactNode } from 'react';
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import { Box, BoxComponentProps } from './components/box/Box';

export const spacing = {
Expand Down Expand Up @@ -33,39 +33,89 @@ export const spacing = {
f40: '40px',
};

const HSeparator = styled.div`
background: ${(props) => props.theme.border};
// The two separator treatments. A row Stack draws a full-height rule between
// its children; a column Stack draws a short dash. They are different shapes,
// not one shape on two axes, so a single element cannot serve both by CSS alone
// — which is why `stackBelow` below restyles the element rather than relying on
// the flip.
const ruleSeparator = css`
width: 1px;
align-self: stretch;
flex-shrink: 0;
margin: ${spacing.r12} 0px;
margin: ${spacing.r12} 0;
`;

const VSeparator = styled.div`
height: 1px;
const dashSeparator = css`
width: ${spacing.r24};
height: 1px;
align-self: auto;
margin: 0;
`;

// One element for both directions. `Stack` used to pick between two components
// at render time, which meant a CSS direction change left the separators
// pointing the wrong way — CSS can restyle an element but cannot swap which one
// React rendered.
const Separator = styled.div<{
$direction: 'vertical' | 'horizontal';
$stackBelow?: number;
}>`
background: ${(props) => props.theme.border};
${(props) =>
props.$direction === 'horizontal' ? ruleSeparator : dashSeparator}

${(props) =>
props.$direction === 'horizontal' &&
props.$stackBelow !== undefined &&
css`
@container responsive (max-width: ${props.$stackBelow}px) {
${dashSeparator}
}
`}
`;

const Separator = ({ type }: { type?: 'vertical' | 'horizontal' }) => {
return (
<>
{type === 'horizontal' && <HSeparator>&nbsp;</HSeparator>}
{type === 'vertical' && <VSeparator>&nbsp;</VSeparator>}
</>
);
};
// Layout lives here rather than on `Box`'s props so the container query below
// isn't fighting styled-system output for specificity.
const StackBox = styled(Box)<{
$direction: 'vertical' | 'horizontal';
$stackBelow?: number;
}>`
display: flex;
flex-direction: ${(props) =>
props.$direction === 'horizontal' ? 'row' : 'column'};
align-items: ${(props) =>
props.$direction === 'horizontal' ? 'center' : 'normal'};

${(props) =>
props.$direction === 'horizontal' &&
props.$stackBelow !== undefined &&
css`
@container responsive (max-width: ${props.$stackBelow}px) {
flex-direction: column;
align-items: normal;
}
`}
`;

export const Stack = ({
gap,
direction,
withSeparators,
stackBelow,
children,
...rest
}: {
gap?: keyof typeof spacing;
direction?: 'vertical' | 'horizontal';
withSeparators?: boolean;
/**
* Below this container width (px) a horizontal Stack becomes vertical, and
* its separators become the vertical treatment along with it. Requires an
* ancestor that establishes the `responsive` container — `<Box container>`.
* Without one the query never matches and the Stack stays horizontal.
* Ignored when `direction="vertical"`.
*/
stackBelow?: number;
children: ReactNode[];
container?: boolean;
} & HTMLAttributes<HTMLDivElement>) => {
Expand All @@ -75,10 +125,9 @@ export const Stack = ({
const numberOfChildren = Children.count(children);

return (
<Box
display={'flex'}
flexDirection={direction === 'horizontal' ? 'row' : 'column'}
alignItems={direction === 'horizontal' ? 'center' : 'normal'}
<StackBox
$direction={direction}
$stackBelow={stackBelow}
gap={spacing[gap]}
{...rest}
>
Expand All @@ -87,12 +136,12 @@ export const Stack = ({
<>
{node}
{withSeparators && nodeIndex + 1 !== numberOfChildren && (
<Separator type={direction} />
<Separator $direction={direction} $stackBelow={stackBelow} />
)}
</>
);
})}
</Box>
</StackBox>
);
};

Expand Down
60 changes: 59 additions & 1 deletion stories/spacing.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { useTheme } from 'styled-components';
import styled, { useTheme } from 'styled-components';
import { Icon } from '../src/lib/components/icon/Icon.component';
import {
EmphaseText,
Expand Down Expand Up @@ -113,6 +113,64 @@ export const StackStory = {
},
};

// `stackBelow` queries the `responsive` container, so an ancestor has to
// establish one. `<Box container>` does this in app code.
const ResizableContainer = styled.div`
container-type: inline-size;
container-name: responsive;
resize: horizontal;
overflow: auto;
min-width: 240px;
max-width: 100%;
width: 700px;
background: ${(props) => props.theme.backgroundLevel2};
padding: ${spacing.r16};
`;

export const StackBelowStory = {
name: 'stackBelow — direction follows the container width',
render: ({}) => (
<>
<h2>Drag the bottom-right handle below 500px</h2>
<SecondaryText>
One prop. The Stack flips from row to column in CSS only — no re-render
— and the separators switch from the row treatment (a full-height rule)
to the column treatment (a short dash) along with it.
</SecondaryText>
<ResizableContainer>
<Stack withSeparators gap="r24" stackBelow={500}>
<Stack gap="r16">
<Icon name="Account" size="2x" color="infoPrimary" withWrapper />
<Stack direction="vertical" gap="r4">
<LargerText>12</LargerText>
<SmallerSecondaryText>Accounts</SmallerSecondaryText>
</Stack>
</Stack>
<Stack gap="r16">
<Icon name="Bucket" size="2x" color="infoPrimary" withWrapper />
<Stack direction="vertical" gap="r4">
<LargerText>148</LargerText>
<SmallerSecondaryText>Buckets</SmallerSecondaryText>
</Stack>
</Stack>
<Stack gap="r16">
<Icon
name="Node-backend"
size="2x"
color="infoPrimary"
withWrapper
/>
<Stack direction="vertical" gap="r4">
<LargerText>3</LargerText>
<SmallerSecondaryText>Endpoints</SmallerSecondaryText>
</Stack>
</Stack>
</Stack>
</ResizableContainer>
</>
),
};

export const WrapStory = {
render: ({}) => {
const theme = useTheme();
Expand Down
Loading