-
Notifications
You must be signed in to change notification settings - Fork 536
feat: add Auto/Light/Dark theme support (fixes #1702) #2496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,7 +42,7 @@ const IsNotConnected = () => { | |
| { !isSameOrigin && ( | ||
| <div> | ||
| <Trans i18nKey='notConnected.paragraph3' t={t}> | ||
| <li className='mb3 mt4'>Is your Kubo RPC API configured to allow <a className='link blue' href='https://github.com/ipfs/ipfs-webui#configure-kubo-rpc-api-cors-headers'>cross-origin (CORS) requests</a>? If not, run these commands and then start your daemon from the terminal:</li> | ||
| <li className='mb3'>Is your Kubo RPC API configured to allow <a className='link blue' href='https://github.com/ipfs/ipfs-webui#configure-kubo-rpc-api-cors-headers'>cross-origin (CORS) requests</a>? If not, run these commands and then start your daemon from the terminal:</li> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The margin changes in this file look unrelated to the theme work. Mind reverting them, or explaining why they are needed? |
||
| </Trans> | ||
| <div className='br1 overflow-hidden'> | ||
| <div className='f7 mb0 sans-serif charcoal pv1 pl2 bg-black-20 flex items-center overflow-x-auto'> | ||
|
|
@@ -80,7 +80,7 @@ const IsNotConnected = () => { | |
| </div> | ||
| )} | ||
| <Trans i18nKey='notConnected.paragraph4' t={t}> | ||
| <li className='mt4 mb3'>Is your Kubo RPC on a port other than 5001? If your node is configured with a <a className='link blue' href='https://github.com/ipfs/kubo/blob/master/docs/config.md#addresses' target='_blank' rel='noopener noreferrer'>custom RPC API address</a>, enter it here.</li> | ||
| <li className='mb3'>Is your Kubo RPC on a port other than 5001? If your node is configured with a <a className='link blue' href='https://github.com/ipfs/kubo/blob/master/docs/config.md#addresses' target='_blank' rel='noopener noreferrer'>custom RPC API address</a>, enter it here.</li> | ||
| </Trans> | ||
| <ApiAddressForm /> | ||
| </ol> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import React from 'react' | ||
| import { withTranslation } from 'react-i18next' | ||
| import { useTheme } from '../../contexts/theme-context.js' | ||
| import Title from '../../settings/Title.js' | ||
|
|
||
| const ThemeSelector = ({ t }) => { | ||
| const { theme, setTheme } = useTheme() | ||
|
|
||
| const onChange = (e) => { | ||
| setTheme(e.target.value) | ||
| } | ||
|
|
||
| return ( | ||
| <div className='joyride-settings-theme'> | ||
| <Title>{t('theme') || 'Theme'}</Title> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no |
||
| <div className='flex items-center'> | ||
| <select | ||
| className='w-100 mw4 pa2 ba b--black-20 br1 charcoal bg-white focus-outline focus-outline-blue' | ||
| value={theme} | ||
| onChange={onChange} | ||
| > | ||
| <option value='auto'>Auto</option> | ||
| <option value='light'>Light</option> | ||
| <option value='dark'>Dark</option> | ||
| </select> | ||
| <span className='ml3 f6 charcoal-muted'> | ||
| {t('themeDescription') || 'Choose a theme or sync with your system preference.'} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default withTranslation('settings')(ThemeSelector) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // @ts-check | ||
| import ThemeSelector from './ThemeSelector.js' | ||
| import { ThemeProvider } from '../../contexts/theme-context.js' | ||
|
|
||
| /** | ||
| * @type {import('@storybook/react').Meta} | ||
| */ | ||
| export default { | ||
| title: 'Settings/Theme Selector', | ||
| component: ThemeSelector, | ||
| decorators: [ | ||
| (Story) => ( | ||
| <ThemeProvider> | ||
| <div className="pa4 bg-white" style={{ maxWidth: '400px' }}> | ||
| <Story /> | ||
| </div> | ||
| </ThemeProvider> | ||
| ) | ||
| ] | ||
| } | ||
|
|
||
| /** | ||
| * @type {import('@storybook/react').StoryObj} | ||
| */ | ||
| export const Default = {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import React from 'react' | ||
| import '@testing-library/jest-dom' | ||
| import { render, screen } from '@testing-library/react' | ||
| import ThemeSelector from './ThemeSelector.js' | ||
| import { ThemeProvider } from '../../contexts/theme-context.js' | ||
| import { I18nextProvider } from 'react-i18next' | ||
| import i18n from '../../i18n.js' | ||
|
|
||
| describe('ThemeSelector', () => { | ||
| it('renders correctly', () => { | ||
| render( | ||
| <I18nextProvider i18n={i18n}> | ||
| <ThemeProvider> | ||
| <ThemeSelector /> | ||
| </ThemeProvider> | ||
| </I18nextProvider> | ||
| ) | ||
|
|
||
| expect(screen.getByRole('combobox')).toBeInTheDocument() | ||
| expect(screen.getByText('Auto')).toBeInTheDocument() | ||
| expect(screen.getByText('Light')).toBeInTheDocument() | ||
| expect(screen.getByText('Dark')).toBeInTheDocument() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import React from 'react' | ||
| import { useTranslation } from 'react-i18next' | ||
| import { useTheme } from '../../contexts/theme-context.js' | ||
| import StrokeMonitor from '../../icons/StrokeMonitor.js' | ||
|
|
||
| const SunIcon = ({ className, width, height }) => ( | ||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} width={width} height={height}> | ||
| <circle cx="12" cy="12" r="5"></circle> | ||
| <line x1="12" y1="1" x2="12" y2="3"></line> | ||
| <line x1="12" y1="21" x2="12" y2="23"></line> | ||
| <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> | ||
| <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> | ||
| <line x1="1" y1="12" x2="3" y2="12"></line> | ||
| <line x1="21" y1="12" x2="23" y2="12"></line> | ||
| <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> | ||
| <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> | ||
| </svg> | ||
| ) | ||
|
|
||
| const MoonIcon = ({ className, width, height }) => ( | ||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} width={width} height={height}> | ||
| <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> | ||
| </svg> | ||
| ) | ||
|
|
||
| export const TopBarThemeToggle = () => { | ||
| const { theme, setTheme } = useTheme() | ||
| const { t } = useTranslation('settings') | ||
|
|
||
| const cycleTheme = () => { | ||
| if (theme === 'auto') setTheme('light') | ||
| else if (theme === 'light') setTheme('dark') | ||
| else setTheme('auto') | ||
| } | ||
|
|
||
| let Icon = StrokeMonitor | ||
| if (theme === 'light') Icon = SunIcon | ||
| if (theme === 'dark') Icon = MoonIcon | ||
|
|
||
| return ( | ||
| <button | ||
| className="button-reset bg-transparent bn p0 m0 mr3 flex items-center justify-center pointer charcoal-muted hover-navy transition-all glow" | ||
| onClick={cycleTheme} | ||
| title={t('themeDescription')} | ||
| style={{ width: 28, height: 28, outline: 'none' }} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ux nit: Please use the existing |
||
| > | ||
| <Icon width={24} height={24} className="fill-current-color" /> | ||
| </button> | ||
| ) | ||
| } | ||
|
|
||
| export default TopBarThemeToggle | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import React, { createContext, useContext, useEffect, useState } from 'react' | ||
|
|
||
| export const ThemeContext = createContext({ | ||
| theme: 'auto', | ||
| setTheme: (/** @type {string} */ _newTheme) => {} | ||
| }) | ||
|
|
||
| export const useTheme = () => useContext(ThemeContext) | ||
|
|
||
| // Default theme is auto | ||
| const DEFAULT_THEME = 'auto' | ||
|
|
||
| /** | ||
| * @param {Object} props | ||
| * @param {import('react').ReactNode} props.children | ||
| */ | ||
| export const ThemeProvider = ({ children }) => { | ||
| const [theme, setThemeState] = useState(() => { | ||
| return localStorage.getItem('ipfs-webui-theme') || DEFAULT_THEME | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any string in localStorage becomes Please validate against |
||
| }) | ||
|
|
||
| useEffect(() => { | ||
| const mediaQuery = window.matchMedia | ||
| ? window.matchMedia('(prefers-color-scheme: dark)') | ||
| : { matches: false, addEventListener: () => {}, removeEventListener: () => {} } | ||
|
|
||
| const applyTheme = () => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: when the theme is Perhaps expose the resolved light/dark value through the ontext; that fixes both? |
||
| const isDark = theme === 'dark' || (theme === 'auto' && mediaQuery.matches) | ||
| document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light') | ||
| } | ||
|
|
||
| applyTheme() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The theme is applied only after React mounts, so dark-mode users see a white flash on every load. One way to fix it is a tiny script in public/index.html, ahead of the bundle, can set |
||
|
|
||
| const handleChange = () => { | ||
| if (theme === 'auto') { | ||
| applyTheme() | ||
| } | ||
| } | ||
|
|
||
| mediaQuery.addEventListener('change', handleChange) | ||
| return () => { | ||
| mediaQuery.removeEventListener('change', handleChange) | ||
| } | ||
| }, [theme]) | ||
| /** | ||
| * @param {string} newTheme | ||
| */ | ||
| const setTheme = (newTheme) => { | ||
| localStorage.setItem('ipfs-webui-theme', newTheme) | ||
| setThemeState(newTheme) | ||
| } | ||
|
|
||
| return ( | ||
| <ThemeContext.Provider value={{ theme, setTheme }}> | ||
| {children} | ||
| </ThemeContext.Provider> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,17 +2,17 @@ import React, { forwardRef } from 'react' | |
| import { Dropdown as Drop, DropdownMenu as Menu } from '@tableflip/react-dropdown' | ||
| import StrokeCode from '../../icons/StrokeCode.js' | ||
|
|
||
| export const Option = ({ children, onClick, className = '', isCliTutorModeEnabled, onCliTutorMode, ...props }) => ( | ||
| export const Option = ({ children, onClick, className = '', isCliTutorModeEnabled, onCliTutorMode, disabled, ...props }) => ( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Would you mind moving it to its own PR and explain there why you do this? Keeps this one focused on theming. |
||
| isCliTutorModeEnabled | ||
| ? <div className='flex items-center justify-between'> | ||
| <button role='menuitem' className={`bg-animate hover-bg-near-white pa2 pointer flex items-center flex-grow-1 ${className}`} onClick={onClick} {...props}> | ||
| <button role='menuitem' className={`bg-animate hover-bg-near-white pa2 pointer flex items-center flex-grow-1 ${className}`} onClick={onClick} disabled={disabled} {...props}> | ||
| {children} | ||
| </button> | ||
| <button {...props} className={`bg-animate hover-bg-near-white pa2 pointer flex items-center ${className}`}> | ||
| <button {...props} className={`bg-animate hover-bg-near-white pa2 pointer flex items-center ${className}`} disabled={disabled}> | ||
| <StrokeCode {...props} onClick={() => onCliTutorMode(true)} className='dib fill-link pointer' style={{ height: 38 }}/> | ||
| </button> | ||
| </div> | ||
| : <button role="menuitem" className={`bg-animate hover-bg-near-white pa2 pointer flex items-center ${className}`} onClick={onClick} {...props}> | ||
| : <button role="menuitem" className={`bg-animate hover-bg-near-white pa2 pointer flex items-center ${disabled ? 'o-40 not-allowed' : ''} ${className}`} onClick={!disabled ? onClick : undefined} aria-disabled={disabled} {...props}> | ||
| {children} | ||
| </button> | ||
| ) | ||
|
|
@@ -22,7 +22,7 @@ export const DropdownMenu = forwardRef((props, ref) => { | |
|
|
||
| return ( | ||
| <Menu | ||
| className='sans-serif br2 charcoal' | ||
| className='sans-serif br2 charcoal dropdown-menu' | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The library fills the menu arrow with inline Passing |
||
| boxShadow='rgba(105, 196, 205, 0.5) 0px 1px 10px 0px' | ||
| width={width} | ||
| arrowAlign='right' | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -160,9 +160,8 @@ export const FileImportStatus = ({ filesFinished, filesPending, filesErrors, doF | |
| onClick={() => setExpanded(!expanded)} | ||
| onKeyPress={handleExpandByKeyboard} | ||
| role="button" | ||
| className="fileImportStatusButton pv2 ph3 relative flex items-center no-select pointer charcoal w-100 justify-between" | ||
| className="fileImportStatusButton pv2 ph3 relative flex items-center no-select pointer charcoal w-100 justify-between bg-snow" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| aria-expanded={expanded} | ||
| style={{ background: '#F0F6FA' }} | ||
| > | ||
| <span> | ||
| { filesPending.length | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
iiuc this changes disabled buttons in light mode too, and
bg-black-10is close to invisible on a dark background. Consider keeping the old classes and adding a dark override for.bg-gray-mutedin index.css instead.