Skip to content

Commit

Permalink
DOP-4991: Dark Mode Guide Cue (announcement) (#1232)
Browse files Browse the repository at this point in the history
Co-authored-by: Seung Park <[email protected]>
Co-authored-by: Seung Park <[email protected]>
Co-authored-by: Bianca <[email protected]>
Co-authored-by: biancalaube <[email protected]>
  • Loading branch information
5 people authored Sep 18, 2024
1 parent ca00191 commit aff7715
Show file tree
Hide file tree
Showing 13 changed files with 718 additions and 335 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
- "main"
pull_request:
types: ["opened", "synchronize"]
# REVERT BEFORE MERGE
branches:
- main
- "DOP-4616"

name: Run and Upload Lighthouse Reports
jobs:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
pull_request:
branches:
- main
- DOP-4616

jobs:
test:
Expand Down
Binary file added AWSCLIV2.pkg
Binary file not shown.
343 changes: 262 additions & 81 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@leafygreen-ui/code": "^14.3.3",
"@leafygreen-ui/combobox": "^6.0.0",
"@leafygreen-ui/emotion": "^4.0.0",
"@leafygreen-ui/guide-cue": "^5.1.0",
"@leafygreen-ui/hooks": "^8.1.3",
"@leafygreen-ui/icon": "^12.4.0",
"@leafygreen-ui/icon-button": "^15.0.23",
Expand Down
116 changes: 65 additions & 51 deletions src/components/ActionBar/DarkModeDropdown.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useContext, useState } from 'react';
import React, { useCallback, useContext, useRef, useState } from 'react';
import { cx, css } from '@leafygreen-ui/emotion';
import Box from '@leafygreen-ui/box';
import Icon from '@leafygreen-ui/icon';
Expand All @@ -7,6 +7,7 @@ import { Menu, MenuItem } from '@leafygreen-ui/menu';
import { DarkModeContext } from '../../context/dark-mode-context';
import { theme } from '../../theme/docsTheme';
import IconDarkmode from '../icons/DarkMode';
import DarkModeGuideCue from './DarkModeGuideCue';

