Skip to content

Commit

Permalink
Feat(web-react): Introduce custom Stack spacing #DS-1079
Browse files Browse the repository at this point in the history
  • Loading branch information
crishpeen committed Dec 4, 2023
1 parent c1fe433 commit b9ca594
Show file tree
Hide file tree
Showing 13 changed files with 214 additions and 15 deletions.
38 changes: 29 additions & 9 deletions packages/web-react/src/components/Stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,37 @@ Advanced example usage:
</Stack>
```

## Custom Spacing

You can use the `spacing` prop to apply custom spacing between items. The prop
accepts either a spacing token (eg. `space-100`) or an object with breakpoint keys and spacing token values.

```jsx
<Stack hasSpacing spacing="space-1200">
<div>Block 1</div>
<div>Block 2</div>
<div>Block 3</div>
</Stack>

<Stack hasSpacing spacing={{ mobile: 'space-400', tablet: 'space-800' }}>
<div>Block 1</div>
<div>Block 2</div>
<div>Block 3</div>
</Stack>
```

## API

| Name | Type | Default | Required | Description |
| ------------------------- | --------------- | ------- | -------- | -------------------------------------- |
| `elementType` | `string` | `div` || Element type of the wrapper element |
| `hasEndDivider` | `bool` | `false` || Render a divider after the last item |
| `hasIntermediateDividers` | `bool` | `false` || Render dividers between items |
| `hasSpacing` | `bool` | `false` || Apply a spacing between items |
| `hasStartDivider` | `bool` | `false` || Render a divider before the first item |
| `UNSAFE_className` | `string` ||| Wrapper custom class name |
| `UNSAFE_style` | `CSSProperties` ||| Wrapper custom style |
| Name | Type | Default | Required | Description |
| ------------------------- | ---------------------------------------------------------------- | ------- | -------- | ------------------------------------------------------------------- |
| `elementType` | `string` | `div` || Element type of the wrapper element |
| `hasEndDivider` | `bool` | `false` || Render a divider after the last item |
| `hasIntermediateDividers` | `bool` | `false` || Render dividers between items |
| `hasSpacing` | `bool` | `false` || Apply a spacing between items |
| `hasStartDivider` | `bool` | `false` || Render a divider before the first item |
| `spacing` | [`SpaceToken` \| `Partial<Record<BreakpointToken, SpaceToken>>`] ||| Custom spacing between items, see [Custom Spacing](#custom-spacing) |
| `UNSAFE_className` | `string` ||| Wrapper custom class name |
| `UNSAFE_style` | `CSSProperties` ||| Wrapper custom style |

You can add `id`, `data-*` or `aria-*` attributes to further extend component's
descriptiveness and accessibility.
Expand Down
11 changes: 9 additions & 2 deletions packages/web-react/src/components/Stack/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ const defaultProps: SpiritStackProps = {

export const Stack = <T extends ElementType = 'div'>(props: SpiritStackProps<T>): JSX.Element => {
const { elementType: ElementTag = 'div', children, ...restProps } = props;
const { classProps, props: modifiedProps } = useStackStyleProps(restProps);
const { classProps, props: modifiedProps, styleProps: stackStyle } = useStackStyleProps(restProps);
const { styleProps, props: otherProps } = useStyleProps(modifiedProps);

const stackStyleProps = {
style: {
...styleProps.style,
...stackStyle,
},
};

return (
<ElementTag {...otherProps} {...styleProps} className={classNames(classProps, styleProps.className)}>
<ElementTag {...otherProps} {...stackStyleProps} className={classNames(classProps, styleProps.className)}>
{children}
</ElementTag>
);
Expand Down
56 changes: 56 additions & 0 deletions packages/web-react/src/components/Stack/__tests__/Stack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,60 @@ describe('Stack', () => {
const element = dom.container.querySelector('div') as HTMLElement;
expect(element.textContent).toBe('Hello World');
});

it('should render element children', () => {
const dom = render(
<Stack>
<span>Child 1</span>
<span>Child 2</span>
</Stack>,
);

const element = dom.container.querySelector('div') as HTMLElement;
expect(element.children).toHaveLength(2);
expect(element.children[0].textContent).toBe('Child 1');
expect(element.children[1].textContent).toBe('Child 2');
});

it('should render a ul element', () => {
const dom = render(<Stack elementType="ul" />);

const element = dom.container.querySelector('ul') as HTMLElement;
expect(element).toBeInTheDocument();
});

it('should render with spacing', () => {
const dom = render(<Stack hasSpacing />);

const element = dom.container.querySelector('div') as HTMLElement;
expect(element).toHaveClass('Stack--hasSpacing');
});

it('should render with custom spacing', () => {
const dom = render(<Stack hasSpacing spacing="space-1000" />);

const element = dom.container.querySelector('div') as HTMLElement;
expect(element).toHaveClass('Stack--hasSpacing');
expect(element).toHaveStyle({ '--stack-spacing': 'var(--spirit-space-1000)' });
});

it('should render with custom spacing for each breakpoint', () => {
const dom = render(
<Stack hasSpacing spacing={{ mobile: 'space-100', tablet: 'space-1000', desktop: 'space-1200' }} />,
);

const element = dom.container.querySelector('div') as HTMLElement;
expect(element).toHaveClass('Stack--hasSpacing');
expect(element).toHaveStyle({ '--stack-spacing': 'var(--spirit-space-100)' });
expect(element).toHaveStyle({ '--stack-spacing-tablet': 'var(--spirit-space-1000)' });
expect(element).toHaveStyle({ '--stack-spacing-desktop': 'var(--spirit-space-1200)' });
});

it('should render with custom spacing for only one breakpoint', () => {
const dom = render(<Stack hasSpacing spacing={{ tablet: 'space-1000' }} />);

const element = dom.container.querySelector('div') as HTMLElement;
expect(element).toHaveClass('Stack--hasSpacing');
expect(element).toHaveStyle({ '--stack-spacing-tablet': 'var(--spirit-space-1000)' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,24 @@ describe('useStackStyleProps', () => {
expect(result.current.classProps).toBe(expectedClasses);
},
);

it.each([
// spacing, expectedStyle
[undefined, {}],
['space-100', { '--stack-spacing': 'var(--spirit-space-100)' }],
[{ tablet: 'space-100' }, { '--stack-spacing-tablet': 'var(--spirit-space-100)' }],
[
{ mobile: 'space-100', tablet: 'space-200', desktop: 'space-300' },
{
'--stack-spacing': 'var(--spirit-space-100)',
'--stack-spacing-tablet': 'var(--spirit-space-200)',
'--stack-spacing-desktop': 'var(--spirit-space-300)',
},
],
])('should return style', (spacing, expectedStyle) => {
const props = { spacing } as SpiritStackProps;
const { result } = renderHook(() => useStackStyleProps(props));

expect(result.current.styleProps).toEqual(expectedStyle);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import DocsBox from '../../../../docs/DocsBox';
import Stack from '../Stack';

const StackBlocksWithCustomSpacing = () => (
<Stack elementType="ul" hasSpacing spacing="space-1200">
{[1, 2, 3].map((i) => (
<li key={i}>
<DocsBox>Block {i}</DocsBox>
</li>
))}
</Stack>
);

export default StackBlocksWithCustomSpacing;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import DocsBox from '../../../../docs/DocsBox';
import Stack from '../Stack';

const StackBlocksWithCustomSpacingForEachBreakpoint = () => (
<Stack elementType="ul" hasSpacing spacing={{ mobile: 'space-100', tablet: 'space-1000', desktop: 'space-1200' }}>
{[1, 2, 3].map((i) => (
<li key={i}>
<DocsBox>Block {i}</DocsBox>
</li>
))}
</Stack>
);

export default StackBlocksWithCustomSpacingForEachBreakpoint;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import DocsBox from '../../../../docs/DocsBox';
import Stack from '../Stack';

const StackBlocksWithCustomSpacingFromTabletBreakpoint = () => (
<Stack elementType="ul" hasSpacing spacing={{ tablet: 'space-1200' }}>
{[1, 2, 3].map((i) => (
<li key={i}>
<DocsBox>Block {i}</DocsBox>
</li>
))}
</Stack>
);

export default StackBlocksWithCustomSpacingFromTabletBreakpoint;
12 changes: 12 additions & 0 deletions packages/web-react/src/components/Stack/demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import StackBlocksWithVerticalSpacing from './StackBlocksWithVerticalSpacing';
import StackBlocksWithInnerDividersAndVerticalSpacing from './StackBlocksWithInnerDividersAndVerticalSpacing';
import StackBlocksWithInnerAndOuterDividersAndVerticalSpacing from './StackBlocksWithInnerAndOuterDividersAndVerticalSpacing';
import StackBlocksWithInnerDividersWithoutVerticalSpacing from './StackBlocksWithInnerDividersWithoutVerticalSpacing';
import StackBlocksWithCustomSpacing from './StackBlocksWithCustomSpacing';
import StackBlocksWithCustomSpacingFromTabletBreakpoint from './StackBlocksWithCustomSpacingFromTabletBreakpoint';
import StackBlocksWithCustomSpacingForEachBreakpoint from './StackBlocksWithCustomSpacingForEachBreakpoint';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
Expand All @@ -30,5 +33,14 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<DocsSection title="Stacked Blocks with Inner Dividers without Vertical Spacing" stackAlignment="stretch">
<StackBlocksWithInnerDividersWithoutVerticalSpacing />
</DocsSection>
<DocsSection title="Stacked Blocks with Custom Spacing" stackAlignment="stretch">
<StackBlocksWithCustomSpacing />
</DocsSection>
<DocsSection title="Stacked Blocks with Custom Spacing from Tablet Breakpoint" stackAlignment="stretch">
<StackBlocksWithCustomSpacingFromTabletBreakpoint />
</DocsSection>
<DocsSection title="Stacked Blocks with Custom Spacing for Each Breakpoint" stackAlignment="stretch">
<StackBlocksWithCustomSpacingForEachBreakpoint />
</DocsSection>
</React.StrictMode>,
);
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const meta: Meta<typeof Stack> = {
defaultValue: { summary: false },
},
},
spacing: {
control: 'object',
},
},
args: {
children: (
Expand All @@ -65,6 +68,10 @@ const meta: Meta<typeof Stack> = {
hasIntermediateDividers: false,
hasSpacing: false,
hasStartDivider: false,
spacing: {
mobile: 'space-500',
tablet: 'space-600',
},
},
};

Expand Down
24 changes: 22 additions & 2 deletions packages/web-react/src/components/Stack/useStackStyleProps.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import classNames from 'classnames';
import { ElementType } from 'react';
import { CSSProperties, ElementType } from 'react';
import { useClassNamePrefix } from '../../hooks';
import { SpiritStackProps } from '../../types';

interface StackCSSProperties extends CSSProperties {
[key: string]: string | undefined | number;
}

export interface StackStyles {
/** className props */
classProps: string;
/** props to be passed to the element */
props: SpiritStackProps;
/** Style props for the element */
styleProps: StackCSSProperties;
}

export function useStackStyleProps<T extends ElementType = 'div'>(props: SpiritStackProps<T>): StackStyles {
const { hasEndDivider, hasIntermediateDividers, hasSpacing, hasStartDivider, ...restProps } = props;
const { hasEndDivider, hasIntermediateDividers, hasSpacing, hasStartDivider, spacing, ...restProps } = props;

const StackClass = useClassNamePrefix('Stack');
const StackBottomDividerClass = `${StackClass}--hasEndDivider`;
Expand All @@ -25,8 +31,22 @@ export function useStackStyleProps<T extends ElementType = 'div'>(props: SpiritS
[StackTopDividerClass]: hasStartDivider,
});

const stackStyle: StackCSSProperties = {};

if (typeof spacing === 'object' && spacing !== null) {
Object.keys(spacing).forEach((key) => {
const suffix = key === 'mobile' ? '' : `-${key}`;
(stackStyle as Record<string, string | undefined>)[`--stack-spacing${suffix}`] = `var(--spirit-${spacing[
key as keyof typeof spacing
]?.toString()})`;
});
} else if (spacing) {
(stackStyle as Record<string, string | undefined>)['--stack-spacing'] = `var(--spirit-${spacing})`;
}

return {
classProps,
props: restProps,
styleProps: stackStyle,
};
}
1 change: 1 addition & 0 deletions packages/web-react/src/types/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from './events';
export * from './inputs';
export * from './item';
export * from './style';
export * from './tokens';
export * from './refs';
export * from './rest';

Expand Down
4 changes: 4 additions & 0 deletions packages/web-react/src/types/shared/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { breakpoints, space } from '@lmc-eu/spirit-design-tokens';

export type BreakpointToken = keyof typeof breakpoints;
export type SpaceToken = `${'space-'}${keyof typeof space}`;
11 changes: 9 additions & 2 deletions packages/web-react/src/types/stack.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { ElementType } from 'react';
import { ChildrenProps, StyleProps, SpiritPolymorphicElementPropsWithoutRef } from './shared';
import {
BreakpointToken,
ChildrenProps,
SpaceToken,
SpiritPolymorphicElementPropsWithoutRef,
StyleProps,
} from './shared';

export interface StackBaseProps extends ChildrenProps, StyleProps {
/** Whether the Stack has divider on the end */
Expand All @@ -10,12 +16,13 @@ export interface StackBaseProps extends ChildrenProps, StyleProps {
hasSpacing?: boolean;
/** Whether the Stack has divider on the start */
hasStartDivider?: boolean;
/** Custom spacing between items */
spacing?: SpaceToken | Partial<Record<BreakpointToken, SpaceToken>>;
}

export type StackProps<E extends ElementType> = {
/**
* The HTML element or React element used to render the stack, e.g. 'div'.
*
* @default 'div'
*/
elementType?: E;
Expand Down

0 comments on commit b9ca594

Please sign in to comment.