From 991ab0ac9cb472ba4d0825a35719053173292b99 Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Mon, 3 Aug 2020 12:28:10 -0400 Subject: [PATCH] Add lean Studio header component. (#24) * add component from edx/frontend-component-header-edx * adapt header components from edx/frontend-component-header-edx to fit the Studio header use case --- package-lock.json | 60 +++- package.json | 5 +- src/index.jsx | 15 +- src/index.scss | 1 + src/studio-header/Avatar.jsx | 43 +++ src/studio-header/DesktopHeader.jsx | 157 ++++++++++ src/studio-header/Header.jsx | 78 +++++ src/studio-header/Header.test.jsx | 73 +++++ src/studio-header/Icons.jsx | 47 +++ src/studio-header/Logo.jsx | 36 +++ src/studio-header/Menu/Menu.jsx | 279 ++++++++++++++++++ src/studio-header/Menu/index.jsx | 4 + src/studio-header/Menu/menu.scss | 46 +++ src/studio-header/MobileHeader.jsx | 158 ++++++++++ .../__snapshots__/Header.test.jsx.snap | 253 ++++++++++++++++ src/studio-header/assets/studio-logo.png | Bin 0 -> 4755 bytes src/studio-header/header.scss | 12 + 17 files changed, 1256 insertions(+), 11 deletions(-) create mode 100644 src/studio-header/Avatar.jsx create mode 100644 src/studio-header/DesktopHeader.jsx create mode 100644 src/studio-header/Header.jsx create mode 100644 src/studio-header/Header.test.jsx create mode 100644 src/studio-header/Icons.jsx create mode 100644 src/studio-header/Logo.jsx create mode 100644 src/studio-header/Menu/Menu.jsx create mode 100644 src/studio-header/Menu/index.jsx create mode 100644 src/studio-header/Menu/menu.scss create mode 100644 src/studio-header/MobileHeader.jsx create mode 100644 src/studio-header/__snapshots__/Header.test.jsx.snap create mode 100644 src/studio-header/assets/studio-logo.png create mode 100644 src/studio-header/header.scss diff --git a/package-lock.json b/package-lock.json index d687bbc15..8b2f3db51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1385,6 +1385,30 @@ "babel-polyfill": "6.26.0", "react-responsive": "8.0.3", "react-transition-group": "4.3.0" + }, + "dependencies": { + "react-responsive": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.0.3.tgz", + "integrity": "sha512-F9VXyLao7O8XHXbLjQbIr4+mC6Zr0RDTwNjd7ixTmYEAyKyNanBkLkFchNaMZgszoSK6PgSs/3m/QDWw33/gpg==", + "requires": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.3.0", + "prop-types": "^15.6.1", + "shallow-equal": "^1.1.0" + } + }, + "react-transition-group": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz", + "integrity": "sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + } } }, "@edx/frontend-platform": { @@ -16706,9 +16730,9 @@ } }, "react-responsive": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.0.3.tgz", - "integrity": "sha512-F9VXyLao7O8XHXbLjQbIr4+mC6Zr0RDTwNjd7ixTmYEAyKyNanBkLkFchNaMZgszoSK6PgSs/3m/QDWw33/gpg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.1.0.tgz", + "integrity": "sha512-U8Nv2/ZWACIw/fAE9XNPbc2Xo33X5q1bcCASc2SufvJ9ifB+o/rokfogfznSVcvS22hN1rafGi0uZD6GiVFEHw==", "requires": { "hyphenate-style-name": "^1.0.0", "matchmediaquery": "^0.3.0", @@ -16747,10 +16771,34 @@ "tiny-warning": "^1.0.0" } }, + "react-test-renderer": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.9.0.tgz", + "integrity": "sha512-R62stB73qZyhrJo7wmCW9jgl/07ai+YzvouvCXIJLBkRlRqLx4j9RqcLEAfNfU3OxTGucqR2Whmn3/Aad6L3hQ==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.9.0", + "scheduler": "^0.15.0" + }, + "dependencies": { + "scheduler": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.15.0.tgz", + "integrity": "sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + } + } + }, "react-transition-group": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz", - "integrity": "sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", "requires": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", diff --git a/package.json b/package.json index 795a0a2e1..f88e021b0 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,10 @@ "react": "16.12.0", "react-dom": "16.12.0", "react-redux": "7.1.3", + "react-responsive": "^8.1.0", "react-router": "5.1.2", "react-router-dom": "5.1.2", + "react-transition-group": "^4.4.1", "redux": "4.0.5" }, "devDependencies": { @@ -64,6 +66,7 @@ "glob": "7.1.6", "husky": "3.1.0", "jest": "24.9.0", - "reactifex": "1.1.1" + "reactifex": "1.1.1", + "react-test-renderer": "16.9.0" } } diff --git a/src/index.jsx b/src/index.jsx index b1001a9ae..bb5816050 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -14,6 +14,7 @@ import Footer, { messages as footerMessages } from '@edx/frontend-component-foot import appMessages from './i18n'; import ExamplePage from './example/ExamplePage'; import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings'; +import StudioHeader from './studio-header/Header'; import './index.scss'; import './assets/favicon.ico'; @@ -21,16 +22,22 @@ import './assets/favicon.ico'; subscribe(APP_READY, () => { ReactDOM.render( -
( - - )} + render={({ match }) => { + const courseId = decodeURIComponent(match.params.course_id); + return ( + <> + + + + ); + }} /> +
diff --git a/src/index.scss b/src/index.scss index c0820ecdb..5c7b5f703 100755 --- a/src/index.scss +++ b/src/index.scss @@ -6,3 +6,4 @@ @import "~@edx/frontend-component-footer/dist/footer"; @import "proctored-exam-settings/proctoredExamSettings.scss"; +@import "studio-header/header.scss"; diff --git a/src/studio-header/Avatar.jsx b/src/studio-header/Avatar.jsx new file mode 100644 index 000000000..1fbe193ca --- /dev/null +++ b/src/studio-header/Avatar.jsx @@ -0,0 +1,43 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import PropTypes from 'prop-types'; + +import { AvatarIcon } from './Icons'; + +function Avatar({ + size, + src, + alt, + className, +}) { + const avatar = src ? ( + {alt} + ) : ( + + ); + + return ( + + {avatar} + + ); +} + +Avatar.propTypes = { + src: PropTypes.string, + size: PropTypes.string, + alt: PropTypes.string, + className: PropTypes.string, +}; + +Avatar.defaultProps = { + src: null, + size: '2rem', + alt: null, + className: null, +}; + +export default Avatar; diff --git a/src/studio-header/DesktopHeader.jsx b/src/studio-header/DesktopHeader.jsx new file mode 100644 index 000000000..812dd5239 --- /dev/null +++ b/src/studio-header/DesktopHeader.jsx @@ -0,0 +1,157 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl } from '@edx/frontend-platform/i18n'; + +// Local Components +import { Menu, MenuTrigger, MenuContent } from './Menu'; +import Avatar from './Avatar'; +import { LinkedLogo, Logo } from './Logo'; + +// Assets +import { CaretIcon } from './Icons'; + +class DesktopHeader extends React.Component { + constructor(props) { // eslint-disable-line no-useless-constructor + super(props); + } + + renderMainMenu() { + const { mainMenu } = this.props; + + // Nodes are accepted as a prop + if (!Array.isArray(mainMenu)) { + return mainMenu; + } + + return mainMenu.map((menuItem) => { + const { + type, + href, + content, + submenuContent, + } = menuItem; + + if (type === 'item') { + return ( + {content} + ); + } + + return ( + + + {content} + + + {submenuContent} + + + ); + }); + } + + renderUserMenu() { + const { + userMenu, + avatar, + username, + } = this.props; + + return ( + + + + {username} + + + {userMenu.map(({ type, href, content }) => ( + {content} + ))} + + + ); + } + + render() { + const { + logo, + logoAltText, + logoDestination, + courseTitleDestination, + } = this.props; + const logoProps = { src: logo, alt: logoAltText, href: logoDestination }; + + return ( +
+
+
+ {logoDestination === null ? : } + {/* This lockup HTML was copied from edx/frontend-app-learning/src/course-header/Header.jsx. */} + + {this.props.courseId} + + + +
+
+
+ ); + } +} + +DesktopHeader.propTypes = { + mainMenu: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.array, + ]), + userMenu: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.oneOf(['item', 'menu']), + href: PropTypes.string, + content: PropTypes.string, + })), + logo: PropTypes.string, + logoAltText: PropTypes.string, + logoDestination: PropTypes.string, + courseTitleDestination: PropTypes.string, + courseId: PropTypes.string, + avatar: PropTypes.string, + username: PropTypes.string, + loggedIn: PropTypes.bool, +}; + +DesktopHeader.defaultProps = { + mainMenu: [], + userMenu: [], + logo: null, + logoAltText: null, + logoDestination: null, + courseTitleDestination: null, + courseId: null, + avatar: null, + username: null, + loggedIn: false, +}; + +export default injectIntl(DesktopHeader); diff --git a/src/studio-header/Header.jsx b/src/studio-header/Header.jsx new file mode 100644 index 000000000..bb8882dd8 --- /dev/null +++ b/src/studio-header/Header.jsx @@ -0,0 +1,78 @@ +// This file was copied from edx/frontend-component-header-edx. +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import Responsive from 'react-responsive'; +import { injectIntl } from '@edx/frontend-platform/i18n'; +import { AppContext } from '@edx/frontend-platform/react'; +import { ensureConfig } from '@edx/frontend-platform'; + +import DesktopHeader from './DesktopHeader'; +import MobileHeader from './MobileHeader'; + +import StudioLogoPNG from './assets/studio-logo.png'; + +ensureConfig([ + 'STUDIO_BASE_URL', + 'LOGOUT_URL', + 'LOGIN_URL', +], 'Header component'); + +function Header({ courseId }) { + const { authenticatedUser, config } = useContext(AppContext); + + const desktopMainMenu = []; + const mobileMainMenu = [ + { + type: 'item', + href: `${config.STUDIO_BASE_URL}/course/${courseId}`, + content: 'Back to Studio Course Outline', + }, + ]; + + const userMenu = authenticatedUser === null ? [] : [ + { + type: 'item', + href: config.STUDIO_BASE_URL, + content: 'Studio Home', + }, + { + type: 'item', + href: `${config.STUDIO_BASE_URL}/maintenance`, + content: 'Maintenance', + }, + { + type: 'item', + href: config.LOGOUT_URL, + content: 'Sign Out', + }, + ]; + + const props = { + logo: StudioLogoPNG, + logoAltText: 'Studio edX', + siteName: 'edX', + logoDestination: config.STUDIO_BASE_URL, + courseTitleDestination: `${config.STUDIO_BASE_URL}/course/${courseId}`, + courseId, + username: authenticatedUser !== null ? authenticatedUser.username : null, + avatar: authenticatedUser !== null ? authenticatedUser.avatar : null, + userMenu, + }; + + return ( + <> + + + + + + + + ); +} + +Header.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default injectIntl(Header); diff --git a/src/studio-header/Header.test.jsx b/src/studio-header/Header.test.jsx new file mode 100644 index 000000000..269633964 --- /dev/null +++ b/src/studio-header/Header.test.jsx @@ -0,0 +1,73 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import TestRenderer from 'react-test-renderer'; +import { AppContext } from '@edx/frontend-platform/react'; +import { getConfig } from '@edx/frontend-platform'; +import { Context as ResponsiveContext } from 'react-responsive'; + +import Header from './Header'; + +jest.mock('@edx/frontend-platform'); + +getConfig.mockReturnValue({}); + +describe('
', () => { + it('renders correctly for authenticated users on desktop', () => { + const component = ( + + + +
+ + + + ); + + const wrapper = TestRenderer.create(component); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('renders correctly for authenticated users on mobile', () => { + const component = ( + + + +
+ + + + ); + + const wrapper = TestRenderer.create(component); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/studio-header/Icons.jsx b/src/studio-header/Icons.jsx new file mode 100644 index 000000000..0a486b18d --- /dev/null +++ b/src/studio-header/Icons.jsx @@ -0,0 +1,47 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; + +export const MenuIcon = props => ( + + + + + +); + +export const AvatarIcon = props => ( + + + +); + +export const CaretIcon = props => ( + + + +); diff --git a/src/studio-header/Logo.jsx b/src/studio-header/Logo.jsx new file mode 100644 index 000000000..6a37bf82c --- /dev/null +++ b/src/studio-header/Logo.jsx @@ -0,0 +1,36 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import PropTypes from 'prop-types'; + +function Logo({ src, alt, ...attributes }) { + return ( + {alt} + ); +} + +Logo.propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string.isRequired, +}; + +function LinkedLogo({ + href, + src, + alt, + ...attributes +}) { + return ( + + {alt} + + ); +} + +LinkedLogo.propTypes = { + href: PropTypes.string.isRequired, + src: PropTypes.string.isRequired, + alt: PropTypes.string.isRequired, +}; + +export { LinkedLogo, Logo }; +export default Logo; diff --git a/src/studio-header/Menu/Menu.jsx b/src/studio-header/Menu/Menu.jsx new file mode 100644 index 000000000..1593fb1e5 --- /dev/null +++ b/src/studio-header/Menu/Menu.jsx @@ -0,0 +1,279 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import { CSSTransition } from 'react-transition-group'; +import PropTypes from 'prop-types'; + +function MenuTrigger({ tag, className, ...attributes }) { + return React.createElement(tag, { + className: `menu-trigger ${className}`, + ...attributes, + }); +} +MenuTrigger.propTypes = { + tag: PropTypes.string, + className: PropTypes.string, +}; +MenuTrigger.defaultProps = { + tag: 'div', + className: null, +}; +const MenuTriggerType = .type; + +function MenuContent({ tag, className, ...attributes }) { + return React.createElement(tag, { + className: ['menu-content', className].join(' '), + ...attributes, + }); +} +MenuContent.propTypes = { + tag: PropTypes.string, + className: PropTypes.string, +}; +MenuContent.defaultProps = { + tag: 'div', + className: null, +}; + +class Menu extends React.Component { + constructor(props) { + super(props); + + this.menu = React.createRef(); + this.state = { + expanded: false, + }; + + this.onTriggerClick = this.onTriggerClick.bind(this); + this.onCloseClick = this.onCloseClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onDocumentClick = this.onDocumentClick.bind(this); + this.onMouseEnter = this.onMouseEnter.bind(this); + this.onMouseLeave = this.onMouseLeave.bind(this); + } + + // Lifecycle Events + componentWillUnmount() { + document.removeEventListener('touchend', this.onDocumentClick, true); + document.removeEventListener('click', this.onDocumentClick, true); + + // Call onClose callback when unmounting and open + if (this.state.expanded && this.props.onClose) { + this.props.onClose(); + } + } + + // Event handlers + onDocumentClick(e) { + if (!this.props.closeOnDocumentClick) { + return; + } + + const clickIsInMenu = this.menu.current === e.target || this.menu.current.contains(e.target); + if (clickIsInMenu) { + return; + } + + this.close(); + } + + onTriggerClick(e) { + // Let the browser follow the link of the trigger if the menu + // is already expanded and the trigger has an href attribute + if (this.state.expanded && e.target.getAttribute('href')) { + return; + } + + e.preventDefault(); + this.toggle(); + } + + onCloseClick() { + this.getFocusableElements()[0].focus(); + this.close(); + } + + onKeyDown(e) { + if (!this.state.expanded) { + return; + } + switch (e.key) { + case 'Escape': { + e.preventDefault(); + e.stopPropagation(); + this.getFocusableElements()[0].focus(); + this.close(); + break; + } + case 'Enter': { + // Using focusable elements instead of a ref to the trigger + // because Hyperlink and Button can handle refs as functional components + if (document.activeElement === this.getFocusableElements()[0]) { + e.preventDefault(); + this.toggle(); + } + break; + } + case 'Tab': { + e.preventDefault(); + if (e.shiftKey) { + this.focusPrevious(); + } else { + this.focusNext(); + } + break; + } + case 'ArrowDown': { + e.preventDefault(); + this.focusNext(); + break; + } + case 'ArrowUp': { + e.preventDefault(); + this.focusPrevious(); + break; + } + default: + } + } + + onMouseEnter() { + if (!this.props.respondToPointerEvents) { + return; + } + this.open(); + } + + onMouseLeave() { + if (!this.props.respondToPointerEvents) { + return; + } + this.close(); + } + + // Internal functions + + getFocusableElements() { + return this.menu.current.querySelectorAll('button:not([disabled]), [href]:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'); + } + + getAttributesFromProps() { + // Any extra props are attributes for the menu + const attributes = {}; + Object.keys(this.props) + // eslint-disable-next-line react/forbid-foreign-prop-types + .filter(property => Menu.propTypes[property] === undefined) + .forEach((property) => { + attributes[property] = this.props[property]; + }); + return attributes; + } + + focusNext() { + const focusableElements = Array.from(this.getFocusableElements()); + const activeIndex = focusableElements.indexOf(document.activeElement); + const nextIndex = (activeIndex + 1) % focusableElements.length; + focusableElements[nextIndex].focus(); + } + + focusPrevious() { + const focusableElements = Array.from(this.getFocusableElements()); + const activeIndex = focusableElements.indexOf(document.activeElement); + const previousIndex = (activeIndex || focusableElements.length) - 1; + focusableElements[previousIndex].focus(); + } + + open() { + if (this.props.onOpen) { + this.props.onOpen(); + } + this.setState({ expanded: true }); + // Listen to touchend and click events to ensure the menu + // can be closed on mobile, pointer, and mixed input devices + document.addEventListener('touchend', this.onDocumentClick, true); + document.addEventListener('click', this.onDocumentClick, true); + } + + close() { + if (this.props.onClose) { + this.props.onClose(); + } + this.setState({ expanded: false }); + document.removeEventListener('touchend', this.onDocumentClick, true); + document.removeEventListener('click', this.onDocumentClick, true); + } + + toggle() { + if (this.state.expanded) { + this.close(); + } else { + this.open(); + } + } + + renderTrigger(node) { + return React.cloneElement(node, { + onClick: this.onTriggerClick, + 'aria-haspopup': 'menu', + 'aria-expanded': this.state.expanded, + }); + } + + renderMenuContent(node) { + return ( + + {node} + + ); + } + + render() { + const { className } = this.props; + + const wrappedChildren = React.Children.map(this.props.children, (child) => { + if (child.type === MenuTriggerType) { + return this.renderTrigger(child); + } + return this.renderMenuContent(child); + }); + + const rootClassName = this.state.expanded ? 'menu expanded' : 'menu'; + + return React.createElement(this.props.tag, { + className: `${rootClassName} ${className}`, + ref: this.menu, + onKeyDown: this.onKeyDown, + onMouseEnter: this.onMouseEnter, + onMouseLeave: this.onMouseLeave, + ...this.getAttributesFromProps(), + }, wrappedChildren); + } +} + +Menu.propTypes = { + tag: PropTypes.string, + onClose: PropTypes.func, + onOpen: PropTypes.func, + closeOnDocumentClick: PropTypes.bool, + respondToPointerEvents: PropTypes.bool, + className: PropTypes.string, + transitionTimeout: PropTypes.number, + transitionClassName: PropTypes.string, + children: PropTypes.arrayOf(PropTypes.node).isRequired, +}; +Menu.defaultProps = { + tag: 'div', + className: null, + onClose: null, + onOpen: null, + respondToPointerEvents: false, + closeOnDocumentClick: true, + transitionTimeout: 250, + transitionClassName: 'menu-content', +}; + +export { Menu, MenuTrigger, MenuContent }; diff --git a/src/studio-header/Menu/index.jsx b/src/studio-header/Menu/index.jsx new file mode 100644 index 000000000..9ad4449bb --- /dev/null +++ b/src/studio-header/Menu/index.jsx @@ -0,0 +1,4 @@ +// This file was copied from edx/frontend-component-header-edx. +import { Menu, MenuTrigger, MenuContent } from './Menu'; + +export { Menu, MenuTrigger, MenuContent }; diff --git a/src/studio-header/Menu/menu.scss b/src/studio-header/Menu/menu.scss new file mode 100644 index 000000000..c8374ff18 --- /dev/null +++ b/src/studio-header/Menu/menu.scss @@ -0,0 +1,46 @@ +// This file was copied from edx/frontend-component-header-edx. +.menu { + position: relative; +} +.menu-content { + position: absolute; + top: 100%; + z-index: 10; + background: #fff; + min-width: 10rem; + &.pin-left { + left: 0; + } + &.pin-right { + right: 0; + } +} + + +.menu-dropdown-enter { + opacity: 0; + transform-origin: 75% 0; + transform: scale3d(0.8, 0.8, 1); +} +.menu-dropdown-enter-active { + transform-origin: 75% 0; + transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1); + transform: scale3d(1, 1, 1); + opacity: 1; +} +.menu-dropdown-enter-done { +} + +.menu-dropdown-exit { + transform-origin: 75% 0; + transform: scale3d(1, 1, 1); + opacity: 1; +} +.menu-dropdown-exit-active { + transform-origin: 75% 0; + transform: scale3d(0.8, 0.8, 1); + transition: all 250ms cubic-bezier(0.8, 0, 0.6, 1); + opacity: 0; +} +.menu-dropdown-exit-done { +} diff --git a/src/studio-header/MobileHeader.jsx b/src/studio-header/MobileHeader.jsx new file mode 100644 index 000000000..3a6554a38 --- /dev/null +++ b/src/studio-header/MobileHeader.jsx @@ -0,0 +1,158 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl } from '@edx/frontend-platform/i18n'; +// Local Components +import { Menu, MenuTrigger, MenuContent } from './Menu'; +import Avatar from './Avatar'; +import { LinkedLogo, Logo } from './Logo'; + +// Assets +import { MenuIcon } from './Icons'; + +class MobileHeader extends React.Component { + constructor(props) { // eslint-disable-line no-useless-constructor + super(props); + } + + renderMainMenu() { + const { mainMenu } = this.props; + + // Nodes are accepted as a prop + if (!Array.isArray(mainMenu)) { + return mainMenu; + } + + return mainMenu.map((menuItem) => { + const { + type, + href, + content, + submenuContent, + } = menuItem; + + if (type === 'item') { + return ( + + {content} + + ); + } + + return ( + + + {content} + + + {submenuContent} + + + ); + }); + } + + renderUserMenuItems() { + const { userMenu } = this.props; + + return userMenu.map(({ type, href, content }) => ( +
  • + {content} +
  • + )); + } + + render() { + const { + logo, + logoAltText, + logoDestination, + avatar, + username, + stickyOnMobile, + mainMenu, + } = this.props; + const logoProps = { src: logo, alt: logoAltText, href: logoDestination }; + const stickyClassName = stickyOnMobile ? 'sticky-top' : ''; + return ( +
    +
    + {mainMenu.length > 0 + ? ( + + + + + + {this.renderMainMenu()} + + + ) : null} +
    +
    + {logoDestination === null ? : } +
    +
    + + + + + + {this.renderUserMenuItems()} + + +
    +
    + ); + } +} + +MobileHeader.propTypes = { + mainMenu: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.array, + ]), + + userMenu: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.oneOf(['item', 'menu']), + href: PropTypes.string, + content: PropTypes.string, + })), + logo: PropTypes.string, + logoAltText: PropTypes.string, + logoDestination: PropTypes.string, + avatar: PropTypes.string, + username: PropTypes.string, + stickyOnMobile: PropTypes.bool, +}; + +MobileHeader.defaultProps = { + mainMenu: [], + userMenu: [], + logo: null, + logoAltText: null, + logoDestination: null, + avatar: null, + username: null, + stickyOnMobile: true, + +}; + +export default injectIntl(MobileHeader); diff --git a/src/studio-header/__snapshots__/Header.test.jsx.snap b/src/studio-header/__snapshots__/Header.test.jsx.snap new file mode 100644 index 000000000..39d520ac2 --- /dev/null +++ b/src/studio-header/__snapshots__/Header.test.jsx.snap @@ -0,0 +1,253 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`
    renders correctly for authenticated users on desktop 1`] = ` +
    + +
    +`; + +exports[`
    renders correctly for authenticated users on mobile 1`] = ` +
    +
    +
    + +
    +
    +
    + + Studio edX + +
    +
    + +
    +
    +`; diff --git a/src/studio-header/assets/studio-logo.png b/src/studio-header/assets/studio-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7d53e50bee81a6658f0e3885db2f7a137ed2b865 GIT binary patch literal 4755 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 h