Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 256 additions & 33 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"upgrade:carbon": "npm-check-updates -u --dep dev,peer,prod --packageFile '{package.json,{config/**,packages/**}/package.json}' --filter '/carbon/' --target minor"
},
"dependencies": {
"@carbon-labs/react-theme-settings": "^0.15.0",
"@carbon/ibm-products": "^2.74.0",
"@carbon/icons-react": "^11.53.0",
"@carbon/react": "^1.73.0",
"@carbon/styles": "^1.72.0",
Expand Down
26 changes: 25 additions & 1 deletion src/components/nav/Nav.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,22 @@ import {
HeaderMenuButton,
HeaderName,
HeaderNavigation,
HeaderPanel,
HeaderSideNavItems,
SideNav,
SideNavItems,
SkipToContent,
} from '@carbon/react';

import { Search, Switcher as SwitcherIcon } from '@carbon/icons-react';
import {
LogoGithub,
MagicWand,
Search,
Switcher as SwitcherIcon,
UserAvatar,
} from '@carbon/icons-react';
import { Link as RouterLink, useLocation } from 'react-router';
import ProfilePanel from '../profilePanel/ProfilePanel';

import { routesInHeader, routesInSideNav } from '../../routes/config';
import { NavHeaderItems } from './NavHeaderItems';
Expand All @@ -29,13 +37,18 @@ import { NavSideItems } from './NavSideItems';
export const Nav = () => {
const location = useLocation();
const [isSideNavExpanded, setIsSideNavExpanded] = useState(false);
const [isProfileOpen, setIsProfileOpen] = useState(false);

const toggleNav = () => {
// Reason for this implementation of state change through an updater function:
// https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state
setIsSideNavExpanded((isExpanded) => !isExpanded);
};

const handleProfileOpen = () => {
setIsProfileOpen((prev) => !prev);
};

return (
<>
<Header aria-label="fed-at-ibm">
Expand All @@ -62,10 +75,21 @@ export const Nav = () => {
<HeaderGlobalAction aria-label="Search">
<Search size={20} />
</HeaderGlobalAction>
<HeaderGlobalAction
aria-label="User profile"
tooltipAlignment="end"
onClick={handleProfileOpen}
>
<UserAvatar size={20} />
</HeaderGlobalAction>
<HeaderGlobalAction aria-label="App switcher" tooltipAlignment="end">
<SwitcherIcon size={20} />
</HeaderGlobalAction>
</HeaderGlobalBar>

<HeaderPanel expanded={isProfileOpen} href="#profile-panel">
{isProfileOpen && <ProfilePanel />}
</HeaderPanel>
</Header>
<SideNav
aria-label="Side navigation"
Expand Down
67 changes: 67 additions & 0 deletions src/components/profilePanel/ProfilePanel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Copyright IBM Corp. 2025
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import PropTypes from 'prop-types';
import classNames from 'classnames';

import './profile-panel.scss';
import { UserAvatar } from '@carbon/ibm-products';
import {
ThemeSettings,
ThemeMenuComplement,
ThemeSwitcher,
} from '@carbon-labs/react-theme-settings/es/index.js';
import { useThemeContext } from '../../context/ThemeContext';

export const ProfilePanel = ({ className }) => {
const {
themeSetting,
setThemeSetting,
themeMenuCompliment,
setThemeMenuCompliment,
} = useThemeContext();

return (
<div className={classNames(className, 'cs--profile-panel')}>
<div className="cs--profile-user-info">
<UserAvatar
name="Anne Profile"
renderIcon=""
size="lg"
tooltipAlignment="bottom"
/>
<div className="cds--profile-user-info__text-wrapper">
<div className="cds--profile-user-info__name">Anne Profile</div>
<div className="cds--profile-user-info__email">
[email protected]
</div>
</div>
</div>

<div className="cs--profile-settings">
<ThemeSettings>
<ThemeSwitcher
onChange={(value) => setThemeSetting(value)}
value={themeSetting}
></ThemeSwitcher>
<ThemeMenuComplement
id="theme-menu-complement"
labelText="Complement menu theme"
checked={themeMenuCompliment}
onChange={(value) => setThemeMenuCompliment(value)}
/>
</ThemeSettings>
</div>
</div>
);
};

ProfilePanel.propTypes = {
className: PropTypes.string,
};

export default ProfilePanel;
21 changes: 21 additions & 0 deletions src/components/profilePanel/profile-panel.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Copyright IBM Corp. 2025
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

@use '@carbon/react/scss/spacing' as *;

.cs--profile-panel {
display: flex;
flex-direction: column;
padding: $spacing-05;
gap: $spacing-05;
}

.cs--profile-user-info {
display: flex;
align-items: center;
gap: $spacing-03;
}
150 changes: 150 additions & 0 deletions src/context/ThemeContext.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Copyright IBM Corp. 2025
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { usePrefersDarkScheme } from '@carbon/react';
import PropTypes from 'prop-types';
import {
getLocalStorageValues,
setLocalStorageValues,
} from '../utils/local-storage';

// Create the context with default values
const ThemeContext = createContext({
themeSetting: 'system', // system, light, or dark
setThemeSetting: () => {},
themeMenuCompliment: false, // true or false
setThemeMenuCompliment: () => {},
theme: 'g10', // g10 or g100
themeMenu: 'g10', // g10 or g100
ready: false, // indicates if values have been initialized
});

export const ThemeProvider = ({ children }) => {
const prefersDark = usePrefersDarkScheme();
const [ready, setReady] = useState(false);
const [updateReady, setUpdateReady] = useState(false);

// Initialize state from local storage
const storedValues = getLocalStorageValues();
const [themeSetting, setThemeSettingState] = useState(
storedValues.themeSetting || 'system',
);
const [themeMenuCompliment, setThemeMenuComplimentState] = useState(
storedValues.headerInverse || false,
);

// Wrapper functions to update both state and local storage
const setThemeSetting = useCallback((value) => {
setThemeSettingState(value);
setLocalStorageValues({ themeSetting: value });
}, []);

const setThemeMenuCompliment = useCallback((value) => {
setThemeMenuComplimentState(value);
setLocalStorageValues({ headerInverse: value });
}, []);

// Calculate the actual theme based on settings
const calculateTheme = useCallback(() => {
if (themeSetting === 'light') {
return 'g10';
} else if (themeSetting === 'dark') {
return 'g100';
} else {
// system setting - use browser preference
return prefersDark ? 'g100' : 'g10';
}
}, [themeSetting, prefersDark]);

// Calculate the menu theme based on settings and compliment option
const calculateMenuTheme = useCallback(
(mainTheme) => {
if (!themeMenuCompliment) {
return mainTheme;
}
// If compliment is enabled, return the opposite theme
return mainTheme === 'g10' ? 'g100' : 'g10';
},
[themeMenuCompliment],
);

const [theme, setTheme] = useState(() => calculateTheme());
const [themeMenu, setThemeMenu] = useState(() => {
const initialTheme = calculateTheme();
return calculateMenuTheme(initialTheme);
});

// Update themes when settings change
useEffect(() => {
const newTheme = calculateTheme();
setReady(false);
setUpdateReady(true);
setTheme(newTheme);
setThemeMenu(calculateMenuTheme(newTheme));

// Update the document element with the appropriate theme data attribute
const root = document.documentElement;

// Remove any existing theme data attribute
root.removeAttribute('cs--theme');

// If not using system theme, add the appropriate data attribute
if (themeSetting !== 'system') {
root.setAttribute('cs--theme', newTheme);
}
}, [
themeSetting,
themeMenuCompliment,
prefersDark,
calculateTheme,
calculateMenuTheme,
]);

useEffect(() => {
if (updateReady) {
setReady(true);
setUpdateReady(false);
}
}, [updateReady]);

const value = {
themeSetting,
setThemeSetting,
themeMenuCompliment,
setThemeMenuCompliment,
theme,
themeMenu,
ready,
};

return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};

ThemeProvider.propTypes = {
children: PropTypes.node.isRequired,
};

// Custom hook to use the theme context
// eslint-disable-next-line react-refresh/only-export-components
export const useThemeContext = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useThemeContext must be used within a ThemeProvider');
}
return context;
};

export default ThemeContext;
9 changes: 6 additions & 3 deletions src/entry-client.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import { BrowserRouter } from 'react-router';
import { Router } from './routes';

// App level imports
import { ThemeProvider } from './context/ThemeContext';

hydrateRoot(
document.getElementById('root'),
<StrictMode>
<BrowserRouter>
<Router />
</BrowserRouter>
<ThemeProvider>
<BrowserRouter>
<Router />
</BrowserRouter>
</ThemeProvider>
</StrictMode>,
);
30 changes: 22 additions & 8 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
* LICENSE file in the root directory of this source tree.
*/

@use '@carbon/ibm-products/css/index.min';
@use '@carbon/react' with (
$font-path: '@ibm/plex'
);
@use '@carbon/react/scss/spacing' as *;
@use '@carbon/react/scss/theme' as *;
@use '@carbon/react/scss/themes';
@use '@carbon/react/scss/colors' as *;
@use '@carbon-labs/react-theme-settings/scss/theme-settings';

/* Importing the component styles here will load them
before the JavaScript, which will make the page
Expand All @@ -24,25 +26,37 @@
@use 'components/commonHeader/commonHeader';
@use 'components/footer/footer';

/* system preference theme by default */
:root {
@include theme(themes.$g10);

@mixin light-brand {
--cs-brand: #{$blue-30};
--cs-brand-alt: #{$blue-20};
--cs-logo-filter: invert(100%);
}

@mixin dark-brand {
--cs-brand: #{$blue-80};
--cs-brand-alt: #{$blue-90};
--cs-logo-filter: initial;
}

/* system preference theme by default */
:root,
:root[cs--theme='g10'] {
@include theme(themes.$g10);
@include light-brand;
}

@media (prefers-color-scheme: dark) {
:root {
@include theme(themes.$g100);

--cs-brand: #{$blue-80};
--cs-brand-alt: #{$blue-90};
--cs-logo-filter: initial;
@include dark-brand;
}
}

:root[cs--theme='g100'] {
@include theme(themes.$g100);
@include dark-brand;
}

body {
background-color: $background;
block-size: calc(100% - #{$spacing-09});
Expand Down
Loading
Loading