diff --git a/client/config/misc.ts b/client/config/misc.ts
index dffa8cb01be..d2fb61fa9a1 100644
--- a/client/config/misc.ts
+++ b/client/config/misc.ts
@@ -1,3 +1,4 @@
export const MAX_MOBILE_WIDTH = 767;
+export const EX_SMALL_VIEWPORT_HEIGHT = 300;
export const TOOL_PANEL_HEIGHT = 37;
export const SEARCH_EXPOSED_WIDTH = 980;
diff --git a/client/src/components/layouts/default.tsx b/client/src/components/layouts/default.tsx
index e6304b4cccd..646944ecfcc 100644
--- a/client/src/components/layouts/default.tsx
+++ b/client/src/components/layouts/default.tsx
@@ -1,6 +1,7 @@
import React, { ReactNode, useEffect } from 'react';
import Helmet from 'react-helmet';
import { useTranslation, withTranslation } from 'react-i18next';
+import { useMediaQuery } from 'react-responsive';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import { createSelector } from 'reselect';
@@ -43,7 +44,11 @@ import StagingWarningModal from '../staging-warning-modal';
import Footer from '../Footer';
import Header from '../Header';
import OfflineWarning from '../OfflineWarning';
-import { Loader } from '../helpers';
+import { Loader, Spacer } from '../helpers';
+import {
+ MAX_MOBILE_WIDTH,
+ EX_SMALL_VIEWPORT_HEIGHT
+} from '../../../config/misc';
import envData from '../../../config/env.json';
import '@freecodecamp/ui/dist/base.css';
@@ -103,6 +108,7 @@ interface DefaultLayoutProps extends StateProps, DispatchProps {
pathname: string;
showFooter?: boolean;
isChallenge?: boolean;
+ usesMultifileEditor?: boolean;
block?: string;
examInProgress: boolean;
superBlock?: string;
@@ -127,6 +133,7 @@ function DefaultLayout({
removeFlashMessage,
showFooter = true,
isChallenge = false,
+ usesMultifileEditor,
block,
superBlock,
theme,
@@ -135,6 +142,14 @@ function DefaultLayout({
updateAllChallengesInfo
}: DefaultLayoutProps): JSX.Element {
const { t } = useTranslation();
+ const isMobileLayout = useMediaQuery({ maxWidth: MAX_MOBILE_WIDTH });
+ const isProject = /project$/.test(block as string);
+ const isRenderBreadcrumbOnMobile =
+ isMobileLayout && (isProject || !usesMultifileEditor);
+ const isRenderBreadcrumb = !isMobileLayout || isRenderBreadcrumbOnMobile;
+ const isExSmallViewportHeight = useMediaQuery({
+ maxHeight: EX_SMALL_VIEWPORT_HEIGHT
+ });
const { challengeEdges, certificateNodes } = useGetAllBlockIds();
useEffect(() => {
// componentDidMount
@@ -243,13 +258,17 @@ function DefaultLayout({
/>
) : null}
- {isChallenge && !examInProgress && (
+ {isChallenge && !examInProgress && isRenderBreadcrumb ? (
+ ) : isExSmallViewportHeight ? (
+
+ ) : (
+
)}
{fetchState.complete && children}
diff --git a/client/src/templates/Challenges/classic/classic.css b/client/src/templates/Challenges/classic/classic.css
index 8b8944ffc6a..0110c250c94 100644
--- a/client/src/templates/Challenges/classic/classic.css
+++ b/client/src/templates/Challenges/classic/classic.css
@@ -190,12 +190,16 @@
#mobile-layout .portal-button-wrap {
position: absolute;
- top: calc(var(--header-height) + var(--breadcrumbs-height));
+ top: auto;
right: 0;
height: 2rem;
border-bottom: 1px solid var(--quaternary-color);
}
+#mobile-layout .tab-content[data-state='active'] .portal-button-wrap {
+ transform: translateY(-2rem);
+}
+
#mobile-layout #mobile-layout-pane-instructions {
overflow-y: auto;
}
@@ -224,3 +228,50 @@
.nav-lists [role='tab']:focus {
outline-offset: -3px;
}
+
+@media screen and (max-width: 480px) {
+ #mobile-layout .monaco-editor-tabs {
+ padding: 10px 5px;
+ }
+}
+
+@media screen and (max-height: 300px) {
+ #mobile-layout .nav-lists {
+ height: 1.5rem;
+ }
+
+ #mobile-layout .nav-lists > button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ #mobile-layout .portal-button-wrap {
+ height: 1.5rem;
+ }
+
+ #mobile-layout .tab-content[data-state='active'] .portal-button-wrap {
+ height: 1.52rem;
+ transform: translateY(-1.5rem);
+ }
+
+ #mobile-layout .portal-button-wrap button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 1.43rem;
+ font-size: initial;
+ }
+
+ #mobile-layout .portal-button-wrap button svg {
+ scale: 0.8;
+ }
+
+ #mobile-layout .monaco-editor-tabs {
+ padding: 5px;
+ }
+
+ .monaco-editor-tabs button {
+ padding: 2px 12px;
+ }
+}
diff --git a/client/src/templates/Challenges/classic/editor-tabs.tsx b/client/src/templates/Challenges/classic/editor-tabs.tsx
index 6af10cff962..3d1e659f6fc 100644
--- a/client/src/templates/Challenges/classic/editor-tabs.tsx
+++ b/client/src/templates/Challenges/classic/editor-tabs.tsx
@@ -11,6 +11,7 @@ import {
challengeFilesSelector
} from '../redux/selectors';
+import { MAX_MOBILE_WIDTH } from '../../../../config/misc';
import type { VisibleEditors } from './multifile-editor';
interface EditorTabsProps {
@@ -34,32 +35,41 @@ const mapDispatchToProps = {
class EditorTabs extends Component {
static displayName: string;
+ isMobile = window.innerWidth < MAX_MOBILE_WIDTH;
render() {
const { challengeFiles, toggleVisibleEditor, visibleEditors } = this.props;
+ const isMobile = window.innerWidth < MAX_MOBILE_WIDTH;
+ const isRenderChallengeFiles =
+ /* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */
+ !isMobile || sortChallengeFiles(challengeFiles).length > 1;
return (
-
- {/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */}
- {sortChallengeFiles(challengeFiles).map(
- (challengeFile: ChallengeFile) => (
-
- )
- )}
-
+ isRenderChallengeFiles && (
+
+ {
+ /* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
+ sortChallengeFiles(challengeFiles).map(
+ (challengeFile: ChallengeFile) => (
+
+ )
+ )
+ }
+
+ )
);
}
}
diff --git a/client/src/templates/Challenges/classic/editor.css b/client/src/templates/Challenges/classic/editor.css
index 645ad78a80d..7bf459bdf1b 100644
--- a/client/src/templates/Challenges/classic/editor.css
+++ b/client/src/templates/Challenges/classic/editor.css
@@ -38,6 +38,13 @@ textarea.inputarea {
height: var(--breadcrumbs-height);
}
+@media screen and (max-height: 300px) {
+ .default-layout:has(#mobile-layout) .breadcrumbs-demo {
+ height: auto;
+ padding-block: 2px;
+ }
+}
+
.editor-upper-jaw,
.editor-lower-jaw {
padding-inline: 0 15px;
@@ -65,6 +72,51 @@ textarea.inputarea {
margin: 0.1em 0 0.6rem;
}
+.description-container .breadcrumbs {
+ display: flex;
+ justify-content: start;
+ gap: 1rem;
+ list-style-type: none;
+ font-size: 16px;
+ text-align: center;
+ margin-bottom: 0.8rem;
+ padding-inline-start: 0;
+ width: 100%;
+ min-width: 0;
+ color: var(--foreground-quaternary);
+}
+
+.description-container .breadcrumbs li:first-child {
+ position: relative;
+ max-width: 35%;
+}
+
+.description-container .breadcrumbs li:first-child:after {
+ content: ' >';
+ font-size: 17px;
+ position: absolute;
+ top: 0;
+ right: -0.7rem;
+}
+
+.description-container .breadcrumbs li:nth-child(2) {
+ min-width: 0;
+}
+
+.description-container .breadcrumbs a {
+ width: 100%;
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: block;
+ border-bottom: 1px solid var(--foreground-quaternary);
+}
+
+.description-container .breadcrumbs a:focus {
+ background-color: inherit;
+}
+
.description-container h1 {
color: var(--secondary-color);
font-size: 1.1rem;
diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx
index 4f26d5fe002..5517e4559c4 100644
--- a/client/src/templates/Challenges/classic/editor.tsx
+++ b/client/src/templates/Challenges/classic/editor.tsx
@@ -77,6 +77,8 @@ export interface EditorProps {
challengeFiles: ChallengeFiles;
challengeType: number;
containerRef?: React.RefObject;
+ block: string;
+ superBlock: string;
description: string;
dimensions?: Dimensions;
editorRef: MutableRefObject;
@@ -243,7 +245,7 @@ const initialData: EditorProperties = {
const Editor = (props: EditorProps): JSX.Element => {
const reduxStore = useStore();
const { t } = useTranslation();
- const { editorRef, initTests, resetAttempts } = props;
+ const { editorRef, initTests, resetAttempts, isMobileLayout } = props;
// These refs are used during initialisation of the editor as well as by
// callbacks. Since they have to be initialised before editorWillMount and
// editorDidMount are called, we cannot use useState. Reason being that will
@@ -804,6 +806,7 @@ const Editor = (props: EditorProps): JSX.Element => {
descContainer.classList.add('description-container');
domNode.classList.add('editor-upper-jaw');
domNode.appendChild(descContainer);
+ if (isMobileLayout) descContainer.appendChild(createBreadcrumb());
descContainer.appendChild(jawHeading);
descContainer.appendChild(desc);
desc.innerHTML = description;
@@ -896,6 +899,35 @@ const Editor = (props: EditorProps): JSX.Element => {
updateFile({ fileKey, editorValue, editableRegionBoundaries });
};
+ function createBreadcrumb(): HTMLElement {
+ const { block, superBlock } = props;
+ const breadcrumb = document.createElement('nav');
+ breadcrumb.setAttribute('aria-label', `${t('aria.breadcrumb-nav')}`);
+ const breadcrumbList = document.createElement('ol'),
+ breadcrumbLeft = document.createElement('li'),
+ breadcrumbLeftLink = document.createElement('a'),
+ breadcrumbRight = document.createElement('li'),
+ breadcrumbRightLink = document.createElement('a');
+ breadcrumbLeftLink.innerHTML = t(`intro:${superBlock}.title`);
+ breadcrumbRightLink.innerHTML = t(
+ `intro:${superBlock}.blocks.${block}.title`
+ );
+ breadcrumbLeftLink.setAttribute('href', `/learn/${superBlock}`);
+ breadcrumbRightLink.setAttribute('href', `/learn/${superBlock}/#${block}`);
+ breadcrumbLeft.appendChild(breadcrumbLeftLink);
+ breadcrumbRight.appendChild(breadcrumbRightLink);
+ breadcrumbList.setAttribute(
+ 'data-playwright-test-label',
+ 'breadcrumb-mobile'
+ );
+ breadcrumbList.className = 'breadcrumbs';
+ breadcrumbList.appendChild(breadcrumbLeft);
+ breadcrumbList.appendChild(breadcrumbRight);
+ breadcrumb.appendChild(breadcrumbList);
+
+ return breadcrumb;
+ }
+
// TODO: DRY this and the update function
function initializeEditableRegion(
range: IRange,
diff --git a/client/src/templates/Challenges/classic/multifile-editor.tsx b/client/src/templates/Challenges/classic/multifile-editor.tsx
index 18f82e449c6..842c7c9066c 100644
--- a/client/src/templates/Challenges/classic/multifile-editor.tsx
+++ b/client/src/templates/Challenges/classic/multifile-editor.tsx
@@ -35,6 +35,8 @@ type MultifileEditorProps = Pick<
| 'initialTests'
| 'editorRef'
| 'containerRef'
+ | 'block'
+ | 'superBlock'
| 'challengeFiles'
| 'description'
// We use dimensions to trigger a re-render of the editor
@@ -65,6 +67,8 @@ const mapStateToProps = createSelector(
const MultifileEditor = (props: MultifileEditorProps) => {
const {
+ block,
+ superBlock,
challengeFiles,
containerRef,
description,
@@ -133,6 +137,8 @@ const MultifileEditor = (props: MultifileEditorProps) => {
>
-
+
-
diff --git a/e2e/bread-crumb.spec.ts b/e2e/bread-crumb.spec.ts
index f519ddcc435..a8b44e3a032 100644
--- a/e2e/bread-crumb.spec.ts
+++ b/e2e/bread-crumb.spec.ts
@@ -2,34 +2,49 @@ import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto(
- '/learn/foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/trophy-write-your-first-code-using-c-sharp'
+ '/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-2'
);
});
-test.describe('Challenge Breadcrumb Component Tests', () => {
- test('should display correctly', async ({ page }) => {
- const superBlock = page.getByRole('listitem').first();
- await expect(superBlock).toBeVisible();
+test.describe('Challenge Breadcrumb Tests', () => {
+ test('should display correctly', async ({ page, isMobile }) => {
+ const breadcrumbTest = async (testId: string) => {
+ const superBlock = page.getByTestId(testId).getByRole('listitem').first();
+ await expect(superBlock).toBeVisible();
- const superBlockLink = superBlock.getByRole('link', {
- name: '(New) Foundational C# with Microsoft'
- });
- await expect(superBlockLink).toBeVisible();
- await expect(superBlockLink).toHaveAttribute(
- 'href',
- '/learn/foundational-c-sharp-with-microsoft'
- );
+ const superBlockLink = superBlock.getByRole('link', {
+ name: 'Responsive Web Design'
+ });
+ await expect(superBlockLink).toBeVisible();
+ await expect(superBlockLink).toHaveAttribute(
+ 'href',
+ '/learn/2022/responsive-web-design'
+ );
- const block = page.getByRole('listitem').last();
- await expect(superBlock).toBeVisible();
+ const block = page.getByTestId(testId).getByRole('listitem').last();
+ await expect(superBlock).toBeVisible();
- const blockLink = block.getByRole('link', {
- name: 'Write Your First Code Using C#'
- });
- await expect(blockLink).toBeVisible();
- await expect(blockLink).toHaveAttribute(
- 'href',
- '/learn/foundational-c-sharp-with-microsoft/#write-your-first-code-using-c-sharp'
- );
+ const blockLink = block.getByRole('link', {
+ name: 'Learn HTML by Building a Cat Photo App'
+ });
+ await expect(blockLink).toBeVisible();
+ await expect(blockLink).toHaveAttribute(
+ 'href',
+ '/learn/2022/responsive-web-design/#learn-html-by-building-a-cat-photo-app'
+ );
+ };
+
+ if (!isMobile) {
+ await expect(page.getByTestId('breadcrumb-mobile')).toBeHidden();
+ await breadcrumbTest('breadcrumb-desktop');
+
+ await page.setViewportSize({
+ width: 766,
+ height: 1080
+ });
+ }
+
+ await expect(page.getByTestId('breadcrumb-desktop')).toBeHidden();
+ await breadcrumbTest('breadcrumb-mobile');
});
});