diff --git a/package-lock.json b/package-lock.json index caa7748f4..c1fdfa42b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@mui/material": "^5.15.13", "@mui/x-date-pickers": "^5.0.20", "@tanstack/react-table": "^8.13.2", - "@tedi-design-system/core": "6.0.1", + "@tedi-design-system/core": "6.1.2", "classnames": "^2.5.1", "draft-js": "^0.11.7", "draftjs-md-converter": "^1.5.2", @@ -7791,9 +7791,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.0.1.tgz", - "integrity": "sha512-SgWbcIofn/LSzGbHYPYZD7i3PPdeL/qTaq99QT+RY6i9ISYViHQcrtNDiLZogx5KrREl/mQcrl3sLZNwmU6bDg==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.1.2.tgz", + "integrity": "sha512-6kBr4pJ5KL1gfZYJd9WjTejAtnF4hJkCgZ4JR7B4UDnbKC7a7STyJC/umDkNbsZ3Xw2tLDZX1ocwkVhgcxjC0Q==", "engines": { "node": ">=24.0.0", "npm": ">=11.0.0" diff --git a/package.json b/package.json index 3b66dad63..1f4096dc3 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@mui/material": "^5.15.13", "@mui/x-date-pickers": "^5.0.20", "@tanstack/react-table": "^8.13.2", - "@tedi-design-system/core": "6.0.1", + "@tedi-design-system/core": "6.1.2", "classnames": "^2.5.1", "draft-js": "^0.11.7", "draftjs-md-converter": "^1.5.2", diff --git a/public/header-logo-white.svg b/public/header-logo-white.svg new file mode 100644 index 000000000..58f4f664f --- /dev/null +++ b/public/header-logo-white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index 7cbe6c3e4..df076315a 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -286,6 +286,45 @@ Same as Checkbox (without indeterminate) Sub-component: `VerticalSpacing.Item` +### Header +**Props:** `HeaderProps` +- `children: ReactNode` (required) +- `toggle?: ReactNode` — mobile side navigation toggle +- `bottom?: ReactNode` — content below header on mobile + +Sub-components: `Header.Logo`, `Header.Center`, `Header.Actions`, `Header.Language`, `Header.Login`, `Header.Logout`, `Header.Profile`, `Header.Role`, `Header.Search` + +**Header.Logo:** `logo: ReactNode`, `logoDark?: ReactNode`, `href?: string`, `showLogo?: boolean = true` +**Header.Center:** `children: ReactNode`, `alignment?: 'flex-start' | 'center' | 'space-between' = 'center'` +**Header.Actions:** `children: ReactNode` +**Header.Role:** `representatives: Representative[]`, `label?: ReactNode`, `showDescription?: boolean = true`, `accordionLabels?: { open?, close? }`, `onRepresentativeChange?`, `onRoleSelectionToggle?` +**Header.Language:** bp — language selector +**Header.Login:** bp — login button +**Header.Logout:** bp — logout button +**Header.Profile:** bp — user profile display +**Header.Search:** wrapper that accepts a Search child (and optional `mobileVariant`). `children: ReactNode`, `mobileVariant?: 'modal' | 'inline'`, `mobileLabels?: { button?, modalTitle? }`, `disabled?: boolean` + +```tsx +
} + bottom={ + + + + } +> + } href="/" /> + About + + + + + + + +
+``` + ### SideNav **Props:** `SideNavProps` | poly - `ariaLabel: string` (required) @@ -524,7 +563,8 @@ Import from `@tedi-design-system/react/community`. These are community-contribut ## Layout ### Header -Comprehensive header with sub-components: HeaderContent, HeaderActions, HeaderNavigation, HeaderLanguage, HeaderRole, HeaderSettings, HeaderNotifications, HeaderLogo +- Sub-components: HeaderContent, HeaderActions, HeaderNavigation, HeaderLanguage, HeaderRole, HeaderSettings, HeaderNotifications, HeaderLogo +- **Note:** The TEDI-Ready Header is now available with a different sub-component API. Prefer the TEDI-Ready version for new work. ## Misc diff --git a/src/community/components/layout/header/header.stories.tsx b/src/community/components/layout/header/header.stories.tsx index cf63b2d84..ed3740dca 100644 --- a/src/community/components/layout/header/header.stories.tsx +++ b/src/community/components/layout/header/header.stories.tsx @@ -17,6 +17,11 @@ import Header, { HeaderProps } from './header/header'; export default { component: Header, title: 'Community/Layout/Header', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, } as Meta; type Story = StoryObj; diff --git a/src/tedi/components/buttons/button-content/button-content.module.scss b/src/tedi/components/buttons/button-content/button-content.module.scss index 15a153cd0..624c2fa26 100644 --- a/src/tedi/components/buttons/button-content/button-content.module.scss +++ b/src/tedi/components/buttons/button-content/button-content.module.scss @@ -382,7 +382,7 @@ $btn-width-large: 3.72rem; } } -.tedi-btn--text-color { +.tedi-btn--text { &.tedi-btn--link { @include link-variant( inherit, diff --git a/src/tedi/components/buttons/button-content/button-content.tsx b/src/tedi/components/buttons/button-content/button-content.tsx index 604d47c7a..15ffc5b99 100644 --- a/src/tedi/components/buttons/button-content/button-content.tsx +++ b/src/tedi/components/buttons/button-content/button-content.tsx @@ -35,7 +35,7 @@ export type ButtonContentProps< */ fullWidth?: boolean; /** - * Color schema for button. PS text-color works only with link type links. + * Color scheme of the button. The 'text' value is only supported when visualType is 'link'. * @default default */ color?: ButtonColor; diff --git a/src/tedi/components/cards/card/card-stories-templates.tsx b/src/tedi/components/cards/card/card-stories-templates.tsx index 37fc11aa6..0bb1eb6f9 100644 --- a/src/tedi/components/cards/card/card-stories-templates.tsx +++ b/src/tedi/components/cards/card/card-stories-templates.tsx @@ -1,5 +1,5 @@ /* istanbul ignore file */ -import { StoryFn } from '@storybook/react/*'; +import { StoryFn } from '@storybook/react'; import { Icon } from '../../base/icon/icon'; import { Heading } from '../../base/typography/heading/heading'; diff --git a/src/tedi/components/layout/header/components/header-language/header-language.module.scss b/src/tedi/components/layout/header/components/header-language/header-language.module.scss new file mode 100644 index 000000000..8197286e3 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-language/header-language.module.scss @@ -0,0 +1,45 @@ +.tedi-header-language { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + height: 100%; + + [data-name='button'] { + color: var(--header-dropdown-link); + } + + &__icon { + transition: transform 0.2s ease-in-out; + + &--open { + transform: rotate(-180deg); + } + } + + &__mobile { + display: flex; + align-items: center; + justify-content: center; + width: var(--layout-header-mobile-button-size); + min-width: var(--layout-header-mobile-button-min-size); + height: var(--layout-header-mobile-button-min-size); + } + + &__selected { + display: flex; + gap: var(--link-inner-spacing-x); + align-items: center; + } + + &__list { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-08); + align-items: flex-start; + + [data-name='button'] { + color: var(--header-dropdown-link); + } + } +} diff --git a/src/tedi/components/layout/header/components/header-language/header-language.spec.tsx b/src/tedi/components/layout/header/components/header-language/header-language.spec.tsx new file mode 100644 index 000000000..23d9b4d26 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-language/header-language.spec.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { HeaderLanguage, Language } from './header-language'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../../../helpers', () => ({ + ...jest.requireActual('../../../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), + useBreakpointProps: jest.fn(), +})); + +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: jest.fn(), +})); + +const mockLanguages: Language[] = [ + { label: 'EST', locale: 'et' as never, isSelected: true, 'aria-label': 'Estonian' }, + { label: 'ENG', locale: 'en' as never, isSelected: false, 'aria-label': 'English' }, + { label: 'RUS', locale: 'ru' as never, isSelected: false, 'aria-label': 'Russian' }, +]; + +describe('HeaderLanguage component', () => { + const mockSetLocale = jest.fn(); + const mockGetLabel = jest.fn((key: string) => key); + + beforeEach(() => { + jest.clearAllMocks(); + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn((props: Record) => props), + }); + (useLabels as jest.Mock).mockReturnValue({ + getLabel: mockGetLabel, + setLocale: mockSetLocale, + locale: 'et', + }); + }); + + it('renders the current language label', () => { + render(); + + expect(screen.getByText('EST')).toBeInTheDocument(); + }); + + it('renders the select label text', () => { + render(); + + expect(screen.getByText('Choose language')).toBeInTheDocument(); + }); + + it('falls back to i18n label when selectLabel is not provided', () => { + render(); + + expect(mockGetLabel).toHaveBeenCalledWith('header.select-lang'); + }); + + it('opens language dropdown on trigger click', () => { + render(); + + const trigger = screen.getByRole('button', { expanded: false }); + fireEvent.click(trigger); + + expect(screen.getByText('ENG')).toBeInTheDocument(); + expect(screen.getByText('RUS')).toBeInTheDocument(); + }); + + it('changes displayed language on selection', () => { + render(); + + const trigger = screen.getByRole('button', { expanded: false }); + fireEvent.click(trigger); + + const engOption = screen.getAllByText('ENG'); + fireEvent.click(engOption[engOption.length - 1]); + + expect(mockSetLocale).toHaveBeenCalledWith('en'); + }); + + it('uses currentLanguage prop as initial label', () => { + (useLabels as jest.Mock).mockReturnValue({ + getLabel: mockGetLabel, + setLocale: mockSetLocale, + locale: undefined, + }); + + render(); + + expect(screen.getByText('ENG')).toBeInTheDocument(); + }); + + it('falls back to first language when no locale or currentLanguage is set', () => { + (useLabels as jest.Mock).mockReturnValue({ + getLabel: mockGetLabel, + setLocale: mockSetLocale, + locale: undefined, + }); + + render(); + + expect(screen.getByText('EST')).toBeInTheDocument(); + }); + + it('calls custom onClick handler when provided', () => { + const mockOnClick = jest.fn(); + const languagesWithClick: Language[] = [ + { label: 'EST', isSelected: true }, + { label: 'ENG', onClick: mockOnClick }, + ]; + + render(); + + const trigger = screen.getByRole('button', { expanded: false }); + fireEvent.click(trigger); + + const engOption = screen.getAllByText('ENG'); + fireEvent.click(engOption[engOption.length - 1]); + + expect(mockOnClick).toHaveBeenCalledWith(expect.objectContaining({ onToggle: expect.any(Function) })); + expect(mockSetLocale).not.toHaveBeenCalled(); + }); + + it('renders expand icon', () => { + const { container } = render(); + + expect(container.querySelector('[class*="header-language__icon"]')).toBeInTheDocument(); + }); +}); diff --git a/src/tedi/components/layout/header/components/header-language/header-language.tsx b/src/tedi/components/layout/header/components/header-language/header-language.tsx new file mode 100644 index 000000000..cbe3df5e0 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-language/header-language.tsx @@ -0,0 +1,143 @@ +import cn from 'classnames'; +import { useMemo, useState } from 'react'; + +import { Text } from '../../../../../components/base/typography/text/text'; +import { isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { TediLanguage, useLabels } from '../../../../../providers/label-provider'; +import { Icon } from '../../../../base/icon/icon'; +import Button from '../../../../buttons/button/button'; +import Popover from '../../../../overlays/popover/popover'; +import styles from './header-language.module.scss'; + +export interface Language { + /** Display text shown in the language selector (e.g. 'EST', 'ENG'). */ + label: string; + /** + * Locale code for this language. When present, `setLocale` from the LabelProvider + * is called with this value on selection. Ignored when `onClick` is provided. + */ + locale?: TediLanguage; + /** + * Custom click handler. When provided, it takes full control of what happens on select — + * the component only updates its displayed label and provides `onToggle` so the handler + * can decide when to close the popover. + */ + onClick?: (props: { onToggle: (open: boolean) => void }) => void; + /** Whether this language is currently active. Used to set `aria-current` on the option. */ + isSelected?: boolean; + /** Accessible label for screen readers (e.g. 'Estonian', 'English'). */ + 'aria-label'?: string; +} + +export interface HeaderLanguageProps { + /** + * List of available languages to display in the selector dropdown. + * Each language object accepts: + * - `label` — display text (e.g. 'EST', 'ENG') + * - `locale` — locale code passed to `setLocale` on selection (ignored when `onClick` is provided) + * - `onClick` — custom click handler that takes full control of selection behavior + * - `isSelected` — marks the language as currently active (`aria-current`) + * - `aria-label` — accessible label for screen readers (e.g. 'Estonian', 'English') + */ + languages: Language[]; + /** Initially displayed language label. Falls back to the label matching the current locale, or the first item. */ + currentLanguage?: string; + /** Label for the language selector. + * Falls back to the default i18n label when not provided. + **/ + selectLabel?: string; +} + +export const HeaderLanguage = (props: HeaderLanguageProps) => { + const { languages, currentLanguage, selectLabel } = props; + const [languageSelectionOpen, setLanguageSelectionOpen] = useState(false); + const { getLabel, setLocale, locale } = useLabels(); + const { getCurrentBreakpointProps } = useBreakpointProps(); + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + const { hideLabel } = getCurrentBreakpointProps({ + hideLabel: true, + lg: { hideLabel: false }, + }); + const displayedLanguage = useMemo(() => { + if (locale) { + const found = languages.find((l) => l.locale === locale); + if (found) return found.label; + } + + if (currentLanguage) return currentLanguage; + return languages[0]?.label ?? ''; + }, [languages, locale, currentLanguage]); + + const changeLanguage = (lang: Language) => { + if (lang.onClick) { + lang.onClick({ onToggle: setLanguageSelectionOpen }); + return; + } + + setLanguageSelectionOpen(false); + + if (lang.locale) { + setLocale(lang.locale); + } + }; + + return ( +
+ + {selectLabel ?? getLabel('header.select-lang')} + + + setLanguageSelectionOpen((prev) => !prev)} + withBorder={true} + > + + + + +
+ {languages.map((lang) => ( + + ))} +
+
+
+
+ ); +}; + +HeaderLanguage.displayName = 'HeaderLanguage'; + +export default HeaderLanguage; diff --git a/src/tedi/components/layout/header/components/header-login/header-login.module.scss b/src/tedi/components/layout/header/components/header-login/header-login.module.scss new file mode 100644 index 000000000..51c9b7c27 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-login/header-login.module.scss @@ -0,0 +1,14 @@ +.tedi-header-login__button { + padding-top: 0; + padding-bottom: 0; + + &--inner { + display: flex; + flex-direction: column; + align-items: center; + } + + &--text { + color: inherit; + } +} diff --git a/src/tedi/components/layout/header/components/header-login/header-login.spec.tsx b/src/tedi/components/layout/header/components/header-login/header-login.spec.tsx new file mode 100644 index 000000000..b7bd8b040 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-login/header-login.spec.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { HeaderLogin } from './header-login'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../../../helpers', () => ({ + ...jest.requireActual('../../../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), + useBreakpointProps: jest.fn(), +})); + +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: jest.fn(), +})); + +describe('HeaderLogin component', () => { + const mockGetLabel = jest.fn((key: string) => key); + + beforeEach(() => { + jest.clearAllMocks(); + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn((props: Record) => props), + }); + (useLabels as jest.Mock).mockReturnValue({ getLabel: mockGetLabel }); + }); + + it('renders default size login button on desktop', () => { + render(); + + expect(mockGetLabel).toHaveBeenCalledWith('header.login'); + }); + + it('renders small login button on mobile', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render(); + + expect(mockGetLabel).toHaveBeenCalledWith('header.login.mobile'); + }); + + it('renders with custom label', () => { + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn((props: Record) => ({ ...props, label: 'Sign in' })), + }); + + const { container } = render(); + + expect(container.textContent).toContain('Sign in'); + }); + + it('renders with href', () => { + const { container } = render(); + + const link = container.querySelector('a[href="/login"]'); + expect(link).toBeInTheDocument(); + }); + + it('handles onClick without href', () => { + const onClick = jest.fn(); + render(); + + fireEvent.click(screen.getByText('header.login')); + + expect(onClick).toHaveBeenCalled(); + }); + + it('handles onClick with href', () => { + const onClick = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('link', { name: /header.login/i })); + + expect(onClick).toHaveBeenCalled(); + }); + + it('renders a button when href is not provided', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders small variant when size is explicitly small', () => { + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn(() => ({ size: 'small' })), + }); + + render(); + + expect(mockGetLabel).toHaveBeenCalledWith('header.login.mobile'); + }); +}); diff --git a/src/tedi/components/layout/header/components/header-login/header-login.tsx b/src/tedi/components/layout/header/components/header-login/header-login.tsx new file mode 100644 index 000000000..6e162981b --- /dev/null +++ b/src/tedi/components/layout/header/components/header-login/header-login.tsx @@ -0,0 +1,79 @@ +import { Text } from '../../../../../../tedi/components/base/typography/text/text'; +import { BreakpointSupport, isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { Button } from '../../../../buttons/button/button'; +import Link from '../../../../navigation/link/link'; +import HeaderMobileButton from '../header-mobile-button/header-mobile-button'; +import styles from './header-login.module.scss'; + +interface HeaderLoginBreakpointProps { + /** + * Controls the visual size of the login button. + * `'small'` renders a compact icon button (used on mobile), `'default'` renders a full-width link. + * Automatically falls back to `'small'` on mobile viewports when not specified. + */ + size?: 'default' | 'small'; + /** Custom label text for the login button. Falls back to the `header.login` or `header.login.mobile` translation key. */ + label?: string; +} + +export interface HeaderLoginProps extends BreakpointSupport { + /** Click handler fired when the login button is activated. */ + onClick?: () => void; + /** URL to navigate to when the login button is clicked. */ + href?: string; +} + +export const HeaderLogin = (props: HeaderLoginProps) => { + const { onClick, href } = props; + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { size: sizeProp, label } = getCurrentBreakpointProps(props); + const { getLabel } = useLabels(); + + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + + const size = sizeProp ?? (isMobileView ? 'small' : 'default'); + const isSmall = size === 'small'; + + const resolvedLabel = label ?? (isSmall ? getLabel('header.login.mobile') : getLabel('header.login')); + + return ( + <> + {isSmall ? ( + + ) : href ? ( + +
+ + {resolvedLabel} + +
+ + ) : ( + + )} + + ); +}; + +HeaderLogin.displayName = 'Header.Login'; + +export default HeaderLogin; diff --git a/src/tedi/components/layout/header/components/header-logout/header-logout.module.scss b/src/tedi/components/layout/header/components/header-logout/header-logout.module.scss new file mode 100644 index 000000000..820ae467f --- /dev/null +++ b/src/tedi/components/layout/header/components/header-logout/header-logout.module.scss @@ -0,0 +1,10 @@ +.tedi-header-logout { + display: flex; + gap: var(--link-inner-spacing-x); + align-items: center; + + &__text { + min-width: max-content; + color: inherit; + } +} diff --git a/src/tedi/components/layout/header/components/header-logout/header-logout.spec.tsx b/src/tedi/components/layout/header/components/header-logout/header-logout.spec.tsx new file mode 100644 index 000000000..c4e7215e6 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-logout/header-logout.spec.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { HeaderLogout } from './header-logout'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../../../helpers', () => ({ + ...jest.requireActual('../../../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), + useBreakpointProps: jest.fn(), +})); + +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: jest.fn(), +})); + +describe('HeaderLogout component', () => { + const mockGetLabel = jest.fn((key: string) => key); + + beforeEach(() => { + jest.clearAllMocks(); + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn((props: Record) => props), + }); + (useLabels as jest.Mock).mockReturnValue({ getLabel: mockGetLabel }); + }); + + it('renders default size logout button on desktop', () => { + render(); + + expect(mockGetLabel).toHaveBeenCalledWith('header.logout'); + }); + + it('renders small logout button on mobile', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render(); + + expect(mockGetLabel).toHaveBeenCalledWith('header.logout.mobile'); + }); + + it('renders with custom label', () => { + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn((props: Record) => ({ ...props, label: 'Sign out' })), + }); + + render(); + + expect(screen.getByText('Sign out')).toBeInTheDocument(); + }); + + it('renders with href', () => { + render(); + + const link = screen.getByRole('link', { name: /header.logout/i }); + expect(link).toHaveAttribute('href', '/logout'); + }); + + it('handles onClick without href', () => { + const onClick = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /header.logout/i })); + + expect(onClick).toHaveBeenCalled(); + }); + + it('handles onClick with href', () => { + const onClick = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('link', { name: /header.logout/i })); + + expect(onClick).toHaveBeenCalled(); + }); + + it('renders a button when href is not provided', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders small variant when size is explicitly small', () => { + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn(() => ({ size: 'small' })), + }); + + render(); + + expect(mockGetLabel).toHaveBeenCalledWith('header.logout.mobile'); + }); +}); diff --git a/src/tedi/components/layout/header/components/header-logout/header-logout.tsx b/src/tedi/components/layout/header/components/header-logout/header-logout.tsx new file mode 100644 index 000000000..dacbec6e0 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-logout/header-logout.tsx @@ -0,0 +1,76 @@ +import { Text } from '../../../../../../tedi/components/base/typography/text/text'; +import { BreakpointSupport, isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { Icon } from '../../../../base/icon/icon'; +import { Button } from '../../../../buttons/button/button'; +import Link from '../../../../navigation/link/link'; +import HeaderMobileButton from '../header-mobile-button/header-mobile-button'; +import styles from './header-logout.module.scss'; + +interface HeaderLogoutBreakpointProps { + /** + * Controls the visual size of the logout button. + * `'small'` renders a compact icon button (used on mobile), `'default'` renders a full-width link with icon. + * Automatically falls back to `'small'` on mobile viewports when not specified. + */ + size?: 'default' | 'small'; + /** Custom label text for the logout button. Falls back to the `header.logout` or `header.logout.mobile` translation key. */ + label?: string; +} + +export interface HeaderLogoutProps extends BreakpointSupport { + /** Click handler fired when the logout button is activated. */ + onClick?: () => void; + /** URL to navigate to when the logout button is clicked. */ + href?: string; +} + +export const HeaderLogout = (props: HeaderLogoutProps) => { + const { onClick, href } = props; + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { size: sizeProp, label } = getCurrentBreakpointProps(props); + const { getLabel } = useLabels(); + + const breakpoint = useBreakpoint(props.defaultServerBreakpoint); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + + const size = sizeProp ?? (isMobileView ? 'small' : 'default'); + const isSmall = size === 'small'; + + const resolvedLabel = label ?? (isSmall ? getLabel('header.logout.mobile') : getLabel('header.logout')); + + return ( + <> + {isSmall ? ( + + ) : href ? ( + +
+ + + {resolvedLabel} + +
+ + ) : ( + + )} + + ); +}; + +HeaderLogout.displayName = 'HeaderLogout'; + +export default HeaderLogout; diff --git a/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.module.scss b/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.module.scss new file mode 100644 index 000000000..7b51588f3 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.module.scss @@ -0,0 +1,47 @@ +.tedi-header-mobile-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: var(--layout-header-mobile-button-size); + min-width: var(--layout-header-mobile-button-min-size); + height: var(--layout-header-mobile-button-min-size); + padding: var(--layout-grid-gutters-08); + color: var(--header-mobile-button-text-default); + cursor: pointer; + background-color: var(--header-mobile-button-background-default); + border: transparent; + border-radius: unset; + + &__inner { + display: flex; + flex-direction: column; + align-items: center; + } + + &__text { + color: inherit; + } + + &:not(&--disabled):hover { + color: var(--header-mobile-button-text-hover); + } + + &:not(&--disabled):active { + color: var(--header-mobile-button-text-active); + } + + &:not(&--disabled):focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--tedi-neutral-100), inset 0 0 0 3px var(--tedi-blue-500); + } + + &--selected { + background: var(--header-mobile-button-background-selected); + } + + &--disabled { + color: var(--header-mobile-button-text-disabled); + background: var(--header-mobile-button-background-disabled); + } +} diff --git a/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.spec.tsx b/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.spec.tsx new file mode 100644 index 000000000..14a0e3184 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.spec.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import HeaderMobileButton from './header-mobile-button'; + +import '@testing-library/jest-dom'; + +describe('HeaderMobileButton component', () => { + it('renders a button with icon and label', () => { + render(); + + expect(screen.getByText('Menu')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('renders as a Button when no href is provided', () => { + render(); + + expect(screen.getByRole('button', { name: /Menu/i })).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders as a Link when href is provided and not disabled', () => { + render(); + + const link = screen.getByRole('link', { name: /Menu/i }); + expect(link).toHaveAttribute('href', '/page'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders as a Button when href is provided but disabled', () => { + render(); + + expect(screen.getByRole('button', { name: /Menu/i })).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('calls onClick when Link is clicked', () => { + const onClick = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('link', { name: /Menu/i })); + + expect(onClick).toHaveBeenCalled(); + }); + + it('calls onClick when Button is clicked', () => { + const onClick = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Menu/i })); + + expect(onClick).toHaveBeenCalled(); + }); + + it('applies selected class when selected is true', () => { + render(); + + const button = screen.getByRole('button', { name: /Menu/i }); + expect(button.className).toMatch(/header-mobile-button--selected/); + }); + + it('applies disabled class when disabled is true', () => { + render(); + + const button = screen.getByRole('button', { name: /Menu/i }); + expect(button.className).toMatch(/header-mobile-button--disabled/); + }); + + it('renders icon from IconWithoutBackgroundProps object', () => { + const { container } = render( + + ); + + expect(container.querySelector('[data-name="icon"]')).toBeInTheDocument(); + }); + + it('renders without label', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.tsx b/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.tsx new file mode 100644 index 000000000..20d3599d5 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.tsx @@ -0,0 +1,69 @@ +import cn from 'classnames'; +import React from 'react'; + +import { Text } from '../../../../../../tedi/components/base/typography/text/text'; +import { Icon, IconWithoutBackgroundProps } from '../../../../base/icon/icon'; +import { Button } from '../../../../buttons/button/button'; +import Link from '../../../../navigation/link/link'; +import styles from './header-mobile-button.module.scss'; + +interface HeaderMobileButtonProps { + /** Click handler for the button. */ + onClick?: () => void; + /** If provided, the button renders as a link navigating to this URL. */ + href?: string; + /** + * Icon displayed inside the button. + * Can be a material icon name (e.g. 'menu') or a full IconWithoutBackgroundProps object for more control. + */ + icon: string | IconWithoutBackgroundProps; + /** Label text displayed below the icon. */ + label?: string; + /** Whether the button is in a selected state. */ + selected?: boolean; + /** Whether the button is disabled. */ + disabled?: boolean; +} + +const HeaderMobileButton = React.forwardRef((props, ref) => { + const { onClick, href, icon, label, selected, disabled } = props; + + const getIcon = (icon: string | IconWithoutBackgroundProps) => { + const iconProps: IconWithoutBackgroundProps = + typeof icon === 'string' ? { name: icon } : { ...icon, className: cn(icon?.className) }; + + return ; + }; + + const innerContent = ( +
+ {getIcon(icon)} + + {label} + +
+ ); + + const className = cn(styles['tedi-header-mobile-button'], { + [styles['tedi-header-mobile-button--selected']]: selected, + [styles['tedi-header-mobile-button--disabled']]: disabled, + }); + + if (disabled || !href) { + return ( + + ); + } + + return ( + + {innerContent} + + ); +}); + +HeaderMobileButton.displayName = 'HeaderMobileButton'; + +export default HeaderMobileButton; diff --git a/src/tedi/components/layout/header/components/header-profile/header-profile.module.scss b/src/tedi/components/layout/header/components/header-profile/header-profile.module.scss new file mode 100644 index 000000000..9ae37e4ec --- /dev/null +++ b/src/tedi/components/layout/header/components/header-profile/header-profile.module.scss @@ -0,0 +1,89 @@ +.tedi-header-profile { + &__button { + [class*='tedi-btn__text'] { + padding-right: 0; + } + } + + &__button-inner { + display: flex; + gap: var(--button-md-inner-spacing); + align-items: center; + justify-content: center; + } + + &__list { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-08); + align-items: flex-start; + + [data-name='separator'] { + align-self: stretch; + width: 100%; + } + + [class*='tedi-header-logout'] [class*='tedi-icon'] { + font-size: var(--icon-02); + } + } + + &__modal { + position: fixed; + top: var(--layout-header-height); + right: 0; + z-index: var(--z-index-header); + display: flex; + flex-direction: column; + width: var(--navigation-vertical-item-width-default); + max-width: 100%; + height: calc(100dvh - var(--layout-header-height)); + overflow-y: auto; + background: var(--general-surface-primary); + border-top: 1px solid var(--general-border-primary); + + &--content { + display: flex; + flex-direction: column; + align-items: flex-start; + border-radius: 0; + + [class*='tedi-header-role__selection-body'] { + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default) var(--card-padding-md-default); + } + + &-styled > * { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: var(--layout-header-modal-item-padding); + background: var(--header-modal-item-default-background); + border-bottom: 1px solid var(--general-border-primary); + border-radius: unset; + } + } + + [class*='tedi-header-logout'] [class*='tedi-icon'] { + font-size: var(--icon-02); + } + } + + &__overlay { + position: fixed; + top: var(--layout-header-height); + left: 0; + z-index: calc(var(--z-index-header) - 1); + width: 100%; + height: calc(100dvh - var(--layout-header-height)); + background: var(--general-surface-overlay); + } + + &__icon { + transition: transform 0.2s ease-in-out; + + &--open { + transform: rotate(-180deg); + } + } +} diff --git a/src/tedi/components/layout/header/components/header-profile/header-profile.spec.tsx b/src/tedi/components/layout/header/components/header-profile/header-profile.spec.tsx new file mode 100644 index 000000000..192b571db --- /dev/null +++ b/src/tedi/components/layout/header/components/header-profile/header-profile.spec.tsx @@ -0,0 +1,252 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { HeaderProfile } from './header-profile'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../../../helpers', () => ({ + ...jest.requireActual('../../../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), + useBreakpointProps: jest.fn(), +})); + +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: jest.fn(), +})); + +describe('HeaderProfile component', () => { + const mockGetLabel = jest.fn((key: string) => key); + + const setDesktopView = () => { + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockImplementation((_bp: string, target: string) => { + if (target === 'md') return false; + if (target === 'lg') return false; + return false; + }); + }; + + const setMobileView = () => { + (useBreakpoint as jest.Mock).mockReturnValue('sm'); + (isBreakpointBelow as jest.Mock).mockImplementation((_bp: string, target: string) => { + if (target === 'md') return true; + if (target === 'lg') return true; + return true; + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + setDesktopView(); + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn((props: Record) => props), + }); + (useLabels as jest.Mock).mockReturnValue({ getLabel: mockGetLabel }); + }); + + it('renders profile button', () => { + render( + + Profile content + + ); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('renders children in dropdown on desktop when dropdown is opened', () => { + render( + + Profile menu item + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.getByText('Profile menu item')).toBeInTheDocument(); + }); + + it('renders with label when showLabel is true', () => { + render( + + Content + + ); + + expect(mockGetLabel).toHaveBeenCalledWith('header.profile'); + }); + + it('uses mobile label on mobile viewport', () => { + setMobileView(); + + render( + + Content + + ); + + expect(mockGetLabel).toHaveBeenCalledWith('header.profile.mobile'); + }); + + it('renders modal view on mobile', () => { + setMobileView(); + + const { container } = render( + + Profile content + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(container.querySelector('[class*="header-profile__modal"]')).toBeInTheDocument(); + expect(screen.getByText('Profile content')).toBeInTheDocument(); + }); + + it('closes modal view on overlay click', () => { + setMobileView(); + + const { container } = render( + + Profile content + + ); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('Profile content')).toBeInTheDocument(); + + const overlay = container.querySelector('[class*="header-profile__overlay"]'); + fireEvent.click(overlay!); + + expect(container.querySelector('[class*="header-profile__modal"]')).not.toBeInTheDocument(); + }); + + it('does not open dropdown when disabled on desktop', () => { + render( + + Profile menu item + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.queryByText('Profile menu item')).not.toBeInTheDocument(); + }); + + it('does not open modal when disabled on mobile', () => { + setMobileView(); + + const { container } = render( + + Profile content + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(container.querySelector('[class*="header-profile__modal"]')).not.toBeInTheDocument(); + }); + + describe('Accessibility', () => { + beforeEach(() => { + setMobileView(); + }); + + it('opens modal via the button directly, not a wrapping div', () => { + const { container } = render( + + Profile content + + ); + + // The button itself should trigger the modal — no non-interactive wrapper with onClick needed + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(container.querySelector('[class*="header-profile__modal"]')).toBeInTheDocument(); + }); + + it('renders modal with role="dialog" and aria-modal="true"', () => { + render( + + Profile content + + ); + + fireEvent.click(screen.getByRole('button')); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); + + it('sets aria-label on the modal', () => { + render( + + Profile content + + ); + + fireEvent.click(screen.getByRole('button')); + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'header.profile.mobile'); + }); + + it('marks the overlay as aria-hidden', () => { + const { container } = render( + + Profile content + + ); + + fireEvent.click(screen.getByRole('button')); + + const overlay = container.querySelector('[class*="header-profile__overlay"]'); + expect(overlay).toHaveAttribute('aria-hidden', 'true'); + }); + + it('closes modal on Escape key press', () => { + render( + + Profile content + + ); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('returns focus to the trigger button when modal closes via Escape', () => { + setDesktopView(); + // Use tablet view (below lg but above md) so it uses the modal path with a focusable Button + (isBreakpointBelow as jest.Mock).mockImplementation((_bp: string, target: string) => { + if (target === 'md') return false; + if (target === 'lg') return true; + return true; + }); + + render( + + Profile content + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(document.activeElement).toBe(button); + }); + }); +}); diff --git a/src/tedi/components/layout/header/components/header-profile/header-profile.tsx b/src/tedi/components/layout/header/components/header-profile/header-profile.tsx new file mode 100644 index 000000000..fb6ebc293 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-profile/header-profile.tsx @@ -0,0 +1,185 @@ +import cn from 'classnames'; +import { useEffect, useId, useRef, useState } from 'react'; + +import { + Breakpoint, + BreakpointSupport, + isBreakpointBelow, + useBreakpoint, + useBreakpointProps, +} from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { Icon } from '../../../../base/icon/icon'; +import Button from '../../../../buttons/button/button'; +import Popover from '../../../../overlays/popover/popover'; +import HeaderMobileButton from '../header-mobile-button/header-mobile-button'; +import styles from './header-profile.module.scss'; + +interface HeaderProfileBreakpointProps { + /** + * Defines the breakpoint from which the profile menu is displayed as a popover. + * Below this breakpoint, it is rendered as a modal. + * + * @default lg + */ + showPopover?: Breakpoint; + /** Custom label text for the profile button. Falls back to the `header.profile` translation key. */ + label?: string; +} + +export interface HeaderProfileProps extends BreakpointSupport { + /** Content rendered inside the profile popover or modal (e.g. navigation links, logout button). */ + children: React.ReactNode; + /** + * Whether to display a text label next to the profile icon on non-mobile viewports. + * @default false + */ + showLabel?: boolean; + /** + * Whether the profile button is disabled. Prevents opening the popover or modal. + * @default false + */ + disabled?: boolean; + /** + * Removes default item styles from the mobile modal content. + * When `true`, children are rendered without padding, borders, or background applied by the component. + * Use when the content requires custom item styling. + * @default false + */ + noStyle?: boolean; +} + +export const HeaderProfile = (props: HeaderProfileProps) => { + const { children, showLabel = false, disabled = false, noStyle = false } = props; + const { getLabel } = useLabels(); + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { showPopover = 'lg', label } = getCurrentBreakpointProps(props); + + const [popoverOpen, setPopoverOpen] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const triggerRef = useRef(null); + const modalId = useId(); + + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + + const usePopover = !isBreakpointBelow(breakpoint, showPopover); + + const resolvedLabel = label ?? (isMobileView ? getLabel('header.profile.mobile') : getLabel('header.profile')); + + useEffect(() => { + if (!modalOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setModalOpen(false); + triggerRef.current?.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [modalOpen]); + + const handleToggleModal = () => { + setModalOpen((prev) => !prev); + }; + + const isOpen = usePopover ? popoverOpen : modalOpen; + + const button = isMobileView ? ( + + ) : showLabel ? ( + + ) : ( + + ); + + return ( + <> + {usePopover ? ( + setPopoverOpen((prev) => !prev)} + > + {button} + +
{children}
+
+
+ ) : ( + <> + {button} + + {modalOpen && ( + <> +
{ + setModalOpen(false); + triggerRef.current?.focus(); + }} + aria-hidden="true" + /> + + + )} + + )} + + ); +}; + +HeaderProfile.displayName = 'Header.Profile'; + +export default HeaderProfile; diff --git a/src/tedi/components/layout/header/components/header-role/header-role-representatives.tsx b/src/tedi/components/layout/header/components/header-role/header-role-representatives.tsx new file mode 100644 index 000000000..00f698367 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-role/header-role-representatives.tsx @@ -0,0 +1,167 @@ +import cn from 'classnames'; +import React, { useId } from 'react'; + +import { useLabels } from '../../../../../providers/label-provider'; +import { Icon, IconProps } from '../../../../base/icon/icon'; +import { Text } from '../../../../base/typography/text/text'; +import { Button } from '../../../../buttons/button/button'; +import { Search } from '../../../../form/search/search'; +import Separator from '../../../../misc/separator/separator'; +import styles from './header-role.module.scss'; + +export interface Representative { + /** Unique identifier for the representative. */ + id: string; + /** Display name shown in the header and selection list. */ + name: string; + /** Additional context shown below the name (e.g. role, organization, personal code). */ + description?: string; + /** + * Icon displayed next to the representative in the selection list. Accepts either a + * Material Icon name as a string (`'person'`) for the common case, or a full + * `IconProps` object (`{ name: 'person', size: 18 }`) when explicit props are needed. + */ + icon?: string | IconProps; +} + +const resolveIcon = (icon: string | IconProps | undefined): IconProps | null => { + if (!icon) return null; + if (typeof icon === 'string') return { name: icon }; + return icon; +}; + +interface HeaderRoleRepresentativesProps { + /** Unique id for the collapsible panel, used for aria-controls on the toggle. */ + id?: string; + /** Id of the toggle button, used for aria-labelledby on the panel. */ + toggleId?: string; + /** List of representatives to display in the selection list. */ + representatives: Representative[]; + /** Currently selected representative. */ + representative?: Representative; + /** Current value of the search input. */ + inputValue: string; + /** Callback to update the search input value. */ + setInputValue: (value: string) => void; + /** Callback to update the selected representative. */ + setRepresentative: (rep: Representative) => void; + /** Callback to control the open/closed state of the role selection. */ + setIsRoleSelectionOpen: (open: boolean) => void; + /** Callback fired when the role selection is toggled. Handles both state update and external notification. */ + onRoleSelectionToggle?: () => void; + /** Whether the role selection panel is currently open. */ + isRoleSelectionOpen: boolean; + /** Whether the representatives belong to an organization context. Affects the search input label. */ + isOrganization?: boolean; + /** + * Label for the search input when selecting a representative. + * Falls back to i18n labels when not provided. + */ + searchLabel?: string; + /** + * Label for the search input when selecting an organization representative. + * Overrides both the default and `searchLabel` when `isOrganization` is true. + */ + organizationSearchLabel?: string; + /** Optional id for the search input. Falls back to a generated unique id. */ + searchId?: string; + /** Whether to keep the role selection open after selecting a representative. */ + keepOpenOnSelect?: boolean; +} + +const HeaderRoleRepresentatives = (props: HeaderRoleRepresentativesProps) => { + const { + id, + toggleId, + representatives, + inputValue, + setInputValue, + setRepresentative, + setIsRoleSelectionOpen, + onRoleSelectionToggle, + isRoleSelectionOpen, + representative, + isOrganization, + searchLabel, + organizationSearchLabel, + searchId, + keepOpenOnSelect, + } = props; + const { getLabel } = useLabels(); + + const resolvedSearchLabel = isOrganization + ? organizationSearchLabel ?? getLabel('header.role-selection.search.organizationLabel') + : searchLabel ?? getLabel('header.role-selection.search.label'); + + const handleSelect = (rep: Representative) => { + setRepresentative(rep); + setInputValue(''); + + if (!keepOpenOnSelect) { + if (isRoleSelectionOpen && onRoleSelectionToggle) { + onRoleSelectionToggle(); + } else { + setIsRoleSelectionOpen(false); + } + } + }; + + const generatedSearchId = useId(); + + return ( +
+
+
+
+ setInputValue(value)} + label={resolvedSearchLabel} + /> + {representatives.map((rep) => { + const isSelected = representative?.id === rep.id; + const icon = resolveIcon(rep.icon); + + return ( + + + + + ); + })} +
+
+
+
+ ); +}; + +HeaderRoleRepresentatives.displayName = 'HeaderRoleRepresentatives'; + +export default HeaderRoleRepresentatives; diff --git a/src/tedi/components/layout/header/components/header-role/header-role.module.scss b/src/tedi/components/layout/header/components/header-role/header-role.module.scss new file mode 100644 index 000000000..50a18c727 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-role/header-role.module.scss @@ -0,0 +1,172 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; + +.tedi-header-role { + display: flex; + flex-direction: column; + align-items: flex-start; + + [data-name='button'] { + color: var(--header-dropdown-link); + } + + &__label { + display: flex; + gap: var(--layout-grid-gutters-04); + } + + &__value { + display: flex; + gap: var(--link-inner-spacing-x); + align-items: center; + min-width: max-content; + } + + &__icon { + transition: transform 0.2s ease-in-out; + + &--open { + transform: rotate(-180deg); + } + } + + &__list { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-08); + width: 100%; + } + + &__item { + display: flex; + flex-direction: row; + justify-content: flex-start; + width: 100%; + padding: var(--card-padding-xs); + color: var(--general-text-secondary); + border-radius: var(--card-radius-rounded); + + &-inner { + display: flex; + flex-direction: row; + gap: var(--layout-grid-gutters-08); + align-items: center; + } + + &-text { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + color: var(--general-text-secondary); + } + + &--selected { + color: var(--general-text-white); + background-color: var(--header-popover-item-selected); + border: transparent; + + .tedi-header-role__item-text { + color: var(--general-text-white); + } + } + + &:not(.tedi-header-role__item--selected):hover { + color: var(--general-text-primary); + background-color: var(--header-popover-item-hover); + + .tedi-header-role__item-text { + color: var(--general-text-primary); + } + } + + &:not(.tedi-header-role__item--selected):active { + color: var(--general-text-white); + background-color: var(--header-popover-item-active); + + .tedi-header-role__item-text { + color: var(--general-text-white); + } + } + } + + &__container { + display: flex; + flex-direction: column; + width: 100%; + padding: 0; + background-color: var(--general-surface-secondary); + border-bottom: 4px solid var(--general-border-brand); + } + + &__content { + display: flex; + gap: var(--layout-grid-gutters-16); + align-items: center; + width: 100%; + padding: var(--card-padding-md-default); + + &--open { + padding: var(--card-padding-md-default) var(--card-padding-md-default) var(--card-padding-xs) + var(--card-padding-md-default); + } + + &--has-representatives { + justify-content: space-between; + } + } + + &__content--body { + display: flex; + flex-direction: column; + align-items: flex-start; + + &-inline { + flex-direction: row; + + [data-name='separator'] { + height: 1rem; + padding: 0; + margin: var(--separator-spacing-y-01) var(--separator-spacing-x-01); + } + } + } + + &__content--title { + display: flex; + flex-wrap: wrap; + gap: var(--layout-grid-gutters-04); + } + + &__content--toggle { + display: flex; + gap: var(--link-inner-spacing-x); + align-items: center; + + &-icon { + transition: transform 0.3s ease; + + &--open { + transform: rotate(-180deg); + } + } + } + + &__selection { + display: grid; + grid-template-rows: 0fr; + width: 100%; + transition: grid-template-rows 0.3s ease; + + &--open { + grid-template-rows: 1fr; + } + + &-inner { + overflow: hidden; + } + + &-body { + padding: 0; + } + } +} diff --git a/src/tedi/components/layout/header/components/header-role/header-role.spec.tsx b/src/tedi/components/layout/header/components/header-role/header-role.spec.tsx new file mode 100644 index 000000000..2a580bb33 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-role/header-role.spec.tsx @@ -0,0 +1,501 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { HeaderRole } from './header-role'; +import HeaderRoleRepresentatives, { Representative } from './header-role-representatives'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../../../helpers', () => ({ + ...jest.requireActual('../../../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), +})); + +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: jest.fn(), +})); + +const mockRepresentatives: Representative[] = [ + { id: '1', name: 'John Doe', description: 'Personal representative' }, + { id: '2', name: 'Jane Smith', description: 'Organization representative' }, + { id: '3', name: 'Bob Wilson', description: 'Another representative' }, +]; + +const singleRepresentative: Representative[] = [{ id: '1', name: 'John Doe', description: 'Personal representative' }]; + +describe('HeaderRole component', () => { + const mockGetLabel = jest.fn((key: string) => key); + + beforeEach(() => { + jest.clearAllMocks(); + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + (useLabels as jest.Mock).mockReturnValue({ getLabel: mockGetLabel }); + }); + + describe('Desktop view', () => { + it('renders the first representative name', () => { + render(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('renders label when provided', () => { + render(Acting as} />); + + expect(screen.getByText('Acting as')).toBeInTheDocument(); + }); + + it('shows description when showDescription is true', () => { + const { container } = render(); + + const labelArea = container.querySelector('[class*="header-role__label"]'); + expect(labelArea?.textContent).toContain('Personal representative'); + }); + + it('hides description when showDescription is false', () => { + const { container } = render(); + + const labelArea = container.querySelector('[class*="header-role__label"]'); + expect(labelArea?.textContent).not.toContain('Personal representative'); + }); + + it('shows expand icon when multiple representatives exist', () => { + const { container } = render(); + + expect(container.querySelector('[class*="header-role__icon"]')).toBeInTheDocument(); + }); + + it('does not show expand icon for single representative', () => { + const { container } = render(); + + expect(container.querySelector('[class*="header-role__icon"]')).not.toBeInTheDocument(); + }); + + it('opens popover when clicking representative name with multiple reps', () => { + render(); + + const trigger = screen.getByText('John Doe').closest('button'); + if (trigger) fireEvent.click(trigger); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + }); + + it('calls onRepresentativeChange when a representative is selected', () => { + const onRepresentativeChange = jest.fn(); + render(); + + const trigger = screen.getByText('John Doe').closest('button'); + if (trigger) fireEvent.click(trigger); + + const janeButton = screen.getByText('Jane Smith').closest('button'); + if (janeButton) fireEvent.click(janeButton); + + expect(onRepresentativeChange).toHaveBeenCalledWith(mockRepresentatives[1]); + }); + + it('calls onRoleSelectionToggle when popover is toggled', () => { + const onRoleSelectionToggle = jest.fn(); + render(); + + const trigger = screen.getByText('John Doe').closest('button'); + if (trigger) fireEvent.click(trigger); + + expect(onRoleSelectionToggle).toHaveBeenCalledWith(true); + }); + }); + + describe('Representative sync on prop change', () => { + it('selects the first representative on initial render', () => { + render(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('falls back to the first representative when the current one is removed', () => { + const { rerender } = render(); + + const updatedReps = [mockRepresentatives[1], mockRepresentatives[2]]; + rerender(); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + }); + + it('keeps the current representative when it still exists in the updated list', () => { + const onRepresentativeChange = jest.fn(); + const { rerender } = render( + + ); + + const trigger = screen.getByText('John Doe').closest('button'); + if (trigger) fireEvent.click(trigger); + const janeButton = screen.getByText('Jane Smith').closest('button'); + if (janeButton) fireEvent.click(janeButton); + + onRepresentativeChange.mockClear(); + + const updatedReps = [mockRepresentatives[1], mockRepresentatives[2]]; + rerender(); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(onRepresentativeChange).not.toHaveBeenCalled(); + }); + + it('clears the representative when the list becomes empty', () => { + const { rerender } = render(); + + rerender(); + + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument(); + }); + }); + + describe('Tablet/Mobile view (accordion)', () => { + beforeEach(() => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + }); + + it('renders the representative name', () => { + render(); + + expect(screen.getAllByText('John Doe').length).toBeGreaterThanOrEqual(1); + }); + + it('renders accordion toggle for multiple representatives', () => { + render(); + + expect(mockGetLabel).toHaveBeenCalledWith('header.role-selection'); + }); + + it('does not render accordion toggle for single representative', () => { + render(); + + expect(screen.queryByText('header.role-selection')).not.toBeInTheDocument(); + }); + + it('uses custom accordion labels', () => { + render( + + ); + + expect(screen.getByText('Switch role')).toBeInTheDocument(); + }); + + it('shows description with separator for single representative', () => { + const { container } = render(); + + expect(container.textContent).toContain('Personal representative'); + }); + + it('sets aria-expanded and aria-controls on the accordion toggle', () => { + render(); + + const toggle = screen.getByText('header.role-selection').closest('button')!; + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveAttribute('aria-controls'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + it('links toggle and panel via aria-controls and aria-labelledby', () => { + const { container } = render(); + + const toggle = screen.getByText('header.role-selection').closest('button')!; + const panelId = toggle.getAttribute('aria-controls'); + const panel = container.querySelector(`#${CSS.escape(panelId!)}`); + + expect(panel).toBeInTheDocument(); + expect(panel).toHaveAttribute('role', 'region'); + expect(panel).toHaveAttribute('aria-labelledby', toggle.id); + }); + }); + + describe('Search functionality', () => { + beforeEach(() => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + }); + + it('forwards searchLabel to representatives', () => { + render(); + + const toggle = screen.getByText('header.role-selection'); + fireEvent.click(toggle); + + expect(screen.getByLabelText('Find person')).toBeInTheDocument(); + }); + + it('forwards organizationSearchLabel when isOrganization', () => { + render( + + ); + + const toggle = screen.getByText('header.role-selection'); + fireEvent.click(toggle); + + expect(screen.getByLabelText('Find organization')).toBeInTheDocument(); + }); + + it('filters representatives by name when search input changes', () => { + render(); + + const toggle = screen.getByText('header.role-selection'); + fireEvent.click(toggle); + + const searchInput = screen.getByLabelText('Search'); + fireEvent.change(searchInput, { target: { value: 'Jane' } }); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.queryByText('Bob Wilson')).not.toBeInTheDocument(); + }); + + it('filters representatives by description', () => { + render(); + + const toggle = screen.getByText('header.role-selection'); + fireEvent.click(toggle); + + const searchInput = screen.getByLabelText('Search'); + fireEvent.change(searchInput, { target: { value: 'Organization' } }); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.queryByText('Bob Wilson')).not.toBeInTheDocument(); + }); + + it('shows all representatives when search input is empty', () => { + render(); + + const toggle = screen.getByText('header.role-selection'); + fireEvent.click(toggle); + + expect(screen.getAllByText('John Doe').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Wilson')).toBeInTheDocument(); + }); + }); + + describe('Representative selection closing behavior', () => { + it('closes role selection via setIsRoleSelectionOpen when selecting in desktop popover without onRoleSelectionToggle', () => { + render(); + + const trigger = screen.getByText('John Doe').closest('button'); + if (trigger) fireEvent.click(trigger); + + const janeButton = screen.getByText('Jane Smith').closest('button'); + if (janeButton) fireEvent.click(janeButton); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + }); + + it('keeps selection open with keepOpenOnSelect in tablet view', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render(); + + const toggle = screen.getByText('header.role-selection'); + fireEvent.click(toggle); + + const janeButton = screen.getByText('Jane Smith').closest('button'); + if (janeButton) fireEvent.click(janeButton); + + expect(screen.getByText('header.role-selection.close')).toBeInTheDocument(); + }); + + it('shows close label after accordion is toggled open', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + ); + + fireEvent.click(screen.getByText('Switch role')); + + expect(screen.getByText('Close selector')).toBeInTheDocument(); + }); + }); +}); + +describe('HeaderRoleRepresentatives component', () => { + const mockGetLabel = jest.fn((key: string) => key); + + beforeEach(() => { + jest.clearAllMocks(); + (useLabels as jest.Mock).mockReturnValue({ getLabel: mockGetLabel }); + }); + + it('calls setIsRoleSelectionOpen(false) when onRoleSelectionToggle is not provided', () => { + const mockSetRepresentative = jest.fn(); + const mockSetInputValue = jest.fn(); + const mockSetIsRoleSelectionOpen = jest.fn(); + + render( + + ); + + const janeButton = screen.getByText('Jane Smith').closest('button'); + fireEvent.click(janeButton!); + + expect(mockSetRepresentative).toHaveBeenCalledWith(mockRepresentatives[1]); + expect(mockSetInputValue).toHaveBeenCalledWith(''); + expect(mockSetIsRoleSelectionOpen).toHaveBeenCalledWith(false); + }); + + it('calls setIsRoleSelectionOpen(false) when isRoleSelectionOpen is false even if onRoleSelectionToggle exists', () => { + const mockSetRepresentative = jest.fn(); + const mockSetInputValue = jest.fn(); + const mockSetIsRoleSelectionOpen = jest.fn(); + const mockOnRoleSelectionToggle = jest.fn(); + + render( + + ); + + const janeButton = screen.getByText('Jane Smith').closest('button'); + fireEvent.click(janeButton!); + + expect(mockSetIsRoleSelectionOpen).toHaveBeenCalledWith(false); + expect(mockOnRoleSelectionToggle).not.toHaveBeenCalled(); + }); + + it('calls onRoleSelectionToggle when isRoleSelectionOpen is true and callback exists', () => { + const mockSetRepresentative = jest.fn(); + const mockSetInputValue = jest.fn(); + const mockSetIsRoleSelectionOpen = jest.fn(); + const mockOnRoleSelectionToggle = jest.fn(); + + render( + + ); + + const janeButton = screen.getByText('Jane Smith').closest('button'); + fireEvent.click(janeButton!); + + expect(mockOnRoleSelectionToggle).toHaveBeenCalled(); + expect(mockSetIsRoleSelectionOpen).not.toHaveBeenCalled(); + }); + + it('sets inert on the collapse container when closed', () => { + const { container } = render( + + ); + + const collapse = container.querySelector('[class*="header-role__selection"]'); + expect(collapse).toHaveAttribute('inert'); + }); + + it('does not set inert on the collapse container when open', () => { + const { container } = render( + + ); + + const collapse = container.querySelector('[class*="header-role__selection"]'); + expect(collapse).not.toHaveAttribute('inert'); + }); + + it('sets aria-current on the selected representative', () => { + render( + + ); + + const selectedButton = screen.getByText('John Doe').closest('button')!; + const unselectedButton = screen.getByText('Jane Smith').closest('button')!; + + expect(selectedButton).toHaveAttribute('aria-current', 'true'); + expect(unselectedButton).not.toHaveAttribute('aria-current'); + }); + + it('does not close when keepOpenOnSelect is true', () => { + const mockSetRepresentative = jest.fn(); + const mockSetInputValue = jest.fn(); + const mockSetIsRoleSelectionOpen = jest.fn(); + const mockOnRoleSelectionToggle = jest.fn(); + + render( + + ); + + const janeButton = screen.getByText('Jane Smith').closest('button'); + fireEvent.click(janeButton!); + + expect(mockSetRepresentative).toHaveBeenCalledWith(mockRepresentatives[1]); + expect(mockSetInputValue).toHaveBeenCalledWith(''); + expect(mockOnRoleSelectionToggle).not.toHaveBeenCalled(); + expect(mockSetIsRoleSelectionOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tedi/components/layout/header/components/header-role/header-role.tsx b/src/tedi/components/layout/header/components/header-role/header-role.tsx new file mode 100644 index 000000000..e517fb53f --- /dev/null +++ b/src/tedi/components/layout/header/components/header-role/header-role.tsx @@ -0,0 +1,232 @@ +import cn from 'classnames'; +import React, { useEffect, useId, useMemo, useState } from 'react'; + +import { Text } from '../../../../../components/base/typography/text/text'; +import { isBreakpointBelow, useBreakpoint } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { Icon } from '../../../../base/icon/icon'; +import Button from '../../../../buttons/button/button'; +import Separator from '../../../../misc/separator/separator'; +import Popover from '../../../../overlays/popover/popover'; +import styles from './header-role.module.scss'; +import HeaderRoleRepresentatives, { Representative } from './header-role-representatives'; + +export interface HeaderRoleProps { + /** + * Descriptive label rendered above the representative name (e.g. plain text, Tag, or any ReactNode). + */ + label?: React.ReactNode; + /** + * Whether to display the selected representative's description text in the header area. + * Does not affect the description shown in the selection list items. + * @default true + */ + showDescription?: boolean; + /** List of selectable representatives. */ + representatives: Representative[]; + /** Whether the role represents an organization. */ + isOrganization?: boolean; + /** Custom labels for the accordion toggle button. */ + accordionLabels?: { + open?: string; + close?: string; + }; + /** + * Label for the search input when selecting a representative. + * Falls back to i18n labels when not provided. + */ + searchLabel?: string; + /** + * Label for the search input when selecting an organization representative. + * Overrides both the default and `searchLabel` when `isOrganization` is true. + */ + organizationSearchLabel?: string; + /** Optional id for the search input. Falls back to a generated unique id. */ + searchId?: string; + /** Callback fired when the role selection accordion or popover is toggled. */ + onRoleSelectionToggle?: (isOpen: boolean) => void; + /** Callback fired when a representative is selected. */ + onRepresentativeChange?: (representative: Representative) => void; +} + +export const HeaderRole = (props: HeaderRoleProps) => { + const { + label, + showDescription = true, + representatives, + isOrganization, + accordionLabels, + searchLabel, + organizationSearchLabel, + searchId, + onRoleSelectionToggle, + onRepresentativeChange, + } = props; + const { getLabel } = useLabels(); + const roleId = useId(); + const [representative, setRepresentative] = useState(representatives?.[0]); + const [isRoleSelectionOpen, setIsRoleSelectionOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + + useEffect(() => { + if (!representatives?.length) { + setRepresentative(undefined); + return; + } + + const currentStillExists = representative && representatives.some((r) => r.id === representative.id); + + if (!currentStillExists) { + setRepresentative(representatives[0]); + } + }, [representatives]); // eslint-disable-line react-hooks/exhaustive-deps + + const breakpoint = useBreakpoint(); + const isTabletView = isBreakpointBelow(breakpoint, 'lg'); + const hasMultipleRepresentatives = (representatives?.length ?? 0) > 1; + + const filteredRepresentatives = useMemo(() => { + if (!representatives) return []; + if (!inputValue) return representatives; + + const search = inputValue.toLowerCase(); + return representatives.filter((r) => { + return r.name.toLowerCase().includes(search) || r.description?.toLowerCase().includes(search); + }); + }, [representatives, inputValue]); + + const handleToggle = () => { + const next = !isRoleSelectionOpen; + setIsRoleSelectionOpen(next); + onRoleSelectionToggle?.(next); + }; + + const handleRepresentativeChange = (rep: Representative) => { + setRepresentative(rep); + onRepresentativeChange?.(rep); + }; + + const toggleId = `${roleId}-toggle`; + const panelId = `${roleId}-panel`; + + const representativesProps = { + id: panelId, + toggleId, + representatives: filteredRepresentatives, + representative, + inputValue, + setInputValue, + setRepresentative: handleRepresentativeChange, + setIsRoleSelectionOpen, + onRoleSelectionToggle: handleToggle, + isRoleSelectionOpen, + isOrganization, + searchLabel, + organizationSearchLabel, + searchId, + }; + + if (isTabletView) { + const openLabel = accordionLabels?.open ?? getLabel('header.role-selection'); + const closeLabel = accordionLabels?.close ?? getLabel('header.role-selection.close'); + + return ( +
+
+
+
+ {label} + + {representative?.name} + +
+ {showDescription && representative?.description && ( + <> + {!hasMultipleRepresentatives && } + {representative?.description} + + )} +
+ + {hasMultipleRepresentatives && ( + + )} +
+ + {hasMultipleRepresentatives && } +
+ ); + } + + return ( +
+
+ {label} + {showDescription && !isOrganization && representative?.description && ( + + {representative?.description} + + )} +
+ + {hasMultipleRepresentatives ? ( + + + + + + + + + ) : ( +
+ {representative?.name} +
+ )} +
+ ); +}; + +HeaderRole.displayName = 'Header.Role'; + +export default HeaderRole; diff --git a/src/tedi/components/layout/header/components/header-search/header-search.module.scss b/src/tedi/components/layout/header/components/header-search/header-search.module.scss new file mode 100644 index 000000000..5b1892900 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-search/header-search.module.scss @@ -0,0 +1,44 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; + +.tedi-header-search__modal { + position: fixed; + top: 0; + left: 0; + z-index: var(--z-index-header); + width: 100%; + max-width: 100%; + height: 100dvh; + max-height: 100dvh; + padding: 0; + background: var(--modal-background); + border: 0; + + &[open] { + display: flex; + flex-direction: column; + } + + &::backdrop { + background: transparent; + } + + &-heading { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + padding: var(--modal-heading-padding-y) var(--modal-heading-padding-x); + border-bottom: 1px solid var(--modal-border-inner); + } + + &-body { + flex: 1 1 0; + padding: var(--modal-body-padding); + overflow-y: auto; + } +} + +.tedi-header-search__button-close { + padding: var(--layout-grid-gutters-08); + color: var(--button-close-text-default); +} diff --git a/src/tedi/components/layout/header/components/header-search/header-search.spec.tsx b/src/tedi/components/layout/header/components/header-search/header-search.spec.tsx new file mode 100644 index 000000000..4cc3be87c --- /dev/null +++ b/src/tedi/components/layout/header/components/header-search/header-search.spec.tsx @@ -0,0 +1,238 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { HeaderSearch } from './header-search'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../../../helpers', () => ({ + ...jest.requireActual('../../../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), + useBreakpointProps: jest.fn(), +})); + +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: jest.fn(), +})); + +beforeEach(() => { + HTMLDialogElement.prototype.showModal = jest.fn(function (this: HTMLDialogElement) { + this.setAttribute('open', ''); + }); + HTMLDialogElement.prototype.close = jest.fn(function (this: HTMLDialogElement) { + this.removeAttribute('open'); + }); +}); + +describe('HeaderSearch component', () => { + const mockGetLabel = jest.fn((key: string) => key); + + beforeEach(() => { + jest.clearAllMocks(); + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: jest.fn((props: Record) => props), + }); + (useLabels as jest.Mock).mockReturnValue({ getLabel: mockGetLabel }); + }); + + it('renders children directly on desktop', () => { + render( + + + + ); + + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + it('renders search button on mobile with modal variant', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + + + ); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('opens dialog on mobile when search button is clicked', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + + const dialog = document.querySelector('dialog'); + expect(dialog).toHaveAttribute('open'); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled(); + }); + + it('closes dialog when close button is clicked', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + + const dialog = document.querySelector('dialog')!; + expect(dialog).toHaveAttribute('open'); + + fireEvent.click(screen.getByRole('button', { name: /close/i })); + + expect(dialog).not.toHaveAttribute('open'); + }); + + it('renders children directly on mobile with inline variant', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + + + ); + + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + it('uses custom button label', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + + + ); + + expect(screen.getByText('Find')).toBeInTheDocument(); + }); + + it('uses custom modal title', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + + expect(screen.getByText('Search items')).toBeInTheDocument(); + }); + + it('falls back to i18n labels', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + + + ); + + expect(mockGetLabel).toHaveBeenCalledWith('header.search'); + }); + + it('does not open dialog when disabled on mobile', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const dialog = document.querySelector('dialog'); + expect(dialog).not.toHaveAttribute('open'); + expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled(); + }); + + describe('Accessibility', () => { + beforeEach(() => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + }); + + it('uses a native element for the modal', () => { + render( + + + + ); + + const dialog = document.querySelector('dialog'); + expect(dialog).toBeInTheDocument(); + }); + + it('opens as a modal dialog via showModal()', () => { + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled(); + }); + + it('sets aria-label on the dialog from labels.modalTitle', () => { + render( + + + + ); + + const dialog = document.querySelector('dialog'); + expect(dialog).toHaveAttribute('aria-label', 'Search items'); + }); + + it('sets aria-label on the dialog from i18n fallback', () => { + render( + + + + ); + + const dialog = document.querySelector('dialog'); + expect(dialog).toHaveAttribute('aria-label', 'header.search'); + }); + + it('syncs closed state when dialog emits close event (e.g. Escape key)', () => { + render( + + + + ); + + const triggerButton = screen.getByRole('button'); + fireEvent.click(triggerButton); + + const dialog = document.querySelector('dialog')!; + expect(dialog).toHaveAttribute('open'); + + // Simulate native Escape — browser fires the close event on the dialog + act(() => { + dialog.dispatchEvent(new Event('close')); + }); + + // After the close event, React state synced to false and the effect called dialog.close() + expect(dialog).not.toHaveAttribute('open'); + }); + }); +}); diff --git a/src/tedi/components/layout/header/components/header-search/header-search.tsx b/src/tedi/components/layout/header/components/header-search/header-search.tsx new file mode 100644 index 000000000..3c4562bcb --- /dev/null +++ b/src/tedi/components/layout/header/components/header-search/header-search.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; + +import { isBreakpointBelow, useBreakpoint } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import { Icon } from '../../../../base/icon/icon'; +import { Text } from '../../../../base/typography/text/text'; +import { Button } from '../../../../buttons/button/button'; +import HeaderMobileButton from '../header-mobile-button/header-mobile-button'; +import styles from './header-search.module.scss'; + +type MobileSearchVariant = 'modal' | 'inline'; + +export interface HeaderSearchProps { + /** Search input or form content rendered inside the search area. */ + children: React.ReactNode; + /** + * How the search is presented on mobile viewports. + * `'modal'` opens a full-screen overlay with a close button, `'inline'` renders the search directly in the header bottom area. + * @default modal + */ + mobileVariant?: MobileSearchVariant; + /** Custom label overrides for the search button and modal title. */ + mobileLabels?: { + /** Label for the mobile search toggle button. Falls back to the `header.search` translation key. */ + button?: string; + /** Title displayed at the top of the mobile search modal. Falls back to the `header.search` translation key. */ + modalTitle?: string; + }; + /** + * Whether the mobile search button is disabled. Prevents opening the search modal. + * @default false + */ + disabled?: boolean; +} + +export const HeaderSearch = (props: HeaderSearchProps) => { + const { children, mobileVariant = 'modal', mobileLabels, disabled = false } = props; + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + const { getLabel } = useLabels(); + + const [modalOpen, setModalOpen] = useState(false); + const dialogRef = useRef(null); + const showModal = isMobileView && mobileVariant === 'modal'; + + useEffect(() => { + if (!showModal) { + setModalOpen(false); + } + }, [showModal]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + if (modalOpen && !dialog.open) { + dialog.showModal(); + } else if (!modalOpen && dialog.open) { + dialog.close(); + } + + const handleClose = () => setModalOpen(false); + dialog.addEventListener('close', handleClose); + return () => dialog.removeEventListener('close', handleClose); + }, [modalOpen]); + + return ( + <> + {showModal ? ( + <> + +
+ + {mobileLabels?.modalTitle ?? getLabel('header.search')} + + +
+
{children}
+
+ setModalOpen((prev) => !prev)} + icon={{ name: 'search', size: 24, color: 'inherit' }} + label={mobileLabels?.button ?? getLabel('header.search')} + disabled={disabled} + /> + + ) : ( + <>{children} + )} + + ); +}; + +HeaderSearch.displayName = 'HeaderSearch'; + +export default HeaderSearch; diff --git a/src/tedi/components/layout/header/header.module.scss b/src/tedi/components/layout/header/header.module.scss new file mode 100644 index 000000000..68c840b41 --- /dev/null +++ b/src/tedi/components/layout/header/header.module.scss @@ -0,0 +1,113 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; + +.tedi-header { + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + + &__main { + position: relative; + display: flex; + flex: 1 0 0; + min-height: var(--layout-header-height); + overflow: auto; + background: var(--header-background); + + &--content { + display: flex; + gap: var(--layout-header-items-right-gutter-x); + align-items: center; + justify-content: space-between; + width: 100%; + min-height: var(--layout-header-height); + padding: var(--layout-header-padding-y) var(--layout-header-padding-right) var(--layout-header-padding-y) + var(--layout-header-padding-left); + } + } + + &__center { + display: flex; + flex: 1 0 0; + gap: var(--layout-header-items-center-gutter-x); + align-items: center; + align-self: stretch; + + &--flex-start { + justify-content: flex-start; + } + + &--center { + justify-content: center; + } + + &--space-between { + justify-content: space-between; + } + + a, + [data-name='link'] { + color: var(--header-link-default); + + &:hover:not(:disabled, [aria-disabled='true']) { + color: var(--header-link-hover); + } + + &:active:not(:disabled, [aria-disabled='true']) { + color: var(--header-link-active); + } + + &:focus-visible:not(:disabled) { + outline: var(--tedi-borders-02) solid var(--header-link-focus); + outline-offset: var(--tedi-borders-01); + } + } + + > * { + display: flex; + gap: var(--layout-header-items-center-gutter-x); + } + } + + &__actions { + display: flex; + gap: var(--layout-header-items-right-gutter-x); + align-items: center; + height: 100%; + margin-left: auto; + + > [data-name='separator'] { + padding: var(--layout-header-separator-padding-y) 0; + } + + @include breakpoints.media-breakpoint-only(md) { + max-height: 2.75rem; + } + } + + &__logo { + display: flex; + flex-shrink: 0; + align-items: center; + + img, + svg { + display: block; + width: auto; + height: var(--layout-header-logo-size); + max-height: 100%; + } + } + + &__icon--text { + color: inherit; + } + + &__bottom { + padding: var(--layout-grid-gutters-12) var(--layout-page-spacing-x); + background: var(--general-surface-primary); + border-top: 1px solid var(--general-border-primary); + border-bottom: 1px solid var(--general-border-primary); + + @include breakpoints.media-breakpoint-up(md) { + display: none; + } + } +} diff --git a/src/tedi/components/layout/header/header.spec.tsx b/src/tedi/components/layout/header/header.spec.tsx new file mode 100644 index 000000000..dfe262311 --- /dev/null +++ b/src/tedi/components/layout/header/header.spec.tsx @@ -0,0 +1,234 @@ +import { render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint } from '../../../helpers'; +import { useTheme } from '../../../providers/theme-provider/theme-provider'; +import { Header, HeaderActions, HeaderCenter, HeaderLogo } from './header'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../helpers', () => ({ + ...jest.requireActual('../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), +})); + +jest.mock('../../../providers/theme-provider/theme-provider', () => ({ + useTheme: jest.fn(), +})); + +describe('Header component', () => { + beforeEach(() => { + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + (useTheme as jest.Mock).mockReturnValue({ theme: 'light' }); + }); + + describe('Header', () => { + it('renders children content', () => { + render( +
+ Header content +
+ ); + + expect(screen.getByText('Header content')).toBeInTheDocument(); + }); + + it('renders a header element', () => { + render( +
+ Content +
+ ); + + expect(screen.getByRole('banner')).toBeInTheDocument(); + }); + + it('renders toggle when provided', () => { + render( +
Toggle}> + Content +
+ ); + + expect(screen.getByRole('button', { name: 'Toggle' })).toBeInTheDocument(); + }); + + it('does not render toggle when not provided', () => { + render( +
+ Content +
+ ); + + expect(screen.queryByRole('button', { name: 'Toggle' })).not.toBeInTheDocument(); + }); + + it('renders bottom content when bottom prop is provided', () => { + render( +
Bottom bar
}> + Content + + ); + + expect(screen.getByText('Bottom bar')).toBeInTheDocument(); + }); + + it('does not render bottom section when bottom is not provided', () => { + const { container } = render( +
+ Content +
+ ); + + expect(container.querySelector('[class*="header__bottom"]')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render( +
+ Content +
+ ); + + const headerDiv = container.querySelector('[class*="tedi-header"]'); + expect(headerDiv).toHaveClass('custom-header'); + }); + }); + + describe('HeaderLogo', () => { + it('renders the logo', () => { + render(} />); + + expect(screen.getByAltText('Logo')).toBeInTheDocument(); + }); + + it('wraps logo in a link when href is provided', () => { + render(} href="/home" />); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/home'); + expect(link).toContainElement(screen.getByAltText('Logo')); + }); + + it('does not wrap logo in a link when href is not provided', () => { + render(} />); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('returns null when showLogo is false', () => { + const { container } = render(} showLogo={false} />); + + expect(container).toBeEmptyDOMElement(); + }); + + it('shows logo by default (showLogo defaults to true)', () => { + render(} />); + + expect(screen.getByAltText('Logo')).toBeInTheDocument(); + }); + + it('renders dark logo when theme is dark and logoDark is provided', () => { + (useTheme as jest.Mock).mockReturnValue({ theme: 'dark' }); + + render( + } + logoDark={Dark logo} + /> + ); + + expect(screen.getByAltText('Dark logo')).toBeInTheDocument(); + expect(screen.queryByAltText('Light logo')).not.toBeInTheDocument(); + }); + + it('renders light logo when theme is dark but logoDark is not provided', () => { + (useTheme as jest.Mock).mockReturnValue({ theme: 'dark' }); + + render(} />); + + expect(screen.getByAltText('Light logo')).toBeInTheDocument(); + }); + + it('renders light logo when theme is light', () => { + (useTheme as jest.Mock).mockReturnValue({ theme: 'light' }); + + render( + } + logoDark={Dark logo} + /> + ); + + expect(screen.getByAltText('Light logo')).toBeInTheDocument(); + expect(screen.queryByAltText('Dark logo')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(Logo} className="custom-logo" />); + + expect(container.firstChild).toHaveClass('custom-logo'); + }); + }); + + describe('HeaderCenter', () => { + it('renders children content', () => { + render(Center content); + + expect(screen.getByText('Center content')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(Content); + + expect(container.firstChild).toHaveClass('custom-center'); + }); + }); + + describe('HeaderActions', () => { + it('renders children content', () => { + render(Action buttons); + + expect(screen.getByText('Action buttons')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(Actions); + + expect(container.firstChild).toHaveClass('custom-actions'); + }); + }); + + describe('Compound component assignments', () => { + it('has all expected subcomponents', () => { + expect(Header.Logo).toBeDefined(); + expect(Header.Center).toBeDefined(); + expect(Header.Actions).toBeDefined(); + expect(Header.Language).toBeDefined(); + expect(Header.Login).toBeDefined(); + expect(Header.Logout).toBeDefined(); + expect(Header.Profile).toBeDefined(); + expect(Header.Role).toBeDefined(); + expect(Header.Search).toBeDefined(); + }); + }); + + describe('displayName', () => { + it('has correct displayName for Header', () => { + expect(Header.displayName).toBe('Header'); + }); + + it('has correct displayName for HeaderLogo', () => { + expect(HeaderLogo.displayName).toBe('Header.Logo'); + }); + + it('has correct displayName for HeaderCenter', () => { + expect(HeaderCenter.displayName).toBe('Header.Center'); + }); + + it('has correct displayName for HeaderActions', () => { + expect(HeaderActions.displayName).toBe('Header.Actions'); + }); + }); +}); diff --git a/src/tedi/components/layout/header/header.stories.tsx b/src/tedi/components/layout/header/header.stories.tsx new file mode 100644 index 000000000..bfaeb6db5 --- /dev/null +++ b/src/tedi/components/layout/header/header.stories.tsx @@ -0,0 +1,949 @@ +import { useGlobals } from '@storybook/preview-api'; +import { Meta, StoryObj } from '@storybook/react'; +import { useEffect, useId, useRef, useState } from 'react'; + +import Toggle from '../../../../tedi/components/form/toggle/toggle'; +import Separator from '../../../../tedi/components/misc/separator/separator'; +import { isBreakpointBelow, useBreakpoint } from '../../../helpers'; +import { useLabels } from '../../../providers/label-provider'; +import { useTheme } from '../../../providers/theme-provider/theme-provider'; +import { Icon } from '../../base/icon/icon'; +import { Text } from '../../base/typography/text/text'; +import { Search } from '../../form/search/search'; +import Link from '../../navigation/link/link'; +import { Tag } from '../../tags/tag/tag'; +import { HideAt } from '../hide-at/hide-at'; +import { ShowAt } from '../show-at/show-at'; +import { SideNav } from '../sidenav'; +import { Representative } from './components/header-role/header-role-representatives'; +import { Header, HeaderActions, HeaderCenter, HeaderLogo, HeaderLogoProps } from './header'; + +const STORAGE_KEY = 'tedi-theme'; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ +const meta: Meta = { + title: 'TEDI-Ready/Layout/Header', + component: Header, + subcomponents: { + 'Header.Logo': HeaderLogo, + 'Header.Center': HeaderCenter, + 'Header.Actions': HeaderActions, + 'Header.Language': Header.Language, + 'Header.Login': Header.Login, + 'Header.Logout': Header.Logout, + 'Header.Profile': Header.Profile, + 'Header.Role': Header.Role, + 'Header.Search': Header.Search, + } as never, + decorators: [ + (Story) => { + const [globals, updateGlobals] = useGlobals(); + const originalThemeRef = useRef(null); + + useEffect(() => { + originalThemeRef.current = localStorage.getItem(STORAGE_KEY); + + if (originalThemeRef.current && globals.theme !== originalThemeRef.current) { + updateGlobals({ theme: originalThemeRef.current }); + } + + return () => { + if (originalThemeRef.current !== null) { + localStorage.setItem(STORAGE_KEY, originalThemeRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ; + }, + ], + parameters: { + layout: 'fullscreen', + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?m=dev&node-id=6380-53060', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const languages = [ + { + 'aria-label': 'Estonian', + label: 'EST', + locale: 'et' as const, + }, + { + 'aria-label': 'English', + label: 'ENG', + locale: 'en' as const, + }, + { + 'aria-label': 'Russian', + label: 'RUS', + locale: 'ru' as const, + }, +]; + +const representatives: Representative[] = [ + { id: '1', name: 'Mari Maasikas', description: '49504080934', icon: { name: 'person' } }, + { id: '2', name: 'Juulia Sarapuu', description: 'Peasekretär', icon: { name: 'supervised_user_circle' } }, + { id: '3', name: 'Marta Sarapuu', description: 'Sekretär', icon: { name: 'supervised_user_circle' } }, + { id: '4', name: 'Helgi Sarapuu', description: 'Jurist', icon: { name: 'supervised_user_circle' } }, +]; + +const loggedInNavItems = [ + { + children: 'Minu andmed', + icon: 'account_circle', + subItemGroups: [ + { + subItems: [ + { + children: 'Seadistused', + href: '#', + }, + { + children: 'Esindusõigused', + href: '#', + }, + { + children: 'Minu seotud isikud', + href: '#', + }, + ], + }, + ], + }, + { + children: 'Minu töölaud', + icon: 'dashboard', + href: '#', + }, + { + children: 'Vastuvõtud ja saatekirjad', + icon: 'calendar_today', + href: '#', + }, + { + children: 'Retseptid ja meditsiiniseadmed', + icon: 'medication', + href: '#', + }, + { + children: 'Minu tervise ajalugu', + icon: 'history', + href: '#', + }, + { + children: 'Hammaste tervis', + icon: 'dentistry', + href: '#', + }, + { + children: 'Vaktsineerimine', + icon: 'vaccines', + href: '#', + }, + { + children: 'Töövõime', + icon: 'business_center', + href: '#', + }, + { + children: 'Raviarved', + icon: 'credit_card', + href: '#', + }, +]; + +const representatives2 = [{ id: '1', name: 'Mari Maasikas', description: '49504080934' }]; + +const organizations = [ + { id: 'org-1', name: 'Pärnu linnavolikogu' }, + { id: 'org-2', name: 'Tartu Linnavalitsus' }, +]; +const organizations2 = [{ id: 'org-2', name: 'Tartu Linnavalitsus' }]; + +const logo = Logo; +const logoDark = Logo (Dark Mode); + +const profileTranslations = { + myData: { et: 'Minu andmed', en: 'My data', ru: 'Мои данные' }, + representatives: { et: 'Esindatavad', en: 'Representatives', ru: 'Представители' }, + contacts: { et: 'Kontaktid', en: 'Contacts', ru: 'Контакты' }, + darkMode: { et: 'Tume režiim', en: 'Dark mode', ru: 'Тёмная тема' }, + notifications: { et: 'Riiklikud teated', en: 'National notifications', ru: 'Государственные уведомления' }, + accessibility: { et: 'Ligipääsetavus', en: 'Accessibility', ru: 'Доступность' }, + home: { et: 'Avaleht', en: 'Home', ru: 'Главная' }, + services: { et: 'Teenused', en: 'Services', ru: 'Услуги' }, + blog: { et: 'Blogi', en: 'Blog', ru: 'Блог' }, + contact: { et: 'Kontakt', en: 'Contact', ru: 'Контакт' }, +} as const; + +const ProfileExample = () => { + const { theme, setTheme } = useTheme(); + const { locale = 'en' } = useLabels(); + const id = useId(); + + const t = (key: keyof typeof profileTranslations) => profileTranslations[key][locale] ?? profileTranslations[key].et; + + const handleToggle = () => { + setTheme(theme === 'dark' ? 'default' : 'dark'); + }; + + return ( + <> + + {t('myData')} + + + {t('representatives')} + + + {t('contacts')} + + + + + +
+ +
+ + + + + +
+ + {t('notifications')} +
+ + + + + + + ); +}; + +const AccessibilityLink = () => { + const { locale = 'en' } = useLabels(); + const label = profileTranslations.accessibility[locale] ?? profileTranslations.accessibility.et; + + return ( + +
+ {label} + +
+ + ); +}; + +const NavigationLinks = () => { + const { locale = 'en' } = useLabels(); + const pt = (key: keyof typeof profileTranslations) => profileTranslations[key][locale] ?? profileTranslations[key].et; + + return ( + <> + + {pt('home')} + + + {pt('services')} + + + {pt('blog')} + + + {pt('contact')} + + + ); +}; + +const NavigationSideNav = ({ isMobileOpen }: { isMobileOpen: boolean }) => { + const { locale = 'en' } = useLabels(); + const pt = (key: keyof typeof profileTranslations) => profileTranslations[key][locale] ?? profileTranslations[key].et; + + return ( + + ); +}; + +type StoryWrapperProps = { + children: (args: { isOpen: boolean; setIsOpen: React.Dispatch> }) => React.ReactNode; +}; + +const StoryWrapper = ({ children }: StoryWrapperProps) => { + const [isOpen, setIsOpen] = useState(false); + + return <>{children({ isOpen, setIsOpen })}; +}; + +const SidenavLayout = ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) => { + const breakpoint = useBreakpoint(); + const isMobile = isBreakpointBelow(breakpoint, 'lg'); + const fullHeight = isMobile ? isOpen : true; + + return ( +
{children}
+ ); +}; + +const ResponsiveLogo = (props: HeaderLogoProps) => { + const query = '(min-width: 420px)'; + + const getMatches = () => (typeof window !== 'undefined' ? window.matchMedia(query).matches : true); + + const [show, setShow] = useState(getMatches); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const mql = window.matchMedia(query); + const handler = (e: MediaQueryListEvent) => setShow(e.matches); + + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, [query]); + + return ; +}; + +export const Default: Story = { + render: () => ( + + {({ isOpen, setIsOpen }) => ( +
+
setIsOpen(!isOpen)} />}> + + + + + Link text + + + Link text + + + Link text + + + + + + + + +
+ + +
+ +
+
+
+ )} +
+ ), +}; + +export const LoggedOut1: Story = { + render: () => ( + + {({ isOpen, setIsOpen }) => ( +
+
setIsOpen(!isOpen)} />}> + + + + + + Link text + + + Link text + + + Link text + + + Link text + + + Link text + + + + + + + + + +
+ + +
+ +
+
+
+ )} +
+ ), +}; + +export const LoggedOut2: Story = { + render: () => ( + + {({ isOpen, setIsOpen }) => ( +
+
setIsOpen(!isOpen)} />}> + + + + +
+ +
+ +
+ +
+
+
+
+ + + + + + + + + + + + +
+ + +
+ +
+
+
+ )} +
+ ), +}; + +export const LoggedIn1: Story = { + render: () => ( +
+ + + + + + + Roll: + + } + representatives={representatives} + /> + + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + + +
+ ), +}; + +export const LoggedIn2: Story = { + render: () => ( +
+ + + + + + Esindatav} showDescription={false} representatives={representatives} /> + + + + + + + Esindatav:} showDescription={false} representatives={representatives} /> + + + + + +
+ ), +}; + +export const WithOrganizationSelection1: Story = { + render: () => ( +
+ + + + + + + Asutus + + } + representatives={organizations} + isOrganization + /> + + + Roll: + + } + representatives={representatives} + /> + + + + + + + + Asutus: + + } + representatives={organizations} + isOrganization + /> + + Roll: + + } + representatives={representatives} + /> + + + + + +
+ ), +}; + +export const WithOrganizationSelection2: Story = { + render: () => ( +
+ + + + + + + Asutus + + } + representatives={organizations2} + /> + + + + + + + + Asutus: + + } + representatives={organizations2} + isOrganization + /> + + + + + +
+ ), +}; + +export const AlternativeProfileAndLogoutButton1: Story = { + render: () => ( +
+ + + + + + + Asutus + + } + representatives={organizations} + isOrganization + /> + + + Isikukood: + + } + representatives={representatives} + /> + + + + + + + + Asutus: + + } + representatives={organizations} + isOrganization + /> + + Roll: + + } + representatives={representatives} + /> + + + + + +
+ ), +}; + +export const AlternativeProfileAndLogoutButton2: Story = { + render: () => ( +
+ + + + + + + Isikukood: + + } + representatives={representatives} + /> + + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + + +
+ ), +}; + +export const AlternativeProfileAndLogoutButton3: Story = { + render: () => ( +
+ + + + + + + + + + + + +
+ ), +}; + +export const AlternativeProfileAndLogoutButton4: Story = { + render: () => ( +
+ + + + + + + Asutus + + } + representatives={organizations} + isOrganization + /> + + + + + + + + Asutus: + + } + representatives={organizations} + isOrganization + /> + + + + + + +
+ ), +}; + +export const WithSearch1: Story = { + render: () => ( +
+ + + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + +
+ ), +}; + +export const WithSearch2: Story = { + render: () => ( +
+ + + } + > + + + + + + + + + + + + + + + + + + + + + + + +
+ ), +}; + +export const LoggedInWithSidenav: Story = { + render: () => ( + + {({ isOpen, setIsOpen }) => ( + +
setIsOpen(!isOpen)} />}> + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + + +
+ +
+ +
+
+ )} +
+ ), +}; diff --git a/src/tedi/components/layout/header/header.tsx b/src/tedi/components/layout/header/header.tsx new file mode 100644 index 000000000..55d1425d8 --- /dev/null +++ b/src/tedi/components/layout/header/header.tsx @@ -0,0 +1,142 @@ +import cn from 'classnames'; +import React from 'react'; + +import { useTheme } from '../../../providers/theme-provider/theme-provider'; +import Print from '../../misc/print/print'; +import HeaderLanguage from './components/header-language/header-language'; +import HeaderLogin from './components/header-login/header-login'; +import HeaderLogout from './components/header-logout/header-logout'; +import HeaderProfile from './components/header-profile/header-profile'; +import HeaderRole from './components/header-role/header-role'; +import HeaderSearch from './components/header-search/header-search'; +import styles from './header.module.scss'; + +export interface HeaderProps { + /** + * Content rendered inside the header, typically Header.Logo, Header.Center, and Header.Actions subcomponents. + */ + children: React.ReactNode; + /** + * Toggle element for the mobile side navigation menu. + * Typically a SideNav.Toggle component. + */ + toggle?: React.ReactNode; + /** + * Content rendered below the main header bar on mobile viewports (below `md` breakpoint). + * Commonly used for a mobile-specific search bar or other compact navigation elements. + */ + bottom?: React.ReactNode; + /** Additional CSS class name applied to the header wrapper. */ + className?: string; +} + +export const Header = (props: HeaderProps) => { + const { children, toggle, bottom, className } = props; + + return ( + +
+
+ {toggle} +
{children}
+
+ + {bottom &&
{bottom}
} +
+
+ ); +}; + +Header.displayName = 'Header'; + +export interface HeaderLogoProps { + /** + * The default logo to display (typically used in light theme). + */ + logo: React.ReactNode; + /** + * Optional logo variant for dark theme. + * If provided, it will be used when the current theme is dark. + */ + logoDark?: React.ReactNode; + /** + * Controls visibility of the logo. + * Useful for conditionally hiding the logo based on application state, feature flags, + * or custom media queries that fall between standard breakpoints (e.g. 420px). + * For responsive hiding at standard breakpoints, prefer wrapping Header.Logo with HideAt/ShowAt. + * @default true + */ + showLogo?: boolean; + /** + * Optional link URL. + * If provided, the logo will be wrapped in an anchor element. + */ + href?: string; + /** Additional CSS class name applied to the logo wrapper. */ + className?: string; +} + +export const HeaderLogo = (props: HeaderLogoProps) => { + const { logo, logoDark, href, showLogo = true, className } = props; + const { theme } = useTheme(); + + const resolvedLogo = theme === 'dark' && logoDark ? logoDark : logo; + + if (!showLogo) return null; + + const content = href ? {resolvedLogo} : resolvedLogo; + + return
{content}
; +}; + +HeaderLogo.displayName = 'Header.Logo'; + +export interface HeaderCenterProps { + /** Content rendered in the center area of the header, typically navigation links or a search bar. */ + children: React.ReactNode; + /** + * Controls the horizontal alignment of center content. + * @default center + */ + alignment?: 'flex-start' | 'center' | 'space-between'; + /** Additional CSS class name applied to the center content area. */ + className?: string; +} + +export const HeaderCenter = (props: HeaderCenterProps) => { + const { children, className, alignment = 'center' } = props; + + return ( +
+ {children} +
+ ); +}; + +HeaderCenter.displayName = 'Header.Center'; + +export interface HeaderActionsProps { + /** Action elements rendered on the right side of the header (e.g. language selector, login, profile). */ + children: React.ReactNode; + /** Additional CSS class name applied to the actions wrapper. */ + className?: string; +} + +export const HeaderActions = (props: HeaderActionsProps) => { + const { children, className } = props; + return
{children}
; +}; + +HeaderActions.displayName = 'Header.Actions'; + +Header.Logo = HeaderLogo; +Header.Center = HeaderCenter; +Header.Actions = HeaderActions; +Header.Language = HeaderLanguage; +Header.Login = HeaderLogin; +Header.Logout = HeaderLogout; +Header.Profile = HeaderProfile; +Header.Role = HeaderRole; +Header.Search = HeaderSearch; + +export default Header; diff --git a/src/tedi/components/layout/header/index.ts b/src/tedi/components/layout/header/index.ts new file mode 100644 index 000000000..54cc85f6b --- /dev/null +++ b/src/tedi/components/layout/header/index.ts @@ -0,0 +1,7 @@ +export * from './header'; +export * from './components/header-language/header-language'; +export * from './components/header-login/header-login'; +export * from './components/header-logout/header-logout'; +export * from './components/header-profile/header-profile'; +export * from './components/header-role/header-role'; +export * from './components/header-search/header-search'; diff --git a/src/tedi/components/layout/hide-at/hide-at.spec.tsx b/src/tedi/components/layout/hide-at/hide-at.spec.tsx new file mode 100644 index 000000000..4a437ad77 --- /dev/null +++ b/src/tedi/components/layout/hide-at/hide-at.spec.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint } from '../../../helpers'; +import { HideAt } from './hide-at'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../helpers', () => ({ + ...jest.requireActual('../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), +})); + +describe('HideAt component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders children when no breakpoint matches', () => { + (useBreakpoint as jest.Mock).mockReturnValue('sm'); + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + Visible content + + ); + + expect(screen.getByText('Visible content')).toBeInTheDocument(); + }); + + it('hides children when current breakpoint is at or above the specified breakpoint', () => { + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + + render( + + Hidden content + + ); + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); + }); + + it('renders children when breakpoint prop is false', () => { + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + + render( + + Visible content + + ); + + expect(screen.getByText('Visible content')).toBeInTheDocument(); + }); + + it('hides when any of multiple breakpoints match', () => { + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValueOnce(true).mockReturnValueOnce(false); + + render( + + Hidden content + + ); + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); + }); + + it('hides children when all specified breakpoints are below the current breakpoint', () => { + (useBreakpoint as jest.Mock).mockReturnValue('xxl'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + + render( + + Hidden content + + ); + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); + }); + + it('renders children when current breakpoint is xs (SSR default)', () => { + (useBreakpoint as jest.Mock).mockReturnValue('xs'); + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + Visible content + + ); + + expect(screen.getByText('Visible content')).toBeInTheDocument(); + }); + + it('renders children when no breakpoint props are passed', () => { + (useBreakpoint as jest.Mock).mockReturnValue('md'); + + render( + + Always visible + + ); + + expect(screen.getByText('Always visible')).toBeInTheDocument(); + }); +}); diff --git a/src/tedi/components/layout/hide-at/hide-at.stories.tsx b/src/tedi/components/layout/hide-at/hide-at.stories.tsx new file mode 100644 index 000000000..ccfb06207 --- /dev/null +++ b/src/tedi/components/layout/hide-at/hide-at.stories.tsx @@ -0,0 +1,95 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { Text } from '../../base/typography/text/text'; +import { HideAt } from './hide-at'; + +const meta: Meta = { + component: HideAt, + title: 'TEDI-Ready/Layout/HideAt', + parameters: { + status: { + type: ['devComponent'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const Box = ({ children, color = '#e8f4fd' }: { children: React.ReactNode; color?: string }) => ( +
+ {children} +
+); + +const Description = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +export const Default: Story = { + render: () => ( +
+ + The colored box below is wrapped in HideAt md. It is hidden at the md breakpoint and above. Resize the viewport + below md to see it appear. + + + This content is hidden at md and above. + +
+ ), +}; + +export const MultipleBreakpoints: Story = { + render: () => ( +
+ + The colored box below is wrapped in HideAt sm lg. It is hidden at sm and above, and also at lg and above. It is + only visible below sm (xs). + + + This content is hidden at sm and above, and also at lg and above. + +
+ ), +}; + +const BreakpointOverviewTemplate: StoryFn = () => ( +
+ + Each colored box is hidden at the specified breakpoint and above. Resize the viewport to see boxes appear as you + go below their threshold. + + + Hidden at xs and above + + + Hidden at sm and above + + + Hidden at md and above + + + Hidden at lg and above + + + Hidden at xl and above + + + Hidden at xxl and above + +
+); + +export const BreakpointOverview: Story = { + render: BreakpointOverviewTemplate, +}; diff --git a/src/tedi/components/layout/hide-at/hide-at.tsx b/src/tedi/components/layout/hide-at/hide-at.tsx new file mode 100644 index 000000000..110cc351b --- /dev/null +++ b/src/tedi/components/layout/hide-at/hide-at.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Breakpoint, isBreakpointBelow, useBreakpoint } from '../../../helpers'; + +type HideAtProps = { + children: React.ReactNode; +} & Partial>; + +export const HideAt = ({ children, ...breakpoints }: HideAtProps) => { + const current = useBreakpoint(); + + const shouldHide = Object.entries(breakpoints).some(([bp, value]) => { + if (!value) return false; + return !isBreakpointBelow(current, bp as Breakpoint); + }); + + if (shouldHide) return null; + + return <>{children}; +}; + +HideAt.displayName = 'HideAt'; diff --git a/src/tedi/components/layout/show-at/show-at.spec.tsx b/src/tedi/components/layout/show-at/show-at.spec.tsx new file mode 100644 index 000000000..3c234acd5 --- /dev/null +++ b/src/tedi/components/layout/show-at/show-at.spec.tsx @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/react'; + +import { isBreakpointBelow, useBreakpoint } from '../../../helpers'; +import { ShowAt } from './show-at'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../helpers', () => ({ + ...jest.requireActual('../../../helpers'), + useBreakpoint: jest.fn(), + isBreakpointBelow: jest.fn(), +})); + +describe('ShowAt component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows children when current breakpoint is at or above the specified breakpoint', () => { + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + + render( + + Visible content + + ); + + expect(screen.getByText('Visible content')).toBeInTheDocument(); + }); + + it('hides children when current breakpoint is below the specified breakpoint', () => { + (useBreakpoint as jest.Mock).mockReturnValue('sm'); + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + Hidden content + + ); + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); + }); + + it('hides children when breakpoint prop is false', () => { + (useBreakpoint as jest.Mock).mockReturnValue('lg'); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + + render( + + Hidden content + + ); + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); + }); + + it('shows when any of multiple breakpoints match', () => { + (useBreakpoint as jest.Mock).mockReturnValue('md'); + (isBreakpointBelow as jest.Mock).mockReturnValueOnce(true).mockReturnValueOnce(false); + + render( + + Visible content + + ); + + expect(screen.getByText('Visible content')).toBeInTheDocument(); + }); + + it('hides children when all specified breakpoints are above current', () => { + (useBreakpoint as jest.Mock).mockReturnValue('xs'); + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( + + Hidden content + + ); + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); + }); + + it('hides children when no breakpoint props are passed', () => { + (useBreakpoint as jest.Mock).mockReturnValue('md'); + + render( + + Hidden content + + ); + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); + }); +}); diff --git a/src/tedi/components/layout/show-at/show-at.stories.tsx b/src/tedi/components/layout/show-at/show-at.stories.tsx new file mode 100644 index 000000000..0de35497c --- /dev/null +++ b/src/tedi/components/layout/show-at/show-at.stories.tsx @@ -0,0 +1,95 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { Text } from '../../base/typography/text/text'; +import { ShowAt } from './show-at'; + +const meta: Meta = { + component: ShowAt, + title: 'TEDI-Ready/Layout/ShowAt', + parameters: { + status: { + type: ['devComponent'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const Box = ({ children, color = '#e8f4fd' }: { children: React.ReactNode; color?: string }) => ( +
+ {children} +
+); + +const Description = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +export const Default: Story = { + render: () => ( +
+ + The colored box below is wrapped in ShowAt md. It is only visible at the md breakpoint and above. Resize the + viewport below md to see it disappear. + + + This content is only visible at md and above. + +
+ ), +}; + +export const MultipleBreakpoints: Story = { + render: () => ( +
+ + The colored box below is wrapped in ShowAt sm lg. It is visible at sm and above, or at lg and above. It is + hidden below sm (xs). + + + This content is visible at sm and above, or at lg and above. + +
+ ), +}; + +const BreakpointOverviewTemplate: StoryFn = () => ( +
+ + Each colored box is visible at the specified breakpoint and above. Resize the viewport to see boxes disappear as + you go below their threshold. + + + Visible at xs and above + + + Visible at sm and above + + + Visible at md and above + + + Visible at lg and above + + + Visible at xl and above + + + Visible at xxl and above + +
+); + +export const BreakpointOverview: Story = { + render: BreakpointOverviewTemplate, +}; diff --git a/src/tedi/components/layout/show-at/show-at.tsx b/src/tedi/components/layout/show-at/show-at.tsx new file mode 100644 index 000000000..097cc2c9a --- /dev/null +++ b/src/tedi/components/layout/show-at/show-at.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Breakpoint, isBreakpointBelow, useBreakpoint } from '../../../helpers'; + +type ShowAtProps = { + children: React.ReactNode; +} & Partial>; + +export const ShowAt = ({ children, ...breakpoints }: ShowAtProps) => { + const current = useBreakpoint(); + + const shouldShow = Object.entries(breakpoints).some(([bp, value]) => { + if (!value) return false; + return !isBreakpointBelow(current, bp as Breakpoint); + }); + + if (!shouldShow) return null; + + return <>{children}; +}; + +ShowAt.displayName = 'ShowAt'; diff --git a/src/tedi/components/layout/sidenav/components/sidenav-toggle/sidenav-toggle.module.scss b/src/tedi/components/layout/sidenav/components/sidenav-toggle/sidenav-toggle.module.scss index d6c0ca9c0..413513ada 100644 --- a/src/tedi/components/layout/sidenav/components/sidenav-toggle/sidenav-toggle.module.scss +++ b/src/tedi/components/layout/sidenav/components/sidenav-toggle/sidenav-toggle.module.scss @@ -7,13 +7,19 @@ $toggle-button-width: 3.5rem; z-index: var(--z-index-sidenav); width: $toggle-button-width; height: $toggle-button-height; + padding: 1rem; margin: 0; + border: 0; border-radius: 0; @include breakpoints.media-breakpoint-up(lg) { display: none; } + &__icon { + color: var(--button-main-primary-text-default); + } + &.tedi-sidenav-toggle--collapse { position: absolute; top: calc(var(--navigation-vertical-item-min-height-medium) / 5.5); diff --git a/src/tedi/components/layout/sidenav/sidenav.module.scss b/src/tedi/components/layout/sidenav/sidenav.module.scss index f6c569cdc..52bbdab48 100644 --- a/src/tedi/components/layout/sidenav/sidenav.module.scss +++ b/src/tedi/components/layout/sidenav/sidenav.module.scss @@ -253,7 +253,7 @@ } } -.tedi-sidenav__item--mobile { +.tedi-sidenav__item--mobile:not(:last-child) { border-bottom: var(--tedi-borders-01) solid var(--navigation-vertical-item-border); } diff --git a/src/tedi/components/overlays/overlay/overlay.tsx b/src/tedi/components/overlays/overlay/overlay.tsx index 205db5fe1..dea295d26 100644 --- a/src/tedi/components/overlays/overlay/overlay.tsx +++ b/src/tedi/components/overlays/overlay/overlay.tsx @@ -103,6 +103,13 @@ export interface OverlayProps { * @default false */ trackReferencePosition?: boolean; + /** + * Minimum distance (in px) between the arrow and the edges of the content. + * Helps keep the arrow away from rounded corners, especially on `-start` and `-end` placements. + * Use a larger value for bigger arrows or arrows with borders. + * @default 4 + */ + arrowPadding?: number; } export interface OverlayContextType { @@ -173,6 +180,7 @@ export const Overlay = (props: OverlayProps) => { role = 'tooltip', arrowDimensions, offset: offsetOptions = GAP + (arrowDimensions?.height ?? 0), + arrowPadding = 4, focusManager, dismissible, scrollLock, @@ -212,7 +220,7 @@ export const Overlay = (props: OverlayProps) => { shift({ padding: 8 }), arrow({ element: arrowRef, - padding: 4, + padding: arrowPadding, }), ], whileElementsMounted: trackReferencePosition diff --git a/src/tedi/components/overlays/popover/index.ts b/src/tedi/components/overlays/popover/index.ts index 0597055ad..f19260099 100644 --- a/src/tedi/components/overlays/popover/index.ts +++ b/src/tedi/components/overlays/popover/index.ts @@ -1,3 +1,4 @@ export * from './popover'; export * from './popover-content'; +export * from './popover-context'; export * from './popover-trigger'; diff --git a/src/tedi/components/overlays/popover/popover-content.tsx b/src/tedi/components/overlays/popover/popover-content.tsx index 0dedfac18..4d9cb6cc6 100644 --- a/src/tedi/components/overlays/popover/popover-content.tsx +++ b/src/tedi/components/overlays/popover/popover-content.tsx @@ -7,6 +7,7 @@ import ClosingButton, { ClosingButtonProps } from '../../buttons/closing-button/ import { OverlayContext } from '../overlay/overlay'; import { OverlayContent, OverlayContentProps } from '../overlay/overlay-content'; import styles from './popover.module.scss'; +import { PopoverContext } from './popover-context'; export interface PopoverContentProps extends Omit { /** @@ -34,7 +35,7 @@ export interface PopoverContentProps extends Omit { @@ -48,6 +49,10 @@ export const PopoverContent = (props: PopoverContentProps) => { closeProps = { size: 'default' }, } = props; const { onOpenChange } = useContext(OverlayContext); + // `withBorder` is owned by because it also influences floating-ui + // arrow padding, not just styling. Reading it from context keeps the two + // concerns in sync. + const { withBorder } = useContext(PopoverContext); const titleId = useId(); const hasDescription = Boolean(children); const descriptionId = useId(); @@ -55,8 +60,15 @@ export const PopoverContent = (props: PopoverContentProps) => { return ( { ); }; + +PopoverContent.displayName = 'PopoverContent'; diff --git a/src/tedi/components/overlays/popover/popover-context.tsx b/src/tedi/components/overlays/popover/popover-context.tsx new file mode 100644 index 000000000..48ba22913 --- /dev/null +++ b/src/tedi/components/overlays/popover/popover-context.tsx @@ -0,0 +1,14 @@ +import { createContext } from 'react'; + +export interface PopoverContextValue { + /** + * If true, the popover is rendered with an illustrative border on the arrow + * side. Consumed by `Popover.Content` to apply the `--border` modifier + * classes. + */ + withBorder: boolean; +} + +export const PopoverContext = createContext({ + withBorder: false, +}); diff --git a/src/tedi/components/overlays/popover/popover.module.scss b/src/tedi/components/overlays/popover/popover.module.scss index 6aceedab3..aca5c5398 100644 --- a/src/tedi/components/overlays/popover/popover.module.scss +++ b/src/tedi/components/overlays/popover/popover.module.scss @@ -27,6 +27,63 @@ $popover-width: ( clip-path: inset(0 -5px -5px -5px); filter: drop-shadow(0 0 5px var(--tedi-alpha-20)); fill: var(--popover-background); + + &--border { + box-sizing: border-box; + width: 1.5rem; + height: 1.5rem; + clip-path: polygon(100% 0, 100% 100%, 0 100%); + background: var(--popover-background); + border-right: 4px solid var(--header-popover-border-top); + border-bottom: 4px solid var(--header-popover-border-top); + fill: transparent; + } + } + + &[data-placement^='bottom'] { + &.tedi-popover--border { + border-top: 4px solid var(--header-popover-border-top); + } + + .tedi-popover__arrow--border { + top: -0.75rem; + transform: rotate(-135deg) !important; + } + } + + &[data-placement^='top'] { + &.tedi-popover--border { + border-bottom: 4px solid var(--header-popover-border-top); + } + + .tedi-popover__arrow--border { + top: auto !important; + bottom: -0.75rem; + transform: rotate(45deg) !important; + } + } + + &[data-placement^='right'] { + &.tedi-popover--border { + border-left: 4px solid var(--header-popover-border-top); + } + + .tedi-popover__arrow--border { + left: -0.75rem; + transform: rotate(135deg) !important; + } + } + + &[data-placement^='left'] { + &.tedi-popover--border { + border-right: 4px solid var(--header-popover-border-top); + } + + .tedi-popover__arrow--border { + right: -0.75rem; + left: auto !important; + transform: rotate(-45deg) !important; + } } &__header { diff --git a/src/tedi/components/overlays/popover/popover.stories.tsx b/src/tedi/components/overlays/popover/popover.stories.tsx index a77658489..5eeb12bae 100644 --- a/src/tedi/components/overlays/popover/popover.stories.tsx +++ b/src/tedi/components/overlays/popover/popover.stories.tsx @@ -419,6 +419,13 @@ export const ArrowPosition: Story = { args: {}, }; +export const WithBorder: Story = { + render: ArrowPositionTemplate, + args: { + withBorder: true, + }, +}; + export const Size: Story = { render: SizeTemplate, args: {}, @@ -497,7 +504,7 @@ export const FocusLocked: Story = { docs: { description: { story: ` - This story demonstrates a Popover with a “locked” focus behavior, where keyboard navigation (Tab) is confined + This story demonstrates a Popover with a “locked” focus behavior, where keyboard navigation (Tab) is confined to the Popover content until the user clicks an action like "Cancel" or "Submit". Key points: diff --git a/src/tedi/components/overlays/popover/popover.tsx b/src/tedi/components/overlays/popover/popover.tsx index 0b933c717..a9e0e272a 100644 --- a/src/tedi/components/overlays/popover/popover.tsx +++ b/src/tedi/components/overlays/popover/popover.tsx @@ -2,12 +2,15 @@ import { OffsetOptions } from '@floating-ui/react'; import Overlay, { OverlayOpenWith, OverlayProps } from '../overlay/overlay'; import { PopoverContent } from './popover-content'; +import { PopoverContext } from './popover-context'; import { PopoverTrigger } from './popover-trigger'; const ARROW_WIDTH = 34 as const; const ARROW_HEIGHT = 17 as const; +const ARROW_PADDING_BORDERED = 12 as const; +const ARROW_PADDING_DEFAULT = 4 as const; -export interface PopoverProps extends Omit { +export interface PopoverProps extends Omit { /** * Adds correct event listeners that change the open state. * @default click @@ -18,21 +21,31 @@ export interface PopoverProps extends Omit { - const { openWith = 'click', ...rest } = props; + const { openWith = 'click', withBorder = false, ...rest } = props; return ( - + + + ); }; diff --git a/src/tedi/providers/label-provider/label-provider.tsx b/src/tedi/providers/label-provider/label-provider.tsx index cd3b45d74..7639d48e0 100644 --- a/src/tedi/providers/label-provider/label-provider.tsx +++ b/src/tedi/providers/label-provider/label-provider.tsx @@ -42,6 +42,8 @@ export interface ILabelContext { key: TKey, ...args: TArgs ): string; + setLocale: (locale: TediLanguage) => void; + locale: TediLanguage; } export const LabelContext = React.createContext({ @@ -51,6 +53,12 @@ export const LabelContext = React.createContext({ } return key; }, + setLocale: () => { + if (!isTestEnvironment) { + console.error('LabelProvider missing! Application must be wrapped with '); + } + }, + locale: 'en', }); export interface LabelProviderProps = Record> { @@ -78,6 +86,12 @@ export const LabelProvider = >( ): JSX.Element => { const { labels = {}, children, locale = 'en' } = props; + const [currentLocale, setCurrentLocale] = React.useState(locale); + + React.useEffect(() => { + setCurrentLocale(locale); + }, [locale]); + const mergedLabels = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = {} as Record>; @@ -85,14 +99,14 @@ export const LabelProvider = >( for (const k of allKeys) { const key = k as keyof TediLabels; - const defaultEntry = labelsMap[key] ? labelsMap[key][locale] : undefined; + const defaultEntry = labelsMap[key] ? labelsMap[key][currentLocale] : undefined; const customEntry = labels[key] ?? undefined; let newEntry; if (customEntry) { if (typeof customEntry === 'object') { - newEntry = customEntry[locale]; + newEntry = customEntry[currentLocale]; } else { newEntry = customEntry; } @@ -104,9 +118,11 @@ export const LabelProvider = >( } return result; - }, [labels, locale]); + }, [labels, currentLocale]); - dayjs.locale(locale); + React.useEffect(() => { + dayjs.locale(currentLocale); + }, [currentLocale]); const getLabel = useCallback( < @@ -138,6 +154,11 @@ export const LabelProvider = >( [mergedLabels] ); + const contextValue = useMemo( + () => ({ getLabel, setLocale: setCurrentLocale, locale: currentLocale }), + [getLabel, setCurrentLocale, currentLocale] + ); + // find all labels that we need to pass into LocalizationProvider const muiLabels = Object.keys(mergedLabels).reduce((a, c) => { return { @@ -151,12 +172,12 @@ export const LabelProvider = >( }, {} as Partial>); return ( - + {children} diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 76ed01347..04b437179 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -170,20 +170,62 @@ export const labelsMap = validateDefaultLabels({ en: 'I represent:', ru: 'я представляю:', }, + 'header.role-selection': { + description: 'Label for Role selection on mobile', + components: ['HeaderRole'], + et: 'Roll', + en: 'Role', + ru: 'Роль', + }, + 'header.role-selection.close': { + description: 'Label for closing the Role selection on mobile when the selection view is expanded', + components: ['HeaderRole'], + et: 'Sulge', + en: 'Close', + ru: 'Закрыть', + }, + 'header.role-selection.search.label': { + description: 'Label for Search in Role selection', + components: ['HeaderRole'], + et: 'Otsi isikut', + en: 'Search representative', + ru: 'Найти представителя', + }, + 'header.role-selection.search.organizationLabel': { + description: 'Label for Organization Search in Role selection', + components: ['HeaderRole'], + et: 'Otsi asutust', + en: 'Search organization', + ru: 'Найти организацию', + }, 'header.login': { description: 'Label for login button', - components: ['Header'], + components: ['Header', 'HeaderLogin'], et: 'Sisene portaali', en: 'Log in', - ru: 'авторизоваться', + ru: 'Зайти на портал', + }, + 'header.login.mobile': { + description: 'Label for login button (small)', + components: ['Header', 'HeaderLogin'], + et: 'Sisene', + en: 'Log in', + ru: 'Войти', }, 'header.logout': { description: 'Label for logout button', - components: ['Header'], + components: ['Header', 'HeaderLogout'], et: 'Logi välja', en: 'Log out', ru: 'Выйти', }, + 'header.logout.mobile': { + description: 'Label for logout button (small)', + components: ['Header', 'HeaderLogout'], + et: 'Välju', + en: 'Log out', + ru: 'Выйти', + }, 'header.logo': { description: 'Alt Label for logo', components: ['Header'], @@ -191,6 +233,27 @@ export const labelsMap = validateDefaultLabels({ en: 'Logo', ru: 'Логотип', }, + 'header.search': { + description: 'Label for search button', + components: ['HeaderSearch'], + et: 'Otsing', + en: 'Search', + ru: 'Поиск', + }, + 'header.profile': { + description: 'Label for profile button', + components: ['HeaderProfile'], + et: 'Minu profiil', + en: 'My profile', + ru: 'Мой профиль', + }, + 'header.profile.mobile': { + description: 'Label for profile button on mobile', + components: ['HeaderProfile'], + et: 'Profiil', + en: 'Profile', + ru: 'Профиль', + }, 'file-upload.add': { description: 'Label for add file button', components: ['FileUpload'],