@cloud-ru/ds-uikit-product-modal-predefined 1.0.16 → 1.0.18-preview-177d5565.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -0
  3. package/dist/cjs/constants.d.ts +13 -0
  4. package/dist/cjs/constants.js +14 -1
  5. package/dist/cjs/helperComponents/NoteItem/NoteItem.d.ts +2 -2
  6. package/dist/cjs/helperComponents/NoteItem/NoteItem.js +31 -5
  7. package/dist/cjs/helperComponents/NoteItem/NoteItemSkeleton.d.ts +10 -0
  8. package/dist/cjs/helperComponents/NoteItem/NoteItemSkeleton.js +20 -0
  9. package/dist/cjs/helperComponents/NoteItem/index.d.ts +1 -0
  10. package/dist/cjs/helperComponents/NoteItem/index.js +1 -0
  11. package/dist/cjs/helperComponents/NoteItem/styles.module.css +17 -3
  12. package/dist/cjs/helperComponents/ReleaseNotesContent/ReleaseNotesContent.d.ts +2 -2
  13. package/dist/cjs/helperComponents/ReleaseNotesContent/ReleaseNotesContent.js +1 -2
  14. package/dist/cjs/helperComponents/ReleaseNotesContent/styles.module.css +0 -4
  15. package/dist/cjs/helperComponents/ReleaseNotesFooter/ReleaseNotesFooter.d.ts +2 -1
  16. package/dist/cjs/helperComponents/ReleaseNotesFooter/ReleaseNotesFooter.js +1 -1
  17. package/dist/cjs/style.css +17 -7
  18. package/dist/cjs/types.d.ts +4 -1
  19. package/dist/esm/constants.d.ts +13 -0
  20. package/dist/esm/constants.js +13 -0
  21. package/dist/esm/helperComponents/NoteItem/NoteItem.d.ts +2 -2
  22. package/dist/esm/helperComponents/NoteItem/NoteItem.js +34 -8
  23. package/dist/esm/helperComponents/NoteItem/NoteItemSkeleton.d.ts +10 -0
  24. package/dist/esm/helperComponents/NoteItem/NoteItemSkeleton.js +14 -0
  25. package/dist/esm/helperComponents/NoteItem/index.d.ts +1 -0
  26. package/dist/esm/helperComponents/NoteItem/index.js +1 -0
  27. package/dist/esm/helperComponents/NoteItem/styles.module.css +17 -3
  28. package/dist/esm/helperComponents/ReleaseNotesContent/ReleaseNotesContent.d.ts +2 -2
  29. package/dist/esm/helperComponents/ReleaseNotesContent/ReleaseNotesContent.js +2 -3
  30. package/dist/esm/helperComponents/ReleaseNotesContent/styles.module.css +0 -4
  31. package/dist/esm/helperComponents/ReleaseNotesFooter/ReleaseNotesFooter.d.ts +2 -1
  32. package/dist/esm/helperComponents/ReleaseNotesFooter/ReleaseNotesFooter.js +2 -2
  33. package/dist/esm/style.css +17 -7
  34. package/dist/esm/types.d.ts +4 -1
  35. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  36. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  37. package/package.json +18 -18
  38. package/src/constants.ts +15 -0
  39. package/src/helperComponents/NoteItem/NoteItem.tsx +87 -32
  40. package/src/helperComponents/NoteItem/NoteItemSkeleton.tsx +36 -0
  41. package/src/helperComponents/NoteItem/index.ts +1 -0
  42. package/src/helperComponents/NoteItem/styles.module.scss +23 -3
  43. package/src/helperComponents/ReleaseNotesContent/ReleaseNotesContent.tsx +5 -6
  44. package/src/helperComponents/ReleaseNotesContent/styles.module.scss +0 -4
  45. package/src/helperComponents/ReleaseNotesFooter/ReleaseNotesFooter.tsx +4 -3
  46. package/src/types.ts +6 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## 1.0.17 (2026-09-03)
7
+
8
+ **Note:** Version bump only for package @ds/uikit-product-modal-predefined
9
+
6
10
  ## 1.0.16 (2026-09-03)
7
11
 
8
12
  **Note:** Version bump only for package @ds/uikit-product-modal-predefined
