Skip to content

Commit bbe7899

Browse files
committed
refactor(docs-shell): share the media card and route frame across content containers
- RouteContentFrame wraps every route container (markdown, HTML, video, audio) with the shared frame props - MediaCard renders the player between the route title and description for video and audio - ContentContainerChildren type reused by the wrapper - audio route controls use one ControlButton for play, restart and loop
1 parent 5dcf8b2 commit bbe7899

7 files changed

Lines changed: 126 additions & 79 deletions

File tree

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"use client";
22

33
import type { LanguageCode } from "@/entities/docs";
4-
import { ContentContainerWrapper } from "./content-container-wrapper";
54
import { AudioRouteControls, type AudioRouteControlsConfig } from "./audio-route-controls";
65
import { getAudioRouteCaptions } from "./captions-track";
7-
import { CardInsideDescription, CardInsideTitle } from "./card-inside-text";
86
import { resolveContentHeaderText } from "./content-header-text";
9-
import { toContainerWrapperProps, type RouteContainerFrameProps } from "./container-wrapper-props";
7+
import type { RouteContainerFrameProps } from "./container-wrapper-props";
108
import { getFullscreenAlign } from "./fullscreen-alignment";
11-
import styles from "../../docs-shell.module.css";
9+
import { MediaCard } from "./route-content-frame";
1210

