Skip to content

Commit

Permalink
feat: add toggle theme button (#18)
Browse files Browse the repository at this point in the history
* feat: read theme cookies

* styled the toggle theme buutton

* fix: update test snapshots
  • Loading branch information
hinakhadim committed Nov 19, 2024
1 parent 8ef3569 commit b565039
Show file tree
Hide file tree
Showing 7 changed files with 466 additions and 16 deletions.
46 changes: 31 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"classnames": "^2.5.1",
"jest-environment-jsdom": "^29.7.0",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5"
"react-transition-group": "4.4.5",
"universal-cookie": "^7.2.0"
},
"peerDependencies": {
"@edx/frontend-platform": "^7.0.0 || ^8.0.0",
Expand Down
224 changes: 224 additions & 0 deletions src/DesktopHeader.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import ThemeToggleButton from './ThemeToggleButton';

// Local Components
import { Menu, MenuTrigger, MenuContent } from './Menu';
import Avatar from './Avatar';
import { LinkedLogo, Logo } from './Logo';

// i18n
import messages from './Header.messages';

// Assets
import { CaretIcon } from './Icons';

class DesktopHeader extends React.Component {
constructor(props) { // eslint-disable-line no-useless-constructor
super(props);
}

renderMenu(menu) {
// Nodes are accepted as a prop
if (!Array.isArray(menu)) {
return menu;
}

return menu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
onClick,
} = menuItem;

if (type === 'item') {
return (
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
);
}

return (
<Menu key={`${type}-${content}`} tag="div" className="nav-item" respondToPointerEvents>
<MenuTrigger onClick={onClick || null} tag="a" className="nav-link d-inline-flex align-items-center" href={href}>
{content} <CaretIcon role="img" aria-hidden focusable="false" />
</MenuTrigger>
<MenuContent className="pin-left pin-right shadow py-2">
{submenuContent}
</MenuContent>
</Menu>
);
});
}

renderMainMenu() {
const { mainMenu } = this.props;
return this.renderMenu(mainMenu);
}

renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return this.renderMenu(secondaryMenu);
}

renderUserMenu() {
const {
userMenu,
avatar,
username,
intl,
} = this.props;

return (
<Menu transitionClassName="menu-dropdown" transitionTimeout={250}>
<MenuTrigger
tag="button"
aria-label={intl.formatMessage(messages['header.label.account.menu.for'], { username })}
className="btn btn-outline-primary d-inline-flex align-items-center pl-2 pr-3"
>
<Avatar size="1.5em" src={avatar} alt="" className="mr-2" />
{username} <CaretIcon role="img" aria-hidden focusable="false" />
</MenuTrigger>
<MenuContent className="mb-0 dropdown-menu show dropdown-menu-right pin-right shadow py-2">
{userMenu.map((group, index) => (
// eslint-disable-next-line react/jsx-no-comment-textnodes,react/no-array-index-key
<React.Fragment key={index}>
{group.heading && <div className="dropdown-header" role="heading" aria-level="1">{group.heading}</div>}
{group.items.map(({
type, content, href, disabled, isActive, onClick,
}) => (
<a
className={`dropdown-${type}${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
key={`${type}-${content}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
))}
{index < userMenu.length - 1 && <div className="dropdown-divider" role="separator" />}
</React.Fragment>
))}
</MenuContent>
</Menu>
);
}

renderLoggedOutItems() {
const { loggedOutItems } = this.props;

return loggedOutItems.map((item, i, arr) => (
<a
key={`${item.type}-${item.content}`}
className={i < arr.length - 1 ? 'btn mr-2 btn-link' : 'btn mr-2 btn-outline-primary'}
href={item.href}
>
{item.content}
</a>
));
}

render() {
const {
logo,
logoAltText,
logoDestination,
loggedIn,
intl,
} = this.props;
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'mw-100' : null;

return (
<header className="site-header-desktop">
<a className="nav-skip sr-only sr-only-focusable" href="#main">{intl.formatMessage(messages['header.label.skip.nav'])}</a>
<div className={`container-fluid ${logoClasses}`}>
<div className="nav-container position-relative d-flex align-items-center">
{logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...logoProps} />}
<nav
aria-label={intl.formatMessage(messages['header.label.main.nav'])}
className="nav main-nav"
>
{this.renderMainMenu()}
</nav>
<nav
aria-label={intl.formatMessage(messages['header.label.secondary.nav'])}
className="nav secondary-menu-container align-items-center ml-auto"
>
<ThemeToggleButton />
{loggedIn
? (
<>
{this.renderSecondaryMenu()}
{this.renderUserMenu()}
</>
) : this.renderLoggedOutItems()}
</nav>
</div>
</div>
</header>
);
}
}

DesktopHeader.propTypes = {
mainMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
secondaryMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
userMenu: PropTypes.arrayOf(PropTypes.shape({
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
onClick: PropTypes.func,
})),
})),
loggedOutItems: 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,
loggedIn: PropTypes.bool,

// i18n
intl: intlShape.isRequired,
};

DesktopHeader.defaultProps = {
mainMenu: [],
secondaryMenu: [],
userMenu: [],
loggedOutItems: [],
logo: null,
logoAltText: null,
logoDestination: null,
avatar: null,
username: null,
loggedIn: false,
};

export default injectIntl(DesktopHeader);
47 changes: 47 additions & 0 deletions src/ThemeToggleButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import Cookies from 'universal-cookie';

const ThemeToggleButton = () => {
const getNextWeek = () => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7);
};

const onToggleTheme = () => {
const cookies = new Cookies();
const serverURL = new URL(getConfig().LMS_BASE_URL);
const themeCookieName = getConfig().THEME_COOKIE_NAME;

const options = { domain: serverURL.hostname, path: '/', expires: getNextWeek() };
let themeName = '';

if (cookies.get(themeCookieName) === 'dark') {
document.body.classList.remove('indigo-dark-theme');
themeName = 'light';
} else {
document.body.classList.add('indigo-dark-theme');
themeName = 'dark';
}
cookies.set(themeCookieName, themeName, options);
};

return (
<div className="theme-toggle-button">
<span id="darkmode" role="button" tabIndex="0" onClick={onToggleTheme} onKeyDown={onToggleTheme}>
<div className="darkmode_icon">
<span className="ray" />
<span className="ray" />
<span className="ray" />
<span className="ray" />
<span className="ray" />
<span className="ray" />
<span className="ray" />
<span className="ray" />
</div>
</span>
</div>
);
};

export default ThemeToggleButton;
Loading

0 comments on commit b565039

Please sign in to comment.