From 06850246df2413fc22ec4a509c334e04f69582d8 Mon Sep 17 00:00:00 2001 From: Roman Barlos Date: Mon, 9 Dec 2024 17:35:40 +0300 Subject: [PATCH] feat(Stories): add unstable versions of Stories and StoriesGroup components --- package.json | 22 ++ src/components/unstable/Stories/README.md | 52 +++++ src/components/unstable/Stories/Stories.scss | 13 ++ src/components/unstable/Stories/Stories.tsx | 135 +++++++++++ .../Stories/__stories__/Stories.stories.tsx | 103 ++++++++ .../components/ImageView/ImageView.scss | 10 + .../components/ImageView/ImageView.tsx | 18 ++ .../MediaRenderer/MediaRenderer.tsx | 16 ++ .../StoriesLayout/StoriesLayout.scss | 128 ++++++++++ .../StoriesLayout/StoriesLayout.tsx | 138 +++++++++++ .../components/VideoView/VideoView.scss | 10 + .../components/VideoView/VideoView.tsx | 29 +++ .../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 | 23 ++ .../unstable/StoriesGroup/README.md | 66 ++++++ .../unstable/StoriesGroup/StoriesGroup.scss | 14 ++ .../unstable/StoriesGroup/StoriesGroup.tsx | 178 ++++++++++++++ .../__stories__/StoriesGroup.stories.tsx | 86 +++++++ .../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 + 32 files changed, 1439 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 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..045ed091 --- /dev/null +++ b/src/components/unstable/Stories/README.md @@ -0,0 +1,52 @@ +## 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 | +| action | `ButtonProps` | | | Custom action button props for the last step | + +### StoriesItem object + +| Field | Type | Required | Default | Description | +| ----------- | ------------------ | -------- | ------- | -------------------------------- | +| title | `String` | | | Title | +| description | `String` | | | Main text, deprecated | +| content | `React.ReactNode` | | | Main content | +| url | `String` | | | Link to display more information | +| media | `StoriesItemMedia` | | | Media content | + +### StoriesItemMedia object + +| Field | Type | Required | Default | Description | +| --------- | -------- | -------- | ------- | --------------------------------- | +| type | `String` | | image | Content type (`image` or `video`) | +| url | `String` | ✓ | | File link | +| posterUrl | `String` | | | Poster URL (only used for video) | + +#### Usage example + +```jsx harmony +Story text, + media: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-2.png', + }, + }, + ]} +/> +``` diff --git a/src/components/unstable/Stories/Stories.scss b/src/components/unstable/Stories/Stories.scss new file mode 100644 index 00000000..894c2b9a --- /dev/null +++ b/src/components/unstable/Stories/Stories.scss @@ -0,0 +1,13 @@ +@use '../../variables'; + +$block: '.#{variables.$ns}stories'; +$borderRadius: 20px; + +#{$block} { + --g-modal-border-radius: #{$borderRadius}; + --g-modal-margin: 20px; + + &__modal-content { + border-radius: $borderRadius; + } +} diff --git a/src/components/unstable/Stories/Stories.tsx b/src/components/unstable/Stories/Stories.tsx new file mode 100644 index 00000000..6ce000ec --- /dev/null +++ b/src/components/unstable/Stories/Stories.tsx @@ -0,0 +1,135 @@ +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, + type StoriesLayoutProps, +} 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; + action?: StoriesLayoutProps['action']; + syncInTabsKey?: string; +} + +export function Stories({ + open, + onClose, + items, + onPreviousClick, + onNextClick, + initialStoryIndex = 0, + disableOutsideClick = true, + className, + action, + 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..83025dc9 --- /dev/null +++ b/src/components/unstable/Stories/__stories__/Stories.stories.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import {Button} 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'; + +export default { + title: 'Components/Stories', + component: Stories, +} as Meta; + +const items: StoriesItem[] = [ + { + title: 'New navigation', + content: + '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: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-2.png', + }, + }, + { + title: 'New navigation (2)', + content: 'A little more about the new navigation', + media: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/sample_960x400_ocean_with_audio.mp4', + type: 'video', + }, + }, + { + title: 'New navigation (3)', + content: Switch to the new navigation right now, + media: { + url: 'https://storage.yandexcloud.net/uikit-storybook-assets/story-picture-4.png', + }, + }, +]; + +const DefaultTemplate: StoryFn = (props: StoriesProps) => { + const [visible, setVisible] = React.useState(props.open); + + React.useEffect(() => { + setVisible(props.open); + }, [props.open]); + + return ( + +
+ +
+ { + setVisible(false); + }} + /> +
+ ); +}; +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]], + action: { + view: 'action', + children: 'View examples', + }, +}; + +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..b4b5a556 --- /dev/null +++ b/src/components/unstable/Stories/components/ImageView/ImageView.scss @@ -0,0 +1,10 @@ +@use '../../../../variables'; + +$block: '.#{variables.$ns}stories-image-view'; + +#{$block} { + width: auto; + max-width: 100%; + max-height: 100%; + margin: auto; +} 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..36ba57d4 --- /dev/null +++ b/src/components/unstable/Stories/components/ImageView/ImageView.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import {block} from '../../../../utils/cn'; +import type {StoriesItemMedia} from '../../types'; + +import './ImageView.scss'; + +const b = block('stories-image-view'); + +export interface ImageViewProps { + media: StoriesItemMedia; +} + +export function ImageView({media}: ImageViewProps) { + const type = media.type || 'image'; + + return type === 'image' ? : 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..81fad6fc --- /dev/null +++ b/src/components/unstable/Stories/components/MediaRenderer/MediaRenderer.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import {ImageView, VideoView} from '../../components'; +import type {StoriesItemMedia} from '../../types'; + +export interface MediaRendererProps { + media: StoriesItemMedia; +} + +export function MediaRenderer({media}: 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..9c881309 --- /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'; +$borderRadius: 20px; +$maxWidth: 1280px; +$maxHeight: 640px; +$minWidth: 800px; +$minHeight: 480px; +$leftPaneBorderRadius: 17px; +$leftPanePadding: 32px; +$rightPanePadding: 68px; +$smallMargin: 8px; +$textBlockMargin: 16px; + +#{$block} { + &__wrap-outer { + height: calc(100vh - 2 * var(--g-modal-margin)); + width: calc(100vw - 2 * var(--g-modal-margin)); + display: flex; + border-radius: $borderRadius; + max-width: $maxWidth; + max-height: $maxHeight; + min-width: $minWidth; + min-height: $minHeight; + background-color: var(--g-color-base-selection); + } + + &__wrap-inner { + background-color: var(--g-color-base-background); + border-radius: $borderRadius; + max-width: $maxWidth; + max-height: $maxHeight; + min-width: $minWidth; + min-height: $minHeight; + width: 100%; + height: 100%; + } + + &__container { + display: flex; + background-color: var(--g-color-base-selection); + box-shadow: 0 8px 20px var(--g-color-sfx-shadow); + border-radius: $borderRadius; + position: relative; + + width: 100%; + height: 100%; + } + + &__left-pane { + width: 464px; + flex-shrink: 0; + margin-inline-start: $smallMargin; + margin-block: $smallMargin; + background-color: var(--g-color-base-background); + border-radius: $leftPaneBorderRadius; + padding: $leftPanePadding; + display: flex; + flex-direction: column; + align-items: stretch; + box-sizing: border-box; + } + + &__right-pane { + padding: $rightPanePadding; + display: flex; + flex-grow: 1; + align-items: center; + } + + &__counter { + @include mixins.text-body-2(); + color: var(--g-color-text-secondary); + } + + &__text-block { + display: flex; + flex-grow: 1; + align-items: flex-start; + justify-content: center; + flex-direction: column; + margin-block-end: $smallMargin; + overflow: hidden; + } + + &__text-header { + @include mixins.text-display-2(); + color: var(--g-color-text-primary); + } + + &__text-content { + @include mixins.text-body-2(); + color: var(--g-color-text-complementary); + overflow-y: scroll; + + #{$block}__text-header + & { + margin-block-start: $textBlockMargin; + } + } + + &__story-link-block { + margin-block-start: $textBlockMargin; + } + + &__controls-block { + display: flex; + gap: #{$smallMargin}; + + button { + max-width: 50%; + } + } + + &__media-block { + position: relative; + display: flex; + width: 100%; + height: 100%; + } + + &__close-btn { + position: absolute; + inset-block-start: 14px; + inset-inline-end: 20px; + z-index: 1; + } +} 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..f703b258 --- /dev/null +++ b/src/components/unstable/Stories/components/StoriesLayout/StoriesLayout.tsx @@ -0,0 +1,138 @@ +import React from 'react'; + +import {Xmark} from '@gravity-ui/icons'; +import {Button, Icon, Link} from '@gravity-ui/uikit'; +import type {ButtonProps} from '@gravity-ui/uikit'; + +import {MediaRenderer} from '..'; +import {block} from '../../../../utils/cn'; +import {i18n} from '../../i18n'; +import type {StoriesItem} 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; + action?: ButtonProps; +}; + +// StoriesGroup component also use it +export const StoriesLayout = (props: StoriesLayoutProps) => { + const currentStory = props.items[props.storyIndex]; + + return ( +
+
+
+
+ {props.items.length > 1 && ( +
+ + {props.storyIndex + 1} / {props.items.length} + +
+ )} +
+ {currentStory && ( + + {currentStory.title && ( +
{currentStory.title}
+ )} + {currentStory.content && ( +
+ {currentStory.content} +
+ )} + {!currentStory.content && currentStory.description && ( +
+ {currentStory.description} +
+ )} + {currentStory.url && ( +
+ + {i18n('label_more')} + +
+ )} +
+ )} +
+
+ {IndexType.Single === props.indexType ? ( + + ) : ( + + {IndexType.Start !== props.indexType && ( + + )} + {IndexType.InProccess !== props.indexType && ( + + )} + {IndexType.End !== props.indexType && ( + + )} + + )} + {props.action &&
+
+
+ + {currentStory?.media && ( +
+ +
+ )} +
+
+
+
+ ); +}; 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..a902bcd1 --- /dev/null +++ b/src/components/unstable/Stories/components/VideoView/VideoView.scss @@ -0,0 +1,10 @@ +@use '../../../../variables'; + +$block: '.#{variables.$ns}stories-video-view'; + +#{$block} { + width: auto; + max-width: 100%; + max-height: 100%; + margin: auto; +} 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..1dabb36a --- /dev/null +++ b/src/components/unstable/Stories/components/VideoView/VideoView.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import {block} from '../../../../utils/cn'; +import type {StoriesItemMedia} from '../../types'; + +import './VideoView.scss'; + +const b = block('stories-video-view'); + +export interface VideoViewProps { + media: StoriesItemMedia; +} + +export function VideoView({media}: VideoViewProps) { + return media.type === 'video' ? ( +