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..fa14a6d968 --- /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: flexStyle } = useFlexStyleProps(restProps); + const { styleProps, props: otherProps } = useStyleProps(modifiedProps); + + const flexStyleProps = { + style: { + ...styleProps.style, + ...flexStyle, + }, + }; + + 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..92d060faf0 --- /dev/null +++ b/packages/web-react/src/components/Flex/README.md @@ -0,0 +1,162 @@ +# 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 an 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 wrapping layout, pass an object as the value for the `isWrapping` property, using breakpoint keys to specify different wrapping for each screen size. + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    +
    +``` + +## Alignment + +### Horizontal Alignment + +Flex can be horizontally aligned as stretched (default), 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]. + +### Vertical Alignment + +Similarly to the horizontal alignment, Flex can be vertically aligned as stretched (default), to the top, +center, bottom. There is also an option to align the items to the baseline. These values come from the extended +[alignmentY dictionary][dictionary-alignment]. + +Example: + +```jsx + +
    Item 1
    +
    Item 2
    +
    Item 3
    + +``` + +### Responsive Alignment + +To create a responsive alignment, pass an object as the value for the property, using breakpoint keys to specify different alignments 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][dictionary-alignment] \| `object`] | `stretch` | ✕ | Apply horizontal alignment of items, use an object to set responsive values, e.g. `{ mobile: 'left', tablet: 'center', desktop: 'right' }` | +| `alignmentY` | \[[AlignmentYExtended dictionary][dictionary-alignment] \| `object`] | `stretch` | ✕ | Apply vertical alignment of items, use an object to set responsive values, e.g. `{ mobile: 'top', tablet: 'center', desktop: 'bottom' }` | +| `direction` | \[[Direction dictionary][direction-dictionary] \| `object` ] | `row` | ✕ | Direction of the items, use an 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` \| `object` ] | `false` | ✕ | Whether items will wrap, use an 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 +[dictionary-direction]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#direction +[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..6d59c38397 --- /dev/null +++ b/packages/web-react/src/components/Flex/__tests__/Flex.test.tsx @@ -0,0 +1,123 @@ +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 class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--column'); + }); + + it('should have responsive direction class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--row Flex--tablet--column Flex--desktop--column'); + }); + + it('should have alignmentX class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--alignmentXLeft'); + }); + + it('should have responsive alignmentX class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass( + 'Flex--alignmentXLeft Flex--tablet--alignmentXCenter Flex--desktop--alignmentXRight', + ); + }); + + it('should have alignmentY class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--alignmentYTop'); + }); + + it('should have responsive alignmentY class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass( + 'Flex--alignmentYTop Flex--tablet--alignmentYCenter Flex--desktop--alignmentYBottom', + ); + }); + + it('should have both alignmentX and alignmentY class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--alignmentYTop Flex--alignmentYTop'); + }); + + it('should have responsive both alignmentX and alignmentY class name', () => { + render( + , + ); + + expect(screen.getByTestId(testId)).toHaveClass( + 'Flex--alignmentXLeft Flex--tablet--alignmentXCenter Flex--desktop--alignmentXRight Flex--alignmentYTop Flex--tablet--alignmentYCenter Flex--desktop--alignmentYBottom', + ); + }); + + it('should have wrapping class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--wrap'); + }); + + it('should have responsive wrapping class name', () => { + render(); + + expect(screen.getByTestId(testId)).toHaveClass('Flex--wrap Flex--tablet--noWrap Flex--desktop--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..4b25689766 --- /dev/null +++ b/packages/web-react/src/components/Flex/__tests__/useFlexStyleProps.test.ts @@ -0,0 +1,86 @@ +import { renderHook } from '@testing-library/react'; +import { SpiritFlexProps } from '../../../types'; +import { useFlexStyleProps } from '../useFlexStyleProps'; + +describe('useFlexStyleProps', () => { + it.each([ + // props, expectedClasses + [{}, 'Flex Flex--noWrap'], + [{ isWrapping: true }, 'Flex Flex--wrap'], + [ + { + isWrapping: { mobile: true, tablet: false, desktop: true }, + }, + 'Flex Flex--wrap Flex--tablet--noWrap Flex--desktop--wrap', + ], + [{ direction: 'row' }, 'Flex Flex--noWrap Flex--row'], + [{ direction: 'column' }, 'Flex Flex--noWrap Flex--column'], + [ + { + direction: { mobile: 'row', tablet: 'column', desktop: 'row' }, + }, + 'Flex Flex--noWrap Flex--row Flex--tablet--column Flex--desktop--row', + ], + [{ alignmentX: 'left' }, 'Flex Flex--noWrap Flex--alignmentXLeft'], + [ + { + alignmentX: 'left', + alignmentY: 'top', + }, + 'Flex Flex--noWrap Flex--alignmentXLeft Flex--alignmentYTop', + ], + [ + { + alignmentX: { mobile: 'left', tablet: 'center', desktop: 'right' }, + }, + 'Flex Flex--noWrap Flex--alignmentXLeft Flex--tablet--alignmentXCenter Flex--desktop--alignmentXRight', + ], + [ + { + alignmentX: { mobile: 'left', tablet: 'center', desktop: 'right' }, + alignmentY: { 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', + ], + [ + { + alignmentX: 'left', + alignmentY: { mobile: 'top', tablet: 'center', desktop: 'bottom' }, + }, + 'Flex Flex--noWrap Flex--alignmentXLeft Flex--alignmentYTop Flex--tablet--alignmentYCenter Flex--desktop--alignmentYBottom', + ], + ])('should return the correct classes for props %o', (props, expectedClasses) => { + const { result } = renderHook(() => useFlexStyleProps(props as SpiritFlexProps)); + + expect(result.current.classProps).toBe(expectedClasses); + }); + + it.each([ + // props, expectedStyleProps + [{}, {}], + [{ spacing: 'space-100' }, { '--flex-spacing': 'var(--spirit-space-100)' }], + [ + { + spacing: { mobile: 'space-100', tablet: 'space-200' }, + }, + { + '--flex-spacing': 'var(--spirit-space-100)', + '--flex-spacing-tablet': 'var(--spirit-space-200)', + }, + ], + [ + { + spacing: { mobile: 'space-100', tablet: 'space-200', desktop: 'space-400' }, + }, + { + '--flex-spacing': 'var(--spirit-space-100)', + '--flex-spacing-tablet': 'var(--spirit-space-200)', + '--flex-spacing-desktop': 'var(--spirit-space-400)', + }, + ], + ])('should return the correct style properties for props %o', (props, expectedStyleProps) => { + const { result } = renderHook(() => useFlexStyleProps(props as SpiritFlexProps)); + + expect(result.current.styleProps).toEqual(expectedStyleProps); + }); +}); 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..7f0c782304 --- /dev/null +++ b/packages/web-react/src/components/Flex/demo/FlexResponsiveAlignment.tsx @@ -0,0 +1,19 @@ +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..289cedd075 --- /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(15)].map((_, index) => ( + + Item {index + 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..8c9531dbe0 --- /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(15)].map((_, index) => ( + + Item {index + 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..ea3f77099d --- /dev/null +++ b/packages/web-react/src/components/Flex/useFlexStyleProps.ts @@ -0,0 +1,36 @@ +import classNames from 'classnames'; +import { ElementType } from 'react'; +import { useAlignmentClass, useClassNamePrefix, useDirectionClass, useSpacingStyle, useWrapClass } from '../../hooks'; +import { + SpiritFlexProps, + SpacingCSSProperties, + FlexDirectionType, + FlexAlignmentXType, + FlexAlignmentYType, +} from '../../types'; + +export interface FlexStyles { + /** className props */ + classProps: string; + /** Props for the flex element. */ + props: T; + /** Style props for the element */ + styleProps: SpacingCSSProperties; +} + +export function useFlexStyleProps(props: SpiritFlexProps): FlexStyles> { + const { alignmentX, alignmentY, direction, spacing, isWrapping, ...restProps } = props; + const flexClass = useClassNamePrefix('Flex'); + const flexStyle = useSpacingStyle(spacing, 'flex'); + const classes = classNames(flexClass, useWrapClass(flexClass, isWrapping), { + [useAlignmentClass(flexClass, alignmentX as FlexAlignmentXType, 'alignmentX')]: alignmentX, + [useAlignmentClass(flexClass, alignmentY as FlexAlignmentYType, 'alignmentY')]: alignmentY, + [useDirectionClass(flexClass, direction as FlexDirectionType)]: 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..a3d3510d88 --- /dev/null +++ b/packages/web-react/src/hooks/useAlignmentClass.ts @@ -0,0 +1,13 @@ +import { FlexAlignmentXType, FlexAlignmentYType } from '../types'; +import { generateStylePropsClassNames } from '../utils'; + +type AlignmentPropertyType = FlexAlignmentXType | FlexAlignmentYType; + +const DEFAULT_MOBILE_ALIGNMENT = 'stretch'; + +export function useAlignmentClass(componentClass: string, property: AlignmentPropertyType, type?: string) { + const responsiveProperty = + property && typeof property === 'object' ? { mobile: DEFAULT_MOBILE_ALIGNMENT, ...property } : property; + + return generateStylePropsClassNames(componentClass, responsiveProperty as AlignmentPropertyType, 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..42c71a9d17 --- /dev/null +++ b/packages/web-react/src/hooks/useDirectionClass.ts @@ -0,0 +1,6 @@ +import { FlexDirectionType } from '../types'; +import { generateStylePropsClassNames } from '../utils'; + +export function useDirectionClass(componentClass: string, property: FlexDirectionType) { + return generateStylePropsClassNames(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..3868218523 --- /dev/null +++ b/packages/web-react/src/hooks/useWrapClass.ts @@ -0,0 +1,18 @@ +type WrappingType = boolean | Record | undefined; + +const WRAP_CLASS = '--wrap'; +const NO_WRAP_CLASS = '--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 ? WRAP_CLASS : NO_WRAP_CLASS}`; + }) + .join(' '); + } + + return `${componentClass}${property ? WRAP_CLASS : NO_WRAP_CLASS}`; +} diff --git a/packages/web-react/src/types/flex.ts b/packages/web-react/src/types/flex.ts new file mode 100644 index 0000000000..ecb60616f2 --- /dev/null +++ b/packages/web-react/src/types/flex.ts @@ -0,0 +1,46 @@ +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 FlexDirection = 'row' | 'column'; +export type FlexDirectionType = FlexDirection | { [key: string]: FlexDirection }; +export type FlexAlignmentXType = + | NonNullable + | { [key: string]: NonNullable }; +export type FlexAlignmentYType = + | NonNullable + | { [key: string]: NonNullable }; +export type FlexWrapType = boolean | { [key: string]: boolean }; + +export interface FlexCustomLayoutProps { + alignmentX?: FlexAlignmentXType; + alignmentY?: FlexAlignmentYType; + direction?: FlexDirectionType; + isWrapping?: FlexWrapType; + /** Custom spacing between items */ + 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/index.ts b/packages/web-react/src/utils/index.ts index 09fa577ebb..80d5082b54 100644 --- a/packages/web-react/src/utils/index.ts +++ b/packages/web-react/src/utils/index.ts @@ -3,3 +3,5 @@ export * from './compose'; export * from './debounce'; export * from './delayedCallback'; export * from './string'; +export * from './stylePropsClassesGenerator'; +export * from './toPascalCase'; diff --git a/packages/web-react/src/utils/stylePropsClassesGenerator.ts b/packages/web-react/src/utils/stylePropsClassesGenerator.ts new file mode 100644 index 0000000000..3a1b536f0a --- /dev/null +++ b/packages/web-react/src/utils/stylePropsClassesGenerator.ts @@ -0,0 +1,38 @@ +import { toPascalCase } from './toPascalCase'; + +type Breakpoints = 'mobile' | 'tablet' | 'desktop'; +type ResponsiveProp = { + [key in Breakpoints]: string; +}; +type StaticProp = string; + +export function generateStaticStylePropsClasses(componentClass: string, property: StaticProp, type?: string): string { + return `${componentClass}--${type || ''}${type ? toPascalCase(property) : property}`; +} + +export function generateResponsiveStylePropsClasses( + componentClass: string, + property: ResponsiveProp, + type?: string, +): string { + return Object.keys(property) + .map((key) => { + const infix = key === 'mobile' ? '' : `--${key}`; + const responsiveProperty = property[key as Breakpoints]; + + return `${componentClass}${infix}--${type || ''}${type ? toPascalCase(responsiveProperty) : responsiveProperty}`; + }) + .join(' '); +} + +function isResponsiveProperty

    (property: P) { + return property && typeof property === 'object'; +} + +export function generateStylePropsClassNames

    (componentClass: string, property: P, type?: string): string { + const generate = isResponsiveProperty

    (property) + ? generateResponsiveStylePropsClasses + : generateStaticStylePropsClasses; + + return generate(componentClass, property as unknown as ResponsiveProp & StaticProp, type); +} 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 diff --git a/yarn.lock b/yarn.lock index 7f51687915..8bf422c6d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5450,6 +5450,18 @@ __metadata: languageName: node linkType: hard +"@nrwl/tao@npm:19.6.4": + version: 19.6.4 + resolution: "@nrwl/tao@npm:19.6.4" + dependencies: + nx: "npm:19.6.4" + tslib: "npm:^2.3.0" + bin: + tao: index.js + checksum: 10/26b3d619c978d5d913a3947c28d7c096689237524d493ba9bd5b3d4dd748c2f7ab3d2639096a540109e96ee3114d97d2110c295d3bbd66b484bdcd255b6bd69d + languageName: node + linkType: hard + "@nx/devkit@npm:19.5.1, @nx/devkit@npm:>=17.1.2 < 20": version: 19.5.1 resolution: "@nx/devkit@npm:19.5.1" @@ -5483,6 +5495,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-darwin-arm64@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-darwin-arm64@npm:19.6.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@nx/nx-darwin-x64@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-darwin-x64@npm:19.5.1" @@ -5497,6 +5516,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-darwin-x64@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-darwin-x64@npm:19.6.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@nx/nx-freebsd-x64@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-freebsd-x64@npm:19.5.1" @@ -5511,6 +5537,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-freebsd-x64@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-freebsd-x64@npm:19.6.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@nx/nx-linux-arm-gnueabihf@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-linux-arm-gnueabihf@npm:19.5.1" @@ -5525,6 +5558,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-linux-arm-gnueabihf@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-linux-arm-gnueabihf@npm:19.6.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@nx/nx-linux-arm64-gnu@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-linux-arm64-gnu@npm:19.5.1" @@ -5539,6 +5579,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-linux-arm64-gnu@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-linux-arm64-gnu@npm:19.6.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@nx/nx-linux-arm64-musl@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-linux-arm64-musl@npm:19.5.1" @@ -5553,6 +5600,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-linux-arm64-musl@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-linux-arm64-musl@npm:19.6.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@nx/nx-linux-x64-gnu@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-linux-x64-gnu@npm:19.5.1" @@ -5567,6 +5621,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-linux-x64-gnu@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-linux-x64-gnu@npm:19.6.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@nx/nx-linux-x64-musl@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-linux-x64-musl@npm:19.5.1" @@ -5581,6 +5642,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-linux-x64-musl@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-linux-x64-musl@npm:19.6.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@nx/nx-win32-arm64-msvc@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-win32-arm64-msvc@npm:19.5.1" @@ -5595,6 +5663,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-win32-arm64-msvc@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-win32-arm64-msvc@npm:19.6.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@nx/nx-win32-x64-msvc@npm:19.5.1": version: 19.5.1 resolution: "@nx/nx-win32-x64-msvc@npm:19.5.1" @@ -5609,6 +5684,13 @@ __metadata: languageName: node linkType: hard +"@nx/nx-win32-x64-msvc@npm:19.6.4": + version: 19.6.4 + resolution: "@nx/nx-win32-x64-msvc@npm:19.6.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@octokit/auth-token@npm:^3.0.0": version: 3.0.4 resolution: "@octokit/auth-token@npm:3.0.4" @@ -22848,14 +22930,7 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.12, nwsapi@npm:^2.2.2": - version: 2.2.12 - resolution: "nwsapi@npm:2.2.12" - checksum: 10/172119e9ef492467ebfb337f9b5fd12a94d2b519377cde3f6ec2f74a86f6d5c00ef3873539bed7142f908ffca4e35383179be2319d04a563071d146bfa3f1673 - languageName: node - linkType: hard - -"nx@npm:19.5.1, nx@npm:>=17.1.2 < 20": +"nwsapi@npm:^2.2.12, nwsapi@npm:^2.2.2, nx@npm:19.5.1": version: 19.5.1 resolution: "nx@npm:19.5.1" dependencies: @@ -23025,6 +23100,91 @@ __metadata: languageName: node linkType: hard +"nx@npm:19.6.4, nx@npm:>=17.1.2 < 20": + version: 19.6.4 + resolution: "nx@npm:19.6.4" + dependencies: + "@napi-rs/wasm-runtime": "npm:0.2.4" + "@nrwl/tao": "npm:19.6.4" + "@nx/nx-darwin-arm64": "npm:19.6.4" + "@nx/nx-darwin-x64": "npm:19.6.4" + "@nx/nx-freebsd-x64": "npm:19.6.4" + "@nx/nx-linux-arm-gnueabihf": "npm:19.6.4" + "@nx/nx-linux-arm64-gnu": "npm:19.6.4" + "@nx/nx-linux-arm64-musl": "npm:19.6.4" + "@nx/nx-linux-x64-gnu": "npm:19.6.4" + "@nx/nx-linux-x64-musl": "npm:19.6.4" + "@nx/nx-win32-arm64-msvc": "npm:19.6.4" + "@nx/nx-win32-x64-msvc": "npm:19.6.4" + "@yarnpkg/lockfile": "npm:^1.1.0" + "@yarnpkg/parsers": "npm:3.0.0-rc.46" + "@zkochan/js-yaml": "npm:0.0.7" + axios: "npm:^1.7.4" + chalk: "npm:^4.1.0" + cli-cursor: "npm:3.1.0" + cli-spinners: "npm:2.6.1" + cliui: "npm:^8.0.1" + dotenv: "npm:~16.4.5" + dotenv-expand: "npm:~11.0.6" + enquirer: "npm:~2.3.6" + figures: "npm:3.2.0" + flat: "npm:^5.0.2" + front-matter: "npm:^4.0.2" + fs-extra: "npm:^11.1.0" + ignore: "npm:^5.0.4" + jest-diff: "npm:^29.4.1" + jsonc-parser: "npm:3.2.0" + lines-and-columns: "npm:~2.0.3" + minimatch: "npm:9.0.3" + node-machine-id: "npm:1.1.12" + npm-run-path: "npm:^4.0.1" + open: "npm:^8.4.0" + ora: "npm:5.3.0" + semver: "npm:^7.5.3" + string-width: "npm:^4.2.3" + strong-log-transformer: "npm:^2.1.0" + tar-stream: "npm:~2.2.0" + tmp: "npm:~0.2.1" + tsconfig-paths: "npm:^4.1.2" + tslib: "npm:^2.3.0" + yargs: "npm:^17.6.2" + yargs-parser: "npm:21.1.1" + peerDependencies: + "@swc-node/register": ^1.8.0 + "@swc/core": ^1.3.85 + dependenciesMeta: + "@nx/nx-darwin-arm64": + optional: true + "@nx/nx-darwin-x64": + optional: true + "@nx/nx-freebsd-x64": + optional: true + "@nx/nx-linux-arm-gnueabihf": + optional: true + "@nx/nx-linux-arm64-gnu": + optional: true + "@nx/nx-linux-arm64-musl": + optional: true + "@nx/nx-linux-x64-gnu": + optional: true + "@nx/nx-linux-x64-musl": + optional: true + "@nx/nx-win32-arm64-msvc": + optional: true + "@nx/nx-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@swc-node/register": + optional: true + "@swc/core": + optional: true + bin: + nx: bin/nx.js + nx-cloud: bin/nx-cloud.js + checksum: 10/9826bb23b87803f1f28f3c4ab878992ca1d3aed7c5a3b9e0d3d5ae0b10884db6b1f929156e9053796e8e781ba2c4316899aacb782e92bdddeae43d6e6288141b + languageName: node + linkType: hard + "nypm@npm:^0.3.8": version: 0.3.9 resolution: "nypm@npm:0.3.9"