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
130 changes: 127 additions & 3 deletions src/self-service-portal/app/src/shared/ui/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
import { useAuth } from '@/auth/data-access/AuthProvider';
import { usePageTitle } from '@/shared/utils/useRouteTitle';
import { Breadcrumbs, Button, toUiKitTranslation, UserMenu } from '@curity/ui-kit-component-library';
import { IconGeneralKebabMenu, IconVciCredentialHome } from '@curity/ui-kit-icons';
import { useEffect } from 'react';
import { IconGeneralChevron, IconGeneralKebabMenu, IconGeneralLock, IconVciCredentialHome } from '@curity/ui-kit-icons';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import styles from './header.module.css';
import { BOOTSTRAP_UI_CONFIG } from '@/BOOTSTRAP_UI_CONFIG.ts';
import { UiConfigMetadataResponse } from '@/ui-config/typings.ts';
import { setupI18nTranslations } from '@/i18n/setup-translations.ts';

interface HeaderProps {
toggleSidebar: () => void;
Expand All @@ -28,6 +31,72 @@ export const Header = ({ toggleSidebar, isSidebarOpen }: HeaderProps) => {
const uiKitT = toUiKitTranslation(t);
const pageTitle = usePageTitle();
const authContext = useAuth();
const menuContainerRef = useRef<HTMLDivElement | null>(null);
const menuButtonRef = useRef<HTMLButtonElement | null>(null);

const [isLanguageSelectorOpen, setIsLanguageSelectorOpen] = useState(false);
const [language, setLanguage] = useState(null);

const onSelectLanguage = (e: React.MouseEvent<HTMLButtonElement>) => {
setLanguage(e.target.innerText.toLowerCase());
};

async function setNewLanguage(requestURI: string): Promise<{ [key: string]: string }> {
const uiConfigMetadataResponse = await fetch(requestURI, {
credentials: 'include',
});

if (uiConfigMetadataResponse.status !== 200) {
throw new Error('Failed to fetch metadata');
}

const uiConfigMetadataResponseJSON: UiConfigMetadataResponse = await uiConfigMetadataResponse.json();
const { messages } = uiConfigMetadataResponseJSON;

return messages;
}

useEffect(() => {
if (!language) {
return;
}

const requestURI = `${BOOTSTRAP_UI_CONFIG.PATHS.BACKEND}${BOOTSTRAP_UI_CONFIG.PATHS.METADATA}?ui_locales=${language}`;
console.log('requestURI', requestURI);
setNewLanguage(requestURI).then(messages => {
setupI18nTranslations(messages);
});
}, [language]);

// TODO: this logic should be extracted to a custom hook, as it is repeated in the UserMenu component.
// The hook should receive as parameters whether the menu is open, (as well as the setter for it)
// a ref to the menu container and a ref to the button that opens the menu, and should return a function to toggle the menu open state
useEffect(() => {
if (!isLanguageSelectorOpen) return;

const closeMenuOnOutsideClick = (event: MouseEvent) => {
const clickedOutsideMenu = menuContainerRef.current && !menuContainerRef.current.contains(event.target as Node);

if (clickedOutsideMenu) {
setIsLanguageSelectorOpen(false);
}
};

const closeMenuOnEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape' && isLanguageSelectorOpen) {
setIsLanguageSelectorOpen(false);
menuButtonRef.current?.focus();
}
};

document.addEventListener('mousedown', closeMenuOnOutsideClick);
document.addEventListener('keydown', closeMenuOnEscape);

return () => {
document.removeEventListener('mousedown', closeMenuOnOutsideClick);
document.removeEventListener('keydown', closeMenuOnEscape);
};
}, [isLanguageSelectorOpen]);

