diff --git a/CODEOWNERS b/CODEOWNERS index ccc8c5144a..dc1b672284 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -28,6 +28,7 @@ /src/components/Pagination @jhoncool /src/components/Palette @Ruminat /src/components/PinInput @amje +/src/components/PlaceholderContainer @Marginy605 /src/components/Popover @kseniya57 /src/components/Popup @amje /src/components/Portal @amje diff --git a/src/components/PlaceholderContainer/PlaceholderContainer.scss b/src/components/PlaceholderContainer/PlaceholderContainer.scss new file mode 100644 index 0000000000..3e14317664 --- /dev/null +++ b/src/components/PlaceholderContainer/PlaceholderContainer.scss @@ -0,0 +1,224 @@ +@import '../variables'; +@import '../../../styles/mixins'; + +$imageSmallSize: 100px; +$imageMediumSize: 150px; +$imageLargeSize: 230px; + +$contentHeightSmallSize: 130px; +$contentHeightMediumSize: 180px; +$contentHeightLargeSize: 320px; + +$containerSmallRowSize: 320px; +$containerMediumRowSize: 430px; +$containerLargeRowSize: 600px; + +$containerSmallColSize: 320px; +$containerMediumColSize: 320px; +$containerLargeColSize: 430px; + +$normalOffset: var(--g-spacing-5); +$mediumOffset: var(--g-spacing-7); +$bigOffset: var(--g-spacing-10); + +$block: '.#{$ns}placeholder-container'; + +@mixin container-row-sizes($bodyWidth, $imageSize, $contentHeight, $contentOffset) { + #{$block}__body { + max-width: $bodyWidth; + } + + #{$block}__image { + width: $imageSize; + + & > * { + max-width: $imageSize; + display: block; + } + } + + #{$block}__content { + margin-inline-start: $contentOffset; + min-height: $contentHeight; + } +} + +@mixin container-column-sizes($bodyWidth, $imageSize) { + #{$block}__body { + max-width: $bodyWidth; + } + + #{$block}__image { + max-height: $imageSize; + + & > * { + max-height: $imageSize; + } + } +} + +#{$block} { + box-sizing: border-box; + display: flex; + align-items: center; + padding: $mediumOffset; + + &#{$block}_align { + &_left { + justify-content: flex-start; + } + + &_center { + justify-content: center; + } + } + + &__body { + box-sizing: border-box; + display: flex; + align-items: center; + } + + &_size_s { + padding: $normalOffset; + + #{$block}__description { + margin-block-start: var(--g-spacing-1); + } + } + + &_size_m { + padding: $mediumOffset; + + #{$block}__description { + margin-block-start: var(--g-spacing-2); + } + } + + &_size_promo, + &_size_l { + #{$block}__description { + margin-block-start: var(--g-spacing-3); + } + } + + &__image { + flex-shrink: 0; + + img { + display: block; + } + } + + &__content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + flex-grow: 1; + } + + &__title { + #{$block}_size_s & { + @include text-subheader-1(); + } + + #{$block}_size_m & { + @include text-subheader-2(); + } + + #{$block}_size_l & { + @include text-subheader-3(); + } + + #{$block}_size_promo & { + @include text-header-1(); + } + } + + &__actions { + margin-block-start: $normalOffset; + display: flex; + flex-direction: row; + } + + &_direction_row { + &#{$block}_size_s { + @include container-row-sizes( + $containerSmallRowSize, + $imageSmallSize, + $contentHeightSmallSize, + $normalOffset + ); + } + + &#{$block}_size_m { + @include container-row-sizes( + $containerMediumRowSize, + $imageMediumSize, + $contentHeightMediumSize, + $mediumOffset + ); + } + + &#{$block}_size_l { + @include container-row-sizes( + $containerLargeRowSize, + $imageLargeSize, + $contentHeightLargeSize, + $bigOffset + ); + } + + &#{$block}_size_promo { + @include container-row-sizes($containerLargeRowSize, $imageLargeSize, none, $bigOffset); + } + } + + &_direction_column { + #{$block}__body { + flex-direction: column; + } + + #{$block}__content { + margin-block-start: $normalOffset; + align-items: center; + text-align: center; + flex-shrink: 0; + } + + #{$block}__image { + flex-shrink: 0; + } + + &#{$block}_size_s { + @include container-column-sizes($containerSmallColSize, $imageSmallSize); + } + + &#{$block}_size_m { + @include container-column-sizes($containerMediumColSize, $imageMediumSize); + } + + &#{$block}_size_l { + @include container-column-sizes($containerLargeColSize, $imageLargeSize); + } + + &#{$block}_size_promo { + padding: $normalOffset; + + @include container-column-sizes($containerLargeColSize, $imageLargeSize); + + #{$block}__body { + width: 100%; + } + } + } + + &__action { + margin-inline-end: $normalOffset; + } + + &__action:last-child { + margin-inline-end: 0; + } +} diff --git a/src/components/PlaceholderContainer/PlaceholderContainer.tsx b/src/components/PlaceholderContainer/PlaceholderContainer.tsx new file mode 100644 index 0000000000..7ff190f24c --- /dev/null +++ b/src/components/PlaceholderContainer/PlaceholderContainer.tsx @@ -0,0 +1,107 @@ +import React from 'react'; + +import {Button} from '../Button'; +import {block} from '../utils/cn'; + +import {componentClassName} from './constants'; +import type {PlaceholderContainerActionProps, PlaceholderContainerProps} from './types'; + +import './PlaceholderContainer.scss'; + +const b = block(componentClassName); + +const PlaceholderContainerAction = (props: PlaceholderContainerActionProps) => { + return ( +
+ +
+ ); +}; + +export const PlaceholderContainer = ({ + direction = 'row', + align = 'center', + size = 'l', + className, + title, + description, + image, + content, + actions, +}: PlaceholderContainerProps) => { + const renderTitle = () => { + if (!title) { + return null; + } + + return
{title}
; + }; + const renderDescription = () => { + if (!description) { + return null; + } + + return
{description}
; + }; + + const renderImage = (): NonNullable => { + if (typeof image === 'object' && 'src' in image) { + return {image.alt; + } + + return image; + }; + + const renderAction = () => { + if (!actions || !(React.isValidElement(actions) || Array.isArray(actions))) { + return null; + } + + if (React.isValidElement(actions)) { + return {actions}; + } + + return ( +
+ {(actions as PlaceholderContainerActionProps[]).map((actionItem) => ( + + ))} +
+ ); + }; + + const renderContent = () => { + const contentNode = content || ( + + {renderTitle()} + {renderDescription()} + + ); + + return ( +
+ {contentNode} + {renderAction()} +
+ ); + }; + + return ( +
+
+
{renderImage()}
+ {renderContent()} +
+
+ ); +}; diff --git a/src/components/PlaceholderContainer/README.md b/src/components/PlaceholderContainer/README.md new file mode 100644 index 0000000000..e7b1b6b9b8 --- /dev/null +++ b/src/components/PlaceholderContainer/README.md @@ -0,0 +1,221 @@ + + +# PlaceholderContainer + + + +`PlaceholderContainer` is a component for displaying content with image, text content and action controls. + +## Direction + +The component has `row` and `column` directions of the content layout. To control it use the `direction` property. The default size is `row`. + +## Size + +To control the size of the `PlaceholderContainer` use the `size` property. The default size is `l`. Possible values: `s`, `m`, `l`, `promo`. The `promo` value sets full width of the content block without minimal content height and a larger title size. + +## Action controls + +The component can render button control or array of buttons. To display it use `actions` property. + + + +```tsx + + + + + 1:1 + + + + } + actions={[ + { + text: 'Main button', + view: 'normal', + onClick: () => console.log('Click by main button'), + }, + ]} +/> +``` + + + +It is also possible to render custom controls: + + + +```tsx + + + + + 1:1 + + + + } + actions={ + console.log()}, + {text: 'text 2', action: () => console.log()}, + ]} + onSwitcherClick={(e) => console.log(e)} + switcher={ + + } + /> + } +/> +``` + + + +## Image and content + +The property `image` allows to set up image `src` and `alt` settings or react node. + + + +```tsx + + + + + 1:1 + + + + } +/> +``` + +with src and alt settings + +```tsx + +``` + + + +The content of component contains from title and description blocks that can be set by the same properties names. To render custom content use `content` property. + +```tsx + + + + + 1:1 + + + + } + content={ +
+

There is any custom title here

+

+ You can add here any long text with custom content and use custom content + size for displaying very long texts. +

+
+ } +/> +``` + +## Align + +To control alignment of content inside parent container use `align` property. The default value is `center`. + +## Properties + +| Name | Description | Type | Default | +| :---------- | :---------------------------------------------------------------------------------- | :---------------------------------------------------------------------------: | :--------: | +| className | Optional HTML `class` attribute | `string` | | +| direction | Used to set the direction of content layout, possible values: `"row"` or `"column"` | `string` | `"row"` | +| size | Size of component, possible values: `"s"`, `"m"`, `"l"` or `"promo"` | `string` | `"l"` | +| align | Used to set content horizontal align, possible values: `"center"` or `"left"` | `string` | `"center"` | +| title | Content title text | `string` | | +| description | Content description text | `string` | | +| image | Used to set image by src or passing react node | `PlaceholderContainerImageNodeProps`
`\| PlaceholderContainerImageProps` | | +| content | Used to render node instead of content (title, description and actions) | `React.ReactNode` | | +| actions | Used to render array of button controls or custom node | `PlaceholderContainerActionProps[]`
`\| React.ReactNode ` | | diff --git a/src/components/PlaceholderContainer/__stories__/Docs.mdx b/src/components/PlaceholderContainer/__stories__/Docs.mdx new file mode 100644 index 0000000000..8a02df5a2f --- /dev/null +++ b/src/components/PlaceholderContainer/__stories__/Docs.mdx @@ -0,0 +1,7 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; +import * as Stories from './PlaceholderContainer.stories'; +import Readme from '../README.md?raw'; + + + +{Readme} diff --git a/src/components/PlaceholderContainer/__stories__/PlaceholderContainer.stories.tsx b/src/components/PlaceholderContainer/__stories__/PlaceholderContainer.stories.tsx new file mode 100644 index 0000000000..5215c57589 --- /dev/null +++ b/src/components/PlaceholderContainer/__stories__/PlaceholderContainer.stories.tsx @@ -0,0 +1,183 @@ +import React from 'react'; + +import {ChevronDown} from '@gravity-ui/icons'; +import type {Meta, StoryObj} from '@storybook/react'; + +import {Showcase} from '../../../demo/Showcase'; +import {Button} from '../../Button'; +import {DropdownMenu} from '../../DropdownMenu'; +import {Icon} from '../../Icon'; +import {block} from '../../utils/cn'; +import {PlaceholderContainer} from '../PlaceholderContainer'; +import type {PlaceholderContainerActionProps} from '../types'; + +import './PlaceholderContainerShowcase.scss'; + +export default { + title: 'Components/Data Display/PlaceholderContainer', + component: PlaceholderContainer, + parameters: { + a11y: { + element: '#storybook-root', + config: { + rules: [ + { + id: 'color-contrast', + enabled: false, + }, + ], + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +const b = block('placeholder-container-showcase'); + +const ImageComponentTest = () => { + return ( + + + + + 1:1 + + + + ); +}; + +const actionComponentTest = ( +
+ {}}, + {text: 'text 2', action: () => {}}, + ]} + onSwitcherClick={(e) => e?.stopPropagation()} + switcher={ + + } + /> +
+); + +const actionMainProps: PlaceholderContainerActionProps = { + text: 'Main button', + view: 'normal', + onClick: () => alert('Click by main button'), +}; + +const actionAdditionalBtnProps: PlaceholderContainerActionProps = { + text: 'Additional button', + view: 'flat-secondary', + onClick: () => alert('Click by additional button'), +}; + +export const Default: Story = { + args: { + title: 'Some title', + image: , + description: + 'Some long descriptionProps text that can contain of long long very long text etc. It can be repeated like this. Some long descriptionProps text that can contain of long long very long text etc.', + }, +}; + +export const Direction: Story = { + render: (args) => ( + + + + + + + + + ), + args: { + ...Default.args, + title: 'Direction', + }, +}; + +export const Align: Story = { + render: (args) => ( + + + + + + + + + ), + args: { + ...Default.args, + title: 'Align of component inside flex parent', + }, +}; + +export const Size: Story = { + render: (args) => ( + + + + + + + + + + + + + + + ), + args: { + ...Default.args, + description: 'Description text', + }, +}; + +export const Actions: Story = { + render: (args) => ( + + + + + + + + + ), + args: { + ...Default.args, + }, +}; diff --git a/src/components/PlaceholderContainer/__stories__/PlaceholderContainerShowcase.scss b/src/components/PlaceholderContainer/__stories__/PlaceholderContainerShowcase.scss new file mode 100644 index 0000000000..00eb2d1078 --- /dev/null +++ b/src/components/PlaceholderContainer/__stories__/PlaceholderContainerShowcase.scss @@ -0,0 +1,23 @@ +@import '../../variables'; + +$block: '.#{$ns}placeholder-container-showcase'; +$body: '.#{$ns}placeholder-container__body'; + +#{$block} { + &__custom-action { + margin-block-start: 20px; + } + + &__full-width .showcase__content { + display: block; + width: 100%; + } + + &__container { + border: 3px dashed var(--g-color-line-generic); + + #{$body} { + border: 3px dashed var(--g-color-line-generic); + } + } +} diff --git a/src/components/PlaceholderContainer/constants.ts b/src/components/PlaceholderContainer/constants.ts new file mode 100644 index 0000000000..65ffe4dbc9 --- /dev/null +++ b/src/components/PlaceholderContainer/constants.ts @@ -0,0 +1 @@ +export const componentClassName = 'placeholder-container'; diff --git a/src/components/PlaceholderContainer/index.ts b/src/components/PlaceholderContainer/index.ts new file mode 100644 index 0000000000..b7219e682a --- /dev/null +++ b/src/components/PlaceholderContainer/index.ts @@ -0,0 +1,2 @@ +export * from './PlaceholderContainer'; +export * from './types'; diff --git a/src/components/PlaceholderContainer/types.ts b/src/components/PlaceholderContainer/types.ts new file mode 100644 index 0000000000..db0b4c691c --- /dev/null +++ b/src/components/PlaceholderContainer/types.ts @@ -0,0 +1,31 @@ +import type React from 'react'; + +import type {ButtonProps} from '../Button'; + +type Size = 's' | 'm' | 'l' | 'promo'; + +type PlaceholderContainerImageNodeProps = NonNullable; + +export type PlaceholderContainerImageProps = { + src: string; + alt?: string; +}; + +export type PlaceholderContainerActionProps = Pick< + ButtonProps, + 'disabled' | 'loading' | 'view' | 'size' | 'href' | 'onClick' +> & { + text: string; +}; + +export interface PlaceholderContainerProps { + size?: Size; + direction?: 'row' | 'column'; + align?: 'left' | 'center'; + title?: string; + description?: React.ReactNode; + content?: React.ReactNode; + actions?: PlaceholderContainerActionProps[] | React.ReactNode; + className?: string; + image: PlaceholderContainerImageNodeProps | PlaceholderContainerImageProps; +}