Skip to content

Commit d751fc6

Browse files
feat: add dark mode (#2322)
* feat: add theme provider * Update src/index.css Co-authored-by: Russell Dempsey <[email protected]> * Update src/index.css Co-authored-by: Russell Dempsey <[email protected]> * Update src/index.css Co-authored-by: Russell Dempsey <[email protected]> * Update src/hooks/theme.ts Co-authored-by: Russell Dempsey <[email protected]> * Update src/context/theme-provider.tsx Co-authored-by: Russell Dempsey <[email protected]> * Update src/hooks/theme.ts Co-authored-by: Russell Dempsey <[email protected]> * Update src/hooks/theme.ts Co-authored-by: Russell Dempsey <[email protected]> * refactor: useTheme hook based on feedback provided * chore: refactor the theme hoook - previously, pretty much all the keys on the keyboard can trigger the theme toggle when it is focused. we don't want that. Instead we should only limit it to specific keys (spacebar & enter). - include all the varying colors when the theme is dark on all routes - extend the Box component to use the theme value instead of explicitly passing it in the style prop across the codebase * fix: theme uses system settings and listens for changes * fix: storybook testing * fix: theme hook and provider context values --------- Co-authored-by: Russell Dempsey <[email protected]>
1 parent b7b94c2 commit d751fc6

File tree

18 files changed

+411
-92
lines changed

18 files changed

+411
-92
lines changed

.storybook/preview.js

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,27 @@ import getStore from '../src/bundles/index.js'
99
import i18n from '../src/i18n.js'
1010
import DndBackend from '../src/lib/dnd-backend.js'
1111
import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers'
12+
import { ThemeProvider } from '../src/context/theme-provider.tsx'
1213

1314
/**
1415
* @type {import('@storybook/addons').BaseAnnotations}
1516
*/
1617
const baseAnnotations = {
1718
decorators: [
1819
(Story) => (
19-
<Provider store={getStore(undefined)}>
20-
<I18nextProvider i18n={i18n} >
21-
<DndProvider backend={DndBackend}>
22-
<HeliaProvider>
23-
<ExploreProvider>
24-
<Story />
25-
</ExploreProvider>
26-
</HeliaProvider>
27-
</DndProvider>
28-
</I18nextProvider>
29-
</Provider>
20+
<ThemeProvider>
21+
<Provider store={getStore(undefined)}>
22+
<I18nextProvider i18n={i18n} >
23+
<DndProvider backend={DndBackend}>
24+
<HeliaProvider>
25+
<ExploreProvider>
26+
<Story />
27+
</ExploreProvider>
28+
</HeliaProvider>
29+
</DndProvider>
30+
</I18nextProvider>
31+
</Provider>
32+
</ThemeProvider>
3033
)
3134
],
3235
/**

src/App.js

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import Notify from './components/notify/Notify.js'
1818
import Connected from './components/connected/Connected.js'
1919
import TourHelper from './components/tour/TourHelper.js'
2020
import FilesExploreForm from './files/explore-form/files-explore-form.tsx'
21+
import { ThemeProvider, ThemeContext } from './context/theme-provider.tsx'
22+
import { ThemeToggle } from './components/theme-toggle/toggle.tsx'
2123

2224
export class App extends Component {
2325
static propTypes = {
@@ -32,6 +34,8 @@ export class App extends Component {
3234
isOver: PropTypes.bool.isRequired
3335
}
3436

37+
static contextType = ThemeContext
38+
3539
constructor (props) {
3640
super(props)
3741
props.doSetupLocalStorage()
@@ -63,44 +67,49 @@ export class App extends Component {
6367
render () {
6468
const { t, route: Page, ipfsReady, doFilesNavigateTo, routeInfo: { url }, connectDropTarget, canDrop, isOver, showTooltip } = this.props
6569
return connectDropTarget(
66-
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
67-
<div className='sans-serif h-100 relative' onClick={getNavHelper(this.props.doUpdateUrl)}>
68-
{/* Tinted overlay that appears when dragging and dropping an item */}
69-
{ canDrop && isOver && <div className='h-100 top-0 right-0 fixed appOverlay' style={{ background: 'rgba(99, 202, 210, 0.2)' }} /> }
70-
<div className='flex flex-row-reverse-l flex-column-reverse justify-end justify-start-l' style={{ minHeight: '100vh' }}>
71-
<div className='flex-auto-l'>
72-
<div className='flex items-center ph3 ph4-l' style={{ WebkitAppRegion: 'drag', height: 75, background: '#F0F6FA', paddingTop: '20px', paddingBottom: '15px' }}>
73-
<div className='joyride-app-explore' style={{ width: 560 }}>
74-
<FilesExploreForm onBrowse={doFilesNavigateTo} />
70+
<div>
71+
<ThemeProvider>
72+
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
73+
<div className='sans-serif h-100 relative' onClick={getNavHelper(this.props.doUpdateUrl)}>
74+
{/* Tinted overlay that appears when dragging and dropping an item */}
75+
{ canDrop && isOver && <div className='h-100 top-0 right-0 fixed appOverlay' style={{ background: 'rgba(99, 202, 210, 0.2)' }} /> }
76+
<div className='flex flex-row-reverse-l flex-column-reverse justify-end justify-start-l' style={{ minHeight: '100vh' }}>
77+
<div className='flex-auto-l'>
78+
<div className='flex items-center ph3 ph4-l webui-header' style={{ WebkitAppRegion: 'drag', height: 75, background: '#F0F6FA', paddingTop: '20px', paddingBottom: '15px' }}>
79+
<div className='joyride-app-explore' style={{ width: 560 }}>
80+
<FilesExploreForm onBrowse={doFilesNavigateTo} />
81+
</div>
82+
<div className='dn flex-ns flex-auto items-center justify-end'>
83+
<TourHelper />
84+
<Connected className='joyride-app-status' />
85+
<div className='pa3'>
86+
<ThemeToggle />
87+
</div>
88+
</div>
89+
</div>
90+
<main className='bg-white pv3 pa3 pa4-l'>
91+
{ (ipfsReady || url === '/welcome' || url.startsWith('/settings'))
92+
? <Page />
93+
: <ComponentLoader />
94+
}
95+
</main>
7596
</div>
76-
<div className='dn flex-ns flex-auto items-center justify-end'>
77-
<TourHelper />
78-
<Connected className='joyride-app-status' />
97+
<div className='navbar-container flex-none-l bg-navy'>
98+
<NavBar />
7999
</div>
80100
</div>
81-
<main className='bg-white pv3 pa3 pa4-l'>
82-
{ (ipfsReady || url === '/welcome' || url.startsWith('/settings'))
83-
? <Page />
84-
: <ComponentLoader />
85-
}
86-
</main>
87-
</div>
88-
<div className='navbar-container flex-none-l bg-navy'>
89-
<NavBar />
101+
<ReactJoyride
102+
run={showTooltip}
103+
steps={appTour.getSteps({ t })}
104+
styles={appTour.styles}
105+
callback={this.handleJoyrideCb}
106+
scrollToFirstStep
107+
disableOverlay
108+
locale={getJoyrideLocales(t)}
109+
/>
110+
<Notify />
90111
</div>
91-
</div>
92-
93-
<ReactJoyride
94-
run={showTooltip}
95-
steps={appTour.getSteps({ t })}
96-
styles={appTour.styles}
97-
callback={this.handleJoyrideCb}
98-
scrollToFirstStep
99-
disableOverlay
100-
locale={getJoyrideLocales(t)}
101-
/>
102-
103-
<Notify />
112+
</ThemeProvider>
104113
</div>
105114
)
106115
}