const iconStyling = css`
display: block;
Expand All @@ -30,6 +31,8 @@ const darkModeSvgStyle = {
};

const DarkModeDropdown = () => {
const guideCueRef = useRef();

// not using dark mode from LG/provider here to account for case of 'system' dark theme
const { setDarkModePref, darkModePref } = useContext(DarkModeContext);

Expand All @@ -44,56 +47,67 @@ const DarkModeDropdown = () => {
);

return (
<Menu
className={cx(menuStyling)}
usePortal={false}
justify={'start'}
align={'bottom'}
open={open}
setOpen={setOpen}
trigger={
// using Box here to prevent warning of Button within Button
// since we are using usePortal=false to mitigate sticky header
<IconButton as={Box} className={cx(iconStyling)} aria-label="Dark Mode Menu" aria-labelledby="Dark Mode Menu">
{darkModePref === 'system' ? (
<IconDarkmode />
) : (
<Icon size={24} glyph={darkModePref === 'dark-theme' ? 'Moon' : 'Sun'} />
)}
</IconButton>
}
>
<MenuItem
active={darkModePref === 'light-theme'}
onClick={() => select('light-theme')}
glyph={<Icon size={DROPDOWN_ICON_SIZE} glyph={'Sun'} />}
>
Light
</MenuItem>
<MenuItem
active={darkModePref === 'dark-theme'}
onClick={() => select('dark-theme')}
glyph={<Icon size={DROPDOWN_ICON_SIZE} glyph={'Moon'} />}
>
Dark
</MenuItem>
<MenuItem
active={darkModePref === 'system'}
onClick={() => select('system')}
glyph={
<IconDarkmode
className={css`
svg {
margin-right: ${theme.size.default};
}
`}
styles={darkModeSvgStyle}
/>
}
>
System
</MenuItem>
</Menu>
// Remove Fragment and div when Dark Mode Guide Cue is removed - only used for guide cue placement
<>
<div ref={guideCueRef}>
<Menu
className={cx(menuStyling)}
usePortal={false}
justify={'start'}
align={'bottom'}
open={open}
setOpen={setOpen}
trigger={
// using Box here to prevent warning of Button within Button
// since we are using usePortal=false to mitigate sticky header
<IconButton
as={Box}
className={cx(iconStyling)}
aria-label="Dark Mode Menu"
aria-labelledby="Dark Mode Menu"
>
{darkModePref === 'system' ? (
<IconDarkmode />
) : (
<Icon size={24} glyph={darkModePref === 'dark-theme' ? 'Moon' : 'Sun'} />
)}
</IconButton>
}
>
<MenuItem
active={darkModePref === 'light-theme'}
onClick={() => select('light-theme')}
glyph={<Icon size={DROPDOWN_ICON_SIZE} glyph={'Sun'} />}
>
Light
</MenuItem>
<MenuItem
active={darkModePref === 'dark-theme'}
onClick={() => select('dark-theme')}
glyph={<Icon size={DROPDOWN_ICON_SIZE} glyph={'Moon'} />}
>
Dark
</MenuItem>
<MenuItem
active={darkModePref === 'system'}
onClick={() => select('system')}
glyph={
<IconDarkmode
className={css`
svg {
margin-right: ${theme.size.default};
}
`}
styles={darkModeSvgStyle}
/>
}
>
System
</MenuItem>
</Menu>
</div>
<DarkModeGuideCue guideCueRef={guideCueRef} dropdownIsOpen={open} />
</>
);
};

Expand Down
164 changes: 164 additions & 0 deletions src/components/ActionBar/DarkModeGuideCue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { css, cx } from '@leafygreen-ui/emotion';
import { GuideCue } from '@leafygreen-ui/guide-cue';
import { palette } from '@leafygreen-ui/palette';
import { Body, H3 } from '@leafygreen-ui/typography';

import { withPrefix } from 'gatsby';
import { useClickOutside } from '../../hooks/use-click-outside';
import useScreenSize from '../../hooks/useScreenSize';
import CloseButton from '../Widgets/FeedbackWidget/components/CloseButton';
import { getLocalValue, setLocalValue } from '../../utils/browser-storage';
import { theme } from '../../theme/docsTheme';

export const DARK_MODE_ANNOUNCED = 'dark-mode-announced';
const VIDEO_PATH = 'assets/darkModeGuideCue.mov';

const GuideCueContent = styled.div`
min-width: 360px;
h3 {
font-size: ${theme.fontSize.h3};
line-height: ${theme.size.medium};
color: ${palette.black};
margin: 0 0 ${theme.size.small};
}
p {
font-size: ${theme.fontSize.small};
line-height: 20px;
color: ${palette.black};
}
`;

const GuideCueHeader = styled.div`
background-color: ${palette.purple.light3};
border-radius: ${theme.size.small} ${theme.size.small} 0 0;
padding: ${theme.size.medium} ${theme.size.small};
display: flex;
align-items: center;
justify-content: center;
`;

const FocusTrapInvisibleButton = styled.button`
border: none;
outline: none;
width: 0;
height: 0;
padding: 0;
margin: 0;
position: absolute;
`;

const VideoContainer = styled.div`
width: 242px;
overflow: hidden;
display: flex;
justify-content: center;
`;

const Video = styled.video`
border-radius: 12px;
// This width is two pixels larger than container to cut off black border :/ hence the overflow hidden and flex of the container
width: 244px;
`;

const GuideCueFooter = styled.div`
padding: ${theme.size.medium} ${theme.size.medium} ${theme.size.small};
`;

const DarkModeGuideCue = ({ guideCueRef, dropdownIsOpen }) => {
const ref = useRef();
const { isMobile } = useScreenSize();
const [isOpen, setIsOpen] = useState(false);
const onClose = () => setIsOpen(false);

useClickOutside(ref, onClose);

// Use localStorage to only show only once to each user
useEffect(() => {
if (isMobile) return;
const darkModeAnnounced = getLocalValue(DARK_MODE_ANNOUNCED);
if (!darkModeAnnounced) setIsOpen(true);
setLocalValue(DARK_MODE_ANNOUNCED, true);
}, [isMobile]);

// Close GuideCue if dark mode dropdown is opened
useEffect(() => {
if (dropdownIsOpen) onClose();
}, [dropdownIsOpen]);

if (isMobile) return null;

return (
<GuideCue
open={isOpen}
tooltipAlign="bottom"
tooltipJustify="start"
setOpen={setIsOpen}
refEl={guideCueRef}
numberOfSteps={1}
currentStep={1}
portalRef={ref}
scrollContainer={guideCueRef?.current}
portalContainer={guideCueRef?.current}
tooltipClassName={cx(css`
min-width: 360px;
padding: 0;
background-color: ${palette.white};
svg {
fill: ${palette.purple.light3};
}
// Hide title label (ghost margin)
#guide-cue-label {
display: none;
}
// Hide Footer with button (no way with LG to hide)
> div > div > div:last-child {
display: none;
}
`)}
>
<GuideCueContent>
<GuideCueHeader>
{/* Invisible button to trap focus. Ask from design to not have close button auto-focused */}
<FocusTrapInvisibleButton />
<CloseButton
onClick={onClose}
className={cx(css`
color: ${palette.gray.base};
&:hover,
&:active {
color: ${palette.black};
}
&:hover::before {
background-color: rgba(61, 79, 88, 0.1);
}
`)}
/>
<VideoContainer>
<Video autoPlay muted>
<source src={withPrefix(VIDEO_PATH)} type="video/mp4"></source>
</Video>
</VideoContainer>
</GuideCueHeader>
<GuideCueFooter>
<H3>Announcing: Dark Mode 🌙</H3>
<Body>Choose between dark mode, light mode or system theme to match your reading preferences</Body>
</GuideCueFooter>
</GuideCueContent>
</GuideCue>
);
};

DarkModeGuideCue.propTypes = {
guideCueRef: PropTypes.shape({ current: PropTypes.object }),
dropdownIsOpen: PropTypes.bool.isRequired,
};

export default DarkModeGuideCue;
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ const buttonStyles = css`
}
`;

const CloseButton = ({ onClick, size = 'default', ...props }) => {
const CloseButton = ({ onClick, size = 'default', className, ...props }) => {
return (
<IconButton
aria-label={CLOSE_BUTTON_ALT_TEXT}
className={cx(buttonStyles)}
className={cx(buttonStyles, className)}
onClick={onClick}
size={size}
fill={palette.gray.light1}
Expand Down
Binary file added static/assets/darkModeGuideCue.mov
Binary file not shown.
3 changes: 3 additions & 0 deletions tests/unit/ActionBar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, act } from '@testing-library/react';
import * as snootyMetadata from '../../src/utils/use-snooty-metadata';
import * as useAllDocsets from '../../src/hooks/useAllDocsets';
import ActionBar from '../../src/components/ActionBar/ActionBar';
import * as DarkModeGuideCue from '../../src/components/ActionBar/DarkModeGuideCue';

jest.mock('../../src/hooks/use-site-metadata', () => ({
useSiteMetadata: () => ({ reposDatabase: 'pool_test' }),
Expand All @@ -21,6 +22,8 @@ useAllDocsetsMock.mockImplementation(() => [
prefix: {},
},
]);
// Not testing Guide Cue announcement here
jest.spyOn(DarkModeGuideCue, 'default').mockImplementation(() => <></>);

const conversationSpy = jest.fn();
// eslint-disable-next-line no-unused-vars
Expand Down
9 changes: 8 additions & 1 deletion tests/unit/DarkModeDropdown.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { DarkModeContext } from '../../src/context/dark-mode-context';
import * as MediaHooks from '../../src/hooks/use-media';
import DarkModeDropdown from '../../src/components/ActionBar/DarkModeDropdown';

import { setDesktop } from '../utils';
import { DARK_MODE_ANNOUNCED } from '../../src/components/ActionBar/DarkModeGuideCue';

let darkModePref = 'light-theme';

const setDarkModePref = jest.fn((value) => {
Expand All @@ -15,7 +18,9 @@ const setDarkModePref = jest.fn((value) => {
jest.spyOn(MediaHooks, 'default').mockImplementation(() => ({}));

// mock window.localStorage
const storage = {};
const storage = {
[DARK_MODE_ANNOUNCED]: 'true',
};
jest.spyOn(window.localStorage.__proto__, 'setItem').mockImplementation((key, value) => {
storage[key] = value;
});
Expand All @@ -31,6 +36,8 @@ const mountDarkModeDropdown = () => {
};

describe('DarkMode Dropdown component', () => {
beforeEach(setDesktop);

it('renders dark mode dropdown', async () => {
// first snapshot of closed menu
const elm = mountDarkModeDropdown();
Expand Down
Loading

0 comments on commit aff7715

Please sign in to comment.