fix(UI): minimize the fixed contents upper the editor (#54978)

This commit is contained in:
Manabu Matsumoto
2024-06-22 00:47:09 +09:00
committed by GitHub
parent fd1bf0dd5a
commit 2847c7c77a
11 changed files with 243 additions and 51 deletions
+1
View File
@@ -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;
+21 -2
View File
@@ -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}
<SignoutModal />
{isChallenge && !examInProgress && (
{isChallenge && !examInProgress && isRenderBreadcrumb ? (
<div className='breadcrumbs-demo'>
<BreadCrumb
block={block as string}
superBlock={superBlock as string}
/>
</div>
) : isExSmallViewportHeight ? (
<Spacer size='xxSmall' />
) : (
<Spacer size='small' />
)}
{fetchState.complete && children}
</div>
@@ -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;
}
}
@@ -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<EditorTabsProps> {
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 (
<div className='monaco-editor-tabs'>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */}
{sortChallengeFiles(challengeFiles).map(
(challengeFile: ChallengeFile) => (
<button
aria-expanded={
// @ts-expect-error TODO: validate challengeFile on io-boundary,
// then we won't need to ignore this error and we can drop the
// nullish handling.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
visibleEditors[challengeFile.fileKey] ?? 'false'
}
key={challengeFile.fileKey}
onClick={() => toggleVisibleEditor(challengeFile.fileKey)}
>
{`${challengeFile.name}.${challengeFile.ext}`}{' '}
<span className='sr-only'>
{i18next.t('learn.editor-tabs.editor')}
</span>
</button>
)
)}
</div>
isRenderChallengeFiles && (
<div className='monaco-editor-tabs'>
{
/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
sortChallengeFiles(challengeFiles).map(
(challengeFile: ChallengeFile) => (
<button
aria-expanded={
// @ts-expect-error TODO: validate challengeFile on io-boundary,
// then we won't need to ignore this error and we can drop the
// nullish handling.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
visibleEditors[challengeFile.fileKey] ?? 'false'
}
key={challengeFile.fileKey}
onClick={() => toggleVisibleEditor(challengeFile.fileKey)}
>
{`${challengeFile.name}.${challengeFile.ext}`}{' '}
<span className='sr-only'>
{i18next.t('learn.editor-tabs.editor')}
</span>
</button>
)
)
}
</div>
)
);
}
}
@@ -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;
@@ -77,6 +77,8 @@ export interface EditorProps {
challengeFiles: ChallengeFiles;
challengeType: number;
containerRef?: React.RefObject<HTMLElement>;
block: string;
superBlock: string;
description: string;
dimensions?: Dimensions;
editorRef: MutableRefObject<editor.IStandaloneCodeEditor | undefined>;
@@ -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,
@@ -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) => {
>
<Editor
canFocusOnMountRef={canFocusOnMountRef}
block={block}
superBlock={superBlock}
challengeFiles={challengeFiles}
containerRef={containerRef}
description={targetEditor === key ? description : ''}
@@ -406,6 +406,8 @@ function ShowClassic({
reduxChallengeFiles && (
<MultifileEditor
challengeFiles={reduxChallengeFiles}
block={block}
superBlock={superBlock}
containerRef={containerRef}
description={description}
editorRef={editorRef}
@@ -17,7 +17,7 @@ function BreadCrumb({ block, superBlock }: BreadCrumbProps): JSX.Element {
className='challenge-title-breadcrumbs'
aria-label={t('aria.breadcrumb-nav')}
>
<ol>
<ol data-playwright-test-label='breadcrumb-desktop'>
<li className='breadcrumb-left'>
<Link
state={{ breadcrumbBlockClick: block }}
+4
View File
@@ -7,6 +7,7 @@ import FourOhFourPage from '../../src/pages/404';
interface LayoutSelectorProps {
element: JSX.Element;
props: {
data: { challengeNode?: { challenge?: { usesMultifileEditor?: boolean } } };
location: { pathname: string };
pageContext?: { challengeMeta?: { block?: string; superBlock?: string } };
};
@@ -37,6 +38,9 @@ export default function layoutSelector({
pathname={pathname}
showFooter={false}
isChallenge={true}
usesMultifileEditor={
props.data?.challengeNode?.challenge?.usesMultifileEditor
}
block={props.pageContext?.challengeMeta?.block}
superBlock={props.pageContext?.challengeMeta?.superBlock}
>
+38 -23
View File
@@ -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');
});
});