diff --git a/docs/DICTIONARIES.md b/docs/DICTIONARIES.md index c7e1d9dfef..1fb7f5349a 100644 --- a/docs/DICTIONARIES.md +++ b/docs/DICTIONARIES.md @@ -17,12 +17,12 @@ This project uses `dictionaries` to unify props between different components. ### Alignment -| Dictionary | Values | Code name | -| ------------------ | ------------------------- | ------------------ | -| AlignmentX | `left`, `center`, `right` | AlignmentX | -| AlignmentXExtended | AlignmentX + `stretch` | AlignmentXExtended | -| AlignmentY | `top`, `center`, `bottom` | AlignmentY | -| AlignmentYExtended | AlignmentY + `stretch` | AlignmentYExtended | +| Dictionary | Values | Code name | +| ------------------ | --------------------------------------- | ------------------ | +| AlignmentX | `left`, `center`, `right` | AlignmentX | +| AlignmentXExtended | AlignmentX + `stretch`, `space-between` | AlignmentXExtended | +| AlignmentY | `top`, `center`, `bottom` | AlignmentY | +| AlignmentYExtended | AlignmentY + `stretch`, `baseline` | AlignmentYExtended | ### Color diff --git a/packages/web-react/scripts/entryPoints.js b/packages/web-react/scripts/entryPoints.js index 38e79335e8..3d4eda7c05 100644 --- a/packages/web-react/scripts/entryPoints.js +++ b/packages/web-react/scripts/entryPoints.js @@ -23,6 +23,7 @@ const entryPoints = [ { dirs: ['components', 'Field'] }, { dirs: ['components', 'FieldGroup'] }, { dirs: ['components', 'FileUploader'] }, + { dirs: ['components', 'Flex'] }, { dirs: ['components', 'Grid'] }, { dirs: ['components', 'Header'] }, { dirs: ['components', 'Heading'] }, diff --git a/packages/web-react/src/components/Flex/Flex.tsx b/packages/web-react/src/components/Flex/Flex.tsx new file mode 100644 index 0000000000..3324598198 --- /dev/null +++ b/packages/web-react/src/components/Flex/Flex.tsx @@ -0,0 +1,38 @@ +'use client'; + +import classNames from 'classnames'; +import React, { ElementType } from 'react'; +import { AlignmentXExtended, AlignmentYExtended } from '../../constants'; +import { useStyleProps } from '../../hooks'; +import { SpiritFlexProps } from '../../types'; +import { useFlexStyleProps } from './useFlexStyleProps'; + +const defaultProps: Partial = { + alignmentX: AlignmentXExtended.STRETCH, + alignmentY: AlignmentYExtended.STRETCH, + direction: 'row', + elementType: 'div', + isWrapping: false, +}; + +export const Flex = (props: SpiritFlexProps): JSX.Element => { + const propsWithDefaults = { ...defaultProps, ...props }; + const { elementType: ElementTag = 'div', children, ...restProps } = propsWithDefaults; + const { classProps, props: modifiedProps, styleProps: gridStyle } = useFlexStyleProps(restProps); + const { styleProps, props: otherProps } = useStyleProps(modifiedProps); + + const flexStyleProps = { + style: { + ...styleProps.style, + ...gridStyle, + }, + }; + + return ( + + {children} + + ); +}; + +export default Flex; diff --git a/packages/web-react/src/components/Flex/README.md b/packages/web-react/src/components/Flex/README.md new file mode 100644 index 0000000000..862c40ad96 --- /dev/null +++ b/packages/web-react/src/components/Flex/README.md @@ -0,0 +1,161 @@ +# Flex + +Flex is a component that allows you to create a flexible one-dimensional layout. + +## Basic Usage + +Row layout: + +```jsx + +
Item 1
+
Item 2
+
Item 3
+
+``` + +Column layout: + +```jsx + +
Item 1
+
Item 2
+
Item 3
+ +``` + +Usage with a list: + +```jsx + +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
    +``` + +ℹ️ For the row layout, the Flex component uses the [`display: flex`][mdn-display-flex] CSS property. For the column +layout, [`display: grid`][mdn-display-grid] is used because of technical advantages: better overflow control or +alignment API consistency. + +## Responsive Direction + +To create a responsive layout, pass a object as the value for the direction property, using breakpoint keys to specify different layouts for each screen size. + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    +
    +``` + +## Wrapping + +By default, Flex items will not wrap. To enable wrapping on all breakpoints, use the +`isWrapping` prop. + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    +
    +``` + +### Responsive Wrapping + +To create a responsive layout, pass a object as the value for the `isWrapping` property, using breakpoint keys to specify different layouts for each screen size. + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    +
    +``` + +## Alignment + +### Horizontal Alignment + +Flex can be horizontally aligned as stretched (default), or justified to the left, center, or right. Additionally, you +can evenly distribute the items using the space-between value. These values come from the extended +[alignmentX dictionary][dictionary-alignment]. Using a corresponding prop will align the Flex items accordingly: + +### Vertical Alignment + +Similarly to the horizontal alignment, Flex can be vertically aligned as stretched (default), or justified to the top, +center, or bottom. There is also an option to align the items to the baseline. These values come from the extended +[alignmentY dictionary][dictionary-alignment]. Using a corresponding prop will align the Flex items accordingly: + +Example: + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    + +``` + +### Responsive Alignment + +To create a responsive layout, pass a object as the value for the property, using breakpoint keys to specify different layouts for each screen size. + +Example: + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    +
    +``` + +## Custom Spacing + +You can use the `spacing` prop to apply custom spacing between items in both horizontal and vertical directions. The prop +accepts either a spacing token (e.g. `space-100`) or an object with breakpoint keys and spacing token values. + +Custom spacing: + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    + +``` + +Custom responsive spacing: + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    +
    +``` + +## API + +| Name | Type | Default | Required | Description | +| ------------- | ------------------------------------------------------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `alignmentX` | [[AlignmentXExtended dictionary][alignment-dictionary] \| `object`] | `stretch` | ✕ | Apply horizontal alignment of items, use object to set responsive values, e.g. `{ mobile: 'left', tablet: 'center', desktop: 'right' }` | +| `alignmentY` | [[AlignmentYExtended dictionary][alignment-dictionary] \| `object`] | `stretch` | ✕ | Apply vertical alignment of items, use object to set responsive values, e.g. `{ mobile: 'top', tablet: 'center', desktop: 'bottom' }` | +| `direction` | [`row` \| `column`] | `row` | ✕ | Direction of the items, use object to set responsive values, e.g. `{ mobile: 'row', tablet: 'row', desktop: 'column' }` | +| `elementType` | HTML element | `div` | ✕ | Element type to use for the Grid | +| `isWrapping` | `bool` | `false` | ✕ | Whether items will wrap, use object to set responsive values, e.g. `{ mobile: true, tablet: true, desktop: false }` | +| `spacing` | [`SpaceToken` \| `Partial>`] | — | ✕ | Apply [custom spacing](#custom-spacing) in both horizontal and vertical directions between items | + +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-alignment]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#alignment +[mdn-display-flex]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout +[mdn-display-grid]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout +[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/Flex/__tests__/Flex.test.tsx b/packages/web-react/src/components/Flex/__tests__/Flex.test.tsx new file mode 100644 index 0000000000..4277793cdc --- /dev/null +++ b/packages/web-react/src/components/Flex/__tests__/Flex.test.tsx @@ -0,0 +1,81 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import Flex from '../Flex'; + +describe('Flex', () => { + const text = 'Hello world'; + const testId = 'flex-test-id'; + + classNamePrefixProviderTest(Flex, 'Flex'); + + stylePropsTest(Flex); + + restPropsTest(Flex, 'div'); + + it('should render text children', () => { + render({text}); + + expect(screen.getByText(text)).toBeInTheDocument(); + expect(screen.getByTestId(testId)).toHaveClass( + 'Flex Flex--noWrap Flex--row Flex--alignmentXStretch Flex--alignmentYStretch', + ); + }); + + it('should have direction className', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--column'); + }); + + it('should have alignmentX className', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--alignmentXLeft'); + }); + + it('should have alignmentY className', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--alignmentYTop'); + }); + + it('should have both alignmentX and alignmentY classNames', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--alignmentYTop Flex--alignmentYTop'); + }); + + it('should have wrapping className', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--wrap'); + }); + + it('should have custom elementType', () => { + render(); + + expect(screen.getByRole('list')).toBeInTheDocument(); + }); + + it('should render with custom spacing', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveStyle({ '--flex-spacing': 'var(--spirit-space-600)' }); + }); + + it('should render with custom spacing for each breakpoint', () => { + render( + , + ); + + const element = screen.getByTestId(testId) as HTMLElement; + + expect(element).toHaveStyle({ '--flex-spacing': 'var(--spirit-space-100)' }); + expect(element).toHaveStyle({ '--flex-spacing-tablet': 'var(--spirit-space-1000)' }); + expect(element).toHaveStyle({ '--flex-spacing-desktop': 'var(--spirit-space-1200)' }); + }); +}); diff --git a/packages/web-react/src/components/Flex/__tests__/useFlexStyleProps.test.ts b/packages/web-react/src/components/Flex/__tests__/useFlexStyleProps.test.ts new file mode 100644 index 0000000000..f687fd33c0 --- /dev/null +++ b/packages/web-react/src/components/Flex/__tests__/useFlexStyleProps.test.ts @@ -0,0 +1,80 @@ +import { renderHook } from '@testing-library/react'; +import { SpiritFlexProps } from '../../../types'; +import { useFlexStyleProps } from '../useFlexStyleProps'; + +describe('useFlexStyleProps', () => { + it('should return defaults', () => { + const props = {}; + const { result } = renderHook(() => useFlexStyleProps(props)); + + expect(result.current.classProps).toBe('Flex Flex--noWrap'); + }); + + it('should return spacing CSS properties', () => { + const props = { spacing: 'space-100' } as SpiritFlexProps; + const { result } = renderHook(() => useFlexStyleProps(props)); + + expect(result.current.styleProps).toEqual({ + '--flex-spacing': 'var(--spirit-space-100)', + }); + }); + + it('should return responsive spacing CSS properties', () => { + const props = { spacing: { mobile: 'space-100', tablet: 'space-200', desktop: 'space-400' } } as SpiritFlexProps; + const { result } = renderHook(() => useFlexStyleProps(props)); + + expect(result.current.styleProps).toEqual({ + '--flex-spacing': 'var(--spirit-space-100)', + '--flex-spacing-tablet': 'var(--spirit-space-200)', + '--flex-spacing-desktop': 'var(--spirit-space-400)', + }); + }); + + it('should return wrapping CSS classes', () => { + const props = { isWrapping: true } as SpiritFlexProps; + const { result } = renderHook(() => useFlexStyleProps(props)); + + expect(result.current.classProps).toBe('Flex Flex--wrap'); + }); + + it('should return row direction CSS classes', () => { + const props = { direction: 'row' } as SpiritFlexProps; + const { result } = renderHook(() => useFlexStyleProps(props)); + + expect(result.current.classProps).toBe('Flex Flex--noWrap Flex--row'); + }); + + it('should return column direction CSS classes', () => { + const props = { direction: 'column' } as SpiritFlexProps; + const { result } = renderHook(() => useFlexStyleProps(props)); + + expect(result.current.classProps).toBe('Flex Flex--noWrap Flex--column'); + }); + + it.each([ + // alignmentX, alignmentY, expectedClasses + [undefined, undefined, 'Flex Flex--noWrap'], + ['left', undefined, 'Flex Flex--noWrap Flex--alignmentXLeft'], + ['left', 'top', 'Flex Flex--noWrap Flex--alignmentXLeft Flex--alignmentYTop'], + [ + { mobile: 'left', tablet: 'center', desktop: 'right' }, + undefined, + 'Flex Flex--noWrap Flex--alignmentXLeft Flex--tablet--alignmentXCenter Flex--desktop--alignmentXRight', + ], + [ + { mobile: 'left', tablet: 'center', desktop: 'right' }, + { mobile: 'top', tablet: 'center', desktop: 'bottom' }, + 'Flex Flex--noWrap Flex--alignmentXLeft Flex--tablet--alignmentXCenter Flex--desktop--alignmentXRight Flex--alignmentYTop Flex--tablet--alignmentYCenter Flex--desktop--alignmentYBottom', + ], + [ + 'left', + { mobile: 'top', tablet: 'center', desktop: 'bottom' }, + 'Flex Flex--noWrap Flex--alignmentXLeft Flex--alignmentYTop Flex--tablet--alignmentYCenter Flex--desktop--alignmentYBottom', + ], + ])('should return alignment CSS classes', (alignmentX, alignmentY, expectedClasses) => { + const props: SpiritFlexProps = { alignmentX, alignmentY } as SpiritFlexProps; + const { result } = renderHook(() => useFlexStyleProps(props)); + + expect(result.current.classProps).toBe(expectedClasses); + }); +}); diff --git a/packages/web-react/src/components/Flex/demo/FlexColumnLayout.tsx b/packages/web-react/src/components/Flex/demo/FlexColumnLayout.tsx new file mode 100644 index 0000000000..8da0e3a13d --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexColumnLayout.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexColumnLayout = () => ( + + Item 1 + Item 2 + Item 3 + +); + +export default FlexColumnLayout; diff --git a/packages/web-react/src/components/Flex/demo/FlexCustomSpacing.tsx b/packages/web-react/src/components/Flex/demo/FlexCustomSpacing.tsx new file mode 100644 index 0000000000..cb10217d37 --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexCustomSpacing.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexCustomSpacing = () => ( + + Item 1 + Item 2 + Item 3 + +); + +export default FlexCustomSpacing; diff --git a/packages/web-react/src/components/Flex/demo/FlexHorizontalAlignment.tsx b/packages/web-react/src/components/Flex/demo/FlexHorizontalAlignment.tsx new file mode 100644 index 0000000000..83290d071d --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexHorizontalAlignment.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexHorizontalAlignment = () => ( + <> + + Item 1 + Item 2 + Item 3 + + + Item 1 + Item 2 + Item 3 + + + Item 1 + Item 2 + Item 3 + + + Item 1 + Item 2 + Item 3 + + + Item 1 + Item 2 + Item 3 + + +); + +export default FlexHorizontalAlignment; diff --git a/packages/web-react/src/components/Flex/demo/FlexResponsiveAlignment.tsx b/packages/web-react/src/components/Flex/demo/FlexResponsiveAlignment.tsx new file mode 100644 index 0000000000..2b887808c9 --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexResponsiveAlignment.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexResponsiveAlignment = () => ( + + Item 1 + + Item 2
    + is taller +
    + Item 3 +
    +); + +export default FlexResponsiveAlignment; diff --git a/packages/web-react/src/components/Flex/demo/FlexResponsiveLayout.tsx b/packages/web-react/src/components/Flex/demo/FlexResponsiveLayout.tsx new file mode 100644 index 0000000000..2967290d3c --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexResponsiveLayout.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexResponsiveLayout = () => ( + + Item 1 + Item 2 + Item 3 + +); + +export default FlexResponsiveLayout; diff --git a/packages/web-react/src/components/Flex/demo/FlexResponsiveSpacing.tsx b/packages/web-react/src/components/Flex/demo/FlexResponsiveSpacing.tsx new file mode 100644 index 0000000000..fcb03260a1 --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexResponsiveSpacing.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexResponsiveSpacing = () => ( + + Item 1 + Item 2 + Item 3 + +); + +export default FlexResponsiveSpacing; diff --git a/packages/web-react/src/components/Flex/demo/FlexResponsiveWrapping.tsx b/packages/web-react/src/components/Flex/demo/FlexResponsiveWrapping.tsx new file mode 100644 index 0000000000..08cc8cdb5b --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexResponsiveWrapping.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexResponsiveWrapping = () => ( + + {Array.from({ length: 15 }, (_, i) => ( + + Item {i + 1} + + ))} + +); + +export default FlexResponsiveWrapping; diff --git a/packages/web-react/src/components/Flex/demo/FlexRowLayout.tsx b/packages/web-react/src/components/Flex/demo/FlexRowLayout.tsx new file mode 100644 index 0000000000..d6ae0276cb --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexRowLayout.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexRowLayout = () => ( + + Item 1 + Item 2 + Item 3 + +); + +export default FlexRowLayout; diff --git a/packages/web-react/src/components/Flex/demo/FlexVerticalAlignment.tsx b/packages/web-react/src/components/Flex/demo/FlexVerticalAlignment.tsx new file mode 100644 index 0000000000..cfa9e79b08 --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexVerticalAlignment.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexVerticalAlignment = () => ( + <> + + Item 1 + + Item 2
    + is taller +
    + Item 3 +
    + + Item 1 + + Item 2
    + is taller +
    + Item 3 +
    + + Item 1 + + Item 2
    + is taller +
    + Item 3 +
    + + Item 1 + + Item 2
    + is taller +
    + Item 3 +
    + + Item 1 + + Item 2
    + is taller +
    + Item 3 has bigger font size +
    + +); + +export default FlexVerticalAlignment; diff --git a/packages/web-react/src/components/Flex/demo/FlexWrapping.tsx b/packages/web-react/src/components/Flex/demo/FlexWrapping.tsx new file mode 100644 index 0000000000..6a0da71cc4 --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexWrapping.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import Flex from '../Flex'; + +const FlexWrapping = () => ( + + {Array.from({ length: 15 }, (_, i) => ( + + Item {i + 1} + + ))} + +); + +export default FlexWrapping; diff --git a/packages/web-react/src/components/Flex/demo/index.tsx b/packages/web-react/src/components/Flex/demo/index.tsx new file mode 100644 index 0000000000..57506f9ce3 --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/index.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import DocsSection from '../../../../docs/DocsSections'; +import FlexColumnLayout from './FlexColumnLayout'; +import FlexCustomSpacing from './FlexCustomSpacing'; +import FlexHorizontalAlignment from './FlexHorizontalAlignment'; +import FlexResponsiveAlignment from './FlexResponsiveAlignment'; +import FlexResponsiveLayout from './FlexResponsiveLayout'; +import FlexResponsiveSpacing from './FlexResponsiveSpacing'; +import FlexRowLayout from './FlexRowLayout'; +import FlexVerticalAlignment from './FlexVerticalAlignment'; +import FlexWrapping from './FlexWrapping'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , +); diff --git a/packages/web-react/src/components/Flex/index.html b/packages/web-react/src/components/Flex/index.html new file mode 100644 index 0000000000..23972ef557 --- /dev/null +++ b/packages/web-react/src/components/Flex/index.html @@ -0,0 +1 @@ +{{> web-react/demo}} diff --git a/packages/web-react/src/components/Flex/index.ts b/packages/web-react/src/components/Flex/index.ts new file mode 100644 index 0000000000..db025ec7e4 --- /dev/null +++ b/packages/web-react/src/components/Flex/index.ts @@ -0,0 +1,5 @@ +'use client'; + +export * from './Flex'; +export * from './useFlexStyleProps'; +export { default as Flex } from './Flex'; diff --git a/packages/web-react/src/components/Flex/stories/Flex.stories.tsx b/packages/web-react/src/components/Flex/stories/Flex.stories.tsx new file mode 100644 index 0000000000..53b41a149b --- /dev/null +++ b/packages/web-react/src/components/Flex/stories/Flex.stories.tsx @@ -0,0 +1,96 @@ +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import { AlignmentXExtended, AlignmentYExtended } from '../../../constants'; +import ReadMe from '../README.md'; +import { Flex } from '..'; + +const meta: Meta = { + title: 'Components/Flex', + component: Flex, + parameters: { + docs: { + page: () => {ReadMe}, + }, + }, + argTypes: { + alignmentX: { + control: 'select', + options: [undefined, ...Object.values(AlignmentXExtended)], + table: { + defaultValue: { summary: undefined }, + }, + }, + alignmentY: { + control: 'select', + options: [undefined, ...Object.values(AlignmentYExtended)], + table: { + defaultValue: { summary: undefined }, + }, + }, + direction: { + control: 'select', + options: ['row', 'column'], + table: { + defaultValue: { summary: 'row' }, + }, + }, + elementType: { + control: 'text', + table: { + defaultValue: { summary: 'div' }, + }, + }, + isWrapping: { + control: 'boolean', + table: { + defaultValue: { summary: 'false' }, + }, + }, + spacing: { + control: 'object', + }, + }, + args: { + alignmentX: undefined, + alignmentY: undefined, + direction: 'row', + elementType: 'div', + isWrapping: false, + spacing: { + mobile: 'space-600', + tablet: 'space-800', + desktop: 'space-1000', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + name: 'Flex', + render: (args) => ( + + {[...Array(12)].map((_, index) => { + const key = `item-${index}`; + + return index === 2 ? ( + + Item {index + 1} has bigger font size + + ) : ( + + Item {index + 1} + {index === 1 && ( + <> +
    is taller + + )} +
    + ); + })} +
    + ), +}; diff --git a/packages/web-react/src/components/Flex/useFlexStyleProps.ts b/packages/web-react/src/components/Flex/useFlexStyleProps.ts new file mode 100644 index 0000000000..258abd472e --- /dev/null +++ b/packages/web-react/src/components/Flex/useFlexStyleProps.ts @@ -0,0 +1,41 @@ +import classNames from 'classnames'; +import { CSSProperties, ElementType } from 'react'; +import { useAlignmentClass, useClassNamePrefix, useDirectionClass, useSpacingStyle, useWrapClass } from '../../hooks'; +import { FlexAlignmentXType, FlexAlignmentYType, DirectionType, SpiritFlexProps } from '../../types'; + +interface FlexCSSProperties extends CSSProperties { + [key: string]: string | undefined | number; +} + +export interface FlexStyles { + /** className props */ + classProps: string; + /** Props for the flex element. */ + props: T; + /** Style props for the element */ + styleProps: FlexCSSProperties; +} + +export function useFlexStyleProps(props: SpiritFlexProps): FlexStyles> { + const { alignmentX, alignmentY, direction, spacing, isWrapping, ...restProps } = props; + + const flexClass = useClassNamePrefix('Flex'); + + const flexStyle: FlexCSSProperties = { + ...{ + ...useSpacingStyle(spacing, 'flex'), + }, + }; + + const classes = classNames(flexClass, useWrapClass(flexClass, isWrapping), { + [useAlignmentClass(flexClass, alignmentX, 'alignmentX')]: alignmentX, + [useAlignmentClass(flexClass, alignmentY, 'alignmentY')]: alignmentY, + [useDirectionClass(flexClass, direction)]: direction, + }); + + return { + classProps: classes, + props: restProps, + styleProps: flexStyle, + }; +} diff --git a/packages/web-react/src/constants/dictionaries.ts b/packages/web-react/src/constants/dictionaries.ts index 2cd13d3291..9d759b17ae 100644 --- a/packages/web-react/src/constants/dictionaries.ts +++ b/packages/web-react/src/constants/dictionaries.ts @@ -6,6 +6,7 @@ export const AlignmentX = { } as const; export const AlignmentXExtended = { + SPACE_BETWEEN: 'space-between', STRETCH: 'stretch', ...AlignmentX, } as const; @@ -17,6 +18,7 @@ export const AlignmentY = { } as const; export const AlignmentYExtended = { + BASELINE: 'baseline', STRETCH: 'stretch', ...AlignmentY, } as const; diff --git a/packages/web-react/src/hooks/index.ts b/packages/web-react/src/hooks/index.ts index 23a659d86d..887c013a45 100644 --- a/packages/web-react/src/hooks/index.ts +++ b/packages/web-react/src/hooks/index.ts @@ -1,8 +1,10 @@ export * from './styleProps'; +export * from './useAlignmentClass'; export * from './useCancelEvent'; export * from './useClassNamePrefix'; export * from './useClickOutside'; export * from './useDeprecationMessage'; +export * from './useDirectionClass'; export * from './useDragAndDrop'; export * from './useIcon'; export * from './useIsomorphicLayoutEffect'; @@ -10,3 +12,4 @@ export * from './useLastActiveFocus'; export * from './useScrollControl'; export * from './useSpacingStyle'; export * from './useToggle'; +export * from './useWrapClass'; diff --git a/packages/web-react/src/hooks/useAlignmentClass.ts b/packages/web-react/src/hooks/useAlignmentClass.ts new file mode 100644 index 0000000000..5d8ad3d862 --- /dev/null +++ b/packages/web-react/src/hooks/useAlignmentClass.ts @@ -0,0 +1,14 @@ +import { generateResponsiveStylePropsClasses, generateStylePropsClasses } from '../utils/generateStylePropsClasses'; + +export function useAlignmentClass(componentClass: string, property?: T, type?: string) { + if (property && typeof property === 'object') { + const responsiveProperty = { + mobile: 'stretch', + ...property, + }; + + return generateResponsiveStylePropsClasses(componentClass, responsiveProperty, type); + } + + return generateStylePropsClasses(componentClass, property, type); +} diff --git a/packages/web-react/src/hooks/useDirectionClass.ts b/packages/web-react/src/hooks/useDirectionClass.ts new file mode 100644 index 0000000000..596473b168 --- /dev/null +++ b/packages/web-react/src/hooks/useDirectionClass.ts @@ -0,0 +1,9 @@ +import { generateResponsiveStylePropsClasses, generateStylePropsClasses } from '../utils/generateStylePropsClasses'; + +export function useDirectionClass(componentClass: string, property: T) { + if (property && typeof property === 'object') { + return generateResponsiveStylePropsClasses(componentClass, property); + } + + return generateStylePropsClasses(componentClass, property); +} diff --git a/packages/web-react/src/hooks/useWrapClass.ts b/packages/web-react/src/hooks/useWrapClass.ts new file mode 100644 index 0000000000..15c3a97e19 --- /dev/null +++ b/packages/web-react/src/hooks/useWrapClass.ts @@ -0,0 +1,18 @@ +type WrappingType = boolean | Record | undefined; + +const wrapClass = '--wrap'; +const noWrapClass = '--noWrap'; + +export function useWrapClass(componentClass: string, property: WrappingType) { + if (typeof property === 'object' && property !== null) { + return Object.entries(property) + .map(([key, responsiveProperty]) => { + const infix = key === 'mobile' ? '' : `--${key}`; + + return `${componentClass}${infix}${responsiveProperty ? wrapClass : noWrapClass}`; + }) + .join(' '); + } + + return `${componentClass}${property ? wrapClass : noWrapClass}`; +} diff --git a/packages/web-react/src/types/flex.ts b/packages/web-react/src/types/flex.ts new file mode 100644 index 0000000000..c5bef5568a --- /dev/null +++ b/packages/web-react/src/types/flex.ts @@ -0,0 +1,41 @@ +import { ElementType, JSXElementConstructor } from 'react'; +import { + AlignmentXExtendedDictionaryType, + AlignmentYExtendedDictionaryType, + BreakpointToken, + ChildrenProps, + SpaceToken, + StyleProps, + TransferProps, +} from './shared'; + +export interface FlexElementTypeProps { + /** + * The HTML element or React element used to render the Flex, e.g. 'div'. + * + * @default 'div' + */ + elementType?: T | JSXElementConstructor; +} + +export type DirectionType = 'row' | 'column' | unknown; + +export type FlexAlignmentXType = AlignmentXExtendedDictionaryType | { [key: string]: AlignmentXExtendedDictionaryType }; +export type FlexAlignmentYType = AlignmentYExtendedDictionaryType | { [key: string]: AlignmentYExtendedDictionaryType }; + +export interface FlexCustomLayoutProps { + alignmentX?: FlexAlignmentXType; + alignmentY?: FlexAlignmentYType; + direction?: DirectionType | { [key: string]: DirectionType }; + /** Custom spacing between items */ + isWrapping?: boolean | { [key: string]: boolean }; + spacing?: SpaceToken | Partial>; +} + +export interface FlexProps extends FlexElementTypeProps, FlexCustomLayoutProps {} + +export interface SpiritFlexProps + extends FlexProps, + ChildrenProps, + StyleProps, + TransferProps {} diff --git a/packages/web-react/src/types/index.ts b/packages/web-react/src/types/index.ts index 0ec027fd76..781833deaf 100644 --- a/packages/web-react/src/types/index.ts +++ b/packages/web-react/src/types/index.ts @@ -9,6 +9,7 @@ export * from './divider'; export * from './dropdown'; export * from './fieldGroup'; export * from './fileUploader'; +export * from './flex'; export * from './grid'; export * from './header'; export * from './heading'; diff --git a/packages/web-react/src/utils/__tests__/toPascalCase.test.ts b/packages/web-react/src/utils/__tests__/toPascalCase.test.ts new file mode 100644 index 0000000000..a58754611b --- /dev/null +++ b/packages/web-react/src/utils/__tests__/toPascalCase.test.ts @@ -0,0 +1,15 @@ +import { toPascalCase } from '../toPascalCase'; + +describe('string to pascal case', () => { + it.each([ + ['foo-bar', 'FooBar'], + ['test-case', 'TestCase'], + ['some-words-here', 'SomeWordsHere'], + ['single', 'Single'], + ['', ''], + ['kebab-case-test', 'KebabCaseTest'], + ])('should convert kebab-case string "%s" to PascalCase string "%s"', (input, expected) => { + const result = toPascalCase(input); + expect(result).toBe(expected); + }); +}); diff --git a/packages/web-react/src/utils/generateStylePropsClasses.ts b/packages/web-react/src/utils/generateStylePropsClasses.ts new file mode 100644 index 0000000000..e08af8d73a --- /dev/null +++ b/packages/web-react/src/utils/generateStylePropsClasses.ts @@ -0,0 +1,26 @@ +import { toPascalCase } from './toPascalCase'; + +export function generateStylePropsClasses( + componentClass: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + property: any, + type?: string, +): string { + return `${componentClass}--${type || ''}${type ? toPascalCase(property) : property}`; +} + +export function generateResponsiveStylePropsClasses( + componentClass: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + property: Record, + type?: string, +): string { + return Object.keys(property) + .map((key) => { + const infix = key === 'mobile' ? '' : `--${key}`; + const responsiveProperty = property[key]; + + return `${componentClass}${infix}--${type || ''}${type ? toPascalCase(responsiveProperty) : responsiveProperty}`; + }) + .join(' '); +} diff --git a/packages/web-react/src/utils/index.ts b/packages/web-react/src/utils/index.ts index 09fa577ebb..3f6d4cea42 100644 --- a/packages/web-react/src/utils/index.ts +++ b/packages/web-react/src/utils/index.ts @@ -2,4 +2,6 @@ export * from './classname'; export * from './compose'; export * from './debounce'; export * from './delayedCallback'; +export * from './generateStylePropsClasses'; export * from './string'; +export * from './toPascalCase'; diff --git a/packages/web-react/src/utils/toPascalCase.ts b/packages/web-react/src/utils/toPascalCase.ts new file mode 100644 index 0000000000..d29acd557d --- /dev/null +++ b/packages/web-react/src/utils/toPascalCase.ts @@ -0,0 +1,10 @@ +export function toPascalCase(str: string): string { + if (typeof str !== 'string') { + return str; + } + + return str + .split('-') // Split the string by hyphen + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) // Capitalize the first letter of each word + .join(''); // Join the words back together without any hyphens +} diff --git a/tests/e2e/demo-homepages.spec.ts-snapshots/web-react-chromium-linux.png b/tests/e2e/demo-homepages.spec.ts-snapshots/web-react-chromium-linux.png index 4925c3185f..31a7fee2c9 100644 Binary files a/tests/e2e/demo-homepages.spec.ts-snapshots/web-react-chromium-linux.png and b/tests/e2e/demo-homepages.spec.ts-snapshots/web-react-chromium-linux.png differ