diff --git a/memory-bank/usage/content.md b/memory-bank/usage/content.md index f190d056f..af7094a25 100644 --- a/memory-bank/usage/content.md +++ b/memory-bank/usage/content.md @@ -444,10 +444,11 @@ The Content component is composed of several key parts: 1. **Title**: Displays the content title using the Title component 2. **Text**: Displays the main text content using the YFMWrapper component -3. **Content List**: Displays a list of content items using the ContentList component -4. **Additional Info**: Displays additional information using the YFMWrapper component -5. **Links**: Displays links using the Links component -6. **Buttons**: Displays buttons using the Buttons component +3. **Labels**: Displays labels using the ContentLabels component +4. **Content List**: Displays a list of content items using the ContentList component +5. **Additional Info**: Displays additional information using the YFMWrapper component +6. **Links**: Displays links using the Links component +7. **Buttons**: Displays buttons using the Buttons component ### Internal Structure @@ -469,6 +470,11 @@ The Content component is composed of several key parts: /> )} + {labels?.length ? ( +
+ +
+ ) : null} {list?.length ? (
diff --git a/src/blocks/Form/Form.scss b/src/blocks/Form/Form.scss index f6417b347..8417b333d 100644 --- a/src/blocks/Form/Form.scss +++ b/src/blocks/Form/Form.scss @@ -106,6 +106,7 @@ $largeBorderRadius: 32px; } } + $labelsBlock: '.#{$ns}content-labels'; @media (min-width: map-get($gridBreakpoints, 'lg')) { &_form-type_yandex { #{$root}__row { @@ -133,7 +134,7 @@ $largeBorderRadius: 32px; } } - @media (max-width: map-get($gridBreakpoints, 'lg')) and (min-width: map-get($gridBreakpoints, 'md')) { + @media (max-width: calc(map-get($gridBreakpoints, 'lg') - 1px)) and (min-width: map-get($gridBreakpoints, 'md')) { &__row { flex-direction: column; } @@ -161,6 +162,10 @@ $largeBorderRadius: 32px; #{$root}__content-wrapper { text-align: center; padding-bottom: $indentM; + + #{$labelsBlock} { + justify-content: center; + } } } } @@ -172,7 +177,7 @@ $largeBorderRadius: 32px; } } - @media (max-width: map-get($gridBreakpoints, 'md')) { + @media (max-width: calc(map-get($gridBreakpoints, 'md') - 1px)) { &__full-form { padding: $indentM; } diff --git a/src/blocks/Form/Form.tsx b/src/blocks/Form/Form.tsx index 895c06aa9..0b249dfec 100644 --- a/src/blocks/Form/Form.tsx +++ b/src/blocks/Form/Form.tsx @@ -5,6 +5,7 @@ import InnerForm from '../../components/InnerForm/InnerForm'; import {MobileContext} from '../../context/mobileContext'; import {useTheme} from '../../context/theme'; import {Col, Grid, GridAlignItems, GridColumnSize, Row} from '../../grid'; +import {useDeviceValue} from '../../hooks/useDeviceValue'; import type {FormBlockProps} from '../../models'; import { FormBlockDataTypes, @@ -15,6 +16,8 @@ import { import {Content} from '../../sub-blocks'; import {block, getThemedValue} from '../../utils'; +import {hasBackgroundCSS} from './utils'; + import './Form.scss'; const b = block('form-block'); @@ -22,24 +25,33 @@ const b = block('form-block'); const colSizes = {[GridColumnSize.Lg]: 6, [GridColumnSize.All]: 12}; const Form = (props: FormBlockProps) => { - const {formData, title, textContent, direction = FormBlockDirection.Center, background} = props; + const { + formData, + title, + textContent, + direction = FormBlockDirection.Center, + background, + customFormNode, + } = props; const [contentLoaded, setContentLoaded] = React.useState(false); const isMobile = React.useContext(MobileContext); const theme = useTheme(); const themedBackground = getThemedValue(background, theme) || undefined; + const themedBackgroundStyle = useDeviceValue(themedBackground?.style) || undefined; const withBackground = Boolean( themedBackground && (themedBackground.src || themedBackground.desktop || - themedBackground.style?.backgroundColor), + hasBackgroundCSS(themedBackgroundStyle ?? {})), ); + const onContentLoad = React.useCallback(() => { setContentLoaded(true); }, []); - if (!formData) { + if (!formData && !customFormNode) { return null; } @@ -61,6 +73,7 @@ const Form = (props: FormBlockProps) => { {themedBackground && ( @@ -96,21 +109,25 @@ const Form = (props: FormBlockProps) => { hidden: !contentLoaded, })} > - {title && ( - + {customFormNode || ( + <React.Fragment> + {title && ( + <Title + title={{ + text: title, + textSize: 's', + }} + className={b('title', {mobile: isMobile})} + colSizes={{all: 12}} + /> + )} + <InnerForm + className={b('form')} + formData={formData} + onContentLoad={onContentLoad} + /> + </React.Fragment> )} - <InnerForm - className={b('form')} - formData={formData} - onContentLoad={onContentLoad} - /> </div> </div> </Col> diff --git a/src/blocks/Form/__stories__/Form.mdx b/src/blocks/Form/__stories__/Form.mdx index 54da5245b..ad2bf00e5 100644 --- a/src/blocks/Form/__stories__/Form.mdx +++ b/src/blocks/Form/__stories__/Form.mdx @@ -17,6 +17,21 @@ import * as FormBlockStories from './Form.stories.tsx'; `direction?: 'form-content' | 'content-form' | 'center'` - Direction. -`background?: BackgroundImage` — See [background](?path=/docs/components-pics-video-datalens-backgroundimage--docs) properties. +`background?: FormBlockBackgroundProps` — Same as `BackgroundImage` (see [background](?path=/docs/components-pics-video-datalens-backgroundimage--docs) properties), but the `style` prop supports `Device` breakpoints, for example: + +```ts +{ + style: { + desktop: { + background: 'red', + }, + mobile: { + background: 'blue', + }, + } +} +``` + +`customFormNode?: React.ReactNode` - A custom React node that will be rendered instead of the form. </StoryTemplate> diff --git a/src/blocks/Form/__stories__/Form.stories.tsx b/src/blocks/Form/__stories__/Form.stories.tsx index ac7fb3bc3..b531b9b67 100644 --- a/src/blocks/Form/__stories__/Form.stories.tsx +++ b/src/blocks/Form/__stories__/Form.stories.tsx @@ -5,6 +5,8 @@ import {blockTransform} from '../../../../.storybook/utils'; import {FormBlockDirection, FormBlockModel, FormBlockProps} from '../../../models'; import FormBlock from '../Form'; +import ExampleStub from './components/ExmapleStub'; + import data from './data.json'; export default { @@ -42,6 +44,7 @@ export const WithBackgroundColor = VariantsTemplate.bind([]); export const WithBackgroundImage = VariantsTemplate.bind([]); export const DarkTheme = VariantsTemplate.bind([]); export const FormData = VariantsTemplate.bind([]); +export const WithCustomFormNode = DefaultTemplate.bind([]); Default.args = data.default as FormBlockModel; @@ -88,3 +91,5 @@ FormData.parameters = { include: Object.keys(FormData.args), }, }; + +WithCustomFormNode.args = {...data.default, customFormNode: <ExampleStub />} as FormBlockModel; diff --git a/src/blocks/Form/__stories__/components/ExmapleStub.tsx b/src/blocks/Form/__stories__/components/ExmapleStub.tsx new file mode 100644 index 000000000..f16664df2 --- /dev/null +++ b/src/blocks/Form/__stories__/components/ExmapleStub.tsx @@ -0,0 +1,23 @@ +import {Button} from '@gravity-ui/uikit'; + +const ExampleStub = () => { + return ( + <div + style={{ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: 24, + }} + > + <h1>This is an example form component</h1> + <p>It can be anything</p> + <Button size="xl" view="action"> + Got it! + </Button> + </div> + ); +}; + +export default ExampleStub; diff --git a/src/blocks/Form/__tests__/Form.visual.test.tsx b/src/blocks/Form/__tests__/Form.visual.test.tsx index 51ae5b0ed..549eb5b21 100644 --- a/src/blocks/Form/__tests__/Form.visual.test.tsx +++ b/src/blocks/Form/__tests__/Form.visual.test.tsx @@ -7,6 +7,7 @@ import { FormData, WithBackgroundColor, WithBackgroundImage, + WithCustomFormNode, } from './helpers'; const DEFAULT_FORM_DELAY = 20 * 1000; @@ -48,4 +49,10 @@ test.describe('Form', () => { await delay(DEFAULT_FORM_DELAY); await expectScreenshot({skipTheme: 'dark'}); }); + + test.skip('render stories <WithCustomFormNode>', async ({mount, expectScreenshot, delay}) => { + await mount(<WithCustomFormNode />); + await delay(DEFAULT_FORM_DELAY); + await expectScreenshot({skipTheme: 'dark'}); + }); }); diff --git a/src/blocks/Form/__tests__/helpers.tsx b/src/blocks/Form/__tests__/helpers.tsx index 829c99104..252d219ae 100644 --- a/src/blocks/Form/__tests__/helpers.tsx +++ b/src/blocks/Form/__tests__/helpers.tsx @@ -9,4 +9,5 @@ export const { WithBackgroundImage, DarkTheme, FormData, + WithCustomFormNode, } = composeStories(FormStories); diff --git a/src/blocks/Form/schema.ts b/src/blocks/Form/schema.ts index 9e47fa553..c971e17d2 100644 --- a/src/blocks/Form/schema.ts +++ b/src/blocks/Form/schema.ts @@ -42,10 +42,7 @@ export const FormBlock = { direction: { enum: ['content-form', 'form-content', 'center'], }, - image: ImageProps, - backgroundColor: { - type: 'string', - }, + background: ImageProps, }, }, }; diff --git a/src/blocks/Form/utils.ts b/src/blocks/Form/utils.ts new file mode 100644 index 000000000..401cda78e --- /dev/null +++ b/src/blocks/Form/utils.ts @@ -0,0 +1,19 @@ +import * as React from 'react'; + +export const BACKGROUND_STYLE_PROPS = [ + 'background', + 'backgroundAttachment', + 'backgroundBlendMode', + 'backgroundClip', + 'backgroundColor', + 'backgroundImage', + 'backgroundOrigin', + 'backgroundPositionX', + 'backgroundPositionY', + 'backgroundRepeat', + 'backgroundSize', + 'backgroundPosition', +] as const; + +export const hasBackgroundCSS = (style: React.CSSProperties) => + BACKGROUND_STYLE_PROPS.some((backgroundStyleProp) => backgroundStyleProp in style); diff --git a/src/components/ContentList/ContentListItemIcon.tsx b/src/components/ContentIcon/ContentIcon.tsx similarity index 83% rename from src/components/ContentList/ContentListItemIcon.tsx rename to src/components/ContentIcon/ContentIcon.tsx index a48953678..8700991f6 100644 --- a/src/components/ContentList/ContentListItemIcon.tsx +++ b/src/components/ContentIcon/ContentIcon.tsx @@ -3,7 +3,7 @@ import {ClassNameProps, GravityIconProps, ImageProps, QAProps, SVGIcon} from '.. import {ThemeSupporting, getThemedValue} from '../../utils'; import Icon from '../Icon/Icon'; -interface ListItemProps extends QAProps, ClassNameProps { +interface ContentIconProps extends QAProps, ClassNameProps { icon?: ThemeSupporting<ImageProps | SVGIcon>; gravityIcon?: ThemeSupporting<GravityIconProps>; } @@ -12,7 +12,7 @@ function isIconSvg(icon: ImageProps | SVGIcon): icon is SVGIcon { return typeof icon === 'function'; } -const ContentListItemIcon = ({icon, className, qa, gravityIcon}: ListItemProps) => { +const ContentIcon = ({icon, className, qa, gravityIcon}: ContentIconProps) => { const theme = useTheme(); const iconThemed = getThemedValue(icon, theme); const gravityIconThemed = getThemedValue(gravityIcon, theme); @@ -29,4 +29,4 @@ const ContentListItemIcon = ({icon, className, qa, gravityIcon}: ListItemProps) return <Icon icon={iconThemed} gravityIcon={gravityIconThemed} className={className} qa={qa} />; }; -export default ContentListItemIcon; +export default ContentIcon; diff --git a/src/components/ContentLabels/ContentLabels.scss b/src/components/ContentLabels/ContentLabels.scss new file mode 100644 index 000000000..a1201c353 --- /dev/null +++ b/src/components/ContentLabels/ContentLabels.scss @@ -0,0 +1,72 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +$block: '.#{$ns}content-labels'; + +#{$block} { + display: flex; + gap: 10px; + flex-wrap: wrap; + + &__label { + display: flex; + align-items: center; + flex-wrap: nowrap; + flex-shrink: 0; + padding: 5px 11px; + background-color: var(--g-color-base-generic); + border-radius: 6px; + + &, + &-text { + color: var(--g-color-text-primary); + } + + & > .pc-icon, + & > picture { + display: flex; + } + + &-icon { + width: 16px; + height: 16px; + margin-inline-end: 6px; + } + + $label: &; + &_theme { + &_light { + background-color: var(--g-color-private-black-50); + + &, + #{$label}-text { + color: var(--g-color-text-dark-primary); + } + } + + &_dark { + background-color: var(--g-color-private-white-100); + + &, + #{$label}-text { + color: var(--g-color-text-light-primary); + } + } + } + } + + &_size { + &_l, + &_m { + #{$block}__label { + @include text-body-3(); + } + } + + &_s { + #{$block}__label { + @include text-body-2(); + } + } + } +} diff --git a/src/components/ContentLabels/ContentLabels.tsx b/src/components/ContentLabels/ContentLabels.tsx new file mode 100644 index 000000000..9dee547b8 --- /dev/null +++ b/src/components/ContentLabels/ContentLabels.tsx @@ -0,0 +1,41 @@ +import {ClassNameProps, ContentLabelsProps, QAProps} from '../../models'; +import {block, getQaAttrubutes} from '../../utils'; +import ContentIcon from '../ContentIcon/ContentIcon'; + +import './ContentLabels.scss'; + +const b = block('content-labels'); + +const ContentLabels = ({ + className, + labels, + theme, + size = 'l', + qa, +}: ContentLabelsProps & ClassNameProps & QAProps) => { + const qaAttributes = getQaAttrubutes(qa, ['icon', 'text']); + + return ( + <div className={b({size}, className)}> + {labels.map((label) => { + const {text, icon, gravityIcon} = label; + + return ( + <div key={text} className={b('label', {theme})}> + <ContentIcon + className={b('label-icon')} + icon={icon} + gravityIcon={gravityIcon} + qa={qaAttributes.icon} + /> + <span className={b('label-text')} data-qa={qaAttributes.text}> + {text} + </span> + </div> + ); + })} + </div> + ); +}; + +export default ContentLabels; diff --git a/src/components/ContentList/ContentList.tsx b/src/components/ContentList/ContentList.tsx index 152bd88c5..a43c38cd9 100644 --- a/src/components/ContentList/ContentList.tsx +++ b/src/components/ContentList/ContentList.tsx @@ -6,10 +6,9 @@ import {ContentListProps, ContentSize} from '../../models'; import {QAProps} from '../../models/common'; import {block} from '../../utils'; import {getQaAttrubutes} from '../../utils/blocks'; +import ContentIcon from '../ContentIcon/ContentIcon'; import YFMWrapper from '../YFMWrapper/YFMWrapper'; -import ItemIcon from './ContentListItemIcon'; - import './ContentList.scss'; const b = block('content-list'); @@ -33,7 +32,7 @@ const ContentList = ({list, size = 'l', qa, theme}: ContentListProps & QAProps) const {icon, title, text, gravityIcon} = item; return ( <div className={b('item', {'without-title': !title})} key={uuidv4()}> - <ItemIcon + <ContentIcon icon={icon} className={b('icon')} qa={qaAttributes.image} diff --git a/src/context/windowWidthContext/BreakpointContext.tsx b/src/context/windowWidthContext/WindowWidthContext.tsx similarity index 86% rename from src/context/windowWidthContext/BreakpointContext.tsx rename to src/context/windowWidthContext/WindowWidthContext.tsx index adc5e2f13..8746b1224 100644 --- a/src/context/windowWidthContext/BreakpointContext.tsx +++ b/src/context/windowWidthContext/WindowWidthContext.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import throttle from 'lodash/throttle'; import {BREAKPOINTS} from '../../constants'; -import {SSRContext} from '../ssrContext'; const DEFAULT_WIDTH = BREAKPOINTS.xl; const UPDATE_FREQUENCY_MS = 100; @@ -11,15 +10,9 @@ const UPDATE_FREQUENCY_MS = 100; export const WindowWidthContext = React.createContext<number>(DEFAULT_WIDTH); export const WindowWidthProvider = ({children}: React.PropsWithChildren) => { - const {isServer} = React.useContext(SSRContext); - const [windowWidth, setWindowWidth] = React.useState(DEFAULT_WIDTH); React.useEffect(() => { - if (isServer) { - return; - } - const handleResize = throttle( () => { setWindowWidth(window.innerWidth); @@ -34,7 +27,7 @@ export const WindowWidthProvider = ({children}: React.PropsWithChildren) => { // eslint-disable-next-line consistent-return return () => window.removeEventListener('resize', handleResize); - }, [isServer]); + }, []); return ( <WindowWidthContext.Provider value={windowWidth}>{children}</WindowWidthContext.Provider> diff --git a/src/context/windowWidthContext/index.ts b/src/context/windowWidthContext/index.ts index 236848bc4..5078fa65f 100644 --- a/src/context/windowWidthContext/index.ts +++ b/src/context/windowWidthContext/index.ts @@ -1 +1 @@ -export * from './BreakpointContext'; +export * from './WindowWidthContext'; diff --git a/src/hooks/useDeviceValue.ts b/src/hooks/useDeviceValue.ts new file mode 100644 index 000000000..b5b93ad54 --- /dev/null +++ b/src/hooks/useDeviceValue.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import {BREAKPOINTS} from '../constants'; +import {useWindowWidth} from '../context/windowWidthContext'; +import {Device} from '../models'; +import {DeviceSupporting, isDeviceValue} from '../utils'; + +const getDeviceBreakpoints = (inclusive?: boolean): [tablet: number, mobile: number] => { + const shift = inclusive ? 0 : -1; + + return [BREAKPOINTS.md + shift, BREAKPOINTS.sm + shift]; +}; + +export function useDeviceValue<T>(value: DeviceSupporting<T>, inclusive?: boolean): T { + const windowWidth = useWindowWidth(); + + const [tablet, mobile] = React.useMemo(() => getDeviceBreakpoints(inclusive), [inclusive]); + + const isMobile = windowWidth <= mobile; + const isTablet = windowWidth <= tablet; + + return React.useMemo<T>(() => { + if (!isDeviceValue(value)) { + return value; + } + + switch (true) { + case isMobile: + return value[Device.Mobile]; + case isTablet: + return value[Device.Tablet] ?? value[Device.Mobile]; + default: + return value[Device.Desktop]; + } + }, [isMobile, isTablet, value]); +} diff --git a/src/models/constructor-items/blocks.ts b/src/models/constructor-items/blocks.ts index 8c7f24469..be075d38d 100644 --- a/src/models/constructor-items/blocks.ts +++ b/src/models/constructor-items/blocks.ts @@ -4,6 +4,7 @@ import {ButtonSize} from '@gravity-ui/uikit'; import {GridColumnSize, GridColumnSizesType, IndentValue} from '../../grid/types'; import {ThemeSupporting} from '../../utils'; +import {DeviceSupporting} from '../../utils/breakpoint'; import {AnalyticsEventsBase} from '../common'; import { @@ -201,7 +202,10 @@ export interface HeaderBlockProps { } export interface ExtendedFeaturesItem - extends Omit<ContentBlockProps, 'theme' | 'centered' | 'colSizes' | 'size' | 'title'> { + extends Omit< + ContentBlockProps, + 'theme' | 'centered' | 'colSizes' | 'size' | 'title' | 'labels' + > { title: string; label?: string; icon?: ThemedImage; @@ -238,7 +242,7 @@ export interface QuestionItem { } export interface QuestionsProps - extends Omit<ContentBlockProps, 'colSizes' | 'centered' | 'size' | 'theme'> { + extends Omit<ContentBlockProps, 'colSizes' | 'centered' | 'size' | 'theme' | 'labels'> { items: QuestionItem[]; } @@ -255,7 +259,7 @@ export interface FoldableListItem { } export interface FoldableListProps - extends Omit<ContentBlockProps, 'colSizes' | 'centered' | 'size' | 'theme'> { + extends Omit<ContentBlockProps, 'colSizes' | 'centered' | 'size' | 'theme' | 'labels'> { items: FoldableListItem[]; } @@ -281,7 +285,7 @@ export interface MediaBaseBlockProps extends Animatable, MediaContentProps { } export interface MediaContentProps - extends Omit<ContentBlockProps, 'colSizes' | 'text' | 'theme' | 'centered'> { + extends Omit<ContentBlockProps, 'colSizes' | 'text' | 'theme' | 'centered' | 'labels'> { description?: string; /** @deprecated Use array of buttons from ContentBlockProps instead**/ button?: ButtonProps; @@ -306,8 +310,8 @@ export interface InfoBlockProps { sectionsTitle?: string; /** @deprecated **/ links?: Pick<LinkProps, 'text' | 'url'>[]; - leftContent?: Omit<ContentBlockProps, 'colSizes' | 'theme' | 'size'>; - rightContent?: Omit<ContentBlockProps, 'colSizes' | 'theme' | 'size'>; + leftContent?: Omit<ContentBlockProps, 'colSizes' | 'theme' | 'size' | 'labels'>; + rightContent?: Omit<ContentBlockProps, 'colSizes' | 'theme' | 'size' | 'labels'>; } export interface TableProps { @@ -328,7 +332,7 @@ export interface TableBlockProps { } export interface TabsBlockItem - extends Omit<ContentBlockProps, 'size' | 'colSizes' | 'centered' | 'theme'>, + extends Omit<ContentBlockProps, 'size' | 'colSizes' | 'centered' | 'theme' | 'labels'>, WithBorder { tabName: string; /** @@ -406,7 +410,7 @@ interface ContentLayoutBlockParams { } export interface ContentLayoutBlockProps extends ContentLayoutBlockParams { - textContent: ContentBlockProps; + textContent: Omit<ContentBlockProps, 'labels'>; fileContent?: FileLinkProps[]; } @@ -425,6 +429,18 @@ export interface ContentListProps { theme?: ContentTheme; } +export interface ContentLabelProps { + text: string; + icon?: ThemeSupporting<ImageProps | SVGIcon>; + gravityIcon?: ThemeSupporting<GravityIconProps>; +} + +export interface ContentLabelsProps { + labels: ContentLabelProps[]; + size?: ContentSize; + theme?: ContentTheme; +} + export interface ContentBlockProps { title?: TitleItemBaseProps | string; titleId?: string; @@ -438,6 +454,7 @@ export interface ContentBlockProps { centered?: boolean; theme?: ContentTheme; list?: ContentItemProps[]; + labels?: ContentLabelProps[]; controlPosition?: 'default' | 'bottom'; } @@ -475,12 +492,17 @@ export interface FormBlockHubspotData { export type FormBlockData = FormBlockYandexData | FormBlockHubspotData; +export interface FormBlockBackgroundProps extends Omit<BackgroundImageProps, 'style'> { + style?: DeviceSupporting<React.CSSProperties>; +} + export interface FormBlockProps { formData: FormBlockData; title?: string; textContent?: Omit<ContentBlockProps, 'centered' | 'colSizes' | 'size'>; direction?: FormBlockDirection; - background?: ThemeSupporting<BackgroundImageProps>; + background?: ThemeSupporting<FormBlockBackgroundProps>; + customFormNode?: React.ReactNode; } //block models diff --git a/src/models/constructor-items/sub-blocks.ts b/src/models/constructor-items/sub-blocks.ts index cd2ff5fe2..fbb3b8997 100644 --- a/src/models/constructor-items/sub-blocks.ts +++ b/src/models/constructor-items/sub-blocks.ts @@ -147,7 +147,7 @@ export interface BackgroundCardProps extends CardBaseProps, AnalyticsEventsBase, CardLayoutProps, - Omit<ContentBlockProps, 'centered' | 'controlPosition'> { + Omit<ContentBlockProps, 'centered' | 'controlPosition' | 'labels'> { url?: string; urlTitle?: string; background?: ThemeSupporting<ImageObjectProps>; @@ -159,7 +159,7 @@ export interface BasicCardProps extends CardBaseProps, AnalyticsEventsBase, CardLayoutProps, - Omit<ContentBlockProps, 'colSizes' | 'centered' | 'theme' | 'controlPosition'> { + Omit<ContentBlockProps, 'colSizes' | 'centered' | 'theme' | 'controlPosition' | 'labels'> { url: string; urlTitle?: string; icon?: ThemeSupporting<ImageProps>; @@ -182,7 +182,7 @@ export interface BannerCardProps { export interface MediaCardProps extends MediaProps, AnalyticsEventsBase, CardBaseProps {} -export interface PriceCardProps extends CardBaseProps, Pick<ContentBlockProps, 'theme'> { +export interface PriceCardProps extends CardBaseProps, Pick<ContentBlockProps, 'theme' | 'labels'> { title: string; price: string; pricePeriod?: string; @@ -195,7 +195,7 @@ export interface PriceCardProps extends CardBaseProps, Pick<ContentBlockProps, ' } export interface LayoutItemProps extends ClassNameProps, CardLayoutProps, AnalyticsEventsBase { - content: Omit<ContentBlockProps, 'colSizes' | 'centered'>; + content: Omit<ContentBlockProps, 'colSizes' | 'centered' | 'labels'>; contentMargin?: LayoutItemContentMargin; media?: ThemeSupporting<MediaProps>; metaInfo?: string[]; @@ -207,7 +207,7 @@ export interface LayoutItemProps extends ClassNameProps, CardLayoutProps, Analyt export interface ImageCardProps extends CardBaseProps, CardLayoutProps, - Omit<ContentBlockProps, 'colSizes' | 'centered' | 'controlPosition'> { + Omit<ContentBlockProps, 'colSizes' | 'centered' | 'controlPosition' | 'labels'> { image: ThemeSupporting<ImageProps>; enableImageBorderRadius?: boolean; margins?: ImageCardMargins; diff --git a/src/schema/validators/common.ts b/src/schema/validators/common.ts index 766aebd57..7f8a4de4d 100644 --- a/src/schema/validators/common.ts +++ b/src/schema/validators/common.ts @@ -2,6 +2,7 @@ import {ImageProps} from '../../components/Image/schema'; import { CustomControlsButtonPositioning, CustomControlsType, + Device, MediaVideoControlsType, QuoteType, Theme, @@ -482,6 +483,30 @@ export function withTheme<T extends object>(value: T) { }; } +export function withDevice<T extends object>(value: T) { + return { + oneOf: [ + { + ...value, + optionName: 'no device', + }, + { + type: 'object', + additionalProperties: false, + required: [Device.Desktop, Device.Mobile], + properties: Object.values(Device).reduce( + (result, deviceName) => ({ + ...result, + [deviceName]: value, + }), + {}, + ), + optionName: 'devices', + }, + ], + }; +} + export const AnchorProps = { type: 'object', additionalProperties: false, diff --git a/src/sub-blocks/Content/Content.scss b/src/sub-blocks/Content/Content.scss index d6feeaac3..f8b6c9d35 100644 --- a/src/sub-blocks/Content/Content.scss +++ b/src/sub-blocks/Content/Content.scss @@ -54,6 +54,11 @@ $darkSecondary: var(--g-color-text-dark-secondary); text-align: center; } + #{$block}__labels { + justify-content: center; + margin-inline: auto; + } + #{$block}__list { margin-inline: auto; } @@ -93,11 +98,21 @@ $darkSecondary: var(--g-color-text-dark-secondary); } } + #{$block}__labels, #{$block}__list, #{$block}__links, #{$block}__notice { margin-top: $indentXS; } + + #{$block}__labels { + & + #{$block}__list, + & + #{$block}__link, + & + #{$block}__notice { + margin-top: $indentS; + } + } + #{$block}__buttons { margin-top: $indentS; } @@ -131,11 +146,21 @@ $darkSecondary: var(--g-color-text-dark-secondary); @include text-size(body-2); } + #{$block}__labels, #{$block}__list, #{$block}__links, #{$block}__notice { margin-top: $indentS; } + + #{$block}__labels { + & + #{$block}__list, + & + #{$block}__link, + & + #{$block}__notice { + margin-top: $indentSM; + } + } + #{$block}__buttons { margin-top: $indentSM; } @@ -166,11 +191,21 @@ $darkSecondary: var(--g-color-text-dark-secondary); @include text-size(body-2); } + #{$block}__labels, #{$block}__list, #{$block}__links, #{$block}__notice { margin-top: $indentS; } + + #{$block}__labels { + & + #{$block}__list, + & + #{$block}__link, + & + #{$block}__notice { + margin-top: $indentSM; + } + } + #{$block}__buttons { margin-top: $indentSM; } diff --git a/src/sub-blocks/Content/Content.tsx b/src/sub-blocks/Content/Content.tsx index e19f31a94..430a50c09 100644 --- a/src/sub-blocks/Content/Content.tsx +++ b/src/sub-blocks/Content/Content.tsx @@ -1,6 +1,7 @@ import {useUniqId} from '@gravity-ui/uikit'; import {Buttons, ContentList, Links, Title, YFMWrapper} from '../../components'; +import ContentLabels from '../../components/ContentLabels/ContentLabels'; import {Col} from '../../grid'; import { ClassNameProps, @@ -46,10 +47,18 @@ const Content = (props: ContentProps) => { theme, className, list, + labels, qa, controlPosition, } = props; - const qaAttributes = getQaAttrubutes(qa, ['links', 'link', 'buttons', 'button', 'list']); + const qaAttributes = getQaAttrubutes(qa, [ + 'links', + 'link', + 'buttons', + 'button', + 'list', + 'labels', + ]); const titleProps = !title || typeof title === 'string' @@ -85,6 +94,16 @@ const Content = (props: ContentProps) => { /> </div> )} + {labels?.length ? ( + <div className={b('labels')}> + <ContentLabels + labels={labels} + theme={theme} + size={size} + qa={qaAttributes.labels} + /> + </div> + ) : null} {list?.length ? ( <div className={b('list')}> <ContentList list={list} size={size} qa={qaAttributes.list} theme={theme} /> diff --git a/src/sub-blocks/Content/README.md b/src/sub-blocks/Content/README.md index 2085e1d5e..f8e04b30b 100644 --- a/src/sub-blocks/Content/README.md +++ b/src/sub-blocks/Content/README.md @@ -12,7 +12,7 @@ `theme?: 'default' | 'dark' | 'light'` — Component's theme: default, dark, or monochrome light ('default' by default). -- `size?: 's' | 'l'` — Component's size that defines font sizes ('l' by default) +`size?: 's' | 'l'` — Component's size that defines font sizes ('l' by default) `сolSizes?: Object` — Width of buttons tabs, the value ranges from 1 to 12 columns. If 12 columns, buttons takes up the entire width of the row. @@ -21,3 +21,7 @@ - `md: number` — On a screen wider than 769px. - `lg: number` — On a screen wider than 1081px. - `xl: number` — On a screen wider than 1185px. + +`list?: Array` - An Array of items with icon - [ContentList](?path=/docs/components-contentlist--docs) + +`labels?: Array` - An Array of labels with icons `{text: string; icon?: ImageProps; gravityIcon?: GravityIconProps}` diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Centered-light-chromium-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Centered-light-chromium-linux.png index 59fba3d8c..0b5ec822a 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Centered-light-chromium-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Centered-light-chromium-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Centered-light-webkit-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Centered-light-webkit-linux.png index 155be8026..37eb89c57 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Centered-light-webkit-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Centered-light-webkit-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-ContentVariables-light-chromium-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-ContentVariables-light-chromium-linux.png index a09236157..556d3aeee 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-ContentVariables-light-chromium-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-ContentVariables-light-chromium-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-ContentVariables-light-webkit-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-ContentVariables-light-webkit-linux.png index 7a928b3a6..302a998e6 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-ContentVariables-light-webkit-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-ContentVariables-light-webkit-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Default-light-chromium-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Default-light-chromium-linux.png index ed73cf245..31a7ec71d 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Default-light-chromium-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Default-light-chromium-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Default-light-webkit-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Default-light-webkit-linux.png index 9cd0b6266..b7259f6f3 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Default-light-webkit-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Default-light-webkit-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Size-light-chromium-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Size-light-chromium-linux.png index d27d06648..dde4c635f 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Size-light-chromium-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Size-light-chromium-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Size-light-webkit-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Size-light-webkit-linux.png index ca1fa7001..8b03e8b1a 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Size-light-webkit-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Size-light-webkit-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Theme-light-chromium-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Theme-light-chromium-linux.png index 01a866a12..1bcc08e70 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Theme-light-chromium-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Theme-light-chromium-linux.png differ diff --git a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Theme-light-webkit-linux.png b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Theme-light-webkit-linux.png index 97b02d813..9916e2322 100644 Binary files a/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Theme-light-webkit-linux.png and b/src/sub-blocks/Content/__snapshots__/Content.visual.test.tsx-snapshots/Content-render-stories-Theme-light-webkit-linux.png differ diff --git a/src/sub-blocks/Content/__stories__/Content.mdx b/src/sub-blocks/Content/__stories__/Content.mdx index 1dc229846..fdf583595 100644 --- a/src/sub-blocks/Content/__stories__/Content.mdx +++ b/src/sub-blocks/Content/__stories__/Content.mdx @@ -26,7 +26,7 @@ For a detailed usage guide of the Content sub-block, see [Content Usage](https:/ `theme?: 'default' | 'dark' | 'light'` — Component's theme: default, dark, or monochrome light ('default' by default). -- `size?: 's' | 'l'` — Component's size that defines font sizes ('l' by default) +`size?: 's' | 'l'` — Component's size that defines font sizes ('l' by default) `сolSizes?: Object` — Width of buttons tabs, the value ranges from 1 to 12 columns. If 12 columns, buttons takes up the entire width of the row. @@ -36,5 +36,8 @@ For a detailed usage guide of the Content sub-block, see [Content Usage](https:/ - `lg: number` — On a screen wider than 1081px. - `xl: number` — On a screen wider than 1185px. -`list: Array` - An Array of items with icon - [ContentList](?path=/docs/components-contentlist--docs) +`list?: Array` - An Array of items with icon - [ContentList](?path=/docs/components-contentlist--docs) + +`labels?: Array` - An Array of labels with icons `{text: string; icon?: ImageProps; gravityIcon?: GravityIconProps}` + </StoryTemplate> diff --git a/src/sub-blocks/Content/__stories__/Content.stories.tsx b/src/sub-blocks/Content/__stories__/Content.stories.tsx index 4eb9eae55..5db25d6fd 100644 --- a/src/sub-blocks/Content/__stories__/Content.stories.tsx +++ b/src/sub-blocks/Content/__stories__/Content.stories.tsx @@ -57,6 +57,7 @@ ContentVariables.args = [ {additionalInfo: data.default.additionalInfo}, {links: data.default.links}, {buttons: data.default.buttons}, + {labels: data.default.labels}, {links: data.default.links, list: data.default.list}, ].map((content) => ({ ...content, @@ -76,8 +77,10 @@ Size.args = data.size.map((content) => ({ text: data.default.text, buttons: data.default.buttons, list: data.default.list, + labels: data.default.labels, type: data.default.type, })) as ContentBlockProps[]; + Size.parameters = { controls: { include: Object.keys(Size.args), @@ -88,6 +91,7 @@ Centered.args = [ {additionalInfo: data.default.additionalInfo}, {links: data.default.links}, {buttons: data.default.buttons}, + {labels: data.default.labels}, ].map((content) => ({ ...content, ...data.centered, @@ -105,6 +109,7 @@ Theme.args = data.theme.map((content) => ({ ...content, text: data.default.text, additionalInfo: data.default.additionalInfo, + labels: data.default.labels, })) as ContentBlockProps[]; Theme.parameters = { controls: { diff --git a/src/sub-blocks/Content/__stories__/data.json b/src/sub-blocks/Content/__stories__/data.json index 1c150a430..0e14287be 100644 --- a/src/sub-blocks/Content/__stories__/data.json +++ b/src/sub-blocks/Content/__stories__/data.json @@ -52,6 +52,19 @@ "title": "Lorem ipsum", "text": "**Ut enim ad minim veniam** [quis nostrud](https://example.com) exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." } + ], + "labels": [ + { + "text": "Label 1" + }, + { + "text": "Label 2", + "icon": "/story-assets/icon_3_light.svg" + }, + { + "text": "Label 3", + "gravityIcon": {"name": "Gear"} + } ] }, "theme": [ diff --git a/src/sub-blocks/Content/__tests__/Content.test.tsx b/src/sub-blocks/Content/__tests__/Content.test.tsx index fd7a1ebfc..69588ed15 100644 --- a/src/sub-blocks/Content/__tests__/Content.test.tsx +++ b/src/sub-blocks/Content/__tests__/Content.test.tsx @@ -5,6 +5,7 @@ import { testContentWithButtons, testContentWithCentered, testContentWithColSize, + testContentWithLabels, testContentWithLinks, testContentWithList, testContentWithSize, @@ -31,6 +32,12 @@ const contentData: ContentProps = { text: 'list text', }, ], + labels: [ + { + icon: '/mock.png', + text: 'label text', + }, + ], qa: 'content', }; @@ -39,7 +46,7 @@ const colSizesArray: GridColumnSizesType[] = [ {all: 5, lg: 4, md: 3, sm: 2}, ]; -const qaAttributes = getQaAttrubutes(contentData.qa, ['link', 'list']); +const qaAttributes = getQaAttrubutes(contentData.qa, ['link', 'list', 'labels']); describe('Content', () => { test('Render by default', async () => { @@ -130,4 +137,11 @@ describe('Content', () => { options: {qaId: qaAttributes.list}, }); }); + + test('Render with labels', async () => { + testContentWithLabels({ + props: contentData, + options: {qaId: qaAttributes.labels}, + }); + }); }); diff --git a/src/sub-blocks/Content/schema.ts b/src/sub-blocks/Content/schema.ts index 2d5a99092..ea3bd247c 100644 --- a/src/sub-blocks/Content/schema.ts +++ b/src/sub-blocks/Content/schema.ts @@ -14,7 +14,14 @@ import {filteredArray} from '../../schema/validators/utils'; export const ContentItem = { additionalProperties: false, - required: ['icon'], + oneOf: [ + { + required: ['icon'], + }, + { + required: ['gravityIcon'], + }, + ], properties: { title: { type: 'string', @@ -29,6 +36,19 @@ export const ContentItem = { }, }; +export const ContentLabel = { + additionalProperties: false, + required: ['text'], + properties: { + text: { + type: 'string', + contentType: 'text', + }, + icon: withTheme(ImageProps), + gravityIcon: withTheme(GravityIconProps), + }, +}; + export const ContentBase = { title: { oneOf: [ @@ -63,6 +83,7 @@ export const ContentBase = { enum: contentThemes, }, list: filteredArray(ContentItem), + labels: filteredArray(ContentLabel), controlPosition: { type: 'string', enum: ['default', 'bottom'], diff --git a/src/utils/breakpoint.ts b/src/utils/breakpoint.ts new file mode 100644 index 000000000..fd13e0e18 --- /dev/null +++ b/src/utils/breakpoint.ts @@ -0,0 +1,18 @@ +import {Device} from '../models'; + +export interface ValueWithDevice<T> extends Partial<Record<Device, T>> { + [Device.Desktop]: T; + [Device.Mobile]: T; +} + +export type DeviceSupporting<T> = T | ValueWithDevice<T>; + +export function isDeviceValue<T>(value: DeviceSupporting<T>): value is ValueWithDevice<T> { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Device.Desktop in value && + Device.Mobile in value + ); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 372c39071..13dca7be8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './common'; export * from './analytics'; +export * from './breakpoint'; export * from './blocks'; export * from './url'; export * from './cn'; diff --git a/test-utils/shared/content.tsx b/test-utils/shared/content.tsx index a890974dd..994106f0d 100644 --- a/test-utils/shared/content.tsx +++ b/test-utils/shared/content.tsx @@ -130,3 +130,17 @@ export const testContentWithList = ({props, options}: ContentTestFunction) => { expect(title).toHaveTextContent(props.list?.[0]?.title); expect(text).toHaveTextContent(props.list?.[0]?.text); }; + +export const testContentWithLabels = ({props, options}: ContentTestFunction) => { + if (!options?.qaId || !props.labels?.[0]?.icon || !props.labels?.[0]?.text) { + throw new Error(ERROR_INPUT_DATA_MESSAGE); + } + + const labelsQa = getQaAttrubutes(options.qaId, ['text']); + + render(<Content {...pick(props, 'labels', 'qa')} />); + const image = screen.getByRole('img'); + const text = screen.getByTestId(labelsQa.text); + expect(image).toHaveAttribute('src', props.labels?.[0]?.icon); + expect(text).toHaveTextContent(props.labels?.[0]?.text); +};