package/README.md CHANGED
@@ -314,6 +314,10 @@ export function ReleaseNotesVideo() {
314
314
 
315
315
  - `ReleaseNotesContentState` = `"data"` \| `"error"` \| `"noData"`
316
316
 
317
+ ### Иллюстрации
318
+
319
+ Размеры медиа-слота задаёт раскладка, а не сам файл: на desktop высота слота равна высоте текстовой колонки, на mobile слот сохраняет соотношение 16:10. До загрузки слот занят скелетоном, при ошибке загрузки — заглушкой с иконкой, поэтому геометрия окна не меняется. Изображение вписывается по `object-fit: cover`: всё, что выходит за границы слота, обрезается — оставляйте в иллюстрации запас по краям.
320
+
317
321
  ### Адаптивность
318
322
 
319
323
  `ReleaseNotes` — адаптивный компонент с переключением поверхности (surface-swap). Раскладку он берёт из `AdaptiveProvider` (контекст `@cloud-ru/ds-adaptive`); публичный API единый для обеих платформ:
@@ -3,6 +3,16 @@ export declare const CONTENT_STATE: {
3
3
  readonly NoData: "noData";
4
4
  readonly Error: "error";
5
5
  };
6
+ export declare const SURFACE: {
7
+ readonly Modal: "modal";
8
+ readonly BottomSheet: "bottomSheet";
9
+ };
10
+ /** Состояние загрузки иллюстрации/видео в карточке новости. */
11
+ export declare const MEDIA_STATUS: {
12
+ readonly Pending: "pending";
13
+ readonly Ready: "ready";
14
+ readonly Error: "error";
15
+ };
6
16
  export declare const CONFIRM_TEXT_VARIANT: {
7
17
  readonly Name: "name";
8
18
  readonly Text: "text";
@@ -17,6 +27,9 @@ export declare const TEST_IDS: {
17
27
  readonly confirmCopyButton: "modal-predefined__confirm-copy-button";
18
28
  readonly releaseNotesItem: "modal-predefined__release-notes-item";
19
29
  readonly releaseNotesMedia: "modal-predefined__release-notes-media";
30
+ readonly releaseNotesImage: "modal-predefined__release-notes-image";
31
+ readonly releaseNotesMediaFallback: "modal-predefined__release-notes-media-fallback";
32
+ readonly releaseNotesSkeleton: "modal-predefined__release-notes-skeleton";
20
33
  readonly releaseNotesVideo: "modal-predefined__release-notes-video";
21
34
  readonly releaseNotesError: "modal-predefined__release-notes-error";
22
35
  readonly releaseNotesNoData: "modal-predefined__release-notes-no-data";
@@ -1,11 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TEST_IDS = exports.CONFIRM_TEXT_VARIANT = exports.CONTENT_STATE = void 0;
3
+ exports.TEST_IDS = exports.CONFIRM_TEXT_VARIANT = exports.MEDIA_STATUS = exports.SURFACE = exports.CONTENT_STATE = void 0;
4
4
  exports.CONTENT_STATE = {
5
5
  Data: 'data',
6
6
  NoData: 'noData',
7
7
  Error: 'error',
8
8
  };
9
+ exports.SURFACE = {
10
+ Modal: 'modal',
11
+ BottomSheet: 'bottomSheet',
12
+ };
13
+ /** Состояние загрузки иллюстрации/видео в карточке новости. */
14
+ exports.MEDIA_STATUS = {
15
+ Pending: 'pending',
16
+ Ready: 'ready',
17
+ Error: 'error',
18
+ };
9
19
  exports.CONFIRM_TEXT_VARIANT = {
10
20
  Name: 'name',
11
21
  Text: 'text',
@@ -20,6 +30,9 @@ exports.TEST_IDS = {
20
30
  confirmCopyButton: 'modal-predefined__confirm-copy-button',
21
31
  releaseNotesItem: 'modal-predefined__release-notes-item',
22
32
  releaseNotesMedia: 'modal-predefined__release-notes-media',
33
+ releaseNotesImage: 'modal-predefined__release-notes-image',
34
+ releaseNotesMediaFallback: 'modal-predefined__release-notes-media-fallback',
35
+ releaseNotesSkeleton: 'modal-predefined__release-notes-skeleton',
23
36
  releaseNotesVideo: 'modal-predefined__release-notes-video',
24
37
  releaseNotesError: 'modal-predefined__release-notes-error',
25
38
  releaseNotesNoData: 'modal-predefined__release-notes-no-data',
@@ -1,6 +1,6 @@
1
- import { NoteItemProps } from '../../types';
1
+ import { NoteItemProps, Surface } from '../../types';
2
2
  type NoteItemComponentProps = NoteItemProps & {
3
- surface?: 'modal' | 'bottomSheet';
3
+ surface?: Surface;
4
4
  };
5
5
  export declare function NoteItem({ title, description, image, video, surface }: NoteItemComponentProps): import("react/jsx-runtime").JSX.Element;
6
6
  export {};
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.NoteItem = NoteItem;
7
7
  const jsx_runtime_1 = require("react/jsx-runtime");
8
+ const system_1 = require("@cloud-ru/ds-icons/interface/system");
8
9
  const markdown_1 = require("@cloud-ru/ds-markdown");
9
10
  const scroll_1 = require("@cloud-ru/ds-scroll");
10
11
  const skeleton_1 = require("@cloud-ru/ds-skeleton");
@@ -12,9 +13,34 @@ const typography_1 = require("@cloud-ru/ds-typography");
12
13
  const react_1 = require("react");
13
14
  const constants_1 = require("../../constants");
14
15
  const styles_module_scss_1 = __importDefault(require('./styles.module.css'));
15
- function NoteItem({ title, description, image, video, surface = 'modal' }) {
16
- const [isVideoReady, setIsVideoReady] = (0, react_1.useState)(false);
17
- const [hasVideoError, setHasVideoError] = (0, react_1.useState)(false);
18
- const shouldShowVideo = Boolean(video && !hasVideoError);
19
- return ((0, jsx_runtime_1.jsxs)("article", { className: styles_module_scss_1.default.root, "data-surface": surface, "data-test-id": constants_1.TEST_IDS.releaseNotesItem, children: [(0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.media, "data-test-id": constants_1.TEST_IDS.releaseNotesMedia, children: [shouldShowVideo && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("video", { className: styles_module_scss_1.default.video, src: video, muted: true, loop: true, playsInline: true, autoPlay: true, onLoadedData: () => setIsVideoReady(true), onError: () => setHasVideoError(true), "data-test-id": constants_1.TEST_IDS.releaseNotesVideo }), !isVideoReady && ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.videoSkeleton, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(skeleton_1.Skeleton, { loading: true, width: '100%', height: '100%' }) }))] })), !shouldShowVideo && (0, jsx_runtime_1.jsx)("img", { className: styles_module_scss_1.default.image, src: image.src, alt: image.alt })] }), (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.content, children: [(0, jsx_runtime_1.jsx)(typography_1.Typography, { variant: 'headline', size: 's', children: title }), (0, jsx_runtime_1.jsx)(scroll_1.Scroll, { className: styles_module_scss_1.default.description, barHideStrategy: scroll_1.BAR_HIDE_STRATEGY.Leave, children: (0, jsx_runtime_1.jsx)(markdown_1.Markdown, { value: description }) })] })] }));
16
+ /** Размер иконки-заглушки в медиа-слоте, когда иллюстрация не загрузилась. */
17
+ const FALLBACK_ICON_SIZE = 48;
18
+ /**
19
+ * Медиа-слот новости: иллюстрация или видео. Размеры слота задаёт разметка, а не intrinsic-размер
20
+ * файла, поэтому загрузка не меняет раскладку карточки. До готовности файла слот занят скелетоном,
21
+ * при ошибке загрузки — заглушкой с иконкой.
22
+ */
23
+ function NoteItemMedia({ image, video }) {
24
+ const [imageStatus, setImageStatus] = (0, react_1.useState)(constants_1.MEDIA_STATUS.Pending);
25
+ const [videoStatus, setVideoStatus] = (0, react_1.useState)(constants_1.MEDIA_STATUS.Pending);
26
+ // При ошибке видео отображается статичная иллюстрация, и состояние слота определяет уже она.
27
+ const withVideo = Boolean(video) && videoStatus !== constants_1.MEDIA_STATUS.Error;
28
+ const status = withVideo ? videoStatus : imageStatus;
29
+ // Изображение из кеша может завершить загрузку до подписки на `onLoad` — состояние снимается с узла.
30
+ const handleImageRef = (0, react_1.useCallback)((node) => {
31
+ if (!node?.complete || !node.getAttribute('src')) {
32
+ return;
33
+ }
34
+ setImageStatus(node.naturalWidth > 0 ? constants_1.MEDIA_STATUS.Ready : constants_1.MEDIA_STATUS.Error);
35
+ }, []);
36
+ const handleImageLoad = (0, react_1.useCallback)(() => setImageStatus(constants_1.MEDIA_STATUS.Ready), []);
37
+ const handleImageError = (0, react_1.useCallback)(() => setImageStatus(constants_1.MEDIA_STATUS.Error), []);
38
+ const handleVideoLoad = (0, react_1.useCallback)(() => setVideoStatus(constants_1.MEDIA_STATUS.Ready), []);
39
+ const handleVideoError = (0, react_1.useCallback)(() => setVideoStatus(constants_1.MEDIA_STATUS.Error), []);
40
+ return ((0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.media, "data-status": status, "data-test-id": constants_1.TEST_IDS.releaseNotesMedia, children: [withVideo ? ((0, jsx_runtime_1.jsx)("video", { className: styles_module_scss_1.default.file, src: video, muted: true, loop: true, playsInline: true, autoPlay: true, "aria-hidden": true, onLoadedData: handleVideoLoad, onError: handleVideoError, "data-test-id": constants_1.TEST_IDS.releaseNotesVideo })) : (
41
+ // При ошибке `<img>` не рендерится: иначе поверх заглушки остаётся превью браузера с alt-текстом.
42
+ status !== constants_1.MEDIA_STATUS.Error && ((0, jsx_runtime_1.jsx)("img", { ref: handleImageRef, className: styles_module_scss_1.default.file, src: image.src, alt: image.alt, onLoad: handleImageLoad, onError: handleImageError, "data-test-id": constants_1.TEST_IDS.releaseNotesImage }))), status === constants_1.MEDIA_STATUS.Pending && ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.mediaOverlay, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(skeleton_1.Skeleton, { loading: true, width: '100%', height: '100%' }) })), status === constants_1.MEDIA_STATUS.Error && ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.mediaOverlay, role: 'img', "aria-label": image.alt, "data-test-id": constants_1.TEST_IDS.releaseNotesMediaFallback, children: (0, jsx_runtime_1.jsx)(system_1.ImageSVG, { className: styles_module_scss_1.default.mediaFallbackIcon, size: FALLBACK_ICON_SIZE }) }))] }));
43
+ }
44
+ function NoteItem({ title, description, image, video, surface = constants_1.SURFACE.Modal }) {
45
+ return ((0, jsx_runtime_1.jsxs)("article", { className: styles_module_scss_1.default.root, "data-surface": surface, "data-test-id": constants_1.TEST_IDS.releaseNotesItem, children: [(0, jsx_runtime_1.jsx)(NoteItemMedia, { image: image, video: video }, `${video ?? ''}|${image.src}`), (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.content, children: [(0, jsx_runtime_1.jsx)(typography_1.Typography, { variant: 'headline', size: 's', children: title }), (0, jsx_runtime_1.jsx)(scroll_1.Scroll, { className: styles_module_scss_1.default.description, barHideStrategy: scroll_1.BAR_HIDE_STRATEGY.Leave, children: (0, jsx_runtime_1.jsx)(markdown_1.Markdown, { value: description }) })] })] }));
20
46
  }
