diff --git a/packages/web-react/src/components/Box/Box.tsx b/packages/web-react/src/components/Box/Box.tsx new file mode 100644 index 0000000000..54a1b75fd1 --- /dev/null +++ b/packages/web-react/src/components/Box/Box.tsx @@ -0,0 +1,34 @@ +'use client'; + +import classNames from 'classnames'; +import React, { ElementType } from 'react'; +import { useStyleProps } from '../../hooks'; +import { SpiritBoxProps } from '../../types'; +import { useBoxStyleProps } from './useBoxStyleProps'; + +const defaultProps: Partial = { + elementType: 'div', +}; + +const Box = (props: SpiritBoxProps) => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { elementType: ElementTag = 'div', children, ...restProps } = propsWithDefaults; + + const { classProps, props: modifiedProps, styleProps: boxStyle } = useBoxStyleProps(restProps); + const { styleProps, props: otherProps } = useStyleProps(modifiedProps); + + const boxStyleProps = { + style: { + ...styleProps.style, + ...boxStyle, + }, + }; + + return ( + + {children} + + ); +}; + +export default Box; diff --git a/packages/web-react/src/components/Box/README.md b/packages/web-react/src/components/Box/README.md new file mode 100644 index 0000000000..a693a93bef --- /dev/null +++ b/packages/web-react/src/components/Box/README.md @@ -0,0 +1,82 @@ +# Box + +The Box component is a simple container around content or other components. + +```jsx +{/* Content go here */} +``` + +## Border + +You can define border width, color, and radius using the `borderColor`, `borderRadius`, and `borderWidth` props. + +```jsx + + {/* Content go here */} + +``` + +## Padding + +You can define padding using the `padding` prop. + +```jsx +{/* Content go here */} +``` + +It is also possible to define padding for horizontal and vertical sides using the `paddingX` and `paddingY` props. + +```jsx + + {/* Content go here */} + +``` + +You can also define padding for each side using the `paddingTop`, `paddingRight`, `paddingBottom`, and `paddingLeft` props. + +```jsx + + {/* Content go here */} + +``` + +Responsive values can be set for each prop using an object: + +```jsx +{/* Content go here */} +``` + +## Background Color + +You can define background color using the `backgroundColor` prop. + +```jsx +{/* Content go here */} +``` + +## API + +| Name | Type | Default | Required | Description | +| ----------------- | ----------------------------------------------------------------- | ------- | -------- | ----------------------------- | +| `backgroundColor` | [Background Color dictionary][dictionary-background-color] | - | ✕ | Background color of the Box | +| `borderColor` | [Border Color dictionary][dictionary-border-color] | - | ✕ | Border color of the Box | +| `borderRadius` | `string` | - | ✕ | Border radius of the Box | +| `borderWidth` | `string` | - | ✕ | Border width of the Box | +| `elementType` | `ElementType` | `div` | ✕ | Type of element | +| `padding` | \[`SpaceToken` \| `Partial>`] | - | ✕ | Padding of the Box | +| `paddingX` | \[`SpaceToken` \| `Partial>`] | - | ✕ | Horizontal padding of the Box | +| `paddingY` | \[`SpaceToken` \| `Partial>`] | - | ✕ | Vertical padding of the Box | +| `paddingTop` | \[`SpaceToken` \| `Partial>`] | - | ✕ | Padding top of the Box | +| `paddingRight` | \[`SpaceToken` \| `Partial>`] | - | ✕ | Padding right of the Box | +| `paddingBottom` | \[`SpaceToken` \| `Partial>`] | - | ✕ | Padding bottom of the Box | +| `paddingLeft` | \[`SpaceToken` \| `Partial>`] | - | ✕ | Padding left of the Box | + +On top of the API options, the components accept [additional attributes][readme-additional-attributes]. +If you need more control over the styling of a component, you can use [style props][readme-style-props] +and [escape hatches][readme-escape-hatches]. + +[dictionary-background-color]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#color +[dictionary-border-color]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#color +[readme-additional-attributes]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#additional-attributes +[readme-escape-hatches]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#escape-hatches +[readme-style-props]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#style-props diff --git a/packages/web-react/src/components/Box/__tests__/Box.test.tsx b/packages/web-react/src/components/Box/__tests__/Box.test.tsx new file mode 100644 index 0000000000..ef33864ff8 --- /dev/null +++ b/packages/web-react/src/components/Box/__tests__/Box.test.tsx @@ -0,0 +1,123 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import Box from '../Box'; + +const dataProvider = [ + { + prop: 'backgroundColor', + value: 'primary', + className: 'bg-primary', + description: 'background color', + }, + { + prop: 'borderColor', + value: 'basic', + className: 'border-basic', + description: 'border color', + }, + { + prop: 'borderWidth', + value: '100', + className: 'border-100', + description: 'border width', + }, + { + prop: 'padding', + value: 'space-800', + className: 'p-800', + description: 'padding', + }, + { + prop: 'paddingX', + value: 'space-800', + className: 'px-800', + description: 'horizontal padding', + }, + { + prop: 'paddingY', + value: 'space-800', + className: 'py-800', + description: 'vertical padding', + }, + { + prop: 'paddingTop', + value: 'space-800', + className: 'pt-800', + description: 'padding top', + }, + { + prop: 'paddingBottom', + value: 'space-800', + className: 'pb-800', + description: 'padding bottom', + }, + { + prop: 'paddingLeft', + value: 'space-800', + className: 'pl-800', + description: 'padding left', + }, + { + prop: 'paddingRight', + value: 'space-800', + className: 'pr-800', + description: 'padding right', + }, + { + prop: 'padding', + value: { mobile: 'space-600', tablet: 'space-800', desktop: 'space-1000' }, + className: 'p-600 p-tablet-800 p-desktop-1000', + description: 'responsive padding', + }, +]; + +describe('Box', () => { + stylePropsTest(Box); + + restPropsTest(Box, 'div'); + + it('should render children', () => { + render(Content); + + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + it('should render with background color', () => { + render(Content); + + expect(screen.getByText('Content')).toHaveStyle('background-color: var(--color-primary)'); + }); + + it('should render with border radius and width', () => { + render( + + Content + , + ); + + expect(screen.getByTestId('Box')).toHaveClass('rounded-200'); + }); + + it('should not render correct border radius class when border width is not set', () => { + render( + + Content + , + ); + + expect(screen.getByTestId('Box')).not.toHaveClass('rounded-200'); + }); + + it.each(dataProvider)('should render with $description', ({ prop, value, className }) => { + render( + + Content + , + ); + + expect(screen.getByTestId('Box')).toHaveClass(className); + }); +}); diff --git a/packages/web-react/src/components/Box/__tests__/useBoxStyleProps.test.ts b/packages/web-react/src/components/Box/__tests__/useBoxStyleProps.test.ts new file mode 100644 index 0000000000..29e7805391 --- /dev/null +++ b/packages/web-react/src/components/Box/__tests__/useBoxStyleProps.test.ts @@ -0,0 +1,122 @@ +import { renderHook } from '@testing-library/react'; +import { SpiritBoxProps } from '../../../types'; +import { useBoxStyleProps } from '../useBoxStyleProps'; + +describe('useBoxStyleProps', () => { + it('should return defaults', () => { + const props: SpiritBoxProps = {}; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe(''); + }); + + it('should return background classProps', () => { + const props: SpiritBoxProps = { + backgroundColor: 'secondary', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('bg-secondary'); + }); + + it('should return padding classProps', () => { + const props: SpiritBoxProps = { + padding: 'space-400', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('p-400'); + }); + + it('should return paddingX classProps', () => { + const props: SpiritBoxProps = { + paddingX: 'space-400', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('px-400'); + }); + + it('should return paddingY classProps', () => { + const props: SpiritBoxProps = { + paddingY: 'space-400', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('py-400'); + }); + + it('should return paddingTop classProps', () => { + const props: SpiritBoxProps = { + paddingTop: 'space-400', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('pt-400'); + }); + + it('should return paddingBottom classProps', () => { + const props: SpiritBoxProps = { + paddingBottom: 'space-400', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('pb-400'); + }); + + it('should return paddingLeft classProps', () => { + const props: SpiritBoxProps = { + paddingLeft: 'space-400', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('pl-400'); + }); + + it('should return paddingRight classProps', () => { + const props: SpiritBoxProps = { + paddingRight: 'space-400', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('pr-400'); + }); + + it('should return responsive padding classProps', () => { + const props: SpiritBoxProps = { + padding: { mobile: 'space-400', tablet: 'space-500', desktop: 'space-600' }, + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('p-400 p-tablet-500 p-desktop-600'); + }); + + it('should return border radius classProps', () => { + const props: SpiritBoxProps = { + borderRadius: '200', + borderWidth: '100', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('border-basic rounded-200 border-100'); + }); + + it('should not return border radius classProps if border with is not set', () => { + const props: SpiritBoxProps = { + borderRadius: '200', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).not.toBe('rounded-200'); + }); + + it('should return border color classProps', () => { + const props: SpiritBoxProps = { + borderColor: 'basic', + borderWidth: '100', + }; + const { result } = renderHook(() => useBoxStyleProps(props)); + + expect(result.current.classProps).toBe('border-basic border-100'); + }); +}); diff --git a/packages/web-react/src/components/Box/demo/BoxDefault.tsx b/packages/web-react/src/components/Box/demo/BoxDefault.tsx new file mode 100644 index 0000000000..b893752b9c --- /dev/null +++ b/packages/web-react/src/components/Box/demo/BoxDefault.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import Box from '../Box'; + +const BoxDefault = () => Content; + +export default BoxDefault; diff --git a/packages/web-react/src/components/Box/demo/BoxWithBackgroundColor.tsx b/packages/web-react/src/components/Box/demo/BoxWithBackgroundColor.tsx new file mode 100644 index 0000000000..41f4c216f3 --- /dev/null +++ b/packages/web-react/src/components/Box/demo/BoxWithBackgroundColor.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Box from '../Box'; + +const BoxWithBackgroundColor = () => ( + <> +
+ For demo purposes the box is bordered +
+ + Primary Background + + + Secondary Background + + + Tertiary Background + + +); + +export default BoxWithBackgroundColor; diff --git a/packages/web-react/src/components/Box/demo/BoxWithBorder.tsx b/packages/web-react/src/components/Box/demo/BoxWithBorder.tsx new file mode 100644 index 0000000000..00f019888f --- /dev/null +++ b/packages/web-react/src/components/Box/demo/BoxWithBorder.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Box from '../Box'; + +const BoxWithBorder = () => ( + <> +
+ For demo purposes the box has custom padding +
+ + Without radius + + + With custom radius + + + With full radius + + + With thicker border + + + With focus color + + +); + +export default BoxWithBorder; diff --git a/packages/web-react/src/components/Box/demo/BoxWithCustomPadding.tsx b/packages/web-react/src/components/Box/demo/BoxWithCustomPadding.tsx new file mode 100644 index 0000000000..3eb4a83c2f --- /dev/null +++ b/packages/web-react/src/components/Box/demo/BoxWithCustomPadding.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Box from '../Box'; + +const BoxWithCustomPadding = () => ( + <> +
+ For demo purposes the box is bordered +
+ + With custom padding + + + With custom vertical and horizontal padding + + + With custom padding for each direction + + + With responsive padding + + +); + +export default BoxWithCustomPadding; diff --git a/packages/web-react/src/components/Box/demo/index.tsx b/packages/web-react/src/components/Box/demo/index.tsx new file mode 100644 index 0000000000..adda37b538 --- /dev/null +++ b/packages/web-react/src/components/Box/demo/index.tsx @@ -0,0 +1,28 @@ +// Because there is no `dist` directory during the CI run +/* eslint-disable import/no-extraneous-dependencies, import/extensions, import/no-unresolved */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: No declaration file -- @see https://jira.almacareer.tech/browse/DS-561 +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import DocsSection from '../../../../docs/DocsSections'; +import BoxDefault from './BoxDefault'; +import BoxWithBackgroundColor from './BoxWithBackgroundColor'; +import BoxWithBorder from './BoxWithBorder'; +import BoxWithCustomPadding from './BoxWithCustomPadding'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + + + + + + , +); diff --git a/packages/web-react/src/components/Box/index.html b/packages/web-react/src/components/Box/index.html new file mode 100644 index 0000000000..bc182669d0 --- /dev/null +++ b/packages/web-react/src/components/Box/index.html @@ -0,0 +1 @@ +{{> web-react/demo title="Box" parentPageName="Components" }} diff --git a/packages/web-react/src/components/Box/index.ts b/packages/web-react/src/components/Box/index.ts new file mode 100644 index 0000000000..cad48c4879 --- /dev/null +++ b/packages/web-react/src/components/Box/index.ts @@ -0,0 +1,4 @@ +'use client'; + +export { default as Box } from './Box'; +export * from './useBoxStyleProps'; diff --git a/packages/web-react/src/components/Box/stories/Box.stories.tsx b/packages/web-react/src/components/Box/stories/Box.stories.tsx new file mode 100644 index 0000000000..4ce44b3e63 --- /dev/null +++ b/packages/web-react/src/components/Box/stories/Box.stories.tsx @@ -0,0 +1,36 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Container } from '../../Container'; +import Box from '../Box'; +import ReadMe from '../README.md'; + +const meta: Meta = { + title: 'Components/Box', + component: Box, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + elementType: { + control: 'text', + }, + }, + args: { + elementType: 'div', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + name: 'Box', + render: (args) => ( + + Content + + ), +}; diff --git a/packages/web-react/src/components/Box/useBoxStyleProps.ts b/packages/web-react/src/components/Box/useBoxStyleProps.ts new file mode 100644 index 0000000000..4aebde68d0 --- /dev/null +++ b/packages/web-react/src/components/Box/useBoxStyleProps.ts @@ -0,0 +1,87 @@ +import classNames from 'classnames'; +import { CSSProperties, ElementType } from 'react'; +import { BorderColors } from '../../constants'; +import { BreakpointToken, SpaceToken, SpiritBoxProps } from '../../types'; + +interface BoxCSSProperties extends CSSProperties { + [key: string]: string | undefined | number; +} + +export interface UseBoxStyleProps { + /** className props */ + classProps: string; + /** Props for the box element. */ + props: T; + /** Style props for the box element */ + styleProps: BoxCSSProperties; +} + +export const generateResponsiveUtilityClasses = ( + prefix: string, + propValue: SpaceToken | Partial> | undefined, +): string[] => { + if (propValue && typeof propValue === 'object') { + return Object.entries(propValue).map(([breakpoint, value]) => { + const classValue = value?.replace('space-', ''); + + return breakpoint === 'mobile' ? `${prefix}-${classValue}` : `${prefix}-${breakpoint}-${classValue}`; + }); + } + + if (propValue && typeof propValue !== 'object') { + return [`${prefix}-${propValue.replace('space-', '')}`]; + } + + return []; +}; + +export const useBoxStyleProps = ( + props: Partial>, +): UseBoxStyleProps>> => { + const { + backgroundColor, + borderColor, + borderRadius, + borderWidth, + padding, + paddingBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingX, + paddingY, + ...restProps + } = props || {}; + const boxStyle: BoxCSSProperties = {}; + + const boxBackgroundColor = backgroundColor ? `bg-${backgroundColor}` : ''; + let boxBorderColor = borderColor ? borderColor.replace('', 'border-') : ''; + let boxBorderRadius = ''; + const boxBorderWidth = borderWidth ? borderWidth.replace('', 'border-') : ''; + + if (borderWidth && parseInt(borderWidth, 10) > 0) { + boxStyle.borderStyle = 'solid'; + boxBorderRadius = borderRadius ? borderRadius.replace('', 'rounded-') : ''; + if (!borderColor) { + boxBorderColor = `border-${BorderColors.BASIC}`; + } + } + + const paddingClasses = [ + ...generateResponsiveUtilityClasses('p', padding), + ...generateResponsiveUtilityClasses('pb', paddingBottom), + ...generateResponsiveUtilityClasses('pl', paddingLeft), + ...generateResponsiveUtilityClasses('pr', paddingRight), + ...generateResponsiveUtilityClasses('pt', paddingTop), + ...generateResponsiveUtilityClasses('px', paddingX), + ...generateResponsiveUtilityClasses('py', paddingY), + ]; + + const boxClasses = classNames(boxBackgroundColor, boxBorderColor, boxBorderRadius, boxBorderWidth, ...paddingClasses); + + return { + classProps: boxClasses, + props: restProps, + styleProps: boxStyle, + }; +}; diff --git a/packages/web-react/src/constants/dictionaries.ts b/packages/web-react/src/constants/dictionaries.ts index 567d020c3e..e8e3edbbb7 100644 --- a/packages/web-react/src/constants/dictionaries.ts +++ b/packages/web-react/src/constants/dictionaries.ts @@ -50,6 +50,11 @@ export const BackgroundColors = { TERTIARY: 'tertiary', } as const; +export const BorderColors = { + BASIC: 'basic', + FOCUS: 'focus', +} as const; + export const EmotionColors = { SUCCESS: 'success', INFORMATIVE: 'informative', diff --git a/packages/web-react/src/types/box.ts b/packages/web-react/src/types/box.ts new file mode 100644 index 0000000000..f2d8bbea1f --- /dev/null +++ b/packages/web-react/src/types/box.ts @@ -0,0 +1,47 @@ +import { ElementType, JSXElementConstructor } from 'react'; +import { + BackgroundColorsDictionaryType, + BorderColorsDictionaryType, + BreakpointToken, + ChildrenProps, + SpaceToken, + SpiritPolymorphicElementPropsWithRef, + StyleProps, +} from './shared'; + +export interface BoxBaseProps extends ChildrenProps, StyleProps { + /** The background color of the box. */ + backgroundColor?: BackgroundColorsDictionaryType; + /** The border color of the box. */ + borderColor?: BorderColorsDictionaryType; + /** The border radius of the box. */ + borderRadius?: string; + /** The border width of the box. */ + borderWidth?: string; + /** Padding of the box. */ + padding?: SpaceToken | Partial>; + /** Horizontal padding of the box. */ + paddingX?: SpaceToken | Partial>; + /** Vertical padding of the box. */ + paddingY?: SpaceToken | Partial>; + /** Padding top of the box. */ + paddingTop?: SpaceToken | Partial>; + /** Padding bottom of the box. */ + paddingBottom?: SpaceToken | Partial>; + /** Padding left of the box. */ + paddingLeft?: SpaceToken | Partial>; + /** Padding right of the box. */ + paddingRight?: SpaceToken | Partial>; +} + +export type BoxProps = { + /** + * The HTML element or React element used to render the button, e.g. 'div', 'a', or `RouterLink`. + * + * @default 'button' + */ + elementType?: E | JSXElementConstructor; +} & BoxBaseProps; + +export type SpiritBoxProps = BoxProps & + SpiritPolymorphicElementPropsWithRef>; diff --git a/packages/web-react/src/types/index.ts b/packages/web-react/src/types/index.ts index 2b2d412fb3..78e1484ea8 100644 --- a/packages/web-react/src/types/index.ts +++ b/packages/web-react/src/types/index.ts @@ -1,6 +1,7 @@ export * from './accordion'; export * from './alert'; export * from './avatar'; +export * from './box'; export * from './breadcrumbs'; export * from './button'; export * from './card'; diff --git a/packages/web-react/src/types/shared/dictionaries.ts b/packages/web-react/src/types/shared/dictionaries.ts index e84d89f07c..d6227c14c4 100644 --- a/packages/web-react/src/types/shared/dictionaries.ts +++ b/packages/web-react/src/types/shared/dictionaries.ts @@ -6,6 +6,7 @@ import { AlignmentXExtended, AlignmentY, AlignmentYExtended, + BorderColors, EmotionColors, Emphasis, Placements, @@ -52,6 +53,9 @@ export type BackgroundColorsDictionaryType = | (typeof BackgroundColors)[BackgroundColorsDictionaryKeys] | C; +export type BorderColorsDictionaryKeys = keyof typeof BorderColors; +export type BorderColorsDictionaryType = (typeof BorderColors)[BorderColorsDictionaryKeys] | C; + export type EmotionColorsDictionaryKeys = keyof typeof EmotionColors; export type EmotionColorsDictionaryType = (typeof EmotionColors)[EmotionColorsDictionaryKeys] | C;