1311
interface AudioContainerProps extends RouteContainerFrameProps {
1412
audioType: string;
@@ -20,23 +18,22 @@ interface AudioContainerProps extends RouteContainerFrameProps {
2018

2119
export function AudioContainer(props: Readonly<AudioContainerProps>) {
2220
const { audioType, pathAudio, language, config, isDarkMode = false, controlsConfig } = props;
23-
const header = resolveContentHeaderText(config, language, isDarkMode);
2421

2522
return (
26-
<ContentContainerWrapper {...toContainerWrapperProps(props)} fullscreenAlign={getFullscreenAlign("audio")}>
27-
<article className={styles.card}>
28-
<CardInsideTitle header={header} />
29-
{controlsConfig && (
30-
<AudioRouteControls
31-
audioType={audioType}
32-
pathAudio={pathAudio}
33-
language={language}
34-
controls={controlsConfig}
35-
captions={getAudioRouteCaptions(config)}
36-
/>
37-
)}
38-
<CardInsideDescription header={header} />
39-
</article>
40-
</ContentContainerWrapper>
23+
<MediaCard
24+
frame={props}
25+
header={resolveContentHeaderText(config, language, isDarkMode)}
26+
fullscreenAlign={getFullscreenAlign("audio")}
27+
>
28+
{controlsConfig && (
29+
<AudioRouteControls
30+
audioType={audioType}
31+
pathAudio={pathAudio}
32+
language={language}
33+
controls={controlsConfig}
34+
captions={getAudioRouteCaptions(config)}
35+
/>
36+
)}
37+
</MediaCard>
4138
);
4239
}

‎frontend/src/widgets/docs-shell/ui/content-type-containers/audio-route-controls.tsx‎

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useMemo } from "react";
3+
import { useMemo, type ReactNode } from "react";
44
import { CiPlay1, FaPause, FiRefreshCw, FiRepeat } from "@/shared/ui/fallback-icons";
55
import { renderAudioControlIcon, useAudioPlayer } from "@/features/audio-player";
66
import type { AudioTrackConfig, LanguageCode } from "@/entities/docs";
@@ -34,6 +34,34 @@ interface AudioRouteControlsProps {
3434
captions?: CaptionsByLanguage;
3535
}
3636

37+
interface ControlButtonProps {
38+
label: string;
39+
onClick: () => void;
40+
/** Marks the button as on (playing, looping) for styling. */
41+
active?: boolean;
42+
/** Toggle state for assistive technology (loop). */
43+
pressed?: boolean;
44+
testId?: string;
45+
children: ReactNode;
46+
}
47+
48+
function ControlButton({ label, onClick, active, pressed, testId, children }: Readonly<ControlButtonProps>) {
49+
return (
50+
<button
51+
type="button"
52+
className={styles.audioRouteControlButton}
53+
onClick={onClick}
54+
aria-label={label}
55+
aria-pressed={pressed}
56+
title={label}
57+
data-active={active || undefined}
58+
data-testid={testId}
59+
>
60+
{children}
61+
</button>
62+
);
63+
}
64+
3765
export function AudioRouteControls({
3866
audioType,
3967
pathAudio,
@@ -77,40 +105,18 @@ export function AudioRouteControls({
77105
return (
78106
<div className={styles.audioRoutePlayer} aria-label={playStatusLabel}>
79107
<div className={styles.audioRouteControls}>
80-
<button
81-
type="button"
82-
className={styles.audioRouteControlButton}
83-
onClick={togglePlay}
84-
aria-label={playStatusLabel}
85-
title={playStatusLabel}
86-
data-active={playing || undefined}
87-
data-testid="audio-route-toggle"
88-
>
108+
<ControlButton label={playStatusLabel} onClick={togglePlay} active={playing} testId="audio-route-toggle">
89109
{renderAudioControlIcon(
90110
playing ? controls.pauseIcon : controls.playIcon,
91111
playing ? <FaPause aria-hidden /> : <CiPlay1 aria-hidden />,
92112
)}
93-
</button>
94-
<button
95-
type="button"
96-
className={styles.audioRouteControlButton}
97-
onClick={restart}
98-
aria-label={controls.restartLabel}
99-
title={controls.restartLabel}
100-
>
113+
</ControlButton>
114+
<ControlButton label={controls.restartLabel} onClick={restart}>
101115
{renderAudioControlIcon(controls.restartIcon, <FiRefreshCw aria-hidden />)}
102-
</button>
103-
<button
104-
type="button"
105-
className={styles.audioRouteControlButton}
106-
onClick={toggleLoop}
107-
aria-label={loopStatusLabel}
108-
aria-pressed={loopEnabled}
109-
title={loopStatusLabel}
110-
data-active={loopEnabled || undefined}
111-
>
116+
</ControlButton>
117+
<ControlButton label={loopStatusLabel} onClick={toggleLoop} active={loopEnabled} pressed={loopEnabled}>
112118
{renderAudioControlIcon(loopEnabled ? controls.loopOnIcon : controls.loopOffIcon, <FiRepeat aria-hidden />)}
113-
</button>
119+
</ControlButton>
114120
</div>
115121
{isNativeTrack && audioSrc && (
116122
<audio

‎frontend/src/widgets/docs-shell/ui/content-type-containers/content-container-wrapper.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ export interface ResolveChildrenOptions {
1313
contentOnly?: boolean;
1414
}
1515

16+
/** Content, or a function that places the fullscreen button inside it (e.g. TOC + markdown layout). */
17+
export type ContentContainerChildren =
18+
| React.ReactNode
19+
| ((fullscreenButton: React.ReactNode, options?: ResolveChildrenOptions) => React.ReactNode);
20+
1621
interface ContentContainerWrapperProps {
1722
header?: React.ReactNode;
18-
/** Content, or function to inject fullscreen button into content (e.g. for TOC + MD layout) */
19-
children:
20-
| React.ReactNode
21-
| ((fullscreenButton: React.ReactNode, options?: ResolveChildrenOptions) => React.ReactNode);
23+
children: ContentContainerChildren;
2224
fullscreenEnabled?: boolean;
2325
fullscreenCloseLabel: string;
2426
fullscreenExpandLabel: string;

‎frontend/src/widgets/docs-shell/ui/content-type-containers/html-container.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import { useMemo } from "react";
44
import type { LanguageCode } from "@/entities/docs";
55
import { isFrameBlockedUrl } from "@/shared/lib/is-frame-blocked-url";
6-
import { ContentContainerWrapper } from "./content-container-wrapper";
76
import { ContentHeaderBlock } from "./content-header-block";
87
import { getContainerStyle } from "./container-style";
9-
import { toContainerWrapperProps, type RouteContainerFrameProps } from "./container-wrapper-props";
8+
import type { RouteContainerFrameProps } from "./container-wrapper-props";
9+
import { RouteContentFrame } from "./route-content-frame";
1010
import styles from "../../docs-shell.module.css";
1111

1212
const BASE_TARGET_BLANK = "<base target=\"_blank\" />";
@@ -132,8 +132,8 @@ export function HtmlContainer(props: Readonly<HtmlContainerProps>) {
132132
);
133133

134134
return (
135-
<ContentContainerWrapper header={header} {...toContainerWrapperProps(props)}>
135+
<RouteContentFrame frame={props} header={header}>
136136
{content}
137-
</ContentContainerWrapper>
137+
</RouteContentFrame>
138138
);
139139
}

‎frontend/src/widgets/docs-shell/ui/content-type-containers/md-container.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import { useMemo } from "react";
44
import { extractHeadingsFromHtml, type BreadcrumbItem, type LanguageCode } from "@/entities/docs";
55
import type { ResolvedRouteGuideIconConfig } from "@/shared/lib/resolve-site-assets";
6-
import { ContentContainerWrapper } from "./content-container-wrapper";
76
import { ContentHeaderBlock } from "./content-header-block";
87
import { getContainerStyle } from "./container-style";
9-
import { toContainerWrapperProps, type RouteContainerFrameProps } from "./container-wrapper-props";
8+
import type { RouteContainerFrameProps } from "./container-wrapper-props";
9+
import { RouteContentFrame } from "./route-content-frame";
1010
import { MdSourceActions, type MdSourceActionLabels } from "./md-source-actions";
1111
import { RouteGuideBreadcrumb, TocContainer } from "@/features/route-guide";
1212
import type { TocPosition } from "@/features/route-guide";
@@ -140,8 +140,8 @@ export function MdContainer(props: Readonly<MdContainerProps>) {
140140
};
141141

142142
return (
143-
<ContentContainerWrapper header={header} {...toContainerWrapperProps(props)}>
143+
<RouteContentFrame frame={props} header={header}>
144144
{content}
145-
</ContentContainerWrapper>
145+
</RouteContentFrame>
146146
);
147147
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"use client";
2+
3+
import type { ReactNode } from "react";
4+
import { ContentContainerWrapper, type ContentContainerChildren } from "./content-container-wrapper";
5+
import { CardInsideDescription, CardInsideTitle } from "./card-inside-text";
6+
import type { ContentHeaderText } from "./content-header-text";
7+
import { toContainerWrapperProps, type RouteContainerFrameProps } from "./container-wrapper-props";
8+
import type { FullscreenAlign } from "./fullscreen-alignment";
9+
import styles from "../../docs-shell.module.css";
10+
11+
interface RouteContentFrameProps {
12+
/** The container's own props: fullscreen, labels, margins and browse navigation. */
13+
frame: RouteContainerFrameProps;
14+
header?: ReactNode;
15+
fullscreenAlign?: FullscreenAlign;
16+
children: ContentContainerChildren;
17+
}
18+
19+
/** Frame shared by every route content container (fullscreen, margins, browse navigation). */
20+
export function RouteContentFrame({ frame, header, fullscreenAlign, children }: Readonly<RouteContentFrameProps>) {
21+
return (
22+
<ContentContainerWrapper header={header} fullscreenAlign={fullscreenAlign} {...toContainerWrapperProps(frame)}>
23+
{children}
24+
</ContentContainerWrapper>
25+
);
26+
}
27+
28+
interface MediaCardProps {
29+
frame: RouteContainerFrameProps;
30+
header: ContentHeaderText;
31+
fullscreenAlign?: FullscreenAlign;
32+
/** The player (video, embed or audio controls). */
33+
children: ReactNode;
34+
}
35+
36+
/** A media route (video, audio): the player in a card, between the route title and description. */
37+
export function MediaCard({ frame, header, fullscreenAlign, children }: Readonly<MediaCardProps>) {
38+
return (
39+
<RouteContentFrame frame={frame} fullscreenAlign={fullscreenAlign}>
40+
<article className={styles.card}>
41+
<CardInsideTitle header={header} />
42+
{children}
43+
<CardInsideDescription header={header} />
44+
</article>
45+
</RouteContentFrame>
46+
);
47+
}

‎frontend/src/widgets/docs-shell/ui/content-type-containers/video-container.tsx‎

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,10 @@ import {
1010
type VideoPlaybackElement,
1111
} from "@/features/video-playback";
1212
import { usePageOrigin } from "@/shared/lib/use-page-origin";
13-
import { ContentContainerWrapper } from "./content-container-wrapper";
1413
import { resolveCaptionsTrackProps } from "./captions-track";
15-
import { CardInsideDescription, CardInsideTitle } from "./card-inside-text";
1614
import { resolveContentHeaderText } from "./content-header-text";
17-
import { toContainerWrapperProps, type RouteContainerFrameProps } from "./container-wrapper-props";
15+
import type { RouteContainerFrameProps } from "./container-wrapper-props";
16+
import { MediaCard } from "./route-content-frame";
1817
import styles from "../../docs-shell.module.css";
1918

2019
/** Stable hook for E2E: the YouTube player replaces the iframe title with the video name. */
@@ -90,20 +89,16 @@ export function VideoContainer(props: Readonly<VideoContainerProps>) {
9089
const header = resolveContentHeaderText(hideTitleDescription ? undefined : config, language, isDarkMode);
9190

9291
return (
93-
<ContentContainerWrapper {...toContainerWrapperProps(props)}>
94-
<article className={styles.card}>
95-
<CardInsideTitle header={header} />
96-
<div className={styles.videoWrapper}>
97-
<MediaElement
98-
type={type}
99-
src={embedUrl}
100-
muted={muted}
101-
captions={resolveCaptionsTrackProps(config?.video?.captions, language)}
102-
attach={attachMedia}
103-
/>
104-
</div>
105-
<CardInsideDescription header={header} />
106-
</article>
107-
</ContentContainerWrapper>
92+
<MediaCard frame={props} header={header}>
93+
<div className={styles.videoWrapper}>
94+
<MediaElement
95+
type={type}
96+
src={embedUrl}
97+
muted={muted}
98+
captions={resolveCaptionsTrackProps(config?.video?.captions, language)}
99+
attach={attachMedia}
100+
/>
101+
</div>
102+
</MediaCard>
108103
);
109104
}

0 commit comments

Comments
 (0)