@@ -0,0 +1,10 @@
1
+ import { Surface } from '../../types';
2
+ export type NoteItemSkeletonProps = {
3
+ /** Поверхность отображения */
4
+ surface?: Surface;
5
+ };
6
+ /**
7
+ * Скелетон карточки новости: те же классы сетки и тот же медиа-слот, что и у `NoteItem`,
8
+ * поэтому переход `loading → data` не меняет геометрию.
9
+ */
10
+ export declare function NoteItemSkeleton({ surface }: NoteItemSkeletonProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.NoteItemSkeleton = NoteItemSkeleton;
7
+ const jsx_runtime_1 = require("react/jsx-runtime");
8
+ const skeleton_1 = require("@cloud-ru/ds-skeleton");
9
+ const constants_1 = require("../../constants");
10
+ const styles_module_scss_1 = __importDefault(require('./styles.module.css'));
11
+ /** Строки скелетона текста: заголовок и абзацы описания — по структуре типовой новости. */
12
+ const TITLE_LINES = 1;
13
+ const PARAGRAPH_LINES = [2, 3, 3];
14
+ /**
15
+ * Скелетон карточки новости: те же классы сетки и тот же медиа-слот, что и у `NoteItem`,
16
+ * поэтому переход `loading → data` не меняет геометрию.
17
+ */
18
+ function NoteItemSkeleton({ surface = constants_1.SURFACE.Modal }) {
19
+ return ((0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.root, "data-surface": surface, "aria-hidden": true, "data-test-id": constants_1.TEST_IDS.releaseNotesSkeleton, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.media, "data-test-id": constants_1.TEST_IDS.releaseNotesMedia, children: (0, jsx_runtime_1.jsx)(skeleton_1.Skeleton, { loading: true, width: '100%', height: '100%' }) }), (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.content, children: [(0, jsx_runtime_1.jsx)(skeleton_1.SkeletonText, { loading: true, lines: TITLE_LINES, variant: 'headline', size: 's' }), PARAGRAPH_LINES.map((lines, index) => ((0, jsx_runtime_1.jsx)(skeleton_1.SkeletonText, { loading: true, lines: lines }, index)))] })] }));
20
+ }
@@ -1 +1,2 @@
1
1
  export * from './NoteItem';
