From 8ac2cdf4ca34ce6c2051f0c0a57c6246d1b9644d Mon Sep 17 00:00:00 2001 From: Roman Barlos Date: Tue, 10 Dec 2024 15:41:03 +0300 Subject: [PATCH] feat(Stories): add unstable versions of Stories and StoriesGroup components (#245) --- package.json | 22 ++ src/components/unstable/Stories/README.md | 65 ++++ src/components/unstable/Stories/Stories.scss | 16 + src/components/unstable/Stories/Stories.tsx | 128 +++++++ .../Stories/__stories__/Stories.stories.tsx | 327 ++++++++++++++++++ .../components/ImageView/ImageView.scss | 33 ++ .../components/ImageView/ImageView.tsx | 25 ++ .../MediaRenderer/MediaRenderer.tsx | 18 + .../StoriesLayout/StoriesLayout.scss | 128 +++++++ .../StoriesLayout/StoriesLayout.tsx | 167 +++++++++ .../components/VideoView/VideoView.scss | 43 +++ .../components/VideoView/VideoView.tsx | 33 ++ .../unstable/Stories/components/index.ts | 3 + .../unstable/Stories/hooks/index.ts | 1 + .../Stories/hooks/useSyncWithLS/README.md | 20 ++ .../Stories/hooks/useSyncWithLS/index.ts | 2 + .../hooks/useSyncWithLS/useSyncWithLS.ts | 46 +++ src/components/unstable/Stories/i18n/en.json | 6 + src/components/unstable/Stories/i18n/index.ts | 8 + src/components/unstable/Stories/i18n/ru.json | 6 + src/components/unstable/Stories/index.ts | 3 + src/components/unstable/Stories/types.ts | 44 +++ .../unstable/StoriesGroup/README.md | 68 ++++ .../unstable/StoriesGroup/StoriesGroup.scss | 18 + .../unstable/StoriesGroup/StoriesGroup.tsx | 178 ++++++++++ .../__stories__/StoriesGroup.stories.tsx | 152 ++++++++ .../StoriesPreview/StoriesPreview.scss | 65 ++++ .../StoriesPreview/StoriesPreview.tsx | 220 ++++++++++++ .../unstable/StoriesGroup/components/index.ts | 1 + src/components/unstable/StoriesGroup/index.ts | 2 + src/components/unstable/StoriesGroup/types.ts | 7 + src/components/unstable/index.ts | 8 + src/components/unstable/utils/cn.ts | 8 + src/components/unstable/variables.scss | 14 + 34 files changed, 1885 insertions(+) create mode 100644 src/components/unstable/Stories/README.md create mode 100644 src/components/unstable/Stories/Stories.scss create mode 100644 src/components/unstable/Stories/Stories.tsx create mode 100644 src/components/unstable/Stories/__stories__/Stories.stories.tsx create mode 100644 src/components/unstable/Stories/components/ImageView/ImageView.scss create mode 100644 src/components/unstable/Stories/components/ImageView/ImageView.tsx create mode 100644 src/components/unstable/Stories/components/MediaRenderer/MediaRenderer.tsx create mode 100644 src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.scss create mode 100644 src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.tsx create mode 100644 src/components/unstable/Stories/components/VideoView/VideoView.scss create mode 100644 src/components/unstable/Stories/components/VideoView/VideoView.tsx create mode 100644 src/components/unstable/Stories/components/index.ts create mode 100644 src/components/unstable/Stories/hooks/index.ts create mode 100644 src/components/unstable/Stories/hooks/useSyncWithLS/README.md create mode 100644 src/components/unstable/Stories/hooks/useSyncWithLS/index.ts create mode 100644 src/components/unstable/Stories/hooks/useSyncWithLS/useSyncWithLS.ts create mode 100644 src/components/unstable/Stories/i18n/en.json create mode 100644 src/components/unstable/Stories/i18n/index.ts create mode 100644 src/components/unstable/Stories/i18n/ru.json create mode 100644 src/components/unstable/Stories/index.ts create mode 100644 src/components/unstable/Stories/types.ts create mode 100644 src/components/unstable/StoriesGroup/README.md create mode 100644 src/components/unstable/StoriesGroup/StoriesGroup.scss create mode 100644 src/components/unstable/StoriesGroup/StoriesGroup.tsx create mode 100644 src/components/unstable/StoriesGroup/__stories__/StoriesGroup.stories.tsx create mode 100644 src/components/unstable/StoriesGroup/components/StoriesPreview/StoriesPreview.scss create mode 100644 src/components/unstable/StoriesGroup/components/StoriesPreview/StoriesPreview.tsx create mode 100644 src/components/unstable/StoriesGroup/components/index.ts create mode 100644 src/components/unstable/StoriesGroup/index.ts create mode 100644 src/components/unstable/StoriesGroup/types.ts create mode 100644 src/components/unstable/index.ts create mode 100644 src/components/unstable/utils/cn.ts create mode 100644 src/components/unstable/variables.scss diff --git a/package.json b/package.json index 5d546c0a..9d712d65 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,31 @@ "version": "3.12.5", "description": "", "license": "MIT", + "exports": { + ".": { + "types": "./build/esm/index.d.ts", + "require": "./build/cjs/index.js", + "import": "./build/esm/index.js" + }, + "./unstable": { + "types": "./build/esm/components/unstable/index.d.ts", + "require": "./build/cjs/components/unstable/index.js", + "import": "./build/esm/components/unstable/index.js" + } + }, "main": "./build/cjs/index.js", "module": "./build/esm/index.js", "types": "./build/esm/index.d.ts", + "typesVersions": { + "*": { + "index.d.ts": [ + "./build/esm/index.d.ts" + ], + "unstable": [ + "./build/esm/components/unstable/index.d.ts" + ] + } + }, "sideEffects": [ "*.css", "*.scss" diff --git a/src/components/unstable/Stories/README.md b/src/components/unstable/Stories/README.md new file mode 100644 index 00000000..db530550 --- /dev/null +++ b/src/components/unstable/Stories/README.md @@ -0,0 +1,65 @@ +## Stories + +Component for displaying stories. It looks like a carousel in a modal with given places to display text and media. + +### PropTypes + +| Property | Type | Required | Default | Description | +| :------------------ | :-------------- | :------- | :------ | :----------------------------------------------- | +| open | `Boolean` | ✓ | | Visibility flag | +| items | `StoriesItem[]` | ✓ | | List of stories to display | +| initialStoryIndex | `Number` | | 0 | Index of the first story to be displayed | +| onClose | `Function` | | | Action on close | +| onPreviousClick | `Function` | | | Action when switching to previous story | +| onNextClick | `Function` | | | Action when switching to next story | +| disableOutsideClick | `Boolean` | | true | If `true`, do not close stories on click outside | +| className | `string` | | | Stories modal class | + +### StoriesItem object + +| Field | Type | Required | Default | Description | +| --------------- | ------------------------ | -------- | ------- | ---------------------------------------- | +| title | `String` | | | Title | +| description | `String` | | | Main text | +| url | `String` | | | Link to display more information | +| media | `StoriesItemMedia` | | | Media content | +| firstAction | `ButtonProps` | | | Custom action button props | +| secondAction | `ButtonProps` | | | Custom action button props | +| textBlockStyle | `StoriesTextBlockStyle` | ✓ | | Props for styling text content in Story | +| mediaBlockStyle | `StoriesMediaBlockStyle` | ✓ | | Props for styling media content in Story | +| textColorStyles | `StoriesItemTextStyles` | | | Props for styling text color in Story | + +### StoriesItemMedia object + +| Field | Type | Required | Default | Description | +| --------- | -------- | -------- | ------- | -------------------------------------------------- | +| type | `String` | ✓ | | Content type (`image` or `video`) | +| url | `String` | ✓ | | File link | +| url2x | `String` | | | File link for Retina display (only used for image) | +| posterUrl | `String` | | | Poster URL (only used for video) | + +### StoriesItemTextStyles object + +| Field | Type | Required | Default | Description | +| ---------------- | -------- | -------- | ------- | -------------------------------- | +| titleColor | `String` | | | Apply color to Story title | +| descriptionColor | `String` | | | Apply color to Story description | +| counterColor | `String` | | | Apply color to Story counter | + +#### Usage example + +```jsx harmony + +``` diff --git a/src/components/unstable/Stories/Stories.scss b/src/components/unstable/Stories/Stories.scss new file mode 100644 index 00000000..bb5639a0 --- /dev/null +++ b/src/components/unstable/Stories/Stories.scss @@ -0,0 +1,16 @@ +@use '../variables'; + +$block: '.#{variables.$ns}stories'; + +#{$block} { + --g-modal-border-radius: var(--g-spacing-5); + --g-modal-margin: var(--g-spacing-5); + + & .g-modal__content-wrapper { + overflow-x: initial; + } + + &__modal-content { + border-radius: var(--g-spacing-5); + } +} diff --git a/src/components/unstable/Stories/Stories.tsx b/src/components/unstable/Stories/Stories.tsx new file mode 100644 index 00000000..19247054 --- /dev/null +++ b/src/components/unstable/Stories/Stories.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +import type {ModalCloseReason} from '@gravity-ui/uikit'; +import {Modal} from '@gravity-ui/uikit'; + +import {block} from '../utils/cn'; + +import {IndexType, StoriesLayout} from './components/StoriesLayout/StoriesLayout'; +import {useSyncWithLS} from './hooks'; +import type {StoriesItem} from './types'; + +import './Stories.scss'; + +const b = block('stories'); + +export interface StoriesProps { + open: boolean; + items: StoriesItem[]; + onClose?: ( + event: MouseEvent | KeyboardEvent | React.MouseEvent, + reason: ModalCloseReason | 'closeButtonClick', + ) => void; + initialStoryIndex?: number; + onPreviousClick?: (storyIndex: number) => void; + onNextClick?: (storyIndex: number) => void; + disableOutsideClick?: boolean; + className?: string; + syncInTabsKey?: string; +} + +export function Stories({ + open, + onClose, + items, + onPreviousClick, + onNextClick, + initialStoryIndex = 0, + disableOutsideClick = true, + className, + syncInTabsKey, +}: StoriesProps) { + const [storyIndex, setStoryIndex] = React.useState(initialStoryIndex); + + const handleClose = React.useCallback>( + (event, reason) => { + onClose?.(event, reason); + }, + [onClose], + ); + + const {callback: closeWithLS} = useSyncWithLS>({ + callback: (event, reason) => { + onClose?.(event, reason); + }, + uniqueKey: `close-story-${syncInTabsKey}`, + }); + + const handleButtonClose = React.useCallback< + (event: MouseEvent | KeyboardEvent | React.MouseEvent) => void + >( + (event) => { + handleClose(event, 'closeButtonClick'); + if (syncInTabsKey) closeWithLS(event, 'closeButtonClick'); + }, + [handleClose, syncInTabsKey, closeWithLS], + ); + + const handleGotoPrevious = React.useCallback(() => { + setStoryIndex((currentStoryIndex) => { + if (currentStoryIndex <= 0) { + return 0; + } + + const newIndex = currentStoryIndex - 1; + onPreviousClick?.(newIndex); + return newIndex; + }); + }, [onPreviousClick]); + + const handleGotoNext = React.useCallback(() => { + setStoryIndex((currentStoryIndex) => { + if (currentStoryIndex >= items.length - 1) { + return items.length - 1; + } + + const newIndex = currentStoryIndex + 1; + onNextClick?.(newIndex); + return newIndex; + }); + }, [items, onNextClick]); + + if (items.length === 0) { + return null; + } + + // case when items has changed and index has ceased to be valid + if (items[storyIndex] === undefined) { + const correctIndex = items[initialStoryIndex] === undefined ? 0 : initialStoryIndex; + setStoryIndex(correctIndex); + + return null; + } + + const indexType = + (items.length === 1 && IndexType.Single) || + (storyIndex === 0 && IndexType.Start) || + (storyIndex === items.length - 1 && IndexType.End) || + IndexType.InProccess; + + return ( + + + + ); +} diff --git a/src/components/unstable/Stories/__stories__/Stories.stories.tsx b/src/components/unstable/Stories/__stories__/Stories.stories.tsx new file mode 100644 index 00000000..fb8849c1 --- /dev/null +++ b/src/components/unstable/Stories/__stories__/Stories.stories.tsx @@ -0,0 +1,327 @@ +import React from 'react'; + +import {Button, Flex, Text} from '@gravity-ui/uikit'; +import type {Meta, StoryFn} from '@storybook/react'; + +import type {StoriesProps} from '../Stories'; +import {Stories} from '../Stories'; +import type {StoriesItem} from '../types'; +import {StoriesMediaBlockStyle, StoriesTextBlockStyle} from '../types'; + +export default { + title: 'Components/unstable/Stories', + component: Stories, +} as Meta; + +const items: StoriesItem[] = [ + { + title: 'New navigation', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + firstAction: { + children: 'First button', + view: 'action', + }, + secondAction: { + children: 'Second button', + view: 'normal', + }, + description: + 'At the top of the panel is the service navigation for each service. ' + + 'Below are common navigation elements: a component for switching between accounts ' + + 'and organizations, settings, help center, search, notifications, favorites.' + + 'At the top of the panel is the service navigation for each service. ' + + 'Below are common navigation elements: a component for switching between accounts ' + + 'and organizations, settings, help center, search, notifications, favorites.' + + 'At the top of the panel is the service navigation for each service. ' + + 'Below are common navigation elements: a component for switching between accounts ' + + 'and organizations, settings, help center, search, notifications, favorites.' + + 'At the top of the panel is the service navigation for each service. ' + + 'Below are common navigation elements: a component for switching between accounts ' + + 'and organizations, settings, help center, search, notifications, favorites.' + + 'At the top of the panel is the service navigation for each service. ' + + 'Below are common navigation elements: a component for switching between accounts ' + + 'and organizations, settings, help center, search, notifications, favorites.', + url: 'https://yandex.eu', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-6.png', + }, + }, + { + title: 'New navigation (2)', + description: 'A little more about the new navigation', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + firstAction: { + children: 'First button', + view: 'action', + }, + secondAction: { + children: 'Second button', + view: 'normal', + }, + media: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-6.png', + type: 'image', + }, + }, + { + title: 'New navigation (2)', + description: 'A little more about the new navigation', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.HalfSize, + media: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + type: 'video', + }, + }, + { + title: 'New navigation (2)', + description: 'A little more about the new navigation', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + media: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + type: 'video', + }, + }, + { + title: 'New navigation (3)', + description: 'Switch to the new navigation right now', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.HalfSizeWithMargins, + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-7.png', + }, + }, +]; + +const BaseStory = ({description, items}: {description: string; items: StoriesProps['items']}) => { + const [visible, setVisible] = React.useState(false); + + return ( + + {description} + + + { + setVisible(false); + }} + /> + + ); +}; + +const DefaultTemplate: StoryFn = () => { + const itemsFirst: StoriesProps['items'] = [ + { + title: 'Default story with simple text', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.HalfSizeWithMargins, + description: + 'This story has default value for props textBlockStyle = "card" and mediaBlockStyle = "half-size-with-margins" ', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-7.png', + }, + }, + { + title: 'Story without margins in media block', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.HalfSize, + description: + 'Props value: textBlockStyle = "card", mediaBlockStyle = "half-size-with-margins" and media type = "image"', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-7.png', + }, + }, + { + title: 'Story with full-size media block', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + description: + 'Props value: textBlockStyle = "card", mediaBlockStyle = "full-size" and media type = "image"', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-6.png', + }, + }, + { + title: 'Story with full-size media block and transparent text-block', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + description: + 'Props value: textBlockStyle = "transparent", mediaBlockStyle = "full-size" and media type = "image"', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-6.png', + }, + }, + ]; + + const itemsSecond: StoriesProps['items'] = [ + { + title: 'Story with video half-size', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.HalfSize, + description: + 'Props value: textBlockStyle = "transparent", mediaBlockStyle = "half-size" and media type = "video"', + media: { + type: 'video', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + }, + }, + { + title: 'Story with video half-size with margins and extra actions', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.HalfSizeWithMargins, + firstAction: { + children: 'First button', + view: 'action', + }, + secondAction: { + children: 'Second button', + view: 'normal', + }, + description: + 'Props value: textBlockStyle = "card", mediaBlockStyle = "half-size-with-margins" and media type = "video"', + media: { + type: 'video', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + }, + }, + { + title: 'Story with video full-size', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + description: + 'Props value: textBlockStyle = "card", mediaBlockStyle = "full-size" and media type = "video"', + media: { + type: 'video', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + }, + }, + ]; + + const itemsThird: StoriesProps['items'] = [ + { + title: 'Story with full-size image, extra actions, long text and default text colors', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + firstAction: { + children: 'First button', + view: 'action', + }, + secondAction: { + children: 'Second button', + view: 'normal', + }, + description: + 'Lorem ipsum odor amet, consectetuer adipiscing elit. Nunc at parturient tristique senectus class duis eget per taciti. Eu rutrum est euismod risus aliquet in. Vehicula habitant nostra enim quis blandit consequat. Blandit ex ut purus; vestibulum accumsan duis? Porttitor accumsan at molestie integer nulla habitant? Egestas urna suscipit eleifend tortor mauris montes vulputate primis?Tempor viverra vitae tempus consectetur egestas? Quam dolor dictumst pellentesque porta; pulvinar conubia placerat risus. Leo at elementum vivamus fermentum erat taciti. Turpis ipsum faucibus primis purus, montes curae eu vel. Lacus metus sagittis dictumst diam libero imperdiet rhoncus neque. Natoque nullam inceptos porttitor integer porttitor nascetur a interdum. Imperdiet scelerisque rutrum congue massa eleifend torquent nisi. Sociosqu libero volutpat nisl orci viverra. Tristique egestas auctor conubia; etiam lectus scelerisque ligula. Magnis ultrices venenatis vivamus hac taciti inceptos leo. Interdum magnis sollicitudin elementum placerat montes. Lacinia platea netus nascetur ornare sociosqu. Inceptos taciti iaculis interdum nisl sodales in eros fermentum. Justo maecenas elementum condimentum feugiat consectetur semper sollicitudin. Primis sodales posuere facilisis donec ipsum efficitur. Faucibus accumsan lectus bibendum rhoncus maecenas, eget aliquam netus. Lectus torquent ut sodales fringilla natoque.', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-6.png', + }, + }, + { + title: 'Story with full-size image, transparent text-block and long content with extra actions and custom text colors', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + textColorStyles: { + titleColor: 'var(--g-color-text-brand)', + counterColor: 'var(--g-color-text-brand)', + descriptionColor: 'var(--g-color-text-inverted-primary)', + }, + firstAction: { + children: 'First button', + view: 'action', + }, + secondAction: { + children: 'Second button', + view: 'normal-contrast', + }, + description: + 'Lorem ipsum odor amet, consectetuer adipiscing elit. Nunc at parturient tristique senectus class duis eget per taciti. Eu rutrum est euismod risus aliquet in. Vehicula habitant nostra enim quis blandit consequat. Blandit ex ut purus; vestibulum accumsan duis? Porttitor accumsan at molestie integer nulla habitant? Egestas urna suscipit eleifend tortor mauris montes vulputate primis?Tempor viverra vitae tempus consectetur egestas? Quam dolor dictumst pellentesque porta; pulvinar conubia placerat risus. Leo at elementum vivamus fermentum erat taciti. Turpis ipsum faucibus primis purus, montes curae eu vel. Lacus metus sagittis dictumst diam libero imperdiet rhoncus neque. Natoque nullam inceptos porttitor integer porttitor nascetur a interdum. Imperdiet scelerisque rutrum congue massa eleifend torquent nisi. Sociosqu libero volutpat nisl orci viverra. Tristique egestas auctor conubia; etiam lectus scelerisque ligula. Magnis ultrices venenatis vivamus hac taciti inceptos leo. Interdum magnis sollicitudin elementum placerat montes. Lacinia platea netus nascetur ornare sociosqu. Inceptos taciti iaculis interdum nisl sodales in eros fermentum. Justo maecenas elementum condimentum feugiat consectetur semper sollicitudin. Primis sodales posuere facilisis donec ipsum efficitur. Faucibus accumsan lectus bibendum rhoncus maecenas, eget aliquam netus. Lectus torquent ut sodales fringilla natoque.', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-7.png', + }, + }, + { + title: 'Story with full-size video, extra actions, long text and default text colors', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + firstAction: { + children: 'First button', + view: 'action', + }, + secondAction: { + children: 'Second button', + view: 'normal', + }, + description: + 'Lorem ipsum odor amet, consectetuer adipiscing elit. Nunc at parturient tristique senectus class duis eget per taciti. Eu rutrum est euismod risus aliquet in. Vehicula habitant nostra enim quis blandit consequat. Blandit ex ut purus; vestibulum accumsan duis? Porttitor accumsan at molestie integer nulla habitant? Egestas urna suscipit eleifend tortor mauris montes vulputate primis?Tempor viverra vitae tempus consectetur egestas? Quam dolor dictumst pellentesque porta; pulvinar conubia placerat risus. Leo at elementum vivamus fermentum erat taciti. Turpis ipsum faucibus primis purus, montes curae eu vel. Lacus metus sagittis dictumst diam libero imperdiet rhoncus neque. Natoque nullam inceptos porttitor integer porttitor nascetur a interdum. Imperdiet scelerisque rutrum congue massa eleifend torquent nisi. Sociosqu libero volutpat nisl orci viverra. Tristique egestas auctor conubia; etiam lectus scelerisque ligula. Magnis ultrices venenatis vivamus hac taciti inceptos leo. Interdum magnis sollicitudin elementum placerat montes. Lacinia platea netus nascetur ornare sociosqu. Inceptos taciti iaculis interdum nisl sodales in eros fermentum. Justo maecenas elementum condimentum feugiat consectetur semper sollicitudin. Primis sodales posuere facilisis donec ipsum efficitur. Faucibus accumsan lectus bibendum rhoncus maecenas, eget aliquam netus. Lectus torquent ut sodales fringilla natoque.', + media: { + type: 'video', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + }, + }, + ]; + + const exampleDescription = [ + 'Stories with different text and media block styles', + 'Stories with video content', + 'Stories with all features', + ]; + + return ( + + {[itemsFirst, itemsSecond, itemsThird].map((items, index) => { + return ( + + ); + })} + + ); +}; +export const Default = DefaultTemplate.bind({}); +Default.args = { + open: false, + items, +}; +Default.argTypes = { + onPreviousClick: {action: 'onPreviousClick'}, + onNextClick: {action: 'onNextClick'}, +}; + +export const Single = DefaultTemplate.bind({}); +Single.args = { + open: false, + items: [items[0]], +}; + +export const WithCustomAction = DefaultTemplate.bind({}); +WithCustomAction.args = { + open: false, + items: [items[0]], +}; + +export const WithSyncInTabs = DefaultTemplate.bind({}); +WithSyncInTabs.args = { + open: true, + syncInTabsKey: 'test-story', + items: [items[0]], +}; diff --git a/src/components/unstable/Stories/components/ImageView/ImageView.scss b/src/components/unstable/Stories/components/ImageView/ImageView.scss new file mode 100644 index 00000000..60c88bcf --- /dev/null +++ b/src/components/unstable/Stories/components/ImageView/ImageView.scss @@ -0,0 +1,33 @@ +@use '../../../variables'; + +$block: '.#{variables.$ns}stories-image-view'; + +$borderRadius: 20px; + +#{$block} { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + + &_style_half-size { + border-radius: $borderRadius; + } + + &_style_half-size-with-margins { + width: calc(100% - 2 * var(--g-spacing-2)); + height: calc(100% - 2 * var(--g-spacing-2)); + border-radius: $borderRadius; + + margin: var(--g-spacing-2); + } + + &_style_full-size { + position: absolute; + border-radius: $borderRadius; + + inset-block-start: 0; + inset-inline-start: 0; + z-index: 0; + } +} diff --git a/src/components/unstable/Stories/components/ImageView/ImageView.tsx b/src/components/unstable/Stories/components/ImageView/ImageView.tsx new file mode 100644 index 00000000..38b2c746 --- /dev/null +++ b/src/components/unstable/Stories/components/ImageView/ImageView.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import {block} from '../../../utils/cn'; +import type {MediaRendererProps} from '../MediaRenderer/MediaRenderer'; + +import './ImageView.scss'; + +const b = block('stories-image-view'); + +export interface ImageViewProps extends MediaRendererProps {} + +export function ImageView({media, style}: ImageViewProps) { + if (media.type === 'image') { + return ( +
+ ); + } + + return null; +} diff --git a/src/components/unstable/Stories/components/MediaRenderer/MediaRenderer.tsx b/src/components/unstable/Stories/components/MediaRenderer/MediaRenderer.tsx new file mode 100644 index 00000000..9ab0e746 --- /dev/null +++ b/src/components/unstable/Stories/components/MediaRenderer/MediaRenderer.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import {ImageView, VideoView} from '../../components'; +import type {StoriesItemMedia} from '../../types'; +import {StoriesMediaBlockStyle} from '../../types'; + +export interface MediaRendererProps { + media: StoriesItemMedia; + style?: StoriesMediaBlockStyle; +} + +export function MediaRenderer({media, style}: MediaRendererProps) { + return (media.type || 'image') === 'image' ? ( + + ) : ( + + ); +} diff --git a/src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.scss b/src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.scss new file mode 100644 index 00000000..75048a9b --- /dev/null +++ b/src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.scss @@ -0,0 +1,128 @@ +@use '@gravity-ui/uikit/styles/mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}stories-layout'; + +$maxWidth: 1280px; +$maxHeight: 640px; +$minWidth: 800px; +$minHeight: 480px; +$fixedBtnSize: 44px; + +$actionBtnZIndex: 50; +$textContentZIndex: 30; + +#{$block} { + &__wrap-outer { + height: calc(100vh - 2 * var(--g-modal-margin)); + width: calc(100vw - 2 * var(--g-modal-margin)); + background-color: var(--g-color-base-background); + display: flex; + border-radius: var(--g-spacing-5); + max-width: $maxWidth; + max-height: $maxHeight; + min-width: $minWidth; + min-height: $minHeight; + } + + &__container { + display: flex; + box-shadow: 0 8px 20px var(--g-color-sfx-shadow); + border-radius: var(--g-spacing-5); + position: relative; + + width: 100%; + height: 100%; + } + + &__left-pane { + flex-basis: calc(50% - var(--g-spacing-4)); + margin-inline: var(--g-spacing-2); + margin-block: var(--g-spacing-2); + padding: var(--g-spacing-8); + + display: flex; + flex-direction: column; + align-items: stretch; + flex-shrink: 0; + box-sizing: border-box; + z-index: $textContentZIndex; + + &_blockStyle_card { + background-color: var(--g-color-base-background); + border-radius: var(--g-spacing-3); + } + + &_blockStyle_transparent { + background-color: transparent; + } + } + + &__right-pane { + flex-basis: 50%; + + width: 100%; + height: 100%; + + display: flex; + } + + &__counter { + color: var(--g-color-text-secondary); + } + + &__text-block { + display: flex; + flex-grow: 1; + align-items: flex-start; + justify-content: flex-start; + flex-direction: column; + margin-block-start: var(--g-spacing-10); + overflow-y: scroll; + } + + &__action-block { + margin-block-start: var(--g-spacing-8); + } + + &__text-header { + color: var(--g-color-text-primary); + } + + &__text-content { + color: var(--g-color-text-complementary); + + #{$block}__text-header + & { + margin-block-start: var(--g-spacing-4); + } + } + + &__story-link-block { + margin-block-start: var(--g-spacing-4); + } + + &__navigation-btn { + --g-border-radius-xl: 50%; + + position: absolute; + inset-block-start: calc(50% - ($fixedBtnSize / 2)); + z-index: $actionBtnZIndex; + + &_back { + inset-inline-start: calc(0px - ($fixedBtnSize / 2)); + } + + &_next { + inset-inline-end: calc(0px - ($fixedBtnSize / 2)); + } + } + + &__close-btn { + --g-border-radius-xl: 50%; + + position: absolute; + inset-block-start: var(--g-spacing-4); + inset-inline-end: var(--g-spacing-4); + z-index: $actionBtnZIndex; + } +} diff --git a/src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.tsx b/src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.tsx new file mode 100644 index 00000000..652346eb --- /dev/null +++ b/src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.tsx @@ -0,0 +1,167 @@ +import React from 'react'; + +import {ChevronLeft, ChevronRight, Xmark} from '@gravity-ui/icons'; +import {Button, Flex, Icon, Link, Text} from '@gravity-ui/uikit'; + +import {MediaRenderer} from '..'; +import {block} from '../../../utils/cn'; +import {i18n} from '../../i18n'; +import type {StoriesItem} from '../../types'; +import {StoriesTextBlockStyle} from '../../types'; + +import './StoriesLayout.scss'; + +const b = block('stories-layout'); + +export enum IndexType { + Start = 1, + End, + InProccess, + Single, +} + +export type StoriesLayoutProps = { + items: StoriesItem[]; + storyIndex: number; + indexType: IndexType; + handleButtonClose: ( + event: MouseEvent | KeyboardEvent | React.MouseEvent, + ) => void; + handleGotoPrevious: () => void; + handleGotoNext: () => void; +}; + +// StoriesGroup component also use it +export const StoriesLayout = ({ + items, + indexType, + storyIndex, + handleButtonClose, + handleGotoNext, + handleGotoPrevious, +}: StoriesLayoutProps) => { + const currentStory = items[storyIndex]; + + return ( +
+
+
+ {items.length > 1 && ( + + {storyIndex + 1} / {items.length} + + )} +
+ {currentStory && ( + + {currentStory.title && ( + + {currentStory.title} + + )} + {currentStory.description && ( + + {currentStory.description} + + )} + {currentStory.url && ( +
+ + {i18n('label_more')} + +
+ )} + {Boolean(currentStory.firstAction || currentStory.secondAction) && ( + + {currentStory.firstAction && ( +
+
+
+ {currentStory?.media && ( + + )} +
+ + {indexType !== IndexType.Start && indexType !== IndexType.Single && ( + + )} + {indexType !== IndexType.End && indexType !== IndexType.Single && ( + + )} +
+
+ ); +}; diff --git a/src/components/unstable/Stories/components/VideoView/VideoView.scss b/src/components/unstable/Stories/components/VideoView/VideoView.scss new file mode 100644 index 00000000..4b3d5d15 --- /dev/null +++ b/src/components/unstable/Stories/components/VideoView/VideoView.scss @@ -0,0 +1,43 @@ +@use '../../../variables'; + +$block: '.#{variables.$ns}stories-video-view'; + +#{$block} { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + + position: relative; + overflow: hidden; + + &_style_half-size { + border-radius: var(--g-spacing-5); + } + + &_style_half-size-with-margins { + width: calc(100% - 2 * var(--g-spacing-2)); + height: calc(100% - 2 * var(--g-spacing-2)); + + border-radius: var(--g-spacing-5); + margin: var(--g-spacing-2); + } + + &_style_full-size { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + border-radius: var(--g-spacing-5); + + z-index: 0; + } + + &__video { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; + + z-index: 0; + } +} diff --git a/src/components/unstable/Stories/components/VideoView/VideoView.tsx b/src/components/unstable/Stories/components/VideoView/VideoView.tsx new file mode 100644 index 00000000..d973f5cb --- /dev/null +++ b/src/components/unstable/Stories/components/VideoView/VideoView.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import {block} from '../../../utils/cn'; +import type {MediaRendererProps} from '../MediaRenderer/MediaRenderer'; + +import './VideoView.scss'; + +const b = block('stories-video-view'); + +export interface VideoViewProps extends MediaRendererProps {} + +export function VideoView({media, style}: VideoViewProps) { + if (media.type === 'video') { + return ( +
+
+ ); + } + + return null; +} diff --git a/src/components/unstable/Stories/components/index.ts b/src/components/unstable/Stories/components/index.ts new file mode 100644 index 00000000..a5c6628f --- /dev/null +++ b/src/components/unstable/Stories/components/index.ts @@ -0,0 +1,3 @@ +export * from './ImageView/ImageView'; +export * from './VideoView/VideoView'; +export * from './MediaRenderer/MediaRenderer'; diff --git a/src/components/unstable/Stories/hooks/index.ts b/src/components/unstable/Stories/hooks/index.ts new file mode 100644 index 00000000..b4312444 --- /dev/null +++ b/src/components/unstable/Stories/hooks/index.ts @@ -0,0 +1 @@ +export * from './useSyncWithLS'; diff --git a/src/components/unstable/Stories/hooks/useSyncWithLS/README.md b/src/components/unstable/Stories/hooks/useSyncWithLS/README.md new file mode 100644 index 00000000..a9ee3ce0 --- /dev/null +++ b/src/components/unstable/Stories/hooks/useSyncWithLS/README.md @@ -0,0 +1,20 @@ + + +# useSyncWithLS + + + +```tsx +import {useSyncWithLS} from '@gravity-ui/components'; +``` + +The `useSyncWithLS` hook executes callback when value changed in Local Storage + +## Properties + +| Name | Description | Type | Default | +| :----------------------- | :----------------------------------------------------------- | :------------: | :---------: | +| callback | Callback function called when key in local storage triggered | `VoidFunction` | | +| dataSourceName | Name for data source of keys | `string` | 'sync-tabs' | +| uniqueKey | Key in local storage for handle | `string` | | +| disableActiveTabCallback | Disable callback in tab that triggers local storage | `boolean` | 'false' | diff --git a/src/components/unstable/Stories/hooks/useSyncWithLS/index.ts b/src/components/unstable/Stories/hooks/useSyncWithLS/index.ts new file mode 100644 index 00000000..d8481236 --- /dev/null +++ b/src/components/unstable/Stories/hooks/useSyncWithLS/index.ts @@ -0,0 +1,2 @@ +export {useSyncWithLS} from './useSyncWithLS'; +export type {UseSyncWithLSInputProps, UseSyncWithLSOutputProps} from './useSyncWithLS'; diff --git a/src/components/unstable/Stories/hooks/useSyncWithLS/useSyncWithLS.ts b/src/components/unstable/Stories/hooks/useSyncWithLS/useSyncWithLS.ts new file mode 100644 index 00000000..5fdcdc40 --- /dev/null +++ b/src/components/unstable/Stories/hooks/useSyncWithLS/useSyncWithLS.ts @@ -0,0 +1,46 @@ +import React from 'react'; + +export type UseSyncWithLSInputProps = { + callback: T; + uniqueKey: string; + dataSourceName?: string; + disableActiveTabCallback?: boolean; +}; +export type UseSyncWithLSOutputProps = {callback: (...args: unknown[]) => void}; + +export const useSyncWithLS = ({ + dataSourceName = 'sync-tabs', + callback, + uniqueKey, + disableActiveTabCallback = false, +}: UseSyncWithLSInputProps): UseSyncWithLSOutputProps => { + const key = `${dataSourceName}.${uniqueKey}`; + + const handler = (event: StorageEvent) => { + if (event.key === key && event.newValue) { + return callback(); + } + return undefined; + }; + + React.useEffect(() => { + // Action in non-active tab + window.addEventListener('storage', handler); + + return () => { + window.removeEventListener('storage', handler); + localStorage.removeItem(key); + }; + }); + + return { + callback: React.useCallback(() => { + localStorage.setItem(key, String(Number(localStorage.getItem(key) || '0') + 1)); + + if (disableActiveTabCallback) { + return undefined; + } + return callback(); + }, [key, disableActiveTabCallback, callback]), + }; +}; diff --git a/src/components/unstable/Stories/i18n/en.json b/src/components/unstable/Stories/i18n/en.json new file mode 100644 index 00000000..c23bb920 --- /dev/null +++ b/src/components/unstable/Stories/i18n/en.json @@ -0,0 +1,6 @@ +{ + "label_back": "Back", + "label_next": "Next", + "label_close": "Close", + "label_more": "More" +} diff --git a/src/components/unstable/Stories/i18n/index.ts b/src/components/unstable/Stories/i18n/index.ts new file mode 100644 index 00000000..4f213944 --- /dev/null +++ b/src/components/unstable/Stories/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '@gravity-ui/uikit/i18n'; + +import {NAMESPACE} from '../../../utils/cn'; + +import en from './en.json'; +import ru from './ru.json'; + +export const i18n = addComponentKeysets({en, ru}, `${NAMESPACE}stories`); diff --git a/src/components/unstable/Stories/i18n/ru.json b/src/components/unstable/Stories/i18n/ru.json new file mode 100644 index 00000000..7c082d74 --- /dev/null +++ b/src/components/unstable/Stories/i18n/ru.json @@ -0,0 +1,6 @@ +{ + "label_back": "Назад", + "label_next": "Дальше", + "label_close": "Закрыть", + "label_more": "Подробнее" +} diff --git a/src/components/unstable/Stories/index.ts b/src/components/unstable/Stories/index.ts new file mode 100644 index 00000000..066d3171 --- /dev/null +++ b/src/components/unstable/Stories/index.ts @@ -0,0 +1,3 @@ +export * from './Stories'; +export * from './hooks'; +export * from './types'; diff --git a/src/components/unstable/Stories/types.ts b/src/components/unstable/Stories/types.ts new file mode 100644 index 00000000..6fc85981 --- /dev/null +++ b/src/components/unstable/Stories/types.ts @@ -0,0 +1,44 @@ +import {ButtonProps} from '@gravity-ui/uikit'; + +export type StoriesItemMedia = {url: string} & ( + | { + type: 'image'; + /** url for img srcSet props - apply on Retina display */ + url2x?: string; + } + | { + type: 'video'; + /** A URL for an image to be shown while the video is downloading */ + posterUrl?: string; + } +); + +export enum StoriesTextBlockStyle { + Card = 'card', + Transparent = 'transparent', +} + +export enum StoriesMediaBlockStyle { + HalfSizeWithMargins = 'half-size-with-margins', + HalfSize = 'half-size', + FullSize = 'full-size', +} + +interface StoriesItemTextColorStyles { + titleColor?: string; + descriptionColor?: string; + counterColor?: string; +} + +export interface StoriesItem { + title?: string; + description?: string; + textColorStyles?: StoriesItemTextColorStyles; + textBlockStyle: StoriesTextBlockStyle; + mediaBlockStyle: StoriesMediaBlockStyle; + firstAction?: ButtonProps; + secondAction?: ButtonProps; + /** Url for link "more" */ + url?: string; + media?: StoriesItemMedia; +} diff --git a/src/components/unstable/StoriesGroup/README.md b/src/components/unstable/StoriesGroup/README.md new file mode 100644 index 00000000..17d8a01f --- /dev/null +++ b/src/components/unstable/StoriesGroup/README.md @@ -0,0 +1,68 @@ +## StoriesGroup + +Component for displaying group of stories. It looks like a carousel in a modal with given places to display text and media. + +### PropTypes + +| Property | Type | Required | Default | Description | +| :------------------ | :---------------------------------------- | :------- | :------ | :--------------------------------------------------------- | +| open | `Boolean` | ✓ | | Visibility flag | +| groups | `StoriesGroupItem[]` | ✓ | | List of groups of stories to display | +| initialStoryIndex | `[groupIndex: Number, itemIndex: Number]` | | [0, 0] | Index of the first story to be displayed | +| onClose | `Function` | | | Action on close | +| onItemSelect | `Function` | | | Action when switching to story | +| disableOutsideClick | `Boolean` | | true | If `true`, do not close stories on click outside | +| maxSliderItemsCount | `Number` | | 12 | Positive maximum number of slider thumbnails on the screen | + +### StoriesGroupItem object + +| Field | Type | Required | Default | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------ | -------- | ------- | ------------------------------------------------------------------------------------------ | +| items | `[StoriesItem](https://github.com/gravity-ui/uikit/tree/main/src/components/Stories#storiesitem-object)[]` | | | Array of items as in `Stories` component | +| thumbnailMedia | `[StoriesItemMedia](https://github.com/gravity-ui/uikit/tree/main/src/components/Stories#storiesitemmedia-object)` | | | Media content for preview, otherwise first StoriesItem.media from StoriesItem will be used | + +#### Usage example + +```jsx harmony + +``` diff --git a/src/components/unstable/StoriesGroup/StoriesGroup.scss b/src/components/unstable/StoriesGroup/StoriesGroup.scss new file mode 100644 index 00000000..6f2249cb --- /dev/null +++ b/src/components/unstable/StoriesGroup/StoriesGroup.scss @@ -0,0 +1,18 @@ +@use '../variables'; + +$block: '.#{variables.$ns}stories-group'; +$borderRadius: 20px; + +#{$block} { + --g-modal-border-radius: #{$borderRadius}; + --g-modal-margin: 20px; + + & .g-modal__content-wrapper { + overflow-x: initial; + } + + &__modal-content { + background-color: inherit; + border-radius: $borderRadius; + } +} diff --git a/src/components/unstable/StoriesGroup/StoriesGroup.tsx b/src/components/unstable/StoriesGroup/StoriesGroup.tsx new file mode 100644 index 00000000..27ebaee9 --- /dev/null +++ b/src/components/unstable/StoriesGroup/StoriesGroup.tsx @@ -0,0 +1,178 @@ +import React from 'react'; + +import {Modal} from '@gravity-ui/uikit'; +import type {ModalCloseReason} from '@gravity-ui/uikit'; + +import {IndexType, StoriesLayout} from '../Stories/components/StoriesLayout/StoriesLayout'; +import {block} from '../utils/cn'; + +import {StoriesPreview} from './components'; +import type {StoriesGroupItem} from './types'; + +import './StoriesGroup.scss'; + +const DEFAULT_MAX_SLIDER_ITEMS_COUNT = 12; + +const b = block('stories-group'); + +export interface StoriesGroupProps { + open: boolean; + groups: StoriesGroupItem[]; + initialStoryIndex?: [groupIndex: number, itemIndex: number]; + disableOutsideClick?: boolean; + maxSliderItemsCount?: number; + onClose?: ( + event: MouseEvent | KeyboardEvent | React.MouseEvent, + reason: ModalCloseReason | 'closeButtonClick', + ) => void; + onItemSelect?: ( + itemIndexes: [groupIndex: number, itemIndex: number], + selectedFromThumbnail: boolean, + ) => void; +} + +// eslint-disable-next-line complexity +export const StoriesGroup = ({ + open, + groups, + onItemSelect, + disableOutsideClick = true, + initialStoryIndex = [0, 0], + maxSliderItemsCount = DEFAULT_MAX_SLIDER_ITEMS_COUNT, + onClose, +}: StoriesGroupProps) => { + const [[groupIndex, itemIndex], setStoryIndex] = React.useState(initialStoryIndex); + + const handleClose = React.useCallback>( + (event, reason) => { + onClose?.(event, reason); + }, + [onClose], + ); + + const handleButtonClose = React.useCallback< + (event: MouseEvent | KeyboardEvent | React.MouseEvent) => void + >( + (event) => { + handleClose(event, 'closeButtonClick'); + }, + [handleClose], + ); + + const handleGotoPrevious = React.useCallback(() => { + setStoryIndex((prevState) => { + const [currentGroupIndex, currentItemIndex] = prevState; + + if (currentItemIndex > 0) { + const newState: [number, number] = [currentGroupIndex, currentItemIndex - 1]; + + onItemSelect?.(newState, false); + return newState; + } + + // try to find previous valid group + for (let i = currentGroupIndex - 1; i >= 0; --i) { + if (groups[i].items.length !== 0) { + const newState: [number, number] = [i, groups[i].items.length - 1]; + + onItemSelect?.(newState, false); + return newState; + } + } + + return prevState; + }); + }, [groups, onItemSelect]); + + const handleGotoNext = React.useCallback(() => { + setStoryIndex((prevState) => { + const [currentGroupIndex, currentItemIndex] = prevState; + + if (currentItemIndex < groups[currentGroupIndex]?.items.length - 1) { + const newState: [number, number] = [currentGroupIndex, currentItemIndex + 1]; + onItemSelect?.(newState, false); + return newState; + } + + // try to find next valid group + for (let i = currentGroupIndex + 1; i < groups.length; ++i) { + if (groups[i].items.length !== 0) { + const newState: [number, number] = [i, 0]; + onItemSelect?.(newState, false); + return newState; + } + } + + return prevState; + }); + }, [groups, onItemSelect]); + + const onGroupSelect = React.useCallback( + (newGroupIndex: number) => { + setStoryIndex([newGroupIndex, 0]); + onItemSelect?.([newGroupIndex, 0], true); + }, + [onItemSelect], + ); + + if (groups.length === 0) { + return null; + } + + const currentGroup = groups[groupIndex]; + const currentItems = currentGroup?.items || []; + + // case when groups has changed and indexs has ceased to be valid + if (currentGroup === undefined || currentItems[itemIndex] === undefined) { + if ( + groups[initialStoryIndex[0]] && + groups[initialStoryIndex[0]].items[initialStoryIndex[1]] + ) { + setStoryIndex(initialStoryIndex); + } else { + // try to find first valid index + for (let i = 0; i < groups.length; ++i) { + if (groups[i] && groups[i].items.length !== 0) { + setStoryIndex([i, 0]); + break; + } + } + } + + return null; + } + + const indexType = + (groups.length === 1 && currentItems.length === 1 && IndexType.Single) || + (groupIndex === 0 && itemIndex === 0 && IndexType.Start) || + (groupIndex === groups.length - 1 && + itemIndex === currentItems.length - 1 && + IndexType.End) || + IndexType.InProccess; + + return ( + + 0 ? maxSliderItemsCount : 1} + groups={groups} + groupIndex={groupIndex} + onGroupSelect={onGroupSelect} + onClose={disableOutsideClick ? undefined : handleClose} + /> + + + ); +}; diff --git a/src/components/unstable/StoriesGroup/__stories__/StoriesGroup.stories.tsx b/src/components/unstable/StoriesGroup/__stories__/StoriesGroup.stories.tsx new file mode 100644 index 00000000..e66fa6d6 --- /dev/null +++ b/src/components/unstable/StoriesGroup/__stories__/StoriesGroup.stories.tsx @@ -0,0 +1,152 @@ +import React from 'react'; + +import {Button} from '@gravity-ui/uikit'; +import type {Meta, StoryFn} from '@storybook/react'; + +import {StoriesMediaBlockStyle, StoriesTextBlockStyle} from '../../Stories'; +import {StoriesGroup} from '../StoriesGroup'; +import type {StoriesGroupProps} from '../StoriesGroup'; +import type {StoriesGroupItem} from '../types'; + +export default { + title: 'Components/unstable/StoriesGroup', + component: StoriesGroup, +} as Meta; + +const baseGroups: StoriesGroupItem[] = new Array(12).fill(0).map((_, index) => { + return { + items: [ + { + title: 'New navigation', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + description: + 'At the top of the panel is the service navigation for each service. ' + + 'Below are common navigation elements: a component for switching between accounts ' + + 'and organizations, settings, help center, search, notifications, favorites.', + url: 'https://yandex.eu', + media: { + type: 'image', + url: `https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-${(index % 3) + 5}.png`, + }, + }, + { + title: 'New navigation (2)', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.HalfSizeWithMargins, + description: 'A little more about the new navigation', + media: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + type: 'video', + }, + }, + ], + }; +}); + +const groups: StoriesGroupItem[] = [ + { + items: [ + { + title: 'New navigation', + textBlockStyle: StoriesTextBlockStyle.Card, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + firstAction: { + children: 'First button', + view: 'action', + }, + secondAction: { + children: 'Second button', + view: 'normal', + }, + description: + 'At the top of the panel is the service navigation for each service. ' + + 'Below are common navigation elements: a component for switching between accounts ' + + 'and organizations, settings, help center, search, notifications, favorites.', + url: 'https://yandex.eu', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-6.png', + }, + }, + { + title: 'New navigation', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.FullSize, + description: + 'At the top of the panel is the service navigation for each service. ' + + 'Below are common navigation elements: a component for switching between accounts ' + + 'and organizations, settings, help center, search, notifications, favorites.', + url: 'https://yandex.eu', + media: { + type: 'image', + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-6.png', + }, + }, + { + title: 'New navigation (2)', + textBlockStyle: StoriesTextBlockStyle.Transparent, + mediaBlockStyle: StoriesMediaBlockStyle.HalfSizeWithMargins, + firstAction: { + children: 'First button', + view: 'action', + }, + secondAction: { + children: 'Second button', + view: 'normal', + }, + description: 'A little more about the new navigation', + media: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + type: 'video', + }, + }, + ], + }, + ...baseGroups, +]; + +const DefaultTemplate: StoryFn = (props: StoriesGroupProps) => { + const [visible, setVisible] = React.useState(props.open); + + React.useEffect(() => { + setVisible(props.open); + }, [props.open]); + + return ( + +
+ +
+ { + setVisible(false); + }} + /> +
+ ); +}; +export const Default = DefaultTemplate.bind({}); +Default.args = { + initialStoryIndex: [0, 0], + open: false, + groups: groups.slice(0, 2), +}; +Default.argTypes = {}; + +export const MoreGroups = DefaultTemplate.bind({}); + +MoreGroups.args = { + initialStoryIndex: [0, 0], + open: false, + groups, +}; +MoreGroups.argTypes = {}; diff --git a/src/components/unstable/StoriesGroup/components/StoriesPreview/StoriesPreview.scss b/src/components/unstable/StoriesGroup/components/StoriesPreview/StoriesPreview.scss new file mode 100644 index 00000000..cb9e00c7 --- /dev/null +++ b/src/components/unstable/StoriesGroup/components/StoriesPreview/StoriesPreview.scss @@ -0,0 +1,65 @@ +@use '../../../variables'; + +$block: '.#{variables.$ns}stories-group-preview'; + +#{$block} { + user-select: none; + padding-block-end: 16px; + + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + + &__slider-preview-list-wrapper { + display: flex; + gap: 8px; + } + + &__stories-preview-item { + $disabled: #{&}_disabled; + + position: relative; + box-sizing: border-box; + + display: flex; + align-items: center; + justify-content: center; + + cursor: pointer; + + overflow: hidden; + width: 40px; + height: 40px; + border-radius: 8px; + + background-color: var(--g-color-base-background); + + &::after { + content: ''; + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + + background-color: var(--g-color-sfx-shadow); + width: 40px; + height: 40px; + } + + &_active, + &:hover:not(#{$disabled}) { + &::after { + display: none; + } + } + + &_active { + border: 2px solid var(--g-color-line-brand); + } + } + + &__slider-button-wrapper { + width: 28px; + height: 28px; + } +} diff --git a/src/components/unstable/StoriesGroup/components/StoriesPreview/StoriesPreview.tsx b/src/components/unstable/StoriesGroup/components/StoriesPreview/StoriesPreview.tsx new file mode 100644 index 00000000..abe7ff04 --- /dev/null +++ b/src/components/unstable/StoriesGroup/components/StoriesPreview/StoriesPreview.tsx @@ -0,0 +1,220 @@ +import React from 'react'; + +import {ChevronLeft, ChevronRight} from '@gravity-ui/icons'; +import {Button, Icon} from '@gravity-ui/uikit'; +import type {ButtonProps, ModalCloseReason} from '@gravity-ui/uikit'; + +import {MediaRenderer} from '../../../Stories/components'; +import type {StoriesItemMedia} from '../../../Stories/types'; +import {block} from '../../../utils/cn'; +import type {StoriesGroupItem} from '../../types'; + +import './StoriesPreview.scss'; + +const PREVIEW_ITEM_SIZE = 40; +const PREVIEW_LIST_GAP = 8; + +const b = block('stories-group-preview'); + +type PreviewItemProps = { + groupIndex: number; + + active: boolean; + disabled: boolean; + + media?: StoriesItemMedia; + onSelectGroup?: (groupIndex: number) => void; +}; +const PreviewItem = ({active, disabled, groupIndex, media, onSelectGroup}: PreviewItemProps) => { + const onClick = React.useCallback>( + (event) => { + event.preventDefault(); + event.stopPropagation(); + + onSelectGroup?.(groupIndex); + }, + [onSelectGroup, groupIndex], + ); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ {media && } +
+ ); +}; + +type StoriesPreviewListProps = { + groups: StoriesGroupItem[]; + groupIndex: number; + onGroupSelect: (groupIndex: number) => void; + offset?: number; +}; +const StoriesPreviewList = ({ + groupIndex, + groups, + onGroupSelect, + offset = 0, +}: StoriesPreviewListProps) => { + return ( + + {groups.map(({thumbnailMedia, items}, i) => { + const media = thumbnailMedia || items[0]?.media; + const currentGroupIndex = offset + i; + + return ( + + ); + })} + + ); +}; + +type StoriesPreviewListWithSliderProps = { + groups: StoriesGroupItem[]; + groupIndex: number; + onGroupSelect: (groupIndex: number) => void; + maxSliderItemsCount: number; +}; +const StoriesPreviewListWithSlider = ({ + groupIndex, + groups, + onGroupSelect, + maxSliderItemsCount, +}: StoriesPreviewListWithSliderProps) => { + const [offset, setOffset] = React.useState(0); + + React.useEffect(() => { + const currentOffset = maxSliderItemsCount * Math.floor(groupIndex / maxSliderItemsCount); + setOffset(currentOffset); + }, [groupIndex, maxSliderItemsCount]); + + const setPreviewOffset = React.useCallback>( + (event) => { + event.preventDefault(); + event.stopPropagation(); + + setOffset((currentOffset) => { + return currentOffset - maxSliderItemsCount; + }); + }, + [maxSliderItemsCount], + ); + + const setNextOffset = React.useCallback>( + (event) => { + event.preventDefault(); + event.stopPropagation(); + + setOffset((currentOffset) => { + return currentOffset + maxSliderItemsCount; + }); + }, + [maxSliderItemsCount], + ); + + return ( + + {groups.length > maxSliderItemsCount && ( +
+ {offset !== 0 && ( + + )} +
+ )} +
+ +
+ {groups.length > maxSliderItemsCount && ( +
+ {offset < groups.length - maxSliderItemsCount && ( + + )} +
+ )} +
+ ); +}; + +export type StoriesPreviewProps = { + groups: StoriesGroupItem[]; + groupIndex: number; + onGroupSelect: (groupIndex: number) => void; + maxSliderItemsCount: number; + onClose?: ( + event: MouseEvent | KeyboardEvent | React.MouseEvent, + reason: ModalCloseReason, + ) => void; +}; + +// all onClick handlers of StoriesPreview childrens should have preventDefault() and stopPropagation() callings + +export const StoriesPreview = ({ + groups, + groupIndex, + onGroupSelect, + maxSliderItemsCount, + onClose, +}: StoriesPreviewProps) => { + const handleClose = React.useCallback>( + (event) => { + onClose?.(event, 'outsideClick'); + }, + [onClose], + ); + + if (groups.length < maxSliderItemsCount) { + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ +
+ ); + } + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ +
+ ); +}; diff --git a/src/components/unstable/StoriesGroup/components/index.ts b/src/components/unstable/StoriesGroup/components/index.ts new file mode 100644 index 00000000..ed721757 --- /dev/null +++ b/src/components/unstable/StoriesGroup/components/index.ts @@ -0,0 +1 @@ +export * from './StoriesPreview/StoriesPreview'; diff --git a/src/components/unstable/StoriesGroup/index.ts b/src/components/unstable/StoriesGroup/index.ts new file mode 100644 index 00000000..94e68de7 --- /dev/null +++ b/src/components/unstable/StoriesGroup/index.ts @@ -0,0 +1,2 @@ +export * from './StoriesGroup'; +export * from './types'; diff --git a/src/components/unstable/StoriesGroup/types.ts b/src/components/unstable/StoriesGroup/types.ts new file mode 100644 index 00000000..48a4f2ee --- /dev/null +++ b/src/components/unstable/StoriesGroup/types.ts @@ -0,0 +1,7 @@ +import type {StoriesItem, StoriesItemMedia} from '../Stories/types'; + +export interface StoriesGroupItem { + items: StoriesItem[]; + /** Custom image for preview, otherwise first StoriesItem.media from StoriesItem will be used */ + thumbnailMedia?: StoriesItemMedia; +} diff --git a/src/components/unstable/index.ts b/src/components/unstable/index.ts new file mode 100644 index 00000000..b261238c --- /dev/null +++ b/src/components/unstable/index.ts @@ -0,0 +1,8 @@ +/* eslint-disable camelcase */ +export { + type StoriesItemMedia as unstable_StoriesItemMedia, + type StoriesItem as unstable_StoriesItem, + type StoriesProps as unstable_StoriesProps, + Stories as unstable_Stories, + useSyncWithLS as unstable_useSyncWithLS, +} from './Stories'; diff --git a/src/components/unstable/utils/cn.ts b/src/components/unstable/utils/cn.ts new file mode 100644 index 00000000..ddad9f31 --- /dev/null +++ b/src/components/unstable/utils/cn.ts @@ -0,0 +1,8 @@ +import {withNaming} from '@bem-react/classname'; + +export type CnMods = Record; + +export const NAMESPACE = 'gcu-'; + +export const cn = withNaming({e: '__', m: '_', v: '_'}); +export const block = withNaming({n: NAMESPACE, e: '__', m: '_', v: '_'}); diff --git a/src/components/unstable/variables.scss b/src/components/unstable/variables.scss new file mode 100644 index 00000000..c3925b57 --- /dev/null +++ b/src/components/unstable/variables.scss @@ -0,0 +1,14 @@ +$ns: 'gcu-'; + +// Sizes +$tinyOffset: 5px; +$inlineOffset: 12px; +$regularOffset: 16px; +$normalOffset: 20px; +$mediumOffset: 30px; +$bigOffset: 40px; +$doubleInlineOffset: $inlineOffset * 2; +$doubleRegularOffset: $regularOffset * 2; +$smallOffset: calc(#{$normalOffset} * 0.5); +$microOffset: calc(#{$regularOffset} * 0.5); +$nanoOffset: calc(#{$microOffset} * 0.5);