diff --git a/packages/web-react/src/components/Stack/README.md b/packages/web-react/src/components/Stack/README.md index 0d5379de84..2485e8a321 100644 --- a/packages/web-react/src/components/Stack/README.md +++ b/packages/web-react/src/components/Stack/README.md @@ -28,17 +28,37 @@ Advanced example usage: ``` +## 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 + +
Block 1
+
Block 2
+
Block 3
+
+ + +
Block 1
+
Block 2
+
Block 3
+
+``` + ## 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>`] | — | ✕ | 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. diff --git a/packages/web-react/src/components/Stack/Stack.tsx b/packages/web-react/src/components/Stack/Stack.tsx index 62a17ae708..e5fd5e917f 100644 --- a/packages/web-react/src/components/Stack/Stack.tsx +++ b/packages/web-react/src/components/Stack/Stack.tsx @@ -14,11 +14,18 @@ const defaultProps: SpiritStackProps = { export const Stack = (props: SpiritStackProps): 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 ( - + {children} ); diff --git a/packages/web-react/src/components/Stack/__tests__/Stack.test.tsx b/packages/web-react/src/components/Stack/__tests__/Stack.test.tsx index 95debab940..790b249064 100644 --- a/packages/web-react/src/components/Stack/__tests__/Stack.test.tsx +++ b/packages/web-react/src/components/Stack/__tests__/Stack.test.tsx @@ -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( + + Child 1 + Child 2 + , + ); + + 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(); + + const element = dom.container.querySelector('ul') as HTMLElement; + expect(element).toBeInTheDocument(); + }); + + it('should render with spacing', () => { + const dom = render(); + + const element = dom.container.querySelector('div') as HTMLElement; + expect(element).toHaveClass('Stack--hasSpacing'); + }); + + it('should render with custom spacing', () => { + const dom = render(); + + 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( + , + ); + + 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(); + + const element = dom.container.querySelector('div') as HTMLElement; + expect(element).toHaveClass('Stack--hasSpacing'); + expect(element).toHaveStyle({ '--stack-spacing-tablet': 'var(--spirit-space-1000)' }); + }); }); diff --git a/packages/web-react/src/components/Stack/__tests__/useStackStyleProps.test.ts b/packages/web-react/src/components/Stack/__tests__/useStackStyleProps.test.ts index db73bd42d2..2d944211c1 100644 --- a/packages/web-react/src/components/Stack/__tests__/useStackStyleProps.test.ts +++ b/packages/web-react/src/components/Stack/__tests__/useStackStyleProps.test.ts @@ -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); + }); }); diff --git a/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacing.tsx b/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacing.tsx new file mode 100644 index 0000000000..7dae8e03ee --- /dev/null +++ b/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacing.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Stack from '../Stack'; + +const StackBlocksWithCustomSpacing = () => ( + + {[1, 2, 3].map((i) => ( +
  • + Block {i} +
  • + ))} +
    +); + +export default StackBlocksWithCustomSpacing; diff --git a/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacingForEachBreakpoint.tsx b/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacingForEachBreakpoint.tsx new file mode 100644 index 0000000000..993ed8c4f5 --- /dev/null +++ b/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacingForEachBreakpoint.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Stack from '../Stack'; + +const StackBlocksWithCustomSpacingForEachBreakpoint = () => ( + + {[1, 2, 3].map((i) => ( +
  • + Block {i} +
  • + ))} +
    +); + +export default StackBlocksWithCustomSpacingForEachBreakpoint; diff --git a/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacingFromTabletBreakpoint.tsx b/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacingFromTabletBreakpoint.tsx new file mode 100644 index 0000000000..a5646dcf06 --- /dev/null +++ b/packages/web-react/src/components/Stack/demo/StackBlocksWithCustomSpacingFromTabletBreakpoint.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Stack from '../Stack'; + +const StackBlocksWithCustomSpacingFromTabletBreakpoint = () => ( + + {[1, 2, 3].map((i) => ( +
  • + Block {i} +
  • + ))} +
    +); + +export default StackBlocksWithCustomSpacingFromTabletBreakpoint; diff --git a/packages/web-react/src/components/Stack/demo/index.tsx b/packages/web-react/src/components/Stack/demo/index.tsx index 2e8da8e9d6..899424cdc7 100644 --- a/packages/web-react/src/components/Stack/demo/index.tsx +++ b/packages/web-react/src/components/Stack/demo/index.tsx @@ -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( @@ -30,5 +33,14 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + , ); diff --git a/packages/web-react/src/components/Stack/stories/Stack.stories.tsx b/packages/web-react/src/components/Stack/stories/Stack.stories.tsx index d7538906fa..4f659a498d 100644 --- a/packages/web-react/src/components/Stack/stories/Stack.stories.tsx +++ b/packages/web-react/src/components/Stack/stories/Stack.stories.tsx @@ -45,6 +45,9 @@ const meta: Meta = { defaultValue: { summary: false }, }, }, + spacing: { + control: 'object', + }, }, args: { children: ( @@ -65,6 +68,10 @@ const meta: Meta = { hasIntermediateDividers: false, hasSpacing: false, hasStartDivider: false, + spacing: { + mobile: 'space-500', + tablet: 'space-600', + }, }, }; diff --git a/packages/web-react/src/components/Stack/useStackStyleProps.ts b/packages/web-react/src/components/Stack/useStackStyleProps.ts index 5cefd17b06..06d7c1617e 100644 --- a/packages/web-react/src/components/Stack/useStackStyleProps.ts +++ b/packages/web-react/src/components/Stack/useStackStyleProps.ts @@ -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(props: SpiritStackProps): StackStyles { - const { hasEndDivider, hasIntermediateDividers, hasSpacing, hasStartDivider, ...restProps } = props; + const { hasEndDivider, hasIntermediateDividers, hasSpacing, hasStartDivider, spacing, ...restProps } = props; const StackClass = useClassNamePrefix('Stack'); const StackBottomDividerClass = `${StackClass}--hasEndDivider`; @@ -25,8 +31,22 @@ export function useStackStyleProps(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)[`--stack-spacing${suffix}`] = `var(--spirit-${spacing[ + key as keyof typeof spacing + ]?.toString()})`; + }); + } else if (spacing) { + (stackStyle as Record)['--stack-spacing'] = `var(--spirit-${spacing})`; + } + return { classProps, props: restProps, + styleProps: stackStyle, }; } diff --git a/packages/web-react/src/types/shared/index.ts b/packages/web-react/src/types/shared/index.ts index 3ee36dedca..5b2edce57d 100644 --- a/packages/web-react/src/types/shared/index.ts +++ b/packages/web-react/src/types/shared/index.ts @@ -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'; diff --git a/packages/web-react/src/types/shared/tokens.ts b/packages/web-react/src/types/shared/tokens.ts new file mode 100644 index 0000000000..f06f696151 --- /dev/null +++ b/packages/web-react/src/types/shared/tokens.ts @@ -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}`; diff --git a/packages/web-react/src/types/stack.ts b/packages/web-react/src/types/stack.ts index b727c20d86..3d2eb048df 100644 --- a/packages/web-react/src/types/stack.ts +++ b/packages/web-react/src/types/stack.ts @@ -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 */ @@ -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>; } export type StackProps = { /** * The HTML element or React element used to render the stack, e.g. 'div'. - * * @default 'div' */ elementType?: E;