From 2d979f1368df3f8eb7a8c5693d49c73cac5c3490 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 17 Apr 2026 20:09:32 +0300 Subject: [PATCH 01/11] feat(header): add new TEDI-Ready component #506 --- public/header-logo-white.svg | 5 + .../button-content/button-content.module.scss | 2 +- .../buttons/button-content/button-content.tsx | 2 +- .../header-language.module.scss | 34 + .../header-language/header-language.tsx | 140 ++++ .../header-login/header-login.module.scss | 14 + .../components/header-login/header-login.tsx | 57 ++ .../header-logout/header-logout.module.scss | 10 + .../header-logout/header-logout.tsx | 54 ++ .../header-mobile-button.module.scss | 47 ++ .../header-mobile-button.tsx | 66 ++ .../header-profile/header-profile.module.scss | 72 ++ .../header-profile/header-profile.tsx | 115 +++ .../header-role-representatives.tsx | 90 +++ .../header-role/header-role.module.scss | 160 ++++ .../components/header-role/header-role.tsx | 230 ++++++ .../header-search/header-search.module.scss | 29 + .../header-search/header-search.tsx | 62 ++ .../layout/header/header.module.scss | 94 +++ .../layout/header/header.stories.tsx | 746 ++++++++++++++++++ src/tedi/components/layout/header/header.tsx | 110 +++ src/tedi/components/layout/header/index.ts | 8 + src/tedi/components/layout/hide-at.tsx | 20 + src/tedi/components/layout/show-at.tsx | 20 + .../sidenav-toggle/sidenav-toggle.module.scss | 2 + .../components/overlays/overlay/overlay.tsx | 10 +- src/tedi/components/overlays/popover/index.ts | 1 + .../overlays/popover/popover-content.tsx | 15 +- .../overlays/popover/popover-context.tsx | 14 + .../overlays/popover/popover.module.scss | 57 ++ .../overlays/popover/popover.stories.tsx | 9 +- .../components/overlays/popover/popover.tsx | 33 +- .../label-provider/label-provider.tsx | 24 +- .../providers/label-provider/labels-map.ts | 58 +- 34 files changed, 2386 insertions(+), 24 deletions(-) create mode 100644 public/header-logo-white.svg create mode 100644 src/tedi/components/layout/header/components/header-language/header-language.module.scss create mode 100644 src/tedi/components/layout/header/components/header-language/header-language.tsx create mode 100644 src/tedi/components/layout/header/components/header-login/header-login.module.scss create mode 100644 src/tedi/components/layout/header/components/header-login/header-login.tsx create mode 100644 src/tedi/components/layout/header/components/header-logout/header-logout.module.scss create mode 100644 src/tedi/components/layout/header/components/header-logout/header-logout.tsx create mode 100644 src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.module.scss create mode 100644 src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.tsx create mode 100644 src/tedi/components/layout/header/components/header-profile/header-profile.module.scss create mode 100644 src/tedi/components/layout/header/components/header-profile/header-profile.tsx create mode 100644 src/tedi/components/layout/header/components/header-role/header-role-representatives.tsx create mode 100644 src/tedi/components/layout/header/components/header-role/header-role.module.scss create mode 100644 src/tedi/components/layout/header/components/header-role/header-role.tsx create mode 100644 src/tedi/components/layout/header/components/header-search/header-search.module.scss create mode 100644 src/tedi/components/layout/header/components/header-search/header-search.tsx create mode 100644 src/tedi/components/layout/header/header.module.scss create mode 100644 src/tedi/components/layout/header/header.stories.tsx create mode 100644 src/tedi/components/layout/header/header.tsx create mode 100644 src/tedi/components/layout/header/index.ts create mode 100644 src/tedi/components/layout/hide-at.tsx create mode 100644 src/tedi/components/layout/show-at.tsx create mode 100644 src/tedi/components/overlays/popover/popover-context.tsx 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/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..7ca9751b3 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 schema for button. PS 'text' works only with link type links. * @default default */ color?: ButtonColor; 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..60f127fde --- /dev/null +++ b/src/tedi/components/layout/header/components/header-language/header-language.module.scss @@ -0,0 +1,34 @@ +.tedi-header-language { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + height: 100%; + + &__icon { + transition: transform 0.2s ease-in-out; + + &--open { + transform: rotate(-180deg); + } + } + + &__mobile { + width: var(--layout-header-mobile-button-size); + min-width: var(--layout-header-mobile-button-min-size); + padding: var(--layout-grid-gutters-08); + } + + &__selected { + display: flex; + gap: var(--link-inner-spacing-x); + align-items: center; + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; + } +} 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..334a313d5 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-language/header-language.tsx @@ -0,0 +1,140 @@ +import cn from 'classnames'; +import { useState } from 'react'; + +import { Text } from '../../../../../components/base/typography/text/text'; +import { BreakpointSupport, 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; +} + +interface HeaderLanguageBreakpointProps { + /** Whether to hide the "Select language" label text. */ + hideLabel?: boolean; +} + +interface HeaderLanguageProps extends BreakpointSupport { + /** List of available languages to display in the selector dropdown. */ + languages: Language[]; + /** Initially displayed language label. Falls back to the label matching the current locale, or the first item. */ + currentLanguage?: string; +} + +const HeaderLanguage = (props: HeaderLanguageProps) => { + const [languageSelectionOpen, setLanguageSelectionOpen] = useState(false); + const { getLabel, setLocale, locale } = useLabels(); + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + const { hideLabel } = getCurrentBreakpointProps({ + hideLabel: true, + lg: { hideLabel: false }, + }); + const availableLanguages: Language[] = props.languages ?? []; + + const initialLabel = (() => { + if (locale) { + const found = availableLanguages.find((l) => l.locale === locale); + if (found) return found.label; + } + + if (props.currentLanguage) return props.currentLanguage; + return availableLanguages[0]?.label ?? ''; + })(); + + const [language, setLanguage] = useState(initialLabel); + + const changeLanguage = (lang: Language) => { + setLanguage(lang.label); + + if (lang.onClick) { + lang.onClick({ onToggle: setLanguageSelectionOpen }); + return; + } + + setLanguageSelectionOpen(false); + + if (lang.locale && setLocale) { + setLocale(lang.locale); + } + }; + + return ( +
+ {!hideLabel && ( + + {getLabel('header.select-lang')} + + )} + + setLanguageSelectionOpen((prev) => !prev)} + withBorder={true} + > + + + + +
+ {availableLanguages.map((lang) => ( + + ))} +
+
+
+
+ ); +}; + +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.tsx b/src/tedi/components/layout/header/components/header-login/header-login.tsx new file mode 100644 index 000000000..62a73bb1b --- /dev/null +++ b/src/tedi/components/layout/header/components/header-login/header-login.tsx @@ -0,0 +1,57 @@ +import { Text } from '../../../../../../tedi/components/base/typography/text/text'; +import { BreakpointSupport, isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +import { useLabels } from '../../../../../providers/label-provider'; +import Link from '../../../../navigation/link/link'; +import HeaderMobileButton from '../header-mobile-button/header-mobile-button'; +import styles from './header-login.module.scss'; + +interface HeaderLoginBreakpointProps { + size?: 'default' | 'small'; +} + +interface HeaderLoginProps extends BreakpointSupport { + onClick?: () => void; + href?: string; +} + +const HeaderLogin = (props: HeaderLoginProps) => { + const { onClick, href } = props; + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { size: sizeProp } = getCurrentBreakpointProps(props); + const { getLabel } = useLabels(); + + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + + const size = sizeProp ?? (isMobileView ? 'small' : 'default'); + const isSmall = size === 'small'; + + return ( + <> + {isSmall ? ( + + ) : ( + +
+ + {getLabel('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.tsx b/src/tedi/components/layout/header/components/header-logout/header-logout.tsx new file mode 100644 index 000000000..3bf4a9d10 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-logout/header-logout.tsx @@ -0,0 +1,54 @@ +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 Link from '../../../../navigation/link/link'; +import HeaderMobileButton from '../header-mobile-button/header-mobile-button'; +import styles from './header-logout.module.scss'; + +interface HeaderLogoutBreakpointProps { + size?: 'default' | 'small'; +} + +interface HeaderLogoutProps extends BreakpointSupport { + onClick?: () => void; + href?: string; +} + +const HeaderLogout = (props: HeaderLogoutProps) => { + const { getLabel } = useLabels(); + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { size: sizeProp } = getCurrentBreakpointProps(props); + + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + + const size = sizeProp ?? (isMobileView ? 'small' : 'default'); + const isSmall = size === 'small'; + + const { onClick, href } = props; + + return ( + <> + {isSmall ? ( + + ) : ( + +
+ + + {getLabel('header.logout')} + +
+ + )} + + ); +}; + +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..b4ed82395 --- /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-min-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; + } + + &:hover { + color: var(--header-mobile-button-text-hover); + } + + &:active { + color: var(--header-mobile-button-text-active); + } + + &: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.tsx b/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.tsx new file mode 100644 index 000000000..bf8e64a50 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.tsx @@ -0,0 +1,66 @@ +import cn from 'classnames'; + +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. */ + text?: string; + /** Whether the button is in a selected state. */ + selected?: boolean; + /** Whether the button is disabled. */ + disabled?: boolean; +} + +const HeaderMobileButton = (props: HeaderMobileButtonProps) => { + const { onClick, href, icon, text, 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)} + + {text} + +
+ ); + + 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} + + ); +}; + +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..37cd88f72 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-profile/header-profile.module.scss @@ -0,0 +1,72 @@ +.tedi-header-profile { + &__button { + 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%; + } + } + + &__modal { + position: absolute; + 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%; + min-height: calc(100dvh - var(--layout-header-height)); + max-height: 100%; + 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; + + &-small > * { + 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; + } + } + } + + &__overlay { + position: absolute; + top: var(--layout-header-height); + left: 0; + z-index: calc(var(--z-index-header) - 1); + width: 100%; + min-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.tsx b/src/tedi/components/layout/header/components/header-profile/header-profile.tsx new file mode 100644 index 000000000..447532266 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-profile/header-profile.tsx @@ -0,0 +1,115 @@ +import cn from 'classnames'; +import { 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 { + /** Breakpoint at which we show dropdown instead of modal */ + showDropdown?: Breakpoint; +} + +export interface HeaderProfileProps extends BreakpointSupport { + /** Content rendered inside the profile dropdown 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; + /** Custom label text for the profile button. Falls back to the `header.profile` translation key. */ + label?: string; + /** Custom label text for the mobile profile button. Falls back to the `header.profile.mobile` translation key. */ + labelMobile?: string; +} + +const HeaderProfile = (props: HeaderProfileProps) => { + const { children, showLabel = false, label, labelMobile } = props; + const { getLabel } = useLabels(); + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { showDropdown = 'lg' } = getCurrentBreakpointProps(props); + + const [dropdownOpen, setDropdownOpen] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + const isTabletView = isBreakpointBelow(breakpoint, 'lg'); + + const useDropdown = !isBreakpointBelow(breakpoint, showDropdown); + + const button = isMobileView ? ( + + ) : showLabel ? ( + + ) : ( + + ); + + return ( + <> + {useDropdown ? ( + setDropdownOpen((prev) => !prev)} + > + {button} + +
{children}
+
+
+ ) : ( + <> +
setModalOpen(!modalOpen)}>{button}
+ + {modalOpen && ( + <> +
setModalOpen(false)} /> +
+
+ {children} +
+
+ + )} + + )} + + ); +}; + +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..373410e90 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-role/header-role-representatives.tsx @@ -0,0 +1,90 @@ +import cn from 'classnames'; +import React from 'react'; + +import Separator from '../../../../../../tedi/components/misc/separator/separator'; +import { Text } from '../../../../../components/base/typography/text/text'; +import { useLabels } from '../../../../../providers/label-provider'; +import { Icon, IconProps } from '../../../../base/icon/icon'; +import { Button } from '../../../../buttons/button/button'; +import { Search } from '../../../../form/search/search'; +import styles from './header-role.module.scss'; + +export interface Representative { + name: string; + description?: string; + icon?: IconProps; +} +interface HeaderRoleRepresentativesProps { + representatives: Representative[]; + representative: Representative; + inputValue: string; + setInputValue: (value: string) => void; + setRepresentative: (rep: Representative) => void; + setIsRoleSelectionOpen: (open: boolean) => void; + isRoleSelectionOpen: boolean; + isOrganization?: boolean; +} + +const HeaderRoleRepresentatives = ({ + representatives, + inputValue, + setInputValue, + setRepresentative, + setIsRoleSelectionOpen, + isRoleSelectionOpen, + representative, + isOrganization, +}: HeaderRoleRepresentativesProps) => { + const { getLabel } = useLabels(); + + const searchLabel = isOrganization + ? getLabel('header.role-selection.search-label-organization') + : getLabel('header.role-selection.search-label'); + + const handleSelect = (rep: Representative) => { + setRepresentative(rep); + setInputValue(''); + setIsRoleSelectionOpen(false); + }; + + return ( +
+
+
+ setInputValue(e)} label={searchLabel} /> + {representatives.map((rep) => { + const isSelected = representative.name === rep.name; + + return ( + + + + + ); + })} +
+
+
+ ); +}; + +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..02f67d3be --- /dev/null +++ b/src/tedi/components/layout/header/components/header-role/header-role.module.scss @@ -0,0 +1,160 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; + +.tedi-header-role { + display: flex; + flex-direction: column; + align-items: flex-start; + + &__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); + } + } + } + + &__accordion--container { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-08); + width: 100%; + padding: var(--card-padding-md-default); + background-color: var(--general-surface-secondary); + border-bottom: 4px solid var(--general-border-brand); + } + + &__accordion { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + + &---has-representatives { + gap: var(--layout-grid-gutters-16); + justify-content: space-between; + } + } + + &__accordion--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); + } + } + } + + &__accordion--title { + display: flex; + flex-wrap: wrap; + gap: var(--layout-grid-gutters-04); + } + + &__accordion--toggle { + display: flex; + gap: var(--link-inner-spacing-x); + align-items: center; + + &-icon { + transition: transform 0.3s ease; + + &--open { + transform: rotate(-180deg); + } + } + } + + &__collapse { + display: grid; + grid-template-rows: 0fr; + width: 100%; + transition: grid-template-rows 0.3s ease; + + &--open { + grid-template-rows: 1fr; + } + + &-inner { + overflow: hidden; + } + } +} 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..737883378 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-role/header-role.tsx @@ -0,0 +1,230 @@ +import cn from 'classnames'; +import { useMemo, useState } from 'react'; + +import Separator from '../../../../../../tedi/components/misc/separator/separator'; +import { Text } from '../../../../../components/base/typography/text/text'; +import { isBreakpointBelow, useBreakpoint } from '../../../../../helpers'; +import { ILabelContext, useLabels } from '../../../../../providers/label-provider'; +import { Icon } from '../../../../base/icon/icon'; +import Link from '../../../../navigation/link/link'; +import Popover from '../../../../overlays/popover/popover'; +import { StatusBadge } from '../../../../tags/status-badge/status-badge'; +import styles from './header-role.module.scss'; +import HeaderRoleRepresentatives, { Representative } from './header-role-representatives'; + +interface HeaderRoleProps { + title?: string; + representatives?: Representative[]; + defaultLanguage?: string; + withStatusBadge?: boolean; + isOrganization?: boolean; +} + +interface HeaderRoleViewProps extends HeaderRoleProps { + representative: Representative; + setRepresentative: (r: Representative) => void; + isRoleSelectionOpen: boolean; + setIsRoleSelectionOpen: (open: boolean) => void; + inputValue: string; + setInputValue: (value: string) => void; + filteredRepresentatives: Representative[]; + getLabel: ILabelContext['getLabel']; +} + +const HeaderRole = (props: HeaderRoleProps) => { + const { getLabel } = useLabels(); + const [representative, setRepresentative] = useState( + props.representatives?.[0] ?? ({} as Representative) + ); + const [isRoleSelectionOpen, setIsRoleSelectionOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + + const breakpoint = useBreakpoint(); + const isTabletView = isBreakpointBelow(breakpoint, 'lg'); + + const filteredRepresentatives = useMemo(() => { + if (!props.representatives) return []; + + if (!inputValue) return props.representatives; + + const search = inputValue.toLowerCase(); + + return props.representatives.filter((r) => { + return r.name.toLowerCase().includes(search) || r.description?.toLowerCase().includes(search); + }); + }, [props.representatives, inputValue]); + + const viewProps: HeaderRoleViewProps = { + ...props, + representative, + setRepresentative, + isRoleSelectionOpen, + setIsRoleSelectionOpen, + inputValue, + setInputValue, + filteredRepresentatives, + getLabel, + }; + + return isTabletView ? : ; +}; + +const HeaderRoleAccordion = ({ + title, + representatives, + withStatusBadge, + isOrganization, + representative, + setRepresentative, + isRoleSelectionOpen, + setIsRoleSelectionOpen, + inputValue, + setInputValue, + filteredRepresentatives, + getLabel, +}: HeaderRoleViewProps) => { + const hasMultipleRepresentatives = (representatives?.length ?? 0) > 1; + const hasSingleRepresentative = representatives?.length === 1; + + return ( +
+
+
+ {withStatusBadge ? ( +
+ {title && {title}} + + {representative.name} + +
+ ) : ( + + {title && {title}} + {representative.name} + + )} + + {hasSingleRepresentative && representative.description && } + {!withStatusBadge && {representative.description}} +
+ + {hasMultipleRepresentatives && ( + setIsRoleSelectionOpen(!isRoleSelectionOpen)}> +
+ {isRoleSelectionOpen ? getLabel('close') : getLabel('header.role-selection')} + +
+ + )} +
+ + +
+ ); +}; + +const HeaderRoleSwitch = ({ + title, + representatives, + withStatusBadge, + isOrganization, + representative, + setRepresentative, + isRoleSelectionOpen, + setIsRoleSelectionOpen, + inputValue, + setInputValue, + filteredRepresentatives, +}: HeaderRoleViewProps) => { + const hasMultipleRepresentatives = (representatives?.length ?? 0) > 1; + const showStatusBadge = withStatusBadge && title; + + return ( +
+
+ {showStatusBadge ? ( + {title} + ) : ( + <> + {title && ( + + {title} + + )} + {!isOrganization && ( + + {representative.description} + + )} + + )} +
+ + {hasMultipleRepresentatives ? ( + setIsRoleSelectionOpen(!isRoleSelectionOpen)} + withBorder={true} + > + + +
+ {representative.name} + +
+ +
+ + + +
+ ) : ( +
+ {representative.name} +
+ )} +
+ ); +}; + +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..3d9e50a50 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-search/header-search.module.scss @@ -0,0 +1,29 @@ +@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%; + height: 100dvh; + background: var(--modal-background); + + &-heading { + display: flex; + 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 { + height: 100%; + padding: var(--modal-body-padding); + } +} + +.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.tsx b/src/tedi/components/layout/header/components/header-search/header-search.tsx new file mode 100644 index 000000000..69e101917 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-search/header-search.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; + +import { BreakpointSupport, 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'; + +interface HeaderProfileBreakpointProps {} + +type MobileSearchVariant = 'modal' | 'inline'; + +export interface HeaderSearchProps extends BreakpointSupport { + children: React.ReactNode; + mobileVariant?: MobileSearchVariant; +} + +const HeaderSearch = (props: HeaderSearchProps) => { + const { children, mobileVariant = 'modal' } = props; + const breakpoint = useBreakpoint(); + const isMobileView = isBreakpointBelow(breakpoint, 'md'); + const { getLabel } = useLabels(); + + const [modalOpen, setModalOpen] = useState(false); + + return ( + <> + {isMobileView && mobileVariant === 'modal' ? ( + <> + {modalOpen && ( +
+
+ + {getLabel('header.search')} + + +
+
{children}
+
+ )} + setModalOpen(!modalOpen)} + icon={{ name: 'search', size: 24, color: 'inherit' }} + text={getLabel('header.search')} + /> + + ) : ( + <>{children} + )} + + ); +}; + +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..1f528a3a6 --- /dev/null +++ b/src/tedi/components/layout/header/header.module.scss @@ -0,0 +1,94 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; + +.tedi-header { + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%)); + + &__main { + position: relative; + display: flex; + flex: 1 0 0; + min-height: var(--layout-header-height); + 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; + color: var(--header-link-default); + + &--flex-start { + justify-content: flex-start; + } + + &--center { + justify-content: center; + } + + &--space-between { + justify-content: space-between; + } + + > * { + 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%; + + > [data-name='separator'] { + padding: var(--layout-header-separator-padding-y) 0; + } + + @include breakpoints.media-breakpoint-down(lg) { + max-height: 2.75rem; + } + + @include breakpoints.media-breakpoint-down(md) { + max-height: unset; + } + } + + &__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); + } +} 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..68f89e3ad --- /dev/null +++ b/src/tedi/components/layout/header/header.stories.tsx @@ -0,0 +1,746 @@ +import { useGlobals } from '@storybook/preview-api'; +import { Meta, StoryObj } from '@storybook/react/*'; +import { useEffect, useRef, useState } from 'react'; + +import Toggle from '../../../../tedi/components/form/toggle/toggle'; +import Separator from '../../../../tedi/components/misc/separator/separator'; +import { useTheme } from '../../../providers/theme-provider/theme-provider'; +import { Icon } from '../../base/icon/icon'; +import { Search } from '../../form/search/search'; +import Link from '../../navigation/link/link'; +import { HideAt } from '../hide-at'; +import { ShowAt } from '../show-at'; +import { SideNav } from '../sidenav'; +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 { Representative } from './components/header-role/header-role-representatives'; +import HeaderSearch from './components/header-search/header-search'; +import Header, { HeaderLogoProps } from './header'; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +const STORAGE_KEY = 'tedi-theme'; + +export default { + title: 'TEDI-Ready/Layout/Header', + component: Header, + subcomponents: { + 'Header.Logo': Header.Logo, + 'Header.Center': Header.Center, + 'Header.Actions': Header.Actions, + }, + decorators: [ + (Story) => { + const [globals, updateGlobals] = useGlobals(); + const originalThemeRef = useRef(null); + + useEffect(() => { + originalThemeRef.current = localStorage.getItem(STORAGE_KEY); + const storedTheme = originalThemeRef.current; + + if (storedTheme && globals.theme !== storedTheme) { + updateGlobals({ theme: storedTheme }); + } + + const originalSetItem = localStorage.setItem.bind(localStorage); + + localStorage.setItem = (key: string, value: string) => { + if (key === STORAGE_KEY) return; + originalSetItem(key, value); + }; + + return () => { + localStorage.setItem = originalSetItem; + + if (originalThemeRef.current !== null) { + originalSetItem(STORAGE_KEY, originalThemeRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ; + }, + ], + parameters: { + layout: 'fullscreen', + }, +} as 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[] = [ + { name: 'Mari Maasikas', description: '49504080934', icon: { name: 'person', size: 24 } }, + { name: 'Juulia Sarapuu', description: 'Peasekretär', icon: { name: 'supervised_user_circle', size: 24 } }, + { name: 'Marta Sarapuu', description: 'Sekretär', icon: { name: 'supervised_user_circle', size: 24 } }, + { name: 'Helgi Sarapuu', description: 'Jurist', icon: { name: 'supervised_user_circle', size: 24 } }, +]; + +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 = [{ name: 'Mari Maasikas', description: '49504080934' }]; + +const organizations = [{ name: 'Pärnu linnavolikogu' }, { name: 'Tartu Linnavalitsus' }]; +const organizations2 = [{ name: 'Tartu Linnavalitsus' }]; + +const logo = Logo; +const logoDark = Logo (Dark Mode); + +const ProfileExample = () => { + const { theme, setTheme } = useTheme(); + + const handleToggle = () => { + setTheme(theme === 'dark' ? 'default' : 'dark'); + }; + + return ( + <> + + Minu andmed + + + Esindatavad + + + Kontaktid + + + + + +
+ +
+ + + + + + Riiklikud teated + + + + + + + ); +}; + +const accessibilityLink = ( + +
+ Ligipääsetavus + +
+ +); + +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 ResponsiveLogo = (props: HeaderLogoProps) => { + const query = '(min-width: 360px)'; + + 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 LoggedOut: Story = { + render: () => ( + <> +
+ + {({ isOpen, setIsOpen }) => ( + <> +
setIsOpen(!isOpen)} />}> + + + + + + Link text + + + Link text + + + Link text + + + Link text + + + Link text + + + + + + + + + +
+ + + + + + )} +
+
+ + + {({ isOpen, setIsOpen }) => ( + <> +
setIsOpen(!isOpen)} />}> + + + + +
+ + Avaleht + + + Teenused + + + Blogi + + + Kontakt + +
+ +
+ +
+
+
+
+ + + + + + + + + + + + +
+ + + + + + )} +
+ + ), +}; + +export const LoggedIn: Story = { + render: () => ( + <> +
+
+ + + + {accessibilityLink} + + + + + + + + + + {accessibilityLink} + + + + +
+
+ +
+ + + + {accessibilityLink} + + + + + + + + + + {accessibilityLink} + + + + +
+ + ), +}; + +export const WithOrganizationSelection: Story = { + render: () => ( + <> +
+
+ + + + {accessibilityLink} + + + + + + + + + + + + + {accessibilityLink} + + + + +
+
+ +
+ + + + {accessibilityLink} + + + + + + + + + + {accessibilityLink} + + + + +
+ + ), +}; + +export const AlternativeProfileAndLogoutButton: Story = { + render: () => ( + <> +
+
+ + + + {accessibilityLink} + + + + + + + + + + + + + {accessibilityLink} + + + + +
+
+ +
+
+ + + + {accessibilityLink} + + + + + + + + + + {accessibilityLink} + + + + +
+
+ +
+
+ + + + + + + + + + + + +
+
+ +
+ + + + {accessibilityLink} + + + + + + + + + + {accessibilityLink} + + + + + +
+ + ), +}; + +export const WithSearch: Story = { + render: () => ( + <> +
+
+ + + + + + + + + + + + + + + + + + + +
+
+ <> +
+ + + } + > + + + + + + + + + + + + + + + + + + + + + + + +
+ + + ), +}; + +export const LoggedInWithSidenav: Story = { + render: () => ( + + {({ isOpen, setIsOpen }) => ( + <> +
setIsOpen(!isOpen)} />}> + + + + {accessibilityLink} + + + + + + + + + + {accessibilityLink} + + + + +
+ + + + )} +
+ ), +}; diff --git a/src/tedi/components/layout/header/header.tsx b/src/tedi/components/layout/header/header.tsx new file mode 100644 index 000000000..04d36f696 --- /dev/null +++ b/src/tedi/components/layout/header/header.tsx @@ -0,0 +1,110 @@ +import cn from 'classnames'; +import React from 'react'; + +import { isBreakpointBelow, useBreakpoint } from '../../../helpers'; +import { useTheme } from '../../../providers/theme-provider/theme-provider'; +import styles from '../header/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; +} + +export interface HeaderContentProps { + /** 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 interface HeaderActionsProps { + /** Action elements rendered on the right side of the header (e.g. language selector, login, profile). */ + children?: React.ReactNode; +} + +const Header = ({ children, toggle, bottom }: HeaderProps) => { + const breakpoint = useBreakpoint(); + const isMobile = isBreakpointBelow(breakpoint, 'md'); + + return ( +
+
+ {toggle && <>{toggle}} +
{children}
+
+ + {bottom && isMobile &&
{bottom}
} +
+ ); +}; + +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. + * Can be used together with responsive utilities (e.g. HideAt/ShowAt or media queries). + * @default true + */ + showLogo?: boolean; + /** + * Optional link URL. + * If provided, the logo will be wrapped in an anchor element. + */ + href?: string; +} + +Header.Logo = function Logo({ logo, logoDark, href, showLogo = true }: HeaderLogoProps) { + const { theme } = useTheme(); + + const resolvedLogo = theme === 'dark' && logoDark ? logoDark : logo; + + if (!showLogo) return null; + + const content = href ? {resolvedLogo} : resolvedLogo; + + return
{content}
; +}; + +Header.Center = function Content(props: HeaderContentProps) { + const { children, className, alignment = 'center', ...rest } = props; + + const centerBEM = cn(styles['tedi-header__center'], styles[`tedi-header__center--${alignment}`], className); + + return ( +
+ {children} +
+ ); +}; + +Header.Actions = function Actions(props: HeaderActionsProps) { + const { children } = props; + return
{children}
; +}; + +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..0952d299d --- /dev/null +++ b/src/tedi/components/layout/header/index.ts @@ -0,0 +1,8 @@ +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-mobile-button/header-mobile-button'; +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.tsx b/src/tedi/components/layout/hide-at.tsx new file mode 100644 index 000000000..b293b261a --- /dev/null +++ b/src/tedi/components/layout/hide-at.tsx @@ -0,0 +1,20 @@ +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}; +}; diff --git a/src/tedi/components/layout/show-at.tsx b/src/tedi/components/layout/show-at.tsx new file mode 100644 index 000000000..24d45ad79 --- /dev/null +++ b/src/tedi/components/layout/show-at.tsx @@ -0,0 +1,20 @@ +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}; +}; 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..b14c92705 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,7 +7,9 @@ $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) { diff --git a/src/tedi/components/overlays/overlay/overlay.tsx b/src/tedi/components/overlays/overlay/overlay.tsx index 338062c87..2f76925b8 100644 --- a/src/tedi/components/overlays/overlay/overlay.tsx +++ b/src/tedi/components/overlays/overlay/overlay.tsx @@ -91,6 +91,13 @@ export interface OverlayProps { * @default GAP + arrow height */ offset?: OffsetOptions; + /** + * 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 { @@ -161,6 +168,7 @@ export const Overlay = (props: OverlayProps) => { role = 'tooltip', arrowDimensions, offset: offsetOptions = GAP + (arrowDimensions?.height ?? 0), + arrowPadding = 4, focusManager, dismissible, scrollLock, @@ -199,7 +207,7 @@ export const Overlay = (props: OverlayProps) => { shift({ padding: 8 }), arrow({ element: arrowRef, - padding: 4, + padding: arrowPadding, }), ], whileElementsMounted: autoUpdate, 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..4bc8fe5d7 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,12 @@ export const PopoverContent = (props: PopoverContentProps) => { return ( ({ + 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..f9a96bd71 100644 --- a/src/tedi/components/overlays/popover/popover.tsx +++ b/src/tedi/components/overlays/popover/popover.tsx @@ -2,10 +2,13 @@ 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 { /** @@ -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..0d4f8adeb 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,8 @@ export const LabelProvider = >( ): JSX.Element => { const { labels = {}, children, locale = 'en' } = props; + const [currentLocale, setCurrentLocale] = React.useState(locale); + const mergedLabels = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = {} as Record>; @@ -85,14 +95,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 +114,11 @@ export const LabelProvider = >( } return result; - }, [labels, locale]); + }, [labels, currentLocale]); - dayjs.locale(locale); + React.useEffect(() => { + dayjs.locale(currentLocale); + }, [currentLocale]); const getLabel = useCallback( < @@ -151,12 +163,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 8483ae1b2..8a48b3e74 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -170,12 +170,40 @@ export const labelsMap = validateDefaultLabels({ en: 'I represent:', ru: 'я представляю:', }, + 'header.role-selection': { + description: 'Label for Role selection on mobile', + components: ['HeaderRole'], + et: 'Vaheta rolli', + en: 'Change role', + ru: 'Изменить роль', + }, + 'header.role-selection.search-label': { + description: 'Label for Search in Role selection (Person)', + components: ['HeaderRole'], + et: 'Otsi isikut', + en: 'Search representative', + ru: 'Найти представителя', + }, + 'header.role-selection.search-label-organization': { + description: 'Label for Search in Role selection (Organization)', + components: ['HeaderRole'], + et: 'Otsi asutust', + en: 'Search representative', + ru: 'Найти представителя', + }, 'header.login': { description: 'Label for login button', components: ['Header'], et: 'Sisene portaali', en: 'Log in', - ru: 'авторизоваться', + ru: 'Зайти на портал', + }, + 'header.login-small': { + description: 'Label for login button (small)', + components: ['Header'], + et: 'Sisene', + en: 'Log in', + ru: 'Войти', }, 'header.logout': { description: 'Label for logout button', @@ -184,6 +212,13 @@ export const labelsMap = validateDefaultLabels({ en: 'Log out', ru: 'Выйти', }, + 'header.logout-small': { + description: 'Label for logout button (small)', + components: ['Header'], + et: 'Välju', + en: 'Log out', + ru: 'Выйти', + }, 'header.logo': { description: 'Alt Label for logo', components: ['Header'], @@ -191,6 +226,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'], From 6ab54dde8cb2644e83e4a9ecf624e1371484ded5 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Tue, 21 Apr 2026 13:38:57 +0300 Subject: [PATCH 02/11] feat(header): add tests, refactor #506 --- .../layout/header/header.stories.tsx | 5 + .../header-language/header-language.spec.tsx | 132 ++++++ .../header-language/header-language.tsx | 42 +- .../header-login/header-login.spec.tsx | 83 ++++ .../components/header-login/header-login.tsx | 21 +- .../header-logout/header-logout.spec.tsx | 83 ++++ .../header-logout/header-logout.tsx | 24 +- .../header-mobile-button.module.scss | 2 +- .../header-mobile-button.spec.tsx | 82 ++++ .../header-mobile-button.tsx | 6 +- .../header-profile/header-profile.spec.tsx | 127 ++++++ .../header-profile/header-profile.tsx | 29 +- .../header-role-representatives.tsx | 70 ++- .../header-role/header-role.spec.tsx | 411 ++++++++++++++++++ .../components/header-role/header-role.tsx | 277 ++++++------ .../header-search/header-search.module.scss | 8 + .../header-search/header-search.spec.tsx | 222 ++++++++++ .../header-search/header-search.tsx | 77 +++- .../layout/header/header.module.scss | 1 + .../components/layout/header/header.spec.tsx | 250 +++++++++++ .../layout/header/header.stories.tsx | 344 +++++++++++---- src/tedi/components/layout/header/header.tsx | 103 +++-- src/tedi/components/layout/hide-at.spec.tsx | 95 ++++ src/tedi/components/layout/show-at.spec.tsx | 95 ++++ .../providers/label-provider/labels-map.ts | 23 +- 25 files changed, 2236 insertions(+), 376 deletions(-) create mode 100644 src/tedi/components/layout/header/components/header-language/header-language.spec.tsx create mode 100644 src/tedi/components/layout/header/components/header-login/header-login.spec.tsx create mode 100644 src/tedi/components/layout/header/components/header-logout/header-logout.spec.tsx create mode 100644 src/tedi/components/layout/header/components/header-mobile-button/header-mobile-button.spec.tsx create mode 100644 src/tedi/components/layout/header/components/header-profile/header-profile.spec.tsx create mode 100644 src/tedi/components/layout/header/components/header-role/header-role.spec.tsx create mode 100644 src/tedi/components/layout/header/components/header-search/header-search.spec.tsx create mode 100644 src/tedi/components/layout/header/header.spec.tsx create mode 100644 src/tedi/components/layout/hide-at.spec.tsx create mode 100644 src/tedi/components/layout/show-at.spec.tsx 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/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 index 334a313d5..381f5abd4 100644 --- a/src/tedi/components/layout/header/components/header-language/header-language.tsx +++ b/src/tedi/components/layout/header/components/header-language/header-language.tsx @@ -2,7 +2,7 @@ import cn from 'classnames'; import { useState } from 'react'; import { Text } from '../../../../../components/base/typography/text/text'; -import { BreakpointSupport, isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; +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'; @@ -29,29 +29,37 @@ export interface Language { 'aria-label'?: string; } -interface HeaderLanguageBreakpointProps { - /** Whether to hide the "Select language" label text. */ - hideLabel?: boolean; -} - -interface HeaderLanguageProps extends BreakpointSupport { - /** List of available languages to display in the selector dropdown. */ +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; } -const HeaderLanguage = (props: HeaderLanguageProps) => { +export const HeaderLanguage = (props: HeaderLanguageProps) => { + const { languages, currentLanguage, selectLabel } = props; const [languageSelectionOpen, setLanguageSelectionOpen] = useState(false); const { getLabel, setLocale, locale } = useLabels(); - const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { getCurrentBreakpointProps } = useBreakpointProps(); const breakpoint = useBreakpoint(); const isMobileView = isBreakpointBelow(breakpoint, 'md'); const { hideLabel } = getCurrentBreakpointProps({ hideLabel: true, lg: { hideLabel: false }, }); - const availableLanguages: Language[] = props.languages ?? []; + const availableLanguages: Language[] = languages ?? []; const initialLabel = (() => { if (locale) { @@ -59,7 +67,7 @@ const HeaderLanguage = (props: HeaderLanguageProps) => { if (found) return found.label; } - if (props.currentLanguage) return props.currentLanguage; + if (currentLanguage) return currentLanguage; return availableLanguages[0]?.label ?? ''; })(); @@ -86,11 +94,9 @@ const HeaderLanguage = (props: HeaderLanguageProps) => { [styles['tedi-header-language__mobile']]: isMobileView, })} > - {!hideLabel && ( - - {getLabel('header.select-lang')} - - )} + + {selectLabel ?? getLabel('header.select-lang')} + { ) : ( - ); 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 index 373410e90..bc6e18ef2 100644 --- 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 @@ -1,5 +1,5 @@ import cn from 'classnames'; -import React from 'react'; +import React, { useId } from 'react'; import Separator from '../../../../../../tedi/components/misc/separator/separator'; import { Text } from '../../../../../components/base/typography/text/text'; @@ -21,41 +21,78 @@ interface HeaderRoleRepresentativesProps { setInputValue: (value: string) => void; setRepresentative: (rep: Representative) => void; setIsRoleSelectionOpen: (open: boolean) => void; + /** Callback fired when the role selection is toggled. Handles both state update and external notification. */ + onRoleSelectionToggle?: () => void; isRoleSelectionOpen: boolean; 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 = ({ - representatives, - inputValue, - setInputValue, - setRepresentative, - setIsRoleSelectionOpen, - isRoleSelectionOpen, - representative, - isOrganization, -}: HeaderRoleRepresentativesProps) => { +const HeaderRoleRepresentatives = (props: HeaderRoleRepresentativesProps) => { + const { + representatives, + inputValue, + setInputValue, + setRepresentative, + setIsRoleSelectionOpen, + onRoleSelectionToggle, + isRoleSelectionOpen, + representative, + isOrganization, + searchLabel, + organizationSearchLabel, + searchId, + keepOpenOnSelect, + } = props; const { getLabel } = useLabels(); - const searchLabel = isOrganization - ? getLabel('header.role-selection.search-label-organization') - : getLabel('header.role-selection.search-label'); + const resolvedSearchLabel = isOrganization + ? organizationSearchLabel ?? getLabel('header.role-selection.search.organizationLabel') + : searchLabel ?? getLabel('header.role-selection.search.label'); const handleSelect = (rep: Representative) => { setRepresentative(rep); setInputValue(''); - setIsRoleSelectionOpen(false); + + if (!keepOpenOnSelect) { + if (isRoleSelectionOpen && onRoleSelectionToggle) { + onRoleSelectionToggle(); + } else { + setIsRoleSelectionOpen(false); + } + } }; + const generatedSearchId = useId(); + return (
- setInputValue(e)} label={searchLabel} /> + setInputValue(e)} + label={resolvedSearchLabel} + /> {representatives.map((rep) => { const isSelected = representative.name === rep.name; @@ -74,6 +111,7 @@ const HeaderRoleRepresentatives = ({ {rep.icon && }
{rep.name} + {rep.description}
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..a9dc4e094 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-role/header-role.spec.tsx @@ -0,0 +1,411 @@ +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[] = [ + { name: 'John Doe', description: 'Personal representative' }, + { name: 'Jane Smith', description: 'Organization representative' }, + { name: 'Bob Wilson', description: 'Another representative' }, +]; + +const singleRepresentative: Representative[] = [{ 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('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'); + }); + }); + + 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__collapse"]'); + 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__collapse"]'); + expect(collapse).not.toHaveAttribute('inert'); + }); + + 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 index 737883378..b114eb203 100644 --- a/src/tedi/components/layout/header/components/header-role/header-role.tsx +++ b/src/tedi/components/layout/header/components/header-role/header-role.tsx @@ -1,197 +1,177 @@ import cn from 'classnames'; -import { useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; -import Separator from '../../../../../../tedi/components/misc/separator/separator'; import { Text } from '../../../../../components/base/typography/text/text'; import { isBreakpointBelow, useBreakpoint } from '../../../../../helpers'; -import { ILabelContext, useLabels } from '../../../../../providers/label-provider'; +import { useLabels } from '../../../../../providers/label-provider'; import { Icon } from '../../../../base/icon/icon'; -import Link from '../../../../navigation/link/link'; +import Button from '../../../../buttons/button/button'; +import Separator from '../../../../misc/separator/separator'; import Popover from '../../../../overlays/popover/popover'; -import { StatusBadge } from '../../../../tags/status-badge/status-badge'; import styles from './header-role.module.scss'; import HeaderRoleRepresentatives, { Representative } from './header-role-representatives'; -interface HeaderRoleProps { - title?: string; - representatives?: Representative[]; - defaultLanguage?: string; - withStatusBadge?: boolean; +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; } -interface HeaderRoleViewProps extends HeaderRoleProps { - representative: Representative; - setRepresentative: (r: Representative) => void; - isRoleSelectionOpen: boolean; - setIsRoleSelectionOpen: (open: boolean) => void; - inputValue: string; - setInputValue: (value: string) => void; - filteredRepresentatives: Representative[]; - getLabel: ILabelContext['getLabel']; -} - -const HeaderRole = (props: HeaderRoleProps) => { +export const HeaderRole = (props: HeaderRoleProps) => { + const { + label, + showDescription = true, + representatives, + isOrganization, + accordionLabels, + searchLabel, + organizationSearchLabel, + searchId, + } = props; const { getLabel } = useLabels(); - const [representative, setRepresentative] = useState( - props.representatives?.[0] ?? ({} as Representative) - ); + const [representative, setRepresentative] = useState(representatives?.[0] ?? ({} as Representative)); const [isRoleSelectionOpen, setIsRoleSelectionOpen] = useState(false); const [inputValue, setInputValue] = useState(''); const breakpoint = useBreakpoint(); const isTabletView = isBreakpointBelow(breakpoint, 'lg'); + const hasMultipleRepresentatives = (representatives?.length ?? 0) > 1; const filteredRepresentatives = useMemo(() => { - if (!props.representatives) return []; - - if (!inputValue) return props.representatives; + if (!representatives) return []; + if (!inputValue) return representatives; const search = inputValue.toLowerCase(); - - return props.representatives.filter((r) => { + return representatives.filter((r) => { return r.name.toLowerCase().includes(search) || r.description?.toLowerCase().includes(search); }); - }, [props.representatives, inputValue]); + }, [representatives, inputValue]); + + const handleToggle = () => { + const next = !isRoleSelectionOpen; + setIsRoleSelectionOpen(next); + props.onRoleSelectionToggle?.(next); + }; + + const handleRepresentativeChange = (rep: Representative) => { + setRepresentative(rep); + props.onRepresentativeChange?.(rep); + }; - const viewProps: HeaderRoleViewProps = { - ...props, + const representativesProps = { + representatives: filteredRepresentatives, representative, - setRepresentative, - isRoleSelectionOpen, - setIsRoleSelectionOpen, inputValue, setInputValue, - filteredRepresentatives, - getLabel, + setRepresentative: handleRepresentativeChange, + setIsRoleSelectionOpen, + onRoleSelectionToggle: handleToggle, + isRoleSelectionOpen, + isOrganization, + searchLabel, + organizationSearchLabel, + searchId, }; - return isTabletView ? : ; -}; - -const HeaderRoleAccordion = ({ - title, - representatives, - withStatusBadge, - isOrganization, - representative, - setRepresentative, - isRoleSelectionOpen, - setIsRoleSelectionOpen, - inputValue, - setInputValue, - filteredRepresentatives, - getLabel, -}: HeaderRoleViewProps) => { - const hasMultipleRepresentatives = (representatives?.length ?? 0) > 1; - const hasSingleRepresentative = representatives?.length === 1; + if (isTabletView) { + const openLabel = accordionLabels?.open ?? getLabel('header.role-selection'); + const closeLabel = accordionLabels?.close ?? getLabel('header.role-selection.close'); - return ( -
-
+ return ( +
- {withStatusBadge ? ( +
- {title && {title}} + {label} {representative.name}
- ) : ( - - {title && {title}} - {representative.name} - - )} + {showDescription && representative.description && ( + <> + {!hasMultipleRepresentatives && } + {representative.description} + + )} +
- {hasSingleRepresentative && representative.description && } - {!withStatusBadge && {representative.description}} + {hasMultipleRepresentatives && ( + + )}
- {hasMultipleRepresentatives && ( - setIsRoleSelectionOpen(!isRoleSelectionOpen)}> -
- {isRoleSelectionOpen ? getLabel('close') : getLabel('header.role-selection')} - -
- - )} +
- - -
- ); -}; - -const HeaderRoleSwitch = ({ - title, - representatives, - withStatusBadge, - isOrganization, - representative, - setRepresentative, - isRoleSelectionOpen, - setIsRoleSelectionOpen, - inputValue, - setInputValue, - filteredRepresentatives, -}: HeaderRoleViewProps) => { - const hasMultipleRepresentatives = (representatives?.length ?? 0) > 1; - const showStatusBadge = withStatusBadge && title; + ); + } return (
- {showStatusBadge ? ( - {title} - ) : ( - <> - {title && ( - - {title} - - )} - {!isOrganization && ( - - {representative.description} - - )} - + {label} + {showDescription && !isOrganization && ( + + {representative.description} + )}
{hasMultipleRepresentatives ? ( - setIsRoleSelectionOpen(!isRoleSelectionOpen)} - withBorder={true} - > + - + - + ) : ( 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 index 3d9e50a50..e24bc07c0 100644 --- 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 @@ -6,8 +6,16 @@ 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; + + &::backdrop { + background: transparent; + } &-heading { display: flex; 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..16aa85cc5 --- /dev/null +++ b/src/tedi/components/layout/header/components/header-search/header-search.spec.tsx @@ -0,0 +1,222 @@ +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'); + + const closeButton = dialog.querySelector('[class*="button-close"]') as HTMLElement; + fireEvent.click(closeButton); + + 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'); + }); + + 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 index 69e101917..1fc7ececc 100644 --- a/src/tedi/components/layout/header/components/header-search/header-search.tsx +++ b/src/tedi/components/layout/header/components/header-search/header-search.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { BreakpointSupport, isBreakpointBelow, useBreakpoint } from '../../../../../helpers'; import { useLabels } from '../../../../../providers/label-provider'; @@ -8,48 +8,79 @@ import { Button } from '../../../../buttons/button/button'; import HeaderMobileButton from '../header-mobile-button/header-mobile-button'; import styles from './header-search.module.scss'; -interface HeaderProfileBreakpointProps {} +interface HeaderSearchBreakpointProps {} type MobileSearchVariant = 'modal' | 'inline'; -export interface HeaderSearchProps extends BreakpointSupport { +export interface HeaderSearchProps extends BreakpointSupport { + /** 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; + }; } -const HeaderSearch = (props: HeaderSearchProps) => { - const { children, mobileVariant = 'modal' } = props; +export const HeaderSearch = (props: HeaderSearchProps) => { + const { children, mobileVariant = 'modal', mobileLabels } = props; const breakpoint = useBreakpoint(); const isMobileView = isBreakpointBelow(breakpoint, 'md'); const { getLabel } = useLabels(); const [modalOpen, setModalOpen] = useState(false); + const dialogRef = useRef(null); + + 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 ( <> {isMobileView && mobileVariant === 'modal' ? ( <> - {modalOpen && ( -
-
- - {getLabel('header.search')} - - -
-
{children}
+ +
+ + {mobileLabels?.modalTitle ?? getLabel('header.search')} + +
- )} +
{children}
+
setModalOpen(!modalOpen)} + onClick={() => setModalOpen((prev) => !prev)} icon={{ name: 'search', size: 24, color: 'inherit' }} - text={getLabel('header.search')} + label={mobileLabels?.button ?? getLabel('header.search')} /> ) : ( diff --git a/src/tedi/components/layout/header/header.module.scss b/src/tedi/components/layout/header/header.module.scss index 1f528a3a6..8a0356012 100644 --- a/src/tedi/components/layout/header/header.module.scss +++ b/src/tedi/components/layout/header/header.module.scss @@ -19,6 +19,7 @@ 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); + overflow: auto; } } 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..8924cd015 --- /dev/null +++ b/src/tedi/components/layout/header/header.spec.tsx @@ -0,0 +1,250 @@ +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 on mobile viewport', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + render( +
Bottom bar
}> + Content + + ); + + expect(screen.getByText('Bottom bar')).toBeInTheDocument(); + }); + + it('does not render bottom content on desktop viewport', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(false); + + render( +
Bottom bar
}> + Content + + ); + + expect(screen.queryByText('Bottom bar')).not.toBeInTheDocument(); + }); + + it('does not render bottom section when bottom is not provided', () => { + (isBreakpointBelow as jest.Mock).mockReturnValue(true); + + 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 index 68f89e3ad..c721f60da 100644 --- a/src/tedi/components/layout/header/header.stories.tsx +++ b/src/tedi/components/layout/header/header.stories.tsx @@ -6,19 +6,15 @@ import Toggle from '../../../../tedi/components/form/toggle/toggle'; import Separator from '../../../../tedi/components/misc/separator/separator'; 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'; import { ShowAt } from '../show-at'; import { SideNav } from '../sidenav'; -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 { Representative } from './components/header-role/header-role-representatives'; -import HeaderSearch from './components/header-search/header-search'; -import Header, { HeaderLogoProps } from './header'; +import { Header, HeaderActions, HeaderCenter, HeaderLogo, HeaderLogoProps } from './header'; /** * Figma ↗
@@ -27,14 +23,20 @@ import Header, { HeaderLogoProps } from './header'; const STORAGE_KEY = 'tedi-theme'; -export default { +const meta: Meta = { title: 'TEDI-Ready/Layout/Header', component: Header, subcomponents: { - 'Header.Logo': Header.Logo, - 'Header.Center': Header.Center, - 'Header.Actions': Header.Actions, - }, + '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(); @@ -70,8 +72,13 @@ export default { ], parameters: { layout: 'fullscreen', + status: { + type: ['breakpointSupport'], + }, }, -} as Meta; +}; + +export default meta; type Story = StoryObj; @@ -208,7 +215,7 @@ const ProfileExample = () => { - + ); }; @@ -273,9 +280,9 @@ export const Default: Story = { - + - + @@ -337,9 +344,9 @@ export const LoggedOut: Story = { - + - + @@ -399,24 +406,24 @@ export const LoggedOut: Story = { Kontakt
- +
- +
-
+ - - - + + + - + - + @@ -462,18 +469,32 @@ export const LoggedIn: Story = { {accessibilityLink} - + + Roll: + + } + representatives={representatives} + /> - + - + - + + Roll: + + } + representatives={representatives} + /> {accessibilityLink} - +
@@ -484,18 +505,18 @@ export const LoggedIn: Story = { {accessibilityLink} - + Esindatav} showDescription={false} representatives={representatives} /> - + - + - + Esindatav:} showDescription={false} representatives={representatives} /> {accessibilityLink} - + @@ -512,21 +533,51 @@ export const WithOrganizationSelection: Story = { {accessibilityLink} - + + Asutus + + } + representatives={organizations} + isOrganization + /> - + + Roll: + + } + representatives={representatives} + /> - + - + - - + + Asutus: + + } + representatives={organizations} + isOrganization + /> + + Roll: + + } + representatives={representatives} + /> {accessibilityLink} - +
@@ -537,18 +588,33 @@ export const WithOrganizationSelection: Story = { {accessibilityLink} - + + Asutus + + } + representatives={organizations2} + /> - + - + - + + Asutus: + + } + representatives={organizations2} + isOrganization + /> {accessibilityLink} - + @@ -565,21 +631,51 @@ export const AlternativeProfileAndLogoutButton: Story = { {accessibilityLink} - + + Asutus + + } + representatives={organizations} + isOrganization + /> - + + Isikukood: + + } + representatives={representatives} + /> - + - + - - + + Asutus: + + } + representatives={organizations} + isOrganization + /> + + Isikukood: + + } + representatives={representatives} + /> {accessibilityLink} - +
@@ -591,18 +687,32 @@ export const AlternativeProfileAndLogoutButton: Story = { {accessibilityLink} - + + Isikukood: + + } + representatives={representatives} + /> - + - + - + + Isikukood: + + } + representatives={representatives} + /> {accessibilityLink} - + @@ -611,15 +721,15 @@ export const AlternativeProfileAndLogoutButton: Story = {
- + - + - + - +
@@ -630,19 +740,35 @@ export const AlternativeProfileAndLogoutButton: Story = { {accessibilityLink} - + + Asutus + + } + representatives={organizations} + isOrganization + /> - + - - + + + Asutus: + + } + representatives={organizations} + isOrganization + /> {accessibilityLink} - + - + @@ -656,55 +782,69 @@ export const WithSearch: Story = {
- - - + + + - + + Roll: + + } + representatives={representatives} + /> - + - + - + + Roll: + + } + representatives={representatives} + /> - +
<>
- - + + + } > - - - + + + - + - + - + - + - + - +
@@ -723,18 +863,32 @@ export const LoggedInWithSidenav: Story = { {accessibilityLink} - + + Roll: + + } + representatives={representatives} + /> - + - + - + + Roll: + + } + representatives={representatives} + /> {accessibilityLink} - + diff --git a/src/tedi/components/layout/header/header.tsx b/src/tedi/components/layout/header/header.tsx index 04d36f696..0624c132a 100644 --- a/src/tedi/components/layout/header/header.tsx +++ b/src/tedi/components/layout/header/header.tsx @@ -3,13 +3,20 @@ import React from 'react'; import { isBreakpointBelow, useBreakpoint } from '../../../helpers'; import { useTheme } from '../../../providers/theme-provider/theme-provider'; +import Print from '../../misc/print/print'; import styles from '../header/header.module.scss'; +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'; export interface HeaderProps { /** * Content rendered inside the header, typically Header.Logo, Header.Center, and Header.Actions subcomponents. */ - children?: React.ReactNode; + children: React.ReactNode; /** * Toggle element for the mobile side navigation menu. * Typically a SideNav.Toggle component. @@ -20,41 +27,31 @@ export interface HeaderProps { * Commonly used for a mobile-specific search bar or other compact navigation elements. */ bottom?: React.ReactNode; -} - -export interface HeaderContentProps { - /** 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. */ + /** Additional CSS class name applied to the header wrapper. */ className?: string; } -export interface HeaderActionsProps { - /** Action elements rendered on the right side of the header (e.g. language selector, login, profile). */ - children?: React.ReactNode; -} - -const Header = ({ children, toggle, bottom }: HeaderProps) => { +export const Header = (props: HeaderProps) => { + const { children, toggle, bottom, className } = props; const breakpoint = useBreakpoint(); const isMobile = isBreakpointBelow(breakpoint, 'md'); return ( -
-
- {toggle && <>{toggle}} -
{children}
-
- - {bottom && isMobile &&
{bottom}
} -
+ +
+
+ {toggle && <>{toggle}} +
{children}
+
+ + {bottom && isMobile &&
{bottom}
} +
+
); }; +Header.displayName = 'Header'; + export interface HeaderLogoProps { /** * The default logo to display (typically used in light theme). @@ -76,9 +73,12 @@ export interface HeaderLogoProps { * If provided, the logo will be wrapped in an anchor element. */ href?: string; + /** Additional CSS class name applied to the logo wrapper. */ + className?: string; } -Header.Logo = function Logo({ logo, logoDark, href, showLogo = true }: HeaderLogoProps) { +export const HeaderLogo = (props: HeaderLogoProps) => { + const { logo, logoDark, href, showLogo = true, className } = props; const { theme } = useTheme(); const resolvedLogo = theme === 'dark' && logoDark ? logoDark : logo; @@ -87,24 +87,57 @@ Header.Logo = function Logo({ logo, logoDark, href, showLogo = true }: HeaderLog const content = href ? {resolvedLogo} : resolvedLogo; - return
{content}
; + return
{content}
; }; -Header.Center = function Content(props: HeaderContentProps) { - const { children, className, alignment = 'center', ...rest } = props; +HeaderLogo.displayName = 'Header.Logo'; - const centerBEM = cn(styles['tedi-header__center'], styles[`tedi-header__center--${alignment}`], className); +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}
); }; -Header.Actions = function Actions(props: HeaderActionsProps) { - const { children } = 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/hide-at.spec.tsx b/src/tedi/components/layout/hide-at.spec.tsx new file mode 100644 index 000000000..563ba14d8 --- /dev/null +++ b/src/tedi/components/layout/hide-at.spec.tsx @@ -0,0 +1,95 @@ +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('renders children when all specified breakpoints are below current', () => { + (useBreakpoint as jest.Mock).mockReturnValue('xxl'); + (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/show-at.spec.tsx b/src/tedi/components/layout/show-at.spec.tsx new file mode 100644 index 000000000..84c8fef2f --- /dev/null +++ b/src/tedi/components/layout/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(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/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 8a48b3e74..0c9a945a9 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -177,15 +177,22 @@ export const labelsMap = validateDefaultLabels({ en: 'Change role', ru: 'Изменить роль', }, - 'header.role-selection.search-label': { - description: 'Label for Search in Role selection (Person)', + '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-label-organization': { - description: 'Label for Search in Role selection (Organization)', + 'header.role-selection.search.organizationLabel': { + description: 'Label for Organization Search in Role selection', components: ['HeaderRole'], et: 'Otsi asutust', en: 'Search representative', @@ -193,28 +200,28 @@ export const labelsMap = validateDefaultLabels({ }, 'header.login': { description: 'Label for login button', - components: ['Header'], + components: ['Header, HeaderLogin'], et: 'Sisene portaali', en: 'Log in', ru: 'Зайти на портал', }, 'header.login-small': { description: 'Label for login button (small)', - components: ['Header'], + 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-small': { description: 'Label for logout button (small)', - components: ['Header'], + components: ['Header, HeaderLogout'], et: 'Välju', en: 'Log out', ru: 'Выйти', From 1643d55ed1fa24baeca3fd0ef4061edaca83cbc2 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Wed, 22 Apr 2026 11:57:44 +0300 Subject: [PATCH 03/11] feat(header): add CR fixes #506 --- .../buttons/button-content/button-content.tsx | 2 +- .../cards/card/card-stories-templates.tsx | 2 +- .../header-language/header-language.tsx | 18 +- .../header-login/header-login.spec.tsx | 25 ++- .../components/header-login/header-login.tsx | 13 +- .../header-logout/header-logout.spec.tsx | 25 ++- .../header-logout/header-logout.tsx | 14 +- .../header-mobile-button.module.scss | 6 +- .../header-mobile-button.spec.tsx | 40 ++--- .../header-mobile-button.tsx | 4 +- .../header-profile/header-profile.spec.tsx | 159 ++++++++++++++++-- .../header-profile/header-profile.tsx | 74 ++++++-- .../header-role-representatives.tsx | 2 + .../header-role/header-role.module.scss | 2 +- .../components/header-role/header-role.tsx | 6 +- .../header-search/header-search.module.scss | 9 +- .../header-search/header-search.spec.tsx | 20 ++- .../header-search/header-search.tsx | 26 ++- .../layout/header/header.module.scss | 6 +- .../components/layout/header/header.spec.tsx | 18 +- .../layout/header/header.stories.tsx | 18 +- src/tedi/components/layout/header/header.tsx | 5 +- src/tedi/components/layout/header/index.ts | 1 - src/tedi/components/layout/hide-at.spec.tsx | 16 +- src/tedi/components/layout/hide-at.tsx | 1 + .../overlays/popover/popover-content.tsx | 7 +- .../components/overlays/popover/popover.tsx | 4 +- .../label-provider/label-provider.tsx | 4 + .../providers/label-provider/labels-map.ts | 12 +- 29 files changed, 404 insertions(+), 135 deletions(-) diff --git a/src/tedi/components/buttons/button-content/button-content.tsx b/src/tedi/components/buttons/button-content/button-content.tsx index 7ca9751b3..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' 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.tsx b/src/tedi/components/layout/header/components/header-language/header-language.tsx index 381f5abd4..3be5af5c7 100644 --- a/src/tedi/components/layout/header/components/header-language/header-language.tsx +++ b/src/tedi/components/layout/header/components/header-language/header-language.tsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Text } from '../../../../../components/base/typography/text/text'; import { isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; @@ -59,9 +59,9 @@ export const HeaderLanguage = (props: HeaderLanguageProps) => { hideLabel: true, lg: { hideLabel: false }, }); - const availableLanguages: Language[] = languages ?? []; + const availableLanguages = useMemo(() => languages ?? [], [languages]); - const initialLabel = (() => { + const displayedLanguage = useMemo(() => { if (locale) { const found = availableLanguages.find((l) => l.locale === locale); if (found) return found.label; @@ -69,13 +69,9 @@ export const HeaderLanguage = (props: HeaderLanguageProps) => { if (currentLanguage) return currentLanguage; return availableLanguages[0]?.label ?? ''; - })(); - - const [language, setLanguage] = useState(initialLabel); + }, [availableLanguages, locale, currentLanguage]); const changeLanguage = (lang: Language) => { - setLanguage(lang.label); - if (lang.onClick) { lang.onClick({ onToggle: setLanguageSelectionOpen }); return; @@ -108,11 +104,11 @@ export const HeaderLanguage = (props: HeaderLanguageProps) => { )} ); }; +HeaderLogin.displayName = 'Header.Login'; + export default HeaderLogin; 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 index 772a703b7..553510c01 100644 --- 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 @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../../../helpers'; import { useLabels } from '../../../../../providers/label-provider'; @@ -61,16 +61,31 @@ describe('HeaderLogout component', () => { expect(link).toBeInTheDocument(); }); - it('handles onClick', () => { + it('handles onClick without href', () => { const onClick = jest.fn(); - const { container } = render(); + render(); - const link = container.querySelector('a'); - link?.click(); + 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' })), 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 index 751b97b82..75bf2b6ba 100644 --- a/src/tedi/components/layout/header/components/header-logout/header-logout.tsx +++ b/src/tedi/components/layout/header/components/header-logout/header-logout.tsx @@ -2,6 +2,7 @@ import { Text } from '../../../../../../tedi/components/base/typography/text/tex 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'; @@ -47,7 +48,7 @@ export const HeaderLogout = (props: HeaderLogoutProps) => { icon={{ name: 'logout', size: 24, color: 'inherit' }} label={resolvedLabel} /> - ) : ( + ) : href ? (
@@ -56,9 +57,20 @@ export const HeaderLogout = (props: HeaderLogoutProps) => {
+ ) : ( + )} ); }; +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 index 940c02d03..7b51588f3 100644 --- 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 @@ -23,15 +23,15 @@ color: inherit; } - &:hover { + &:not(&--disabled):hover { color: var(--header-mobile-button-text-hover); } - &:active { + &:not(&--disabled):active { color: var(--header-mobile-button-text-active); } - &:focus-visible { + &: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); } 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 index 4f54d2dd2..14a0e3184 100644 --- 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 @@ -13,34 +13,32 @@ describe('HeaderMobileButton component', () => { }); it('renders as a Button when no href is provided', () => { - const { container } = render(); + render(); - expect(container.querySelector('button')).toBeInTheDocument(); - expect(container.querySelector('a')).not.toBeInTheDocument(); + 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', () => { - const { container } = render(); + render(); - const link = container.querySelector('a'); - expect(link).toBeInTheDocument(); + const link = screen.getByRole('link', { name: /Menu/i }); expect(link).toHaveAttribute('href', '/page'); - expect(container.querySelector('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); it('renders as a Button when href is provided but disabled', () => { - const { container } = render(); + render(); - expect(container.querySelector('button')).toBeInTheDocument(); - expect(container.querySelector('a')).not.toBeInTheDocument(); + 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(); - const { container } = render(); + render(); - const link = container.querySelector('a')!; - fireEvent.click(link); + fireEvent.click(screen.getByRole('link', { name: /Menu/i })); expect(onClick).toHaveBeenCalled(); }); @@ -49,21 +47,23 @@ describe('HeaderMobileButton component', () => { const onClick = jest.fn(); render(); - fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByRole('button', { name: /Menu/i })); expect(onClick).toHaveBeenCalled(); }); it('applies selected class when selected is true', () => { - const { container } = render(); + render(); - expect(container.querySelector('[class*="header-mobile-button--selected"]')).toBeInTheDocument(); + const button = screen.getByRole('button', { name: /Menu/i }); + expect(button.className).toMatch(/header-mobile-button--selected/); }); it('applies disabled class when disabled is true', () => { - const { container } = render(); + render(); - expect(container.querySelector('[class*="header-mobile-button--disabled"]')).toBeInTheDocument(); + const button = screen.getByRole('button', { name: /Menu/i }); + expect(button.className).toMatch(/header-mobile-button--disabled/); }); it('renders icon from IconWithoutBackgroundProps object', () => { @@ -75,8 +75,8 @@ describe('HeaderMobileButton component', () => { }); it('renders without label', () => { - const { container } = render(); + render(); - expect(container.querySelector('[class*="header-mobile-button__inner"]')).toBeInTheDocument(); + 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 index 2163fa7c6..19b4971fc 100644 --- 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 @@ -24,7 +24,7 @@ interface HeaderMobileButtonProps { disabled?: boolean; } -const HeaderMobileButton = (props: HeaderMobileButtonProps) => { +const HeaderMobileButton = (props: HeaderMobileButtonProps): JSX.Element => { const { onClick, href, icon, label, selected, disabled } = props; const getIcon = (icon: string | IconWithoutBackgroundProps) => { @@ -63,4 +63,6 @@ const HeaderMobileButton = (props: HeaderMobileButtonProps) => { ); }; +HeaderMobileButton.displayName = 'HeaderMobileButton'; + export default HeaderMobileButton; 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 index 7eb4c6de0..192b571db 100644 --- 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 @@ -20,14 +20,27 @@ jest.mock('../../../../../providers/label-provider', () => ({ describe('HeaderProfile component', () => { const mockGetLabel = jest.fn((key: string) => key); - beforeEach(() => { - jest.clearAllMocks(); + 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), }); @@ -68,11 +81,7 @@ describe('HeaderProfile component', () => { }); it('uses mobile label on mobile viewport', () => { - (isBreakpointBelow as jest.Mock).mockImplementation((_bp: string, target: string) => { - if (target === 'md') return true; - if (target === 'lg') return true; - return true; - }); + setMobileView(); render( @@ -84,11 +93,7 @@ describe('HeaderProfile component', () => { }); it('renders modal view on mobile', () => { - (isBreakpointBelow as jest.Mock).mockImplementation((_bp: string, target: string) => { - if (target === 'md') return true; - if (target === 'lg') return true; - return true; - }); + setMobileView(); const { container } = render( @@ -104,11 +109,7 @@ describe('HeaderProfile component', () => { }); it('closes modal view on overlay click', () => { - (isBreakpointBelow as jest.Mock).mockImplementation((_bp: string, target: string) => { - if (target === 'md') return true; - if (target === 'lg') return true; - return true; - }); + setMobileView(); const { container } = render( @@ -124,4 +125,128 @@ describe('HeaderProfile component', () => { 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 index 66c73d6eb..ab5c4c3ee 100644 --- a/src/tedi/components/layout/header/components/header-profile/header-profile.tsx +++ b/src/tedi/components/layout/header/components/header-profile/header-profile.tsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import { useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { Breakpoint, @@ -35,16 +35,23 @@ export interface HeaderProfileProps extends BreakpointSupport { - const { children, showLabel = false } = props; + const { children, showLabel = false, disabled = false } = props; const { getLabel } = useLabels(); const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); const { showDropdown = 'lg', label } = getCurrentBreakpointProps(props); const [dropdownOpen, setDropdownOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false); + const triggerRef = useRef(null); + const modalId = useId(); const breakpoint = useBreakpoint(); const isMobileView = isBreakpointBelow(breakpoint, 'md'); @@ -54,27 +61,62 @@ export const HeaderProfile = (props: HeaderProfileProps) => { 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 = useDropdown ? dropdownOpen : modalOpen; + const button = isMobileView ? ( ) : showLabel ? ( - ) : ( - ); @@ -95,12 +137,22 @@ export const HeaderProfile = (props: HeaderProfileProps) => { ) : ( <> -
setModalOpen(!modalOpen)}>{button}
+ {button} {modalOpen && ( <> -
setModalOpen(false)} /> -
+
setModalOpen(false)} + aria-hidden="true" + /> + ); diff --git a/src/tedi/components/layout/header/index.ts b/src/tedi/components/layout/header/index.ts index 0952d299d..54cc85f6b 100644 --- a/src/tedi/components/layout/header/index.ts +++ b/src/tedi/components/layout/header/index.ts @@ -2,7 +2,6 @@ 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-mobile-button/header-mobile-button'; 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.spec.tsx b/src/tedi/components/layout/hide-at.spec.tsx index 563ba14d8..42482f176 100644 --- a/src/tedi/components/layout/hide-at.spec.tsx +++ b/src/tedi/components/layout/hide-at.spec.tsx @@ -68,9 +68,9 @@ describe('HideAt component', () => { expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); }); - it('renders children when all specified breakpoints are below current', () => { + it('hides children when all specified breakpoints are below the current breakpoint', () => { (useBreakpoint as jest.Mock).mockReturnValue('xxl'); - (isBreakpointBelow as jest.Mock).mockReturnValue(true); + (isBreakpointBelow as jest.Mock).mockReturnValue(false); render( @@ -78,6 +78,18 @@ describe('HideAt component', () => { ); + expect(screen.queryByText('Visible content')).not.toBeInTheDocument(); + }); + + it('renders children when current breakpoint is null (SSR)', () => { + (useBreakpoint as jest.Mock).mockReturnValue(null); + + render( + + Visible content + + ); + expect(screen.getByText('Visible content')).toBeInTheDocument(); }); diff --git a/src/tedi/components/layout/hide-at.tsx b/src/tedi/components/layout/hide-at.tsx index b293b261a..9f93b8b82 100644 --- a/src/tedi/components/layout/hide-at.tsx +++ b/src/tedi/components/layout/hide-at.tsx @@ -11,6 +11,7 @@ export const HideAt = ({ children, ...breakpoints }: HideAtProps) => { const shouldHide = Object.entries(breakpoints).some(([bp, value]) => { if (!value) return false; + if (!current) return false; return !isBreakpointBelow(current, bp as Breakpoint); }); diff --git a/src/tedi/components/overlays/popover/popover-content.tsx b/src/tedi/components/overlays/popover/popover-content.tsx index 4bc8fe5d7..4d9cb6cc6 100644 --- a/src/tedi/components/overlays/popover/popover-content.tsx +++ b/src/tedi/components/overlays/popover/popover-content.tsx @@ -62,7 +62,10 @@ export const PopoverContent = (props: PopoverContentProps) => { classNames={{ content: cn( styles['tedi-popover'], - { [styles[`tedi-popover--${width}`]]: width, [styles['tedi-popover--border']]: withBorder }, + { + [styles[`tedi-popover--${width}`]]: width && width !== 'none', + [styles['tedi-popover--border']]: withBorder, + }, className ), arrow: cn(styles['tedi-popover__arrow'], { [styles['tedi-popover__arrow--border']]: withBorder }), @@ -92,3 +95,5 @@ export const PopoverContent = (props: PopoverContentProps) => { ); }; + +PopoverContent.displayName = 'PopoverContent'; diff --git a/src/tedi/components/overlays/popover/popover.tsx b/src/tedi/components/overlays/popover/popover.tsx index f9a96bd71..a9e0e272a 100644 --- a/src/tedi/components/overlays/popover/popover.tsx +++ b/src/tedi/components/overlays/popover/popover.tsx @@ -10,7 +10,7 @@ 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 @@ -36,6 +36,7 @@ export const Popover = (props: PopoverProps) => { return ( { arrowPadding={withBorder ? ARROW_PADDING_BORDERED : ARROW_PADDING_DEFAULT} openWith={openWith} role="dialog" - {...rest} /> ); diff --git a/src/tedi/providers/label-provider/label-provider.tsx b/src/tedi/providers/label-provider/label-provider.tsx index 0d4f8adeb..306f7d789 100644 --- a/src/tedi/providers/label-provider/label-provider.tsx +++ b/src/tedi/providers/label-provider/label-provider.tsx @@ -88,6 +88,10 @@ export const LabelProvider = >( 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>; diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 0c9a945a9..3bd98d878 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -195,33 +195,33 @@ export const labelsMap = validateDefaultLabels({ description: 'Label for Organization Search in Role selection', components: ['HeaderRole'], et: 'Otsi asutust', - en: 'Search representative', - ru: 'Найти представителя', + en: 'Search organization', + ru: 'Найти организацию', }, 'header.login': { description: 'Label for login button', - components: ['Header, HeaderLogin'], + components: ['Header', 'HeaderLogin'], et: 'Sisene portaali', en: 'Log in', ru: 'Зайти на портал', }, 'header.login-small': { description: 'Label for login button (small)', - components: ['Header, HeaderLogin'], + components: ['Header', 'HeaderLogin'], et: 'Sisene', en: 'Log in', ru: 'Войти', }, 'header.logout': { description: 'Label for logout button', - components: ['Header, HeaderLogout'], + components: ['Header', 'HeaderLogout'], et: 'Logi välja', en: 'Log out', ru: 'Выйти', }, 'header.logout-small': { description: 'Label for logout button (small)', - components: ['Header, HeaderLogout'], + components: ['Header', 'HeaderLogout'], et: 'Välju', en: 'Log out', ru: 'Выйти', From 4772fbfd6ed6946d1ee618d3528b4112eac609c5 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Wed, 22 Apr 2026 15:18:06 +0300 Subject: [PATCH 04/11] feat(header): add CR fixes #506 --- .../header-language/header-language.tsx | 1 + .../header-logout/header-logout.spec.tsx | 10 +- .../header-logout/header-logout.tsx | 2 +- .../header-role-representatives.tsx | 14 ++- .../header-role/header-role.spec.tsx | 44 +++++++++ .../components/header-role/header-role.tsx | 45 ++++++--- .../layout/header/header.stories.tsx | 34 ++++--- src/tedi/components/layout/header/header.tsx | 4 +- .../layout/{ => hide-at}/hide-at.spec.tsx | 6 +- .../layout/hide-at/hide-at.stories.tsx | 95 +++++++++++++++++++ .../layout/{ => hide-at}/hide-at.tsx | 4 +- .../layout/{ => show-at}/show-at.spec.tsx | 6 +- .../layout/show-at/show-at.stories.tsx | 95 +++++++++++++++++++ .../layout/{ => show-at}/show-at.tsx | 4 +- 14 files changed, 318 insertions(+), 46 deletions(-) rename src/tedi/components/layout/{ => hide-at}/hide-at.spec.tsx (94%) create mode 100644 src/tedi/components/layout/hide-at/hide-at.stories.tsx rename src/tedi/components/layout/{ => hide-at}/hide-at.tsx (91%) rename src/tedi/components/layout/{ => show-at}/show-at.spec.tsx (94%) create mode 100644 src/tedi/components/layout/show-at/show-at.stories.tsx rename src/tedi/components/layout/{ => show-at}/show-at.tsx (91%) 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 index 3be5af5c7..ed6c75e89 100644 --- a/src/tedi/components/layout/header/components/header-language/header-language.tsx +++ b/src/tedi/components/layout/header/components/header-language/header-language.tsx @@ -125,6 +125,7 @@ export const HeaderLanguage = (props: HeaderLanguageProps) => { )}
@@ -191,7 +200,7 @@ export const HeaderRole = (props: HeaderRoleProps) => { diff --git a/src/tedi/components/layout/header/header.stories.tsx b/src/tedi/components/layout/header/header.stories.tsx index 127a74f8a..d64331c83 100644 --- a/src/tedi/components/layout/header/header.stories.tsx +++ b/src/tedi/components/layout/header/header.stories.tsx @@ -61,9 +61,6 @@ const meta: Meta = { ], parameters: { layout: 'fullscreen', - status: { - type: [{ name: 'breakpointSupport', url: '?path=/docs/helpers-usebreakpointprops--usebreakpointprops' }], - }, design: { type: 'figma', url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?m=dev&node-id=6380-53060', @@ -94,10 +91,10 @@ const languages = [ ]; const representatives: Representative[] = [ - { name: 'Mari Maasikas', description: '49504080934', icon: { name: 'person', size: 24 } }, - { name: 'Juulia Sarapuu', description: 'Peasekretär', icon: { name: 'supervised_user_circle', size: 24 } }, - { name: 'Marta Sarapuu', description: 'Sekretär', icon: { name: 'supervised_user_circle', size: 24 } }, - { name: 'Helgi Sarapuu', description: 'Jurist', icon: { name: 'supervised_user_circle', size: 24 } }, + { id: '1', name: 'Mari Maasikas', description: '49504080934', icon: { name: 'person', size: 24 } }, + { id: '2', name: 'Juulia Sarapuu', description: 'Peasekretär', icon: { name: 'supervised_user_circle', size: 24 } }, + { id: '3', name: 'Marta Sarapuu', description: 'Sekretär', icon: { name: 'supervised_user_circle', size: 24 } }, + { id: '4', name: 'Helgi Sarapuu', description: 'Jurist', icon: { name: 'supervised_user_circle', size: 24 } }, ]; const loggedInNavItems = [ @@ -165,10 +162,13 @@ const loggedInNavItems = [ }, ]; -const representatives2 = [{ name: 'Mari Maasikas', description: '49504080934' }]; +const representatives2 = [{ id: '1', name: 'Mari Maasikas', description: '49504080934' }]; -const organizations = [{ name: 'Pärnu linnavolikogu' }, { name: 'Tartu Linnavalitsus' }]; -const organizations2 = [{ name: 'Tartu Linnavalitsus' }]; +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); diff --git a/src/tedi/components/layout/hide-at/hide-at.spec.tsx b/src/tedi/components/layout/hide-at/hide-at.spec.tsx index 6abde8afe..4a437ad77 100644 --- a/src/tedi/components/layout/hide-at/hide-at.spec.tsx +++ b/src/tedi/components/layout/hide-at/hide-at.spec.tsx @@ -74,15 +74,16 @@ describe('HideAt component', () => { render( - Visible content + Hidden content ); - expect(screen.queryByText('Visible content')).not.toBeInTheDocument(); + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); }); - it('renders children when current breakpoint is null (SSR)', () => { - (useBreakpoint as jest.Mock).mockReturnValue(null); + it('renders children when current breakpoint is xs (SSR default)', () => { + (useBreakpoint as jest.Mock).mockReturnValue('xs'); + (isBreakpointBelow as jest.Mock).mockReturnValue(true); render( diff --git a/src/tedi/components/layout/hide-at/hide-at.tsx b/src/tedi/components/layout/hide-at/hide-at.tsx index ef9c0b845..110cc351b 100644 --- a/src/tedi/components/layout/hide-at/hide-at.tsx +++ b/src/tedi/components/layout/hide-at/hide-at.tsx @@ -11,7 +11,6 @@ export const HideAt = ({ children, ...breakpoints }: HideAtProps) => { const shouldHide = Object.entries(breakpoints).some(([bp, value]) => { if (!value) return false; - if (!current) return false; return !isBreakpointBelow(current, bp as Breakpoint); }); diff --git a/src/tedi/components/layout/show-at/show-at.spec.tsx b/src/tedi/components/layout/show-at/show-at.spec.tsx index c4479f6dc..3c234acd5 100644 --- a/src/tedi/components/layout/show-at/show-at.spec.tsx +++ b/src/tedi/components/layout/show-at/show-at.spec.tsx @@ -57,7 +57,7 @@ describe('ShowAt component', () => { it('shows when any of multiple breakpoints match', () => { (useBreakpoint as jest.Mock).mockReturnValue('md'); - (isBreakpointBelow as jest.Mock).mockReturnValueOnce(false); + (isBreakpointBelow as jest.Mock).mockReturnValueOnce(true).mockReturnValueOnce(false); render( From 43a85686e25cddaef525a07c7429a18d03852cf6 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 24 Apr 2026 15:57:28 +0300 Subject: [PATCH 06/11] feat(header): add design review fixes #506 --- .../header-profile/header-profile.module.scss | 2 +- .../header-profile/header-profile.tsx | 36 +- .../header-role-representatives.tsx | 70 +- .../header-role/header-role.module.scss | 28 +- .../header-role/header-role.spec.tsx | 50 +- .../components/header-role/header-role.tsx | 19 +- .../layout/header/header.stories.tsx | 1074 +++++++++-------- src/tedi/components/layout/header/header.tsx | 4 +- .../layout/hide-at/hide-at.stories.tsx | 2 +- .../layout/show-at/show-at.stories.tsx | 2 +- .../providers/label-provider/labels-map.ts | 6 +- 11 files changed, 705 insertions(+), 588 deletions(-) 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 index 37cd88f72..72262032c 100644 --- 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 @@ -39,7 +39,7 @@ align-items: flex-start; border-radius: 0; - &-small > * { + &-styled > * { display: flex; flex-direction: column; align-items: flex-start; 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 index ab5c4c3ee..199cef8be 100644 --- a/src/tedi/components/layout/header/components/header-profile/header-profile.tsx +++ b/src/tedi/components/layout/header/components/header-profile/header-profile.tsx @@ -17,18 +17,18 @@ import styles from './header-profile.module.scss'; interface HeaderProfileBreakpointProps { /** - * Defines the breakpoint from which the profile menu is displayed as a dropdown. - * Below this breakpoint, it is rendered as a full-screen modal. + * Defines the breakpoint from which the profile menu is displayed as a popover. + * Below this breakpoint, it is rendered as a modal. * * @default lg */ - showDropdown?: Breakpoint; + 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 dropdown or modal (e.g. navigation links, logout button). */ + /** 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. @@ -36,28 +36,34 @@ export interface HeaderProfileProps extends BreakpointSupport { - const { children, showLabel = false, disabled = false } = props; + const { children, showLabel = false, disabled = false, noStyle = false } = props; const { getLabel } = useLabels(); const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); - const { showDropdown = 'lg', label } = getCurrentBreakpointProps(props); + const { showPopover = 'lg', label } = getCurrentBreakpointProps(props); - const [dropdownOpen, setDropdownOpen] = useState(false); + 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 isTabletView = isBreakpointBelow(breakpoint, 'lg'); - const useDropdown = !isBreakpointBelow(breakpoint, showDropdown); + const usePopover = !isBreakpointBelow(breakpoint, showPopover); const resolvedLabel = label ?? (isMobileView ? getLabel('header.profile.mobile') : getLabel('header.profile')); @@ -79,7 +85,7 @@ export const HeaderProfile = (props: HeaderProfileProps) => { setModalOpen((prev) => !prev); }; - const isOpen = useDropdown ? dropdownOpen : modalOpen; + const isOpen = usePopover ? popoverOpen : modalOpen; const button = isMobileView ? ( { return ( <> - {useDropdown ? ( + {usePopover ? ( setDropdownOpen((prev) => !prev)} + open={popoverOpen} + onToggle={() => setPopoverOpen((prev) => !prev)} > {button} @@ -155,7 +161,7 @@ export const HeaderProfile = (props: HeaderProfileProps) => { >
{children} 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 index 1f801fbbd..bc733a81d 100644 --- 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 @@ -102,46 +102,48 @@ const HeaderRoleRepresentatives = (props: HeaderRoleRepresentativesProps) => { id={id} role="region" aria-labelledby={toggleId} - className={cn(styles['tedi-header-role__collapse'], { - [styles['tedi-header-role__collapse--open']]: isRoleSelectionOpen, + className={cn(styles['tedi-header-role__selection'], { + [styles['tedi-header-role__selection--open']]: isRoleSelectionOpen, })} {...(!isRoleSelectionOpen && { inert: '' })} > -
-
- setInputValue(e)} - label={resolvedSearchLabel} - /> - {representatives.map((rep) => { - const isSelected = representative?.id === rep.id; +
+
+
+ setInputValue(e)} + label={resolvedSearchLabel} + /> + {representatives.map((rep) => { + const isSelected = representative?.id === rep.id; - return ( - - -
- - - ); - })} + + + ); + })} +
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 index fa4b15802..207ab860d 100644 --- 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 @@ -85,29 +85,33 @@ } } - &__accordion--container { + &__container { display: flex; flex-direction: column; - gap: var(--layout-grid-gutters-08); width: 100%; - padding: var(--card-padding-md-default); + padding: 0; background-color: var(--general-surface-secondary); border-bottom: 4px solid var(--general-border-brand); } - &__accordion { + &__content { display: flex; - flex-direction: row; + 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 { - gap: var(--layout-grid-gutters-16); justify-content: space-between; } } - &__accordion--body { + &__content--body { display: flex; flex-direction: column; align-items: flex-start; @@ -123,13 +127,13 @@ } } - &__accordion--title { + &__content--title { display: flex; flex-wrap: wrap; gap: var(--layout-grid-gutters-04); } - &__accordion--toggle { + &__content--toggle { display: flex; gap: var(--link-inner-spacing-x); align-items: center; @@ -143,7 +147,7 @@ } } - &__collapse { + &__selection { display: grid; grid-template-rows: 0fr; width: 100%; @@ -156,5 +160,9 @@ &-inner { overflow: hidden; } + + &-body { + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default) var(--card-padding-md-default); + } } } 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 index 76e846137..2a580bb33 100644 --- 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 @@ -107,6 +107,52 @@ describe('HeaderRole component', () => { }); }); + 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); @@ -380,7 +426,7 @@ describe('HeaderRoleRepresentatives component', () => { /> ); - const collapse = container.querySelector('[class*="header-role__collapse"]'); + const collapse = container.querySelector('[class*="header-role__selection"]'); expect(collapse).toHaveAttribute('inert'); }); @@ -398,7 +444,7 @@ describe('HeaderRoleRepresentatives component', () => { /> ); - const collapse = container.querySelector('[class*="header-role__collapse"]'); + const collapse = container.querySelector('[class*="header-role__selection"]'); expect(collapse).not.toHaveAttribute('inert'); }); 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 index 57f4a7c87..e517fb53f 100644 --- a/src/tedi/components/layout/header/components/header-role/header-role.tsx +++ b/src/tedi/components/layout/header/components/header-role/header-role.tsx @@ -131,19 +131,20 @@ export const HeaderRole = (props: HeaderRoleProps) => { const closeLabel = accordionLabels?.close ?? getLabel('header.role-selection.close'); return ( -
+
-
+
{label} {representative?.name} @@ -166,13 +167,13 @@ export const HeaderRole = (props: HeaderRoleProps) => { aria-expanded={isRoleSelectionOpen} aria-controls={panelId} > - + {isRoleSelectionOpen ? closeLabel : openLabel} diff --git a/src/tedi/components/layout/header/header.stories.tsx b/src/tedi/components/layout/header/header.stories.tsx index d64331c83..891791420 100644 --- a/src/tedi/components/layout/header/header.stories.tsx +++ b/src/tedi/components/layout/header/header.stories.tsx @@ -4,6 +4,8 @@ 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'; @@ -173,10 +175,26 @@ 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'); }; @@ -184,27 +202,27 @@ const ProfileExample = () => { return ( <> - Minu andmed + {t('myData')} - Esindatavad + {t('representatives')} - Kontaktid + {t('contacts')}
- +
- Riiklikud teated + {t('notifications')} @@ -214,14 +232,60 @@ const ProfileExample = () => { ); }; -const accessibilityLink = ( - -
- Ligipääsetavus - -
- -); +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; @@ -233,6 +297,16 @@ const StoryWrapper = ({ children }: StoryWrapperProps) => { 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: 360px)'; @@ -257,7 +331,7 @@ export const Default: Story = { render: () => ( {({ isOpen, setIsOpen }) => ( - <> +
setIsOpen(!isOpen)} />}> @@ -281,568 +355,544 @@ export const Default: Story = {
- +
+ +
- +
)}
), }; -export const LoggedOut: Story = { +export const LoggedOut1: Story = { render: () => ( - <> -
- - {({ isOpen, setIsOpen }) => ( - <> -
setIsOpen(!isOpen)} />}> - - - - - - Link text - - - Link text - - - Link text - - - Link text - - - Link text - - - - - - - - - -
+ + {({ isOpen, setIsOpen }) => ( +
+
setIsOpen(!isOpen)} />}> + - - - - - )} - -
+ + + + Link text + + + Link text + + + Link text + + + Link text + + + Link text + + + - - {({ isOpen, setIsOpen }) => ( - <> -
setIsOpen(!isOpen)} />}> - - - - -
- - Avaleht - - - Teenused - - - Blogi - - - Kontakt - -
- -
- -
-
-
-
- - - - - - - - - - - - -
+ + + + + + - + +
- - - )} - - +
+
+
+ )} + ), }; -export const LoggedIn: Story = { +export const LoggedOut2: Story = { render: () => ( - <> -
-
- - - - {accessibilityLink} - - - Roll: - - } - representatives={representatives} - /> - - - - - + + {({ isOpen, setIsOpen }) => ( +
+
setIsOpen(!isOpen)} />}> + + + + +
+ +
+ +
+ +
+
+
+
+ + - - Roll: - - } - representatives={representatives} - /> - {accessibilityLink} + + + + - - - -
-
+ + + +
+
+ + +
+ +
+
+
+ )} + + ), +}; -
- - - - {accessibilityLink} - - Esindatav} showDescription={false} representatives={representatives} /> - - - +export const LoggedIn1: Story = { + render: () => ( +
+ + + + - - - Esindatav:} showDescription={false} representatives={representatives} /> - {accessibilityLink} - - - - -
- + + Roll: + + } + representatives={representatives} + /> + + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + +
+
), }; -export const WithOrganizationSelection: Story = { +export const LoggedIn2: Story = { render: () => ( - <> -
-
- - - - {accessibilityLink} - - - Asutus - - } - representatives={organizations} - isOrganization - /> - - - Roll: - - } - representatives={representatives} - /> - - - - - - - - Asutus: - - } - representatives={organizations} - isOrganization - /> - - Roll: - - } - representatives={representatives} - /> - {accessibilityLink} - - - - -
-
+
+ + + + + + Esindatav} showDescription={false} representatives={representatives} /> + + + + + + + Esindatav:} showDescription={false} representatives={representatives} /> + + + + + +
+ ), +}; -
- - - - {accessibilityLink} - +export const WithOrganizationSelection1: Story = { + render: () => ( +
+ + + + + + + Asutus + + } + representatives={organizations} + isOrganization + /> + + + Roll: + + } + representatives={representatives} + /> + + + + + + - Asutus + + Asutus: } - representatives={organizations2} + representatives={organizations} + isOrganization /> - - - + + Roll: + + } + representatives={representatives} + /> + + + + + +
+ ), +}; + +export const WithOrganizationSelection2: Story = { + render: () => ( +
+ + + + - - - - Asutus: - - } - representatives={organizations2} - isOrganization - /> - {accessibilityLink} - - - - -
- + + Asutus + + } + representatives={organizations2} + /> + +
+ + + + + + Asutus: + + } + representatives={organizations2} + isOrganization + /> + + + + +
+
), }; -export const AlternativeProfileAndLogoutButton: Story = { +export const AlternativeProfileAndLogoutButton1: Story = { render: () => ( - <> -
-
- - - - {accessibilityLink} - - - Asutus - - } - representatives={organizations} - isOrganization - /> - - - Isikukood: - - } - representatives={representatives} - /> - - - - - - - - Asutus: - - } - representatives={organizations} - isOrganization - /> - - Roll: - - } - representatives={representatives} - /> - {accessibilityLink} - - - - -
-
+
+ + + + + + + Asutus + + } + representatives={organizations} + isOrganization + /> + + + Isikukood: + + } + representatives={representatives} + /> + + + + + + + + Asutus: + + } + representatives={organizations} + isOrganization + /> + + Roll: + + } + representatives={representatives} + /> + + + + + +
+ ), +}; -
-
- - - - {accessibilityLink} - - - Isikukood: - - } - representatives={representatives} - /> - - - - - - - - Roll: - - } - representatives={representatives} - /> - {accessibilityLink} - - - - -
-
+export const AlternativeProfileAndLogoutButton2: Story = { + render: () => ( +
+ + + + + + + Isikukood: + + } + representatives={representatives} + /> + + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + + +
+ ), +}; -
-
- - - - +export const AlternativeProfileAndLogoutButton3: Story = { + render: () => ( +
+ + + + - - - - - - - -
-
+ + + + + + + + + ), +}; -
- - - - {accessibilityLink} - +export const AlternativeProfileAndLogoutButton4: Story = { + render: () => ( +
+ + + + + + + Asutus + + } + representatives={organizations} + isOrganization + /> + + + + + + - Asutus + + Asutus: } representatives={organizations} isOrganization /> - - - + + + + + + +
+ ), +}; + +export const WithSearch1: Story = { + render: () => ( +
+ + + + + + + + + Roll: + + } + representatives={representatives} + /> + + + + - - - Asutus: - - } - representatives={organizations} - isOrganization - /> - {accessibilityLink} - - + + Roll: + + } + representatives={representatives} + /> - - -
- + + +
+
), }; -export const WithSearch: Story = { +export const WithSearch2: Story = { render: () => ( - <> -
-
- - - - - - - - - Roll: - - } - representatives={representatives} - /> - - - - - - - - Roll: - - } - representatives={representatives} - /> - - - - -
-
- <> -
- - - } - > - - - - - - - - - - - - - - - - - - - - - - - -
- - +
+ + + } + > + + + + + + + + + + + + + + + + + + + + + + + +
), }; @@ -850,12 +900,12 @@ export const LoggedInWithSidenav: Story = { render: () => ( {({ isOpen, setIsOpen }) => ( - <> +
setIsOpen(!isOpen)} />}> - {accessibilityLink} + - {accessibilityLink} +
- - +
+ +
+
)}
), diff --git a/src/tedi/components/layout/header/header.tsx b/src/tedi/components/layout/header/header.tsx index 5a72e7426..26bedd7fc 100644 --- a/src/tedi/components/layout/header/header.tsx +++ b/src/tedi/components/layout/header/header.tsx @@ -61,7 +61,9 @@ export interface HeaderLogoProps { logoDark?: React.ReactNode; /** * Controls visibility of the logo. - * Can be used together with responsive utilities (e.g. HideAt/ShowAt or media queries). + * Useful for conditionally hiding the logo based on application state, feature flags, + * or custom media queries that fall between standard breakpoints (e.g. 360px). + * For responsive hiding at standard breakpoints, prefer wrapping Header.Logo with HideAt/ShowAt. * @default true */ showLogo?: boolean; diff --git a/src/tedi/components/layout/hide-at/hide-at.stories.tsx b/src/tedi/components/layout/hide-at/hide-at.stories.tsx index e08fe5545..ccfb06207 100644 --- a/src/tedi/components/layout/hide-at/hide-at.stories.tsx +++ b/src/tedi/components/layout/hide-at/hide-at.stories.tsx @@ -5,7 +5,7 @@ import { HideAt } from './hide-at'; const meta: Meta = { component: HideAt, - title: 'Tedi-Ready/Layout/HideAt', + title: 'TEDI-Ready/Layout/HideAt', parameters: { status: { type: ['devComponent'], diff --git a/src/tedi/components/layout/show-at/show-at.stories.tsx b/src/tedi/components/layout/show-at/show-at.stories.tsx index 7d23a2559..0de35497c 100644 --- a/src/tedi/components/layout/show-at/show-at.stories.tsx +++ b/src/tedi/components/layout/show-at/show-at.stories.tsx @@ -5,7 +5,7 @@ import { ShowAt } from './show-at'; const meta: Meta = { component: ShowAt, - title: 'Tedi-Ready/Layout/ShowAt', + title: 'TEDI-Ready/Layout/ShowAt', parameters: { status: { type: ['devComponent'], diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 3bd98d878..40fc64c80 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -173,9 +173,9 @@ export const labelsMap = validateDefaultLabels({ 'header.role-selection': { description: 'Label for Role selection on mobile', components: ['HeaderRole'], - et: 'Vaheta rolli', - en: 'Change role', - ru: 'Изменить роль', + et: 'Roll', + en: 'Role', + ru: 'Роль', }, 'header.role-selection.close': { description: 'Label for closing the Role selection on mobile when the selection view is expanded', From 18e0526e1af02dde6de4374e0988a1e4843a7053 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 24 Apr 2026 16:15:46 +0300 Subject: [PATCH 07/11] feat(header): add CR fixes #506 --- skills/tedi-react/references/components.md | 14 ++++++++++++-- .../components/header-language/header-language.tsx | 8 +++----- .../header-mobile-button/header-mobile-button.tsx | 7 ++++--- .../components/header-profile/header-profile.tsx | 6 +++++- .../header-role/header-role-representatives.tsx | 6 +++--- .../components/layout/header/header.stories.tsx | 4 ++-- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index a07e36f53..70d0d6a4f 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -282,13 +282,23 @@ Sub-components: `Header.Logo`, `Header.Center`, `Header.Actions`, `Header.Langua **Header.Login:** bp — login button **Header.Logout:** bp — logout button **Header.Profile:** bp — user profile display -**Header.Search:** search input for header +**Header.Search:** wrapper that accepts a Search child (and optional `mobileVariant`). `children: ReactNode`, `mobileVariant?: 'modal' | 'inline'`, `mobileLabels?: { button?, modalTitle? }`, `disabled?: boolean` ```tsx -
} bottom={}> +
} + bottom={ + + + + } +> } href="/" /> About + + + 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 index 5f84b93ef..851448d79 100644 --- a/src/tedi/components/layout/header/components/header-language/header-language.tsx +++ b/src/tedi/components/layout/header/components/header-language/header-language.tsx @@ -59,16 +59,14 @@ export const HeaderLanguage = (props: HeaderLanguageProps) => { hideLabel: true, lg: { hideLabel: false }, }); - const availableLanguages = languages ?? []; - const displayedLanguage = useMemo(() => { if (locale) { - const found = languages?.find((l) => l.locale === locale); + const found = languages.find((l) => l.locale === locale); if (found) return found.label; } if (currentLanguage) return currentLanguage; - return languages?.[0]?.label ?? ''; + return languages[0]?.label ?? ''; }, [languages, locale, currentLanguage]); const changeLanguage = (lang: Language) => { @@ -121,7 +119,7 @@ export const HeaderLanguage = (props: HeaderLanguageProps) => {
- {availableLanguages.map((lang) => ( + {languages.map((lang) => ( ); @@ -61,7 +62,7 @@ const HeaderMobileButton = (props: HeaderMobileButtonProps): JSX.Element => { {innerContent} ); -}; +}); HeaderMobileButton.displayName = 'HeaderMobileButton'; 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 index 199cef8be..d8195b5e4 100644 --- a/src/tedi/components/layout/header/components/header-profile/header-profile.tsx +++ b/src/tedi/components/layout/header/components/header-profile/header-profile.tsx @@ -94,6 +94,7 @@ export const HeaderProfile = (props: HeaderProfileProps) => { label={resolvedLabel} selected={isOpen} disabled={disabled} + ref={triggerRef} /> ) : showLabel ? (