src/components/api-address-form/ApiAddressForm.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { connect } from 'redux-bundler-react'
33
import { withTranslation } from 'react-i18next'
44
import Button from '../button/button.tsx'
55
import { checkValidAPIAddress } from '../../bundles/ipfs-provider.js'
6+
import { useTheme } from '../../hooks/theme'
67

78
const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress, ipfsInitFailed }) => {
89
const [value, setValue] = useState(asAPIString(ipfsApiAddress))
910
const initialIsValidApiAddress = !checkValidAPIAddress(value)
1011
const [showFailState, setShowFailState] = useState(initialIsValidApiAddress || ipfsInitFailed)
1112
const [isValidApiAddress, setIsValidApiAddress] = useState(initialIsValidApiAddress)
13+
const { isDarkTheme } = useTheme()
1214

1315
// Updates the border of the input to indicate validity
1416
useEffect(() => {
@@ -46,12 +48,14 @@ const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress, ipfsInitFai
4648
onChange={onChange}
4749
onKeyPress={onKeyPress}
4850
value={value}
51+
style={{ background: isDarkTheme ? 'var(--filter-peers-dark)' : '', border: isDarkTheme ? '0.4px solid var(--border-color)' : '' }}
4952
/>
5053
<div className='tr'>
5154
<Button
5255
minWidth={100}
5356
height={40}
5457
className='mt2 mt0-l ml2-l tc'
58+
style={{ background: isDarkTheme ? 'var(--input-btn-bg)' : '' }}
5559
disabled={!isValidApiAddress || value === ipfsApiAddress}>
5660
{t('actions.submit')}
5761
</Button>

src/components/box/Box.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import React from 'react'
2+
import { useTheme } from '../../hooks/theme'
23
import ErrorBoundary from '../error/ErrorBoundary.js'
34

45
export const Box = ({
56
className = 'pa4',
67
style,
8+
themed,
79
children,
810
...props
911
}) => {
12+
const { isDarkTheme } = useTheme()
1013
return (
11-
<section className={className} style={{ background: '#fbfbfb', ...style }}>
14+
<section className={className} style={{ background: isDarkTheme ? 'var(--element-bg)' : 'var(--element-bg-light)', ...style }}>
1215
<ErrorBoundary>
1316
{children}
1417
</ErrorBoundary>

src/components/public-gateway-form/PublicGatewayForm.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { connect } from 'redux-bundler-react'
33
import { withTranslation } from 'react-i18next'
44
import Button from '../button/button.tsx'
55
import { checkValidHttpUrl, checkViaImgSrc, DEFAULT_PATH_GATEWAY } from '../../bundles/gateway.js'
6+
import { useTheme } from '../../hooks/theme'
67

78
const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
89
const [value, setValue] = useState(publicGateway)
910
const initialIsValidGatewayUrl = !checkValidHttpUrl(value)
1011
const [showFailState, setShowFailState] = useState(initialIsValidGatewayUrl)
1112
const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl)
12-
13+
const { isDarkTheme } = useTheme()
1314
// Updates the border of the input to indicate validity
1415
useEffect(() => {
1516
setShowFailState(!isValidGatewayUrl)
@@ -60,6 +61,7 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
6061
onChange={onChange}
6162
onKeyPress={onKeyPress}
6263
value={value}
64+
style={{ background: isDarkTheme ? 'var(--filter-peers-dark)' : '', border: isDarkTheme ? '0.4px solid var(--border-color)' : '' }}
6365
/>
6466
<div className='tr'>
6567
<Button
@@ -68,6 +70,7 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
6870
height={40}
6971
bg='bg-charcoal'
7072
className='tc'
73+
style={{ background: isDarkTheme ? 'var(--input-btn-bg)' : '' }}
7174
disabled={value === DEFAULT_PATH_GATEWAY}
7275
onClick={onReset}>
7376
{t('app:actions.reset')}
@@ -77,6 +80,7 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
7780
minWidth={100}
7881
height={40}
7982
className='mt2 mt0-l ml2-l tc'
83+
style={{ background: isDarkTheme ? 'var(--input-btn-bg)' : '' }}
8084
disabled={!isValidGatewayUrl || value === publicGateway}>
8185
{t('actions.submit')}
8286
</Button>

src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { connect } from 'redux-bundler-react'
33
import { withTranslation } from 'react-i18next'
44
import Button from '../button/button.tsx'
55
import { checkValidHttpUrl, checkSubdomainGateway, DEFAULT_SUBDOMAIN_GATEWAY } from '../../bundles/gateway.js'
6+
import { useTheme } from '../../hooks/theme'
67

78
const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicSubdomainGateway }) => {
89
const [value, setValue] = useState(publicSubdomainGateway)
910
const initialIsValidGatewayUrl = !checkValidHttpUrl(value)
1011
const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl)
12+
const { isDarkTheme } = useTheme()
1113

1214
// Updates the border of the input to indicate validity
1315
useEffect(() => {
@@ -64,6 +66,7 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS
6466
onChange={onChange}
6567
onKeyPress={onKeyPress}
6668
value={value}
69+
style={{ background: isDarkTheme ? 'var(--filter-peers-dark)' : '', border: isDarkTheme ? '0.4px solid var(--border-color)' : '' }}
6770
/>
6871
<div className='tr'>
6972
<Button
@@ -72,6 +75,7 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS
7275
height={40}
7376
bg='bg-charcoal'
7477
className='tc'
78+
style={{ background: isDarkTheme ? 'var(--input-btn-bg)' : '' }}
7579
disabled={value === DEFAULT_SUBDOMAIN_GATEWAY}
7680
onClick={onReset}>
7781
{t('app:actions.reset')}
@@ -80,6 +84,7 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS
8084
id='public-subdomain-gateway-submit-button'
8185
minWidth={100}
8286
height={40}
87+
style={{ background: isDarkTheme ? 'var(--input-btn-bg)' : '' }}
8388
className='mt2 mt0-l ml2-l tc'
8489
disabled={!isValidGatewayUrl || value === publicSubdomainGateway}>
8590
{t('actions.submit')}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
.theme-toggle {
2+
--size: 1.55rem;
3+
--icon-fill: hsl(210, 22%, 22%);
4+
--icon-fill-hover: hsl(210, 22%, 12%);
5+
6+
background: none;
7+
border: none;
8+
padding: 0;
9+
inline-size: var(--size);
10+
block-size: var(--size);
11+
aspect-ratio: 1;
12+
border-radius: 50%;
13+
cursor: pointer;
14+
touch-action: manipulation;
15+
-webkit-tap-highlight-color: transparent;
16+
outline-offset: 5px;
17+
}
18+
19+
.theme-toggle > svg {
20+
inline-size: 100%;
21+
block-size: 100%;
22+
stroke-linecap: round;
23+
color: #378085;
24+
}
25+
26+
[data-theme='dark'] .theme-toggle {
27+
--icon-fill: hsl(25, 100%, 50%);
28+
--icon-fill-hover: hsl(25, 100%, 40%);
29+
30+
svg {
31+
color: #fff;
32+
}
33+
}
34+
35+
.theme-toggle:hover,
36+
.theme-toggle:focus-visible {
37+
background: hsl(0 0% 50% / 0.1);
38+
}
39+
40+
@media (prefers-reduced-motion: no-preference) {
41+
.theme-toggle {
42+
transition: background-color 0.3s ease;
43+
}
44+
45+
.theme-toggle > svg {
46+
transition: transform 0.5s ease;
47+
}
48+
49+
.theme-toggle:hover > svg,
50+
.theme-toggle:focus-visible > svg {
51+
transform: scale(1.1);
52+
}
53+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React from 'react'
2+
import './theme-toggle.css'
3+
import { useTheme } from '../../hooks/theme'
4+
5+
export const ThemeToggle = () => {
6+
const { isDarkTheme, toggleTheme } = useTheme()
7+
return (
8+
<button
9+
className="theme-toggle"
10+
onClick={() => toggleTheme()}
11+
onKeyDown={toggleTheme}
12+
tabIndex={0}
13+
aria-label={`Toggle ${isDarkTheme ? 'dark' : 'light'} mode`}
14+
role="switch"
15+
aria-checked={isDarkTheme}
16+
>
17+
<svg
18+
xmlns="http://www.w3.org/2000/svg"
19+
width="24"
20+
height="24"
21+
viewBox="0 0 24 24"
22+
fill="none"
23+
stroke="currentColor"
24+
strokeWidth="2"
25+
strokeLinecap="round"
26+
strokeLinejoin="round"
27+
aria-hidden="true"
28+
color="#fff"
29+
>
30+
{isDarkTheme
31+
? (
32+
<>
33+
<circle cx="12" cy="12" r="5" />
34+
<line x1="12" y1="1" x2="12" y2="3" />
35+
<line x1="12" y1="21" x2="12" y2="23" />
36+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
37+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
38+
<line x1="1" y1="12" x2="3" y2="12" />
39+
<line x1="21" y1="12" x2="23" y2="12" />
40+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
41+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
42+
</>
43+
)
44+
: (
45+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
46+
)}
47+
</svg>
48+
</button>
49+
)
50+
}

0 commit comments

Comments
 (0)