useEffect(() => {
document.title = pageTitle;
Expand All @@ -48,7 +117,62 @@ export const Header = ({ toggleSidebar, isSidebarOpen }: HeaderProps) => {

<div className="flex flex-center flex-gap-1 nowrap">
{authContext?.session?.isLoggedIn && (
<UserMenu username={authContext?.session?.idTokenClaims?.sub} onSignOut={authContext.logout} t={uiKitT} />
<>
{/*
TODO: the language selector should be extracted to its own component in @curity/ui-kit-component-library,
as it is a separate concern from the user menu and it will make the Header component cleaner and more readable.
*/}
<div className="relative" ref={menuContainerRef}>
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@urre I suggest extracting this into its own LanguageSelector component

perhaps better to use

<select>
    <option value="sv"></option>
    ...
</select>

so, it is easier to extract the value of the selected option

WDYT?

Copy link
Copy Markdown
Collaborator

@urre urre Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an option. Another option is to reuse <UserMenu> and making that multi-purpose.

But <select> also works fine, simple and easy. We could also nest it inside the <UserMenu> See this example:

CleanShot 2026-02-16 at 08 41 14@2x

<Button
ref={menuButtonRef}
onClick={() => setIsLanguageSelectorOpen(currentIsOpen => !currentIsOpen)}
className="button button-tiny button-transparent"
aria-expanded={isLanguageSelectorOpen}
aria-haspopup="menu"
aria-controls="language-selector"
data-testid="user-menu-button"
>
<span className={styles['user-menu-username']}>EN</span>
<span
className={`${styles['user-menu-chevron']} ${isLanguageSelectorOpen ? styles['user-menu-chevron-open'] : ''}`}
>
<IconGeneralChevron width={16} height={16} aria-hidden="true" />
</span>
</Button>

{isLanguageSelectorOpen && (
<div
id="language-selector"
className={`flex flex-column flex-gap-0 br-8 ${styles['user-menu']} ${isLanguageSelectorOpen ? styles['user-menu-open'] : ''}`}
role="menu"
tabIndex={-1}
>
<Button
icon={<IconGeneralLock width={24} height={24} aria-hidden="true" />}
title={t('SV')}
className="button-tiny button-link"
onClick={onSelectLanguage}
value="sv"
data-testid="logout-button"
role="menuitem"
tabIndex={1}
/>
<Button
icon={<IconGeneralLock width={24} height={24} aria-hidden="true" />}
title={t('PT')}
className="button-tiny button-link"
onClick={onSelectLanguage}
value="pt"
data-testid="logout-button"
role="menuitem"
tabIndex={2}
/>
</div>
)}
</div>

<UserMenu username={authContext?.session?.idTokenClaims?.sub} onSignOut={authContext.logout} t={uiKitT} />
</>
)}

<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
z-index: 2;
width: max-content;
white-space: nowrap;
width: 100%
width: 100%;
}

@media (width >=64em) {
Expand All @@ -26,3 +26,60 @@
.header svg {
flex-shrink: 0;
}

/*
TODO: this should be moved to @curity/ui-kit-css
to be used by UserMenu and the new language selector component
*/
.user-menu {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@urre could we extract this into css lib and reuse it both for user menu and language selector components?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea. I don't think we should add all this CSS. We already have the <UserMenu> from our component library src/common/component-library/src/components/user-menu/UserMenu.tsx

We can generalize this in to a DropdownMenu component and reuse for both UserMenu and LanguageSelector

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or use <Select> like the other comment

position: absolute;
right: var(--space-1);
height: min-content;
width: max-content;
border: 1px solid var(--color-grey-light);
background: white;

box-shadow:
2.8px 2.8px 2.2px rgba(0, 0, 0, 0.02),
6.7px 6.7px 5.3px rgba(0, 0, 0, 0.028),
12.5px 12.5px 10px rgba(0, 0, 0, 0.035),
22.3px 22.3px 17.9px rgba(0, 0, 0, 0.042),
41.8px 41.8px 33.4px rgba(0, 0, 0, 0.05),
100px 100px 80px rgba(0, 0, 0, 0.07);

z-index: 9999;
opacity: 0;
visibility: hidden;
transform: scale(0.9);
transform-origin: top right;
transition-property: transform, opacity, visibility;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 0.1s;

[role='menuitem'] {
border-radius: calc(var(--button-border-radius) - 1px);
}
}

.user-menu-open {
visibility: visible;
opacity: 1;
transform: scale(1);
}

.user-menu-chevron {
transition: transform 200ms ease-in-out;
transform-origin: center;
}

.user-menu-chevron-open {
transform: rotate(180deg);
}

.user-menu-username {
width: fit-content;
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}