2
+ export * from './NoteItemSkeleton';
@@ -15,3 +15,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./NoteItem"), exports);
18
+ __exportStar(require("./NoteItemSkeleton"), exports);
@@ -18,8 +18,15 @@
18
18
  border-radius:var(--sn-primitive-dimension-16, 16px);
19
19
  }
20
20
 
21
- .image,
22
- .video{
21
+ .media[data-status=error]{
22
+ background:var(--sn-theme-color-neutral-background, var(--sn-brand-color-neutral-90, var(--sn-primitive-color-gray-90, #eeeff3)));
23
+ }
24
+
25
+ .root[data-surface=bottomSheet] .media{
26
+ aspect-ratio:16/10;
27
+ }
28
+
29
+ .file{
23
30
  display:block;
24
31
  width:100%;
25
32
  height:100%;
@@ -27,9 +34,16 @@
27
34
  object-fit:cover;
28
35
  }
29
36
 
30
- .videoSkeleton{
37
+ .mediaOverlay{
31
38
  position:absolute;
32
39
  inset:0;
40
+ display:flex;
41
+ align-items:center;
42
+ justify-content:center;
43
+ }
44
+
45
+ .mediaFallbackIcon{
46
+ color:var(--sn-theme-color-neutral-accent, var(--sn-brand-color-neutral-55, var(--sn-primitive-color-gray-55, #8b8fa7)));
33
47
  }
34
48
 
35
49
  .content{
@@ -1,4 +1,4 @@
1
- import { NoteItemProps, ReleaseNotesContentState } from '../../types';
1
+ import { NoteItemProps, ReleaseNotesContentState, Surface } from '../../types';
2
2
  export type ReleaseNotesContentProps = {
3
3
  /** Визуальное состояние */
4
4
  contentState: ReleaseNotesContentState;
@@ -9,7 +9,7 @@ export type ReleaseNotesContentProps = {
9
9
  /** Текущая страница, zero-based */
10
10
  pageIndex: number;
11
11
  /** Поверхность */
12
- surface: 'modal' | 'bottomSheet';
12
+ surface: Surface;
13
13
  /** Заголовок no-data */
14
14
  noDataTitle: string;
15
15
  /** Описание no-data */
@@ -9,7 +9,6 @@ const button_1 = require("@cloud-ru/ds-button");
9
9
  const carousel_1 = require("@cloud-ru/ds-carousel");
10
10
  const system_1 = require("@cloud-ru/ds-icons/interface/system");
11
11
  const info_block_1 = require("@cloud-ru/ds-info-block");
12
- const skeleton_1 = require("@cloud-ru/ds-skeleton");
13
12
  const classnames_1 = __importDefault(require("classnames"));
14
13
  const constants_1 = require("../../constants");
15
14
  const NoteItem_1 = require("../NoteItem");
@@ -18,7 +17,7 @@ function ReleaseNotesContent({ contentState, items, loading, pageIndex, surface,
18
17
  const total = items.length;
19
18
  const hasItems = total > 0;
20
19
  if (loading) {
21
- return ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.loading, "data-surface": surface, children: (0, jsx_runtime_1.jsx)(skeleton_1.Skeleton, { loading: true, width: '100%', height: '100%', borderRadius: 'var(--border-radius-l)' }) }));
20
+ return ((0, jsx_runtime_1.jsx)("div", { className: (0, classnames_1.default)(styles_module_scss_1.default.root, styles_module_scss_1.default[surface]), children: (0, jsx_runtime_1.jsx)(NoteItem_1.NoteItemSkeleton, { surface: surface }) }));
22
21
  }
23
22
  if (contentState === constants_1.CONTENT_STATE.Error) {
24
23
  return ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.stateWrapper, "data-surface": surface, children: (0, jsx_runtime_1.jsx)(info_block_1.InfoBlock, { className: styles_module_scss_1.default.state, size: 'l', title: errorTitle, content: errorDescription, icon: { icon: system_1.CrossSVG, appearance: 'neutral', background: true }, "data-test-id": constants_1.TEST_IDS.releaseNotesError, footer: (0, jsx_runtime_1.jsx)(button_1.Button, { view: 'filled', appearance: 'neutral', size: 'l', label: retryLabel, onClick: onDataErrorRetryClick, "data-test-id": constants_1.TEST_IDS.releaseNotesRetryButton }) }) }));
@@ -33,8 +33,4 @@
33
33
  .state{
34
34
  width:100%;
35
35
  max-width:calc(9 * var(--sn-primitive-dimension-64, 64px) + 3 * var(--sn-primitive-dimension-16, 16px));
36
- }
37
-
38
- .loading{
39
- height:calc(5 * var(--sn-primitive-dimension-64, 64px));
40
36
  }
@@ -1,8 +1,9 @@
1
1
  import { ButtonProps } from '@cloud-ru/ds-button';
2
2
  import { MouseEvent } from 'react';
3
+ import { Surface } from '../../types';
3
4
  export type ReleaseNotesFooterProps = {
4
5
  /** Поверхность отображения */
5
- surface: 'modal' | 'bottomSheet';
6
+ surface: Surface;
6
7
  /** Количество страниц */
7
8
  total: number;
8
9
  /** Текущая страница, zero-based */
@@ -11,7 +11,7 @@ const constants_1 = require("../../constants");
11
11
  const styles_module_scss_1 = __importDefault(require('./styles.module.css'));
12
12
  function ReleaseNotesFooter({ surface, total, pageIndex, readablePageNumber, counterLabel, readLaterLabel, onReadLaterClick, onPrevPageClick, onNextPageClick, readLaterButtonProps, }) {
13
13
  const hasPagination = total > 1;
14
- const isModal = surface === 'modal';
14
+ const isModal = surface === constants_1.SURFACE.Modal;
15
15
  const buttonSize = isModal ? 'm' : 'l';
16
16
  return ((0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.root, "data-surface": surface, children: [(0, jsx_runtime_1.jsx)(button_1.Button, { view: 'function', appearance: 'neutral', size: buttonSize, label: readLaterLabel, "data-test-id": constants_1.TEST_IDS.releaseNotesReadLaterButton, ...readLaterButtonProps, onClick: onReadLaterClick }), hasPagination && ((0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.controls, children: [!isModal && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.counter, children: counterLabel }), (0, jsx_runtime_1.jsx)(button_1.Button, { view: 'outline', appearance: 'neutral', size: buttonSize, icon: (0, jsx_runtime_1.jsx)(system_1.ChevronLeftSVG, {}), disabled: pageIndex === 0, onClick: onPrevPageClick, "data-test-id": constants_1.TEST_IDS.releaseNotesPrevButton }), (0, jsx_runtime_1.jsx)(button_1.Button, { view: 'outline', appearance: 'neutral', size: buttonSize, icon: (0, jsx_runtime_1.jsx)(system_1.ChevronRightSVG, {}), disabled: readablePageNumber === total, onClick: onNextPageClick, "data-test-id": constants_1.TEST_IDS.releaseNotesNextButton })] }))] }));
17
17
  }
@@ -94,8 +94,15 @@
94
94
  border-radius:var(--sn-primitive-dimension-16, 16px);
95
95
  }
96
96
 
97
- .image,
98
- .video{
97
+ .media[data-status=error]{
98
+ background:var(--sn-theme-color-neutral-background, var(--sn-brand-color-neutral-90, var(--sn-primitive-color-gray-90, #eeeff3)));
99
+ }
100
+
101
+ .root[data-surface=bottomSheet] .media{
102
+ aspect-ratio:16/10;
103
+ }
104
+
105
+ .file{
99
106
  display:block;
100
107
  width:100%;
101
108
  height:100%;
@@ -103,9 +110,16 @@
103
110
  object-fit:cover;
104
111
  }
105
112
 
106
- .videoSkeleton{
113
+ .mediaOverlay{
107
114
  position:absolute;
108
115
  inset:0;
116
+ display:flex;
117
+ align-items:center;
118
+ justify-content:center;
119
+ }
120
+
121
+ .mediaFallbackIcon{
122
+ color:var(--sn-theme-color-neutral-accent, var(--sn-brand-color-neutral-55, var(--sn-primitive-color-gray-55, #8b8fa7)));
109
123
  }
110
124
 
111
125
  .content{
@@ -163,10 +177,6 @@
163
177
  width:100%;
164
178
  max-width:calc(9 * var(--sn-primitive-dimension-64, 64px) + 3 * var(--sn-primitive-dimension-16, 16px));
165
179
  }
166
-
167
- .loading{
168
- height:calc(5 * var(--sn-primitive-dimension-64, 64px));
169
- }
170
180
  .root{
171
181
  position:relative;
172
182
  display:flex;
@@ -2,8 +2,11 @@ import { ButtonProps } from '@cloud-ru/ds-button';
2
2
  import { ModalCustomProps } from '@cloud-ru/ds-modal';
3
3
  import { ValueOf, WithSupportProps } from '@cloud-ru/ds-utils';
4
4
  import { ReactNode } from 'react';
5
- import { CONFIRM_TEXT_VARIANT, CONTENT_STATE } from './constants';
5
+ import { CONFIRM_TEXT_VARIANT, CONTENT_STATE, MEDIA_STATUS, SURFACE } from './constants';
6
6
  export type ReleaseNotesContentState = ValueOf<typeof CONTENT_STATE>;
7
+ /** Поверхность отображения release notes: модальное окно (desktop) или bottom sheet (mobile). */
8
+ export type Surface = ValueOf<typeof SURFACE>;
9
+ export type MediaStatus = ValueOf<typeof MEDIA_STATUS>;
7
10
  export type ConfirmTextVariant = ValueOf<typeof CONFIRM_TEXT_VARIANT>;
8
11
  export type NoteItemProps = {
9
12
  /** Заголовок новости */
@@ -3,6 +3,16 @@ export declare const CONTENT_STATE: {
3
3
  readonly NoData: "noData";
4
4
  readonly Error: "error";
5
5
  };
6
+ export declare const SURFACE: {
7
+ readonly Modal: "modal";
8
+ readonly BottomSheet: "bottomSheet";
9
+ };
10
+ /** Состояние загрузки иллюстрации/видео в карточке новости. */
11
+ export declare const MEDIA_STATUS: {
12
+ readonly Pending: "pending";
13
+ readonly Ready: "ready";
14
+ readonly Error: "error";
15
+ };
6
16
  export declare const CONFIRM_TEXT_VARIANT: {
7
17
  readonly Name: "name";
8
18
  readonly Text: "text";
@@ -17,6 +27,9 @@ export declare const TEST_IDS: {
17
27
  readonly confirmCopyButton: "modal-predefined__confirm-copy-button";
18
28
  readonly releaseNotesItem: "modal-predefined__release-notes-item";
19
29
  readonly releaseNotesMedia: "modal-predefined__release-notes-media";
30
+ readonly releaseNotesImage: "modal-predefined__release-notes-image";
31
+ readonly releaseNotesMediaFallback: "modal-predefined__release-notes-media-fallback";
32
+ readonly releaseNotesSkeleton: "modal-predefined__release-notes-skeleton";
20
33
  readonly releaseNotesVideo: "modal-predefined__release-notes-video";
21
34
  readonly releaseNotesError: "modal-predefined__release-notes-error";
22
35
  readonly releaseNotesNoData: "modal-predefined__release-notes-no-data";
@@ -3,6 +3,16 @@ export const CONTENT_STATE = {
3
3
  NoData: 'noData',
4
4
  Error: 'error',
5
5
  };
6
+ export const SURFACE = {
7
+ Modal: 'modal',
8
+ BottomSheet: 'bottomSheet',
9
+ };
10
+ /** Состояние загрузки иллюстрации/видео в карточке новости. */
11
+ export const MEDIA_STATUS = {
12
+ Pending: 'pending',
13
+ Ready: 'ready',
14
+ Error: 'error',
15
+ };
6
16
  export const CONFIRM_TEXT_VARIANT = {
7
17
  Name: 'name',
8
18
  Text: 'text',
@@ -17,6 +27,9 @@ export const TEST_IDS = {
17
27
  confirmCopyButton: 'modal-predefined__confirm-copy-button',
18
28
  releaseNotesItem: 'modal-predefined__release-notes-item',
19
29
  releaseNotesMedia: 'modal-predefined__release-notes-media',
30
+ releaseNotesImage: 'modal-predefined__release-notes-image',
31
+ releaseNotesMediaFallback: 'modal-predefined__release-notes-media-fallback',
32
+ releaseNotesSkeleton: 'modal-predefined__release-notes-skeleton',
20
33
  releaseNotesVideo: 'modal-predefined__release-notes-video',
21
34
  releaseNotesError: 'modal-predefined__release-notes-error',
22
35
  releaseNotesNoData: 'modal-predefined__release-notes-no-data',
@@ -1,6 +1,6 @@
1
- import { NoteItemProps } from '../../types.js';
1
+ import { NoteItemProps, Surface } from '../../types.js';
2
2
  type NoteItemComponentProps = NoteItemProps & {
3
- surface?: 'modal' | 'bottomSheet';
3
+ surface?: Surface;
4
4
  };
5
5
  export declare function NoteItem({ title, description, image, video, surface }: NoteItemComponentProps): import("react/jsx-runtime").JSX.Element;
6
6
  export {};
@@ -1,14 +1,40 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ImageSVG } from '@cloud-ru/ds-icons/interface/system';
2
3
  import { Markdown } from '@cloud-ru/ds-markdown';
3
4
  import { BAR_HIDE_STRATEGY, Scroll } from '@cloud-ru/ds-scroll';
4
5
  import { Skeleton } from '@cloud-ru/ds-skeleton';
5
6
  import { Typography } from '@cloud-ru/ds-typography';
6
- import { useState } from 'react';
7
- import { TEST_IDS } from '../../constants.js';
7
+ import { useCallback, useState } from 'react';
8
+ import { MEDIA_STATUS, SURFACE, TEST_IDS } from '../../constants.js';
8
9
  import styles from './styles.module.css';
9
- export function NoteItem({ title, description, image, video, surface = 'modal' }) {
10
- const [isVideoReady, setIsVideoReady] = useState(false);
11
- const [hasVideoError, setHasVideoError] = useState(false);
12
- const shouldShowVideo = Boolean(video && !hasVideoError);
13
- return (_jsxs("article", { className: styles.root, "data-surface": surface, "data-test-id": TEST_IDS.releaseNotesItem, children: [_jsxs("div", { className: styles.media, "data-test-id": TEST_IDS.releaseNotesMedia, children: [shouldShowVideo && (_jsxs(_Fragment, { children: [_jsx("video", { className: styles.video, src: video, muted: true, loop: true, playsInline: true, autoPlay: true, onLoadedData: () => setIsVideoReady(true), onError: () => setHasVideoError(true), "data-test-id": TEST_IDS.releaseNotesVideo }), !isVideoReady && (_jsx("div", { className: styles.videoSkeleton, "aria-hidden": true, children: _jsx(Skeleton, { loading: true, width: '100%', height: '100%' }) }))] })), !shouldShowVideo && _jsx("img", { className: styles.image, src: image.src, alt: image.alt })] }), _jsxs("div", { className: styles.content, children: [_jsx(Typography, { variant: 'headline', size: 's', children: title }), _jsx(Scroll, { className: styles.description, barHideStrategy: BAR_HIDE_STRATEGY.Leave, children: _jsx(Markdown, { value: description }) })] })] }));
10
+ /** Размер иконки-заглушки в медиа-слоте, когда иллюстрация не загрузилась. */
11
+ const FALLBACK_ICON_SIZE = 48;
12
+ /**
13
+ * Медиа-слот новости: иллюстрация или видео. Размеры слота задаёт разметка, а не intrinsic-размер
14
+ * файла, поэтому загрузка не меняет раскладку карточки. До готовности файла слот занят скелетоном,
15
+ * при ошибке загрузки — заглушкой с иконкой.
16
+ */
17
+ function NoteItemMedia({ image, video }) {
18
+ const [imageStatus, setImageStatus] = useState(MEDIA_STATUS.Pending);
19
+ const [videoStatus, setVideoStatus] = useState(MEDIA_STATUS.Pending);
20
+ // При ошибке видео отображается статичная иллюстрация, и состояние слота определяет уже она.
21
+ const withVideo = Boolean(video) && videoStatus !== MEDIA_STATUS.Error;
22
+ const status = withVideo ? videoStatus : imageStatus;
23
+ // Изображение из кеша может завершить загрузку до подписки на `onLoad` — состояние снимается с узла.
24
+ const handleImageRef = useCallback((node) => {
25
+ if (!node?.complete || !node.getAttribute('src')) {
26
+ return;
27
+ }
28
+ setImageStatus(node.naturalWidth > 0 ? MEDIA_STATUS.Ready : MEDIA_STATUS.Error);
29
+ }, []);
30
+ const handleImageLoad = useCallback(() => setImageStatus(MEDIA_STATUS.Ready), []);
31
+ const handleImageError = useCallback(() => setImageStatus(MEDIA_STATUS.Error), []);
32
+ const handleVideoLoad = useCallback(() => setVideoStatus(MEDIA_STATUS.Ready), []);
33
+ const handleVideoError = useCallback(() => setVideoStatus(MEDIA_STATUS.Error), []);
34
+ return (_jsxs("div", { className: styles.media, "data-status": status, "data-test-id": TEST_IDS.releaseNotesMedia, children: [withVideo ? (_jsx("video", { className: styles.file, src: video, muted: true, loop: true, playsInline: true, autoPlay: true, "aria-hidden": true, onLoadedData: handleVideoLoad, onError: handleVideoError, "data-test-id": TEST_IDS.releaseNotesVideo })) : (
35
+ // При ошибке `<img>` не рендерится: иначе поверх заглушки остаётся превью браузера с alt-текстом.
36
+ status !== MEDIA_STATUS.Error && (_jsx("img", { ref: handleImageRef, className: styles.file, src: image.src, alt: image.alt, onLoad: handleImageLoad, onError: handleImageError, "data-test-id": TEST_IDS.releaseNotesImage }))), status === MEDIA_STATUS.Pending && (_jsx("div", { className: styles.mediaOverlay, "aria-hidden": true, children: _jsx(Skeleton, { loading: true, width: '100%', height: '100%' }) })), status === MEDIA_STATUS.Error && (_jsx("div", { className: styles.mediaOverlay, role: 'img', "aria-label": image.alt, "data-test-id": TEST_IDS.releaseNotesMediaFallback, children: _jsx(ImageSVG, { className: styles.mediaFallbackIcon, size: FALLBACK_ICON_SIZE }) }))] }));
37
+ }
38
+ export function NoteItem({ title, description, image, video, surface = SURFACE.Modal }) {
39
+ return (_jsxs("article", { className: styles.root, "data-surface": surface, "data-test-id": TEST_IDS.releaseNotesItem, children: [_jsx(NoteItemMedia, { image: image, video: video }, `${video ?? ''}|${image.src}`), _jsxs("div", { className: styles.content, children: [_jsx(Typography, { variant: 'headline', size: 's', children: title }), _jsx(Scroll, { className: styles.description, barHideStrategy: BAR_HIDE_STRATEGY.Leave, children: _jsx(Markdown, { value: description }) })] })] }));
14
40
  }
@@ -0,0 +1,10 @@
1
+ import { Surface } from '../../types.js';
2
+ export type NoteItemSkeletonProps = {
3
+ /** Поверхность отображения */
4
+ surface?: Surface;
5
+ };
6
+ /**
7
+ * Скелетон карточки новости: те же классы сетки и тот же медиа-слот, что и у `NoteItem`,
8
+ * поэтому переход `loading → data` не меняет геометрию.
9
+ */
10
+ export declare function NoteItemSkeleton({ surface }: NoteItemSkeletonProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Skeleton, SkeletonText } from '@cloud-ru/ds-skeleton';
3
+ import { SURFACE, TEST_IDS } from '../../constants.js';
4
+ import styles from './styles.module.css';
5
+ /** Строки скелетона текста: заголовок и абзацы описания — по структуре типовой новости. */
6
+ const TITLE_LINES = 1;
7
+ const PARAGRAPH_LINES = [2, 3, 3];
8
+ /**
9
+ * Скелетон карточки новости: те же классы сетки и тот же медиа-слот, что и у `NoteItem`,
10
+ * поэтому переход `loading → data` не меняет геометрию.
11
+ */
12
+ export function NoteItemSkeleton({ surface = SURFACE.Modal }) {
13
+ return (_jsxs("div", { className: styles.root, "data-surface": surface, "aria-hidden": true, "data-test-id": TEST_IDS.releaseNotesSkeleton, children: [_jsx("div", { className: styles.media, "data-test-id": TEST_IDS.releaseNotesMedia, children: _jsx(Skeleton, { loading: true, width: '100%', height: '100%' }) }), _jsxs("div", { className: styles.content, children: [_jsx(SkeletonText, { loading: true, lines: TITLE_LINES, variant: 'headline', size: 's' }), PARAGRAPH_LINES.map((lines, index) => (_jsx(SkeletonText, { loading: true, lines: lines }, index)))] })] }));
14
+ }
@@ -1 +1,2 @@
1
1
  export * from './NoteItem.js';
2
+ export * from './NoteItemSkeleton.js';
@@ -1 +1,2 @@
1
1
  export * from './NoteItem.js';
2
+ export * from './NoteItemSkeleton.js';
@@ -18,8 +18,15 @@
18
18
  border-radius:var(--sn-primitive-dimension-16, 16px);
19
19
  }
20
20
 
21
- .image,
22
- .video{
21
+ .media[data-status=error]{
22
+ background:var(--sn-theme-color-neutral-background, var(--sn-brand-color-neutral-90, var(--sn-primitive-color-gray-90, #eeeff3)));
23
+ }
24
+
25
+ .root[data-surface=bottomSheet] .media{
26
+ aspect-ratio:16/10;
27
+ }
28
+
29
+ .file{
23
30
  display:block;
24
31
  width:100%;
25
32
  height:100%;
@@ -27,9 +34,16 @@
27
34
  object-fit:cover;
28
35
  }
29
36
 
30
- .videoSkeleton{
37
+ .mediaOverlay{
31
38
  position:absolute;
32
39
  inset:0;
40
+ display:flex;
41
+ align-items:center;
42
+ justify-content:center;
43
+ }
44
+
45
+ .mediaFallbackIcon{
46
+ color:var(--sn-theme-color-neutral-accent, var(--sn-brand-color-neutral-55, var(--sn-primitive-color-gray-55, #8b8fa7)));
33
47
  }
34
48
 
35
49
  .content{
@@ -1,4 +1,4 @@
1
- import { NoteItemProps, ReleaseNotesContentState } from '../../types.js';
1
+ import { NoteItemProps, ReleaseNotesContentState, Surface } from '../../types.js';
2
2
  export type ReleaseNotesContentProps = {
3
3
  /** Визуальное состояние */
4
4
  contentState: ReleaseNotesContentState;
@@ -9,7 +9,7 @@ export type ReleaseNotesContentProps = {
9
9
  /** Текущая страница, zero-based */
10
10
  pageIndex: number;
11
11
  /** Поверхность */
12
- surface: 'modal' | 'bottomSheet';
12
+ surface: Surface;
13
13
  /** Заголовок no-data */
14
14
  noDataTitle: string;
15
15
  /** Описание no-data */
@@ -3,16 +3,15 @@ import { Button } from '@cloud-ru/ds-button';
3
3
  import { Carousel } from '@cloud-ru/ds-carousel';
4
4
  import { CrossSVG } from '@cloud-ru/ds-icons/interface/system';
5
5
  import { InfoBlock } from '@cloud-ru/ds-info-block';
6
- import { Skeleton } from '@cloud-ru/ds-skeleton';
7
6
  import cn from 'classnames';
8
7
  import { CONTENT_STATE, TEST_IDS } from '../../constants.js';
9
- import { NoteItem } from '../NoteItem/index.js';
8
+ import { NoteItem, NoteItemSkeleton } from '../NoteItem/index.js';
10
9
  import styles from './styles.module.css';
11
10
  export function ReleaseNotesContent({ contentState, items, loading, pageIndex, surface, noDataTitle, noDataDescription, errorTitle, errorDescription, retryLabel, onPageChange, onDataErrorRetryClick, }) {
12
11
  const total = items.length;
13
12
  const hasItems = total > 0;
14
13
  if (loading) {
15
- return (_jsx("div", { className: styles.loading, "data-surface": surface, children: _jsx(Skeleton, { loading: true, width: '100%', height: '100%', borderRadius: 'var(--border-radius-l)' }) }));
14
+ return (_jsx("div", { className: cn(styles.root, styles[surface]), children: _jsx(NoteItemSkeleton, { surface: surface }) }));
16
15
  }
17
16
  if (contentState === CONTENT_STATE.Error) {
18
17
  return (_jsx("div", { className: styles.stateWrapper, "data-surface": surface, children: _jsx(InfoBlock, { className: styles.state, size: 'l', title: errorTitle, content: errorDescription, icon: { icon: CrossSVG, appearance: 'neutral', background: true }, "data-test-id": TEST_IDS.releaseNotesError, footer: _jsx(Button, { view: 'filled', appearance: 'neutral', size: 'l', label: retryLabel, onClick: onDataErrorRetryClick, "data-test-id": TEST_IDS.releaseNotesRetryButton }) }) }));
@@ -33,8 +33,4 @@
33
33
  .state{
34
34
  width:100%;
35
35
  max-width:calc(9 * var(--sn-primitive-dimension-64, 64px) + 3 * var(--sn-primitive-dimension-16, 16px));
36
- }
37
-
38
- .loading{
39
- height:calc(5 * var(--sn-primitive-dimension-64, 64px));
40
36
  }
@@ -1,8 +1,9 @@
1
1
  import { ButtonProps } from '@cloud-ru/ds-button';
2
2
  import { MouseEvent } from 'react';
3
+ import { Surface } from '../../types.js';
3
4
  export type ReleaseNotesFooterProps = {
4
5
  /** Поверхность отображения */
5
- surface: 'modal' | 'bottomSheet';
6
+ surface: Surface;
6
7
  /** Количество страниц */
7
8
  total: number;
8
9
  /** Текущая страница, zero-based */