diff --git a/.storybook/_storybook.scss b/.storybook/_storybook.scss index fb163f3..c18a59a 100644 --- a/.storybook/_storybook.scss +++ b/.storybook/_storybook.scss @@ -43,3 +43,10 @@ .ribbonlink-demo { padding: 20px; } + +.header-with-logo-demo { + ul { + list-style-type: none; + padding-left: 20px; + } +} diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 208d2be..ef6470b 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/jest.config.js b/jest.config.js index 33e38fa..f47c607 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ module.exports = { roots: ['./src'], transform: { '^.+\\.tsx?$': 'ts-jest', + "^.+\\.(js)$": "babel-jest" }, testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], diff --git a/package.json b/package.json index 1769e10..c194cbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nhsuk-react-components-extensions", - "version": "2.0.0-beta.1", + "version": "2.1.0-test.3", "author": { "email": "thomas.judd-cooper1@nhs.net", "name": "Thomas Judd-Cooper", @@ -31,6 +31,7 @@ "@types/react-input-mask": "^3.0.5", "@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/parser": "^8.13.0", + "babel-jest": "^29.7.0", "babel-loader": "^8.0.0", "css-loader": "^5.0.0", "eslint": "^9.14.0", @@ -82,7 +83,7 @@ "build:lib": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline && tsc --emitDeclarationOnly", "build:sass": "sass src:css", "cleanup": "bash scripts/cleanup.sh", - "lint": "stylelint src/**/*.scss && eslint --fix src/*.ts src/components/**/*.ts src/components/**/*.tsx", + "lint": "stylelint --fix src/**/*.scss src/**/**/*.scss && eslint --fix src/*.ts src/components/**/*.ts src/components/**/*.tsx", "prebuild": "yarn lint && yarn test --coverage && yarn cleanup", "storybook": "start-storybook", "test": "jest", diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 2aa7bba..b02cb52 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -4,6 +4,7 @@ describe('Index', () => { it('contains all expected elements', () => { expect(Object.keys(index)).toEqual([ 'AccordionMenu', + 'HeaderWithLogo', 'MaskedInput', 'FormGroup', 'Label', diff --git a/src/all.scss b/src/all.scss index e034cd4..067a969 100644 --- a/src/all.scss +++ b/src/all.scss @@ -11,3 +11,5 @@ @use './components/ribbon-link/RibbonLink'; @use './components/timeline/Timeline'; +@use './components/header-with-logo/headerWithLogo' + diff --git a/src/components.scss b/src/components.scss index 01b0402..0bf5a84 100644 --- a/src/components.scss +++ b/src/components.scss @@ -10,3 +10,5 @@ @use './components/tooltip/Tooltip'; @use './components/ribbon-link/RibbonLink'; @use './components/timeline/Timeline'; + +@use './components/header-with-logo/headerWithLogo'; diff --git a/src/components/header-with-logo/HeaderContext.ts b/src/components/header-with-logo/HeaderContext.ts new file mode 100644 index 0000000..88ec252 --- /dev/null +++ b/src/components/header-with-logo/HeaderContext.ts @@ -0,0 +1,34 @@ +import { createContext } from 'react'; + +export interface IHeaderContext { + orgName: string | undefined; + serviceName: string | undefined; + orgSplit: string | undefined; + orgDescriptor: string | undefined; + setSearch: (toggle: boolean) => void; + setMenuToggle: (toggle: boolean) => void; + setServiceName: (toggle: boolean) => void; + toggleMenu: () => void; + hasSearch: boolean; + hasMenuToggle: boolean; + hasServiceName: boolean; + menuOpen: boolean; + transactional: boolean; +} + +export default createContext({ + + orgName: undefined, + serviceName: undefined, + orgSplit: undefined, + orgDescriptor: undefined, + setSearch: () => {}, + setMenuToggle: () => {}, + setServiceName: () => {}, + hasSearch: false, + hasMenuToggle: false, + hasServiceName: false, + toggleMenu: () => {}, + menuOpen: false, + transactional: false, +}); diff --git a/src/components/header-with-logo/HeaderWithLogo.tsx b/src/components/header-with-logo/HeaderWithLogo.tsx new file mode 100644 index 0000000..cca5a06 --- /dev/null +++ b/src/components/header-with-logo/HeaderWithLogo.tsx @@ -0,0 +1,133 @@ +import React, { FC, HTMLProps, useContext, useState, useEffect, useMemo } from 'react'; +import classNames from 'classnames'; +import NHSLogo, { NHSLogoNavProps } from './components/LocalNHSLogo'; +import OrganisationalLogo, { OrganisationalLogoProps } from './components/LocalOrganisationalLogo'; +import HeaderContext, { IHeaderContext } from './HeaderContext'; +import Search from './components/LocalSearch'; +import Nav from './components/LocalNav'; +import NavItem from './components/LocalNavItem'; +import NavDropdownMenu from './components/LocalNavDropdownMenu'; +import { Container } from 'nhsuk-react-components'; +import Content from './components/LocalContent'; +import TransactionalServiceName from './components/LocalTransactionalServiceName'; +import HeaderJs from './header'; + +const BaseHeaderLogo: FC = (props) => { + const { orgName } = useContext(HeaderContext); + if (orgName) { + return ; + } + return ; +}; + +const HeaderContainer: FC> = ({ className, ...rest }) => ( + +); + +interface HeaderProps extends HTMLProps { + transactional?: boolean; + orgName?: string; + orgSplit?: string; + orgDescriptor?: string; + serviceName?: string; + white?: boolean; +} + +const HeaderWithLogo = ({ + className, + children, + transactional, + orgName, + orgSplit, + orgDescriptor, + role = 'banner', + serviceName, + white, + ...rest +}: HeaderProps) => { + + const [hasMenuToggle, setHasMenuToggle] = useState(false); + const [hasSearch, setHasSearch] = useState(false); + const [hasServiceName, setHasServiceName] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + + useEffect(() => { + HeaderJs(); + }, []); + + const setMenuToggle = (toggle: boolean): void => { + setHasMenuToggle(toggle); + }; + + const setSearch = (toggle: boolean): void => { + setHasSearch(toggle); + }; + + const toggleMenu = (): void => { + setMenuOpen(!menuOpen); + }; + + const setServiceName = (toggle: boolean): void => { + setHasServiceName(toggle); + }; + + const contextValue: IHeaderContext = useMemo(() => { + return { + orgName, + orgSplit, + orgDescriptor, + serviceName, + hasSearch, + hasMenuToggle, + hasServiceName, + setMenuToggle, + setSearch, + setServiceName, + toggleMenu, + menuOpen, + transactional: transactional ?? false, + }; + }, [ + orgName, + orgSplit, + orgDescriptor, + serviceName, + hasSearch, + hasMenuToggle, + hasServiceName, + setMenuToggle, + setSearch, + setServiceName, + toggleMenu, + menuOpen, + transactional, + ]); + + return ( +
+ {children} +
+ ); + +}; + +HeaderWithLogo.Logo = BaseHeaderLogo; +HeaderWithLogo.Search = Search; +HeaderWithLogo.Nav = Nav; +HeaderWithLogo.NavItem = NavItem; +HeaderWithLogo.NavDropdownMenu = NavDropdownMenu; +HeaderWithLogo.Container = HeaderContainer; +HeaderWithLogo.Content = Content; +HeaderWithLogo.ServiceName = TransactionalServiceName; + +export default HeaderWithLogo; diff --git a/src/components/header-with-logo/__tests__/HeaderWithLogo.test.tsx b/src/components/header-with-logo/__tests__/HeaderWithLogo.test.tsx new file mode 100644 index 0000000..7ada5cf --- /dev/null +++ b/src/components/header-with-logo/__tests__/HeaderWithLogo.test.tsx @@ -0,0 +1,304 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import HeaderWithLogo from '../HeaderWithLogo'; + +describe('The header component', () => { + it('Matches the snapshot', () => { + const { container } = render( + + + + + + + + Health A-Z + Live Well + Care and support + Health news + Services near you + + Home + + + + + , + ); + + expect(container).toMatchSnapshot(); + }); + + it.each` + transactional | orgName | white + ${true} | ${'org'} | ${true} + ${false} | ${undefined} | ${false} + ${undefined} | ${'org'} | ${true} + ${true} | ${'org'} | ${undefined} + `( + 'Sets the appropriate classNames with transactional $transactional and orgName $orgName and white $white', + ({ transactional, orgName, white }) => { + const { container } = render( + , + ); + + const headerElement = container.querySelector('.nhsuk-header'); + + if (transactional) { + expect(headerElement?.className).toContain('nhsuk-header__transactional'); + } else { + expect(headerElement?.className).not.toContain('nhsuk-header__transactional'); + } + + if (orgName !== undefined) { + expect(headerElement?.className).toContain('nhsuk-header--organisation'); + } else { + expect(headerElement?.className).not.toContain('nhsuk-header--organisation'); + } + + if (white) { + expect(headerElement?.className).toContain('nhsuk-header--white'); + } else { + expect(headerElement?.className).not.toContain('nhsuk-header--white'); + } + }, + ); + + describe('The Nav component', () => { + it.each` + numberOfLinks | expectedLeftAligned + ${0} | ${true} + ${1} | ${true} + ${2} | ${true} + ${3} | ${true} + ${4} | ${false} + ${5} | ${false} + `( + 'When rendered with $numberOfLinks links then it is $expectedLeftAligned that the list has the left aligned class', + ({ numberOfLinks, expectedLeftAligned }) => { + const { container } = render( + + {[...Array(numberOfLinks)].map((_x, i) => ( + + ))} + , + ); + + const navList = container.getElementsByClassName('nhsuk-header__navigation-list')[0]; + + if (expectedLeftAligned) { + expect(navList?.className).toContain('nhsuk-header__navigation-list--left-aligned'); + } else { + expect(navList?.className).not.toContain('nhsuk-header__navigation-list--left-aligned'); + } + }, + ); + + it('Only counts NavItem components when determining whether to set left aligned class', () => { + const { container } = render( + + + + + + , + ); + + const navList = container.getElementsByClassName('nhsuk-header__navigation-list')[0]; + + expect(navList?.className).toContain('nhsuk-header__navigation-list--left-aligned'); + }); + }); + + describe('The NavDropdownMenu', () => { + it.each([undefined, 'Dropdown Text'])( + 'Renders as expected when passed a dropdownText of %s', + (dropdownText) => { + const { container } = render( + + + , + ); + + const visuallyHiddenText = container.querySelector( + '.nhsuk-header__menu-toggle > .nhsuk-u-visually-hidden', + ); + + expect(visuallyHiddenText?.nextSibling?.textContent).toBe(dropdownText ?? 'More'); + }, + ); + + it('Invokes the onClick prop when button is clicked', () => { + const clickFn = jest.fn(); + const { container } = render( + + + , + ); + + const buttonElement = container.querySelector('.nhsuk-header__menu-toggle'); + + expect(clickFn).not.toHaveBeenCalled(); + + fireEvent.click(buttonElement!); + + expect(clickFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('The NavItem component', () => { + it.each([undefined, false, true])( + 'Sets the home className as expected when home is %s', + (home) => { + const { container } = render( + + + + + , + ); + + const navItemElement = container.querySelector('.nhsuk-header__navigation-item'); + + if (home) { + expect(navItemElement?.className).toContain('nhsuk-header__navigation-item--home'); + } else { + expect(navItemElement?.className).not.toContain('nhsuk-header__navigation-item--home'); + } + }, + ); + }); + + describe('The Logo component', () => { + it('Sets logo only class if there is no menu or search', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.nhsuk-header__logo')?.className).toContain( + 'nhsuk-header__logo--only', + ); + }); + + it('Does not set logo only class if there is a menu', () => { + const { container } = render( + + + + + + , + ); + + expect(container.querySelector('.nhsuk-header__logo')?.className).not.toContain( + 'nhsuk-header__logo--only', + ); + }); + + it('Does not set logo only class if there is a search', () => { + const { container } = render( + + + + , + ); + + expect(container.querySelector('.nhsuk-header__logo')?.className).not.toContain( + 'nhsuk-header__logo--only', + ); + }); + + it('Does not set logo only class if there is a service name', () => { + const { container } = render( + + Test + + , + ); + + expect(container.querySelector('.nhsuk-header__logo')?.className).not.toContain( + 'nhsuk-header__logo--only', + ); + }); + + it('Sets the transactional class if the header is transactional', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.nhsuk-header__logo')?.className).toContain( + 'nhsuk-header__transactional--logo', + ); + }); + + it.each([undefined, 'Test service'])( + 'Renders as expected with the service name %s', + (serviceName) => { + const { container } = render( + + + , + ); + + if (serviceName) { + expect(container.querySelector('.nhsuk-header__link')?.className).toContain( + 'nhsuk-header__link--service', + ); + + expect(container.querySelector('.nhsuk-header__service-name')?.textContent).toBe( + 'Test service', + ); + } else { + expect(container.querySelector('.nhsuk-header__link')?.className).not.toContain( + 'nhsuk-header__link--service', + ); + + expect(container.querySelector('.nhsuk-header__service-name')).toBeNull(); + } + }, + ); + }); + + describe('The OrganizationalLogo component', () => { + it('Is rendered when orgName is specified', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.nhsuk-organisation-name')?.textContent).toBe('Test org'); + expect(container.querySelector('.nhsuk-organisation-name-split')).toBeNull(); + expect(container.querySelector('.nhsuk-organisation-descriptor')).toBeNull(); + }); + + it('Renders the orgName, orgSplit and orgDescriptor', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.nhsuk-organisation-name-split')?.textContent).toBe( + 'Org split', + ); + expect(container.querySelector('.nhsuk-organisation-descriptor')?.textContent).toBe( + 'Org descriptor', + ); + }); + + it('Uses the logoUrl if specified', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.nhsuk-org-logo')?.getAttribute('src')).toBe('Test url'); + }); + }); +}); diff --git a/src/components/header-with-logo/__tests__/__snapshots__/HeaderWithLogo.test.tsx.snap b/src/components/header-with-logo/__tests__/__snapshots__/HeaderWithLogo.test.tsx.snap new file mode 100644 index 0000000..7a52dfd --- /dev/null +++ b/src/components/header-with-logo/__tests__/__snapshots__/HeaderWithLogo.test.tsx.snap @@ -0,0 +1,207 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The header component Matches the snapshot 1`] = ` +
+ +
+`; diff --git a/src/components/header-with-logo/_headerWithLogo.scss b/src/components/header-with-logo/_headerWithLogo.scss new file mode 100644 index 0000000..d40f97b --- /dev/null +++ b/src/components/header-with-logo/_headerWithLogo.scss @@ -0,0 +1,151 @@ +/* stylelint-disable selector-max-id */ +/* stylelint-disable declaration-no-important */ + +.nhsuk-header { + display: flex; + min-height: 46px; + align-items: center; + padding: 0; + border-bottom: 1px solid #4d8ecd; +} + +.nhsuk-header__logo { + flex-shrink: 0; + padding-left: 10px; + padding-right: 10px; + + + .nhsuk-header__link { + display: block; + width: 50px; + height: 40px; + + .nhsuk-logo { + width: 50px; + height: 40px; + border: 0; + } + } +} + +.nhsuk-header__transactional-service-name { + width: auto; + max-width: 40vw; + flex-shrink: 0; + padding: 0; + margin-right: auto; + float: initial; + + .nhsuk-header__transactional-service-name--link { + display: inline-block; + margin-top: 3px; + margin-bottom: 3px; + font-size: 24px; + font-weight: 700; + line-height: 1em; + font-style: bold; + padding-right: 30px; + } +} + +.nhsuk-navigation-container { + display: flex; + width: auto; + max-width: 70%; + flex-grow: 1; + flex-shrink: 1; + align-items: center; + justify-content: flex-end; + + .nhsuk-navigation { + width: 100%; + max-width: 100%; + flex-shrink: 1; + margin-bottom: 0 !important; + } +} + +.nhsuk-header__navigation-list { + display: flex; + width: 100%; + align-items: center; + justify-content: flex-end; + padding-left: 0; + margin: 0; + gap: 4px; + list-style: none; + + .nhsuk-header__navigation-item { + position: relative; + display: flex; + margin-bottom: 0; + list-style-type: none !important; + } + + .nhsuk-header__navigation-link { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + padding: 8px 16px; + border-width: 4px 0; + border-style: solid; + border-color: transparent; + font-weight: 500; + white-space: nowrap; + + .nhsuk-icon { + display: block; + width: 18px; + height: 18px; + } + + &:focus { + background-color: #ffeb3b; + color: #212b32; + } + + &.nhsuk-header__menu-toggle { + right: 0; + padding-right: 8px; + border-radius: 0; + + // Chevron fixes + .nhsuk-icon { + position: static; + width: 24px; + height: 24px; + margin-left: 6px; + transform: rotate(90deg); + } + + &[aria-expanded='true'] { + .nhsuk-icon { + transform: rotate(-90deg); + } + } + } + } + + .nhsuk-mobile-menu-container { + display: none; + + &.nhsuk-mobile-menu-container--visible { + display: block; + } + } + + .nhsuk-header__drop-down { + position: absolute; + z-index: 1; + top: auto; + right: 0; + width: 13rem; + padding: 10px; + background-color: #f0f4f5; + } + + .nhsuk-header__drop-down--hidden { + display: none; + }; +} diff --git a/src/components/header-with-logo/components/LocalChevronDown.tsx b/src/components/header-with-logo/components/LocalChevronDown.tsx new file mode 100644 index 0000000..ae087d3 --- /dev/null +++ b/src/components/header-with-logo/components/LocalChevronDown.tsx @@ -0,0 +1,35 @@ +import React, { FC, HTMLProps } from 'react'; +import classNames from 'classnames'; + +export interface BaseIconSVGProps extends HTMLProps { + iconType?: string; + crossOrigin?: '' | 'anonymous' | 'use-credentials'; +} + +export const BaseIconSVG: FC = ({ + className, + children, + height = 34, + width = 34, + iconType, + ...rest + }) => ( + + ); + + +export const ChevronDownIcon: FC = (props) => ( + + + +); diff --git a/src/components/header-with-logo/components/LocalContent.tsx b/src/components/header-with-logo/components/LocalContent.tsx new file mode 100644 index 0000000..1046fc1 --- /dev/null +++ b/src/components/header-with-logo/components/LocalContent.tsx @@ -0,0 +1,7 @@ +import React, { FC, HTMLProps } from 'react'; +import classNames from 'classnames'; + +const Content: FC> = ({ className, ...rest }) => { + return
; +}; +export default Content; diff --git a/src/components/header-with-logo/components/LocalLinkTypes.tsx b/src/components/header-with-logo/components/LocalLinkTypes.tsx new file mode 100644 index 0000000..5ab683f --- /dev/null +++ b/src/components/header-with-logo/components/LocalLinkTypes.tsx @@ -0,0 +1,5 @@ +import { HTMLProps } from 'react'; +export interface AsElementLink extends HTMLProps { + asElement?: React.ElementType; + to?: string; +} diff --git a/src/components/header-with-logo/components/LocalNHSLogo.tsx b/src/components/header-with-logo/components/LocalNHSLogo.tsx new file mode 100644 index 0000000..53eb8ec --- /dev/null +++ b/src/components/header-with-logo/components/LocalNHSLogo.tsx @@ -0,0 +1,61 @@ +import React, { FC, useContext, SVGProps } from 'react'; +import classNames from 'classnames'; +import HeaderContext, { IHeaderContext } from '../HeaderContext'; +import { AsElementLink } from './LocalLinkTypes'; + +interface SVGImageWithSrc extends SVGProps { + src: string; +} + +export type NHSLogoNavProps = AsElementLink; + +const SVGImageWithSrc: FC = (props) => ; + +const NHSLogo: FC = ({ + className, + alt = 'NHS Logo', + asElement: Component = 'a', + 'aria-label': ariaLabel = 'NHS homepage', + ...rest +}) => { + const { serviceName, hasMenuToggle, hasSearch, hasServiceName, transactional } = + useContext(HeaderContext); + return ( +
+ + + {alt} + + + + {serviceName ? {serviceName} : null} + +
+ ); +}; + +export default NHSLogo; diff --git a/src/components/header-with-logo/components/LocalNav.tsx b/src/components/header-with-logo/components/LocalNav.tsx new file mode 100644 index 0000000..c44be38 --- /dev/null +++ b/src/components/header-with-logo/components/LocalNav.tsx @@ -0,0 +1,35 @@ +import React, { Children, FC, HTMLProps } from 'react'; +import classNames from 'classnames'; +import { childIsOfComponentType } from './LocalTypeGuards'; +import NavItem from './LocalNavItem'; + +const Nav: FC> = ({ + className, + children, + id = 'header-navigation', + ...rest +}) => { + const primaryLinks = Children.toArray(children).filter((child) => + childIsOfComponentType(child, NavItem), + ); + return ( +
+ +
+ ); +}; + +export default Nav; diff --git a/src/components/header-with-logo/components/LocalNavDropdownMenu.tsx b/src/components/header-with-logo/components/LocalNavDropdownMenu.tsx new file mode 100644 index 0000000..0c1a784 --- /dev/null +++ b/src/components/header-with-logo/components/LocalNavDropdownMenu.tsx @@ -0,0 +1,41 @@ +import React, { FC, HTMLProps, useContext, useEffect, MouseEvent } from 'react'; +import HeaderContext, { IHeaderContext } from '../HeaderContext'; +import { ChevronDownIcon } from './LocalChevronDown'; +export interface NavDropdownMenuProps extends HTMLProps { + type?: 'button' | 'submit' | 'reset'; + dropdownText?: string; +} + +const NavMenuDropdown: FC = ({ onClick, dropdownText = 'More', ...rest }) => { + const { setMenuToggle, toggleMenu, menuOpen } = useContext(HeaderContext); + + const onToggleClick = (e: MouseEvent) => { + toggleMenu(); + + if (onClick) { + onClick(e); + } + }; + + useEffect(() => { + setMenuToggle(true); + return () => setMenuToggle(false); + }, []); + + return ( +
  • + +
  • + ); +}; + +export default NavMenuDropdown; diff --git a/src/components/header-with-logo/components/LocalNavItem.tsx b/src/components/header-with-logo/components/LocalNavItem.tsx new file mode 100644 index 0000000..cb52150 --- /dev/null +++ b/src/components/header-with-logo/components/LocalNavItem.tsx @@ -0,0 +1,30 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import { AsElementLink } from './LocalLinkTypes'; + + +export interface NavItemProps extends AsElementLink { + home?: boolean; +} + +const NavItem: FC = ({ + home, + className, + children, + asElement: Component = 'a', + ...rest +}) => ( +
  • + + {children} + +
  • +); + +export default NavItem; diff --git a/src/components/header-with-logo/components/LocalOrganisationalLogo.tsx b/src/components/header-with-logo/components/LocalOrganisationalLogo.tsx new file mode 100644 index 0000000..b81ebd9 --- /dev/null +++ b/src/components/header-with-logo/components/LocalOrganisationalLogo.tsx @@ -0,0 +1,55 @@ +import React, { FC, useContext } from 'react'; +import HeaderContext, { IHeaderContext } from '../HeaderContext'; +import { AsElementLink } from './LocalLinkTypes'; + +export interface OrganisationalLogoProps extends AsElementLink { + logoUrl?: string; +} + +const OrganisationalLogo: FC = ({ + logoUrl, + alt, + asElement: Component = 'a', + ...rest +}) => { + const { orgName, orgSplit, orgDescriptor } = useContext(HeaderContext); + return ( +
    + + {logoUrl ? ( + {alt} + ) : ( + <> + + {alt} + + + + + {orgName} + {orgSplit ? ( + <> + {' '} + {orgSplit} + + ) : null} + + {orgDescriptor ? ( + {orgDescriptor} + ) : null} + + )} + +
    + ); +}; + +export default OrganisationalLogo; diff --git a/src/components/header-with-logo/components/LocalSearch.tsx b/src/components/header-with-logo/components/LocalSearch.tsx new file mode 100644 index 0000000..fe0da07 --- /dev/null +++ b/src/components/header-with-logo/components/LocalSearch.tsx @@ -0,0 +1,52 @@ +import React, { FC, HTMLProps, useContext, useEffect } from 'react'; +import classNames from 'classnames'; +import { SearchIcon } from 'nhsuk-react-components'; +import HeaderContext, { IHeaderContext } from '../HeaderContext'; + +export interface SearchProps extends HTMLProps { + visuallyHiddenText?: string; +} + +const Search: FC = ({ + action, + method = 'get', + type = 'search', + id = 'search-field', + visuallyHiddenText = 'Search the NHS website', + autoComplete = 'off', + role = 'search', + placeholder = 'Search', + ...rest +}) => { + const { setSearch } = useContext(HeaderContext); + useEffect(() => { + setSearch(true); + return () => setSearch(false); + }, []); + return ( +
    +
    +
    + + + +
    +
    +
    + ); +}; + +export default Search; diff --git a/src/components/header-with-logo/components/LocalTransactionalServiceName.tsx b/src/components/header-with-logo/components/LocalTransactionalServiceName.tsx new file mode 100644 index 0000000..8db9b6e --- /dev/null +++ b/src/components/header-with-logo/components/LocalTransactionalServiceName.tsx @@ -0,0 +1,19 @@ +import React, { FC, HTMLProps, useContext, useEffect } from 'react'; +import classNames from 'classnames'; +import HeaderContext, { IHeaderContext } from '../HeaderContext'; + +const TransactionalServiceName: FC> = ({ className, ...rest }) => { + const { setServiceName } = useContext(HeaderContext); + useEffect(() => { + setServiceName(true); + return () => setServiceName(false); + }, []); + + return ( +
    + +
    + ); +}; + +export default TransactionalServiceName; diff --git a/src/components/header-with-logo/components/LocalTypeGuards.tsx b/src/components/header-with-logo/components/LocalTypeGuards.tsx new file mode 100644 index 0000000..a5a942e --- /dev/null +++ b/src/components/header-with-logo/components/LocalTypeGuards.tsx @@ -0,0 +1,29 @@ +import { FC, ReactElement, JSXElementConstructor, ReactNode, ReactPortal } from 'react'; + +/** + * Assert that a child item is of the given component type. + */ + + +/** + * Assert that a child item is of the given component type. + */ +export const childIsOfComponentType = ( + child: + | string + | number + | boolean + | ReactElement> + | Iterable + | ReactPortal + | null + | undefined, + component: FC, + ): child is + | React.ReactElement> + | React.ReactPortal => + child !== undefined && + child !== null && + typeof child === 'object' && + 'type' in child && + child.type === component; \ No newline at end of file diff --git a/src/components/header-with-logo/header.js b/src/components/header-with-logo/header.js new file mode 100644 index 0000000..a5f790a --- /dev/null +++ b/src/components/header-with-logo/header.js @@ -0,0 +1,224 @@ +/* + * Lifted from nhsuk-frontend and brought into this repo to enable compilation to CJS if required + * See Github issue https://github.com/nhsuk/nhsuk-frontend/issues/937 + */ + + +class Header { + constructor() { + this.menuIsOpen = false; + this.navigation = document.querySelector('.nhsuk-navigation'); + this.navigationList = document.querySelector('.nhsuk-header__navigation-list'); + this.mobileMenu = document.createElement('ul'); + this.mobileMenuToggleButton = document.querySelector('.nhsuk-header__menu-toggle'); + this.mobileMenuCloseButton = document.createElement('button'); + this.mobileMenuContainer = document.querySelector('.nhsuk-mobile-menu-container'); + this.breakpoints = []; + this.width = document.body.offsetWidth; + } + + init() { + if ( + !this.navigation || + !this.navigationList || + !this.mobileMenuToggleButton || + !this.mobileMenuContainer + ) { + return; + } + + // calculateBreakpoints and updateNavigtion need to be run twice + // the second run takes into account the width of the logo + this.setupMobileMenu(); + this.calculateBreakpoints(); + this.updateNavigation(); + this.doOnOrientationChange(); + this.calculateBreakpoints(); + this.updateNavigation(); + + this.handleResize = this.debounce(() => { + this.calculateBreakpoints(); + this.updateNavigation(); + }); + + + this.mobileMenuToggleButton.addEventListener('click', this.toggleMobileMenu.bind(this)); + window.addEventListener('resize', this.handleResize); + window.addEventListener('orientationchange', this.doOnOrientationChange()); + } + + debounce(func, timeout = 100) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => { + func.apply(this, args); + }, timeout); + }; + } + + /** + * Calculate breakpoints. + * + * Calculate the breakpoints by summing the widths of + * each navigation item. + * + */ + calculateBreakpoints() { + + let childrenWidth = 0; + + for (let i = 0; i < this.navigationList.children.length; i++) { + childrenWidth += this.navigationList.children[i].offsetWidth; + this.breakpoints[i] = childrenWidth; + } + } + + // Add the mobile menu to the DOM + setupMobileMenu() { + this.mobileMenuContainer.appendChild(this.mobileMenu); + this.mobileMenu.classList.add('nhsuk-header__drop-down', 'nhsuk-header__drop-down--hidden'); + } + + /** + * Close the mobile menu + * + * Closes the mobile menu and updates accessibility state. + * + * Remvoes the margin-bottom from the navigation + */ + closeMobileMenu() { + this.menuIsOpen = false; + this.mobileMenu.classList.add('nhsuk-header__drop-down--hidden'); + this.navigation.style.marginBottom = 0; + this.mobileMenuToggleButton.setAttribute('aria-expanded', 'false'); + this.mobileMenuToggleButton.focus(); + this.mobileMenuCloseButton.removeEventListener('click', this.closeMobileMenu.bind(this)); + document.removeEventListener('keydown', this.handleEscapeKey.bind(this)); + } + + /** + * Escape key handler + * + * This function is called when the user + * presses the escape key to close the mobile menu. + * + */ + handleEscapeKey(e) { + if (e.key === 'Escape') { + this.closeMobileMenu(); + } + } + + /** + * Open the mobile menu + * + * Opens the mobile menu and updates accessibility state. + * + * The mobile menu is absolutely positioned, so it adds a margin + * to the bottom of the navigation to prevent it from overlapping + * + * Adds event listeners for the close button, + */ + + openMobileMenu() { + this.menuIsOpen = true; + this.mobileMenu.classList.remove('nhsuk-header__drop-down--hidden'); + const marginBody = this.mobileMenu.offsetHeight; + this.navigation.style.marginBottom = `${marginBody}px`; + this.mobileMenuToggleButton.setAttribute('aria-expanded', 'true'); + + // add event listerer for esc key to close menu + document.addEventListener('keydown', this.handleEscapeKey.bind(this)); + + // add event listener for close icon to close menu + this.mobileMenuCloseButton.addEventListener('click', this.closeMobileMenu.bind(this)); + } + + /** + * Handle menu button click + * + * Toggles the mobile menu between open and closed + */ + toggleMobileMenu() { + if (this.menuIsOpen) { + this.closeMobileMenu(); + } else { + this.openMobileMenu(); + } + } + + /** + * Update nav for the available space + * + * If the available space is less than the current breakpoint, + * add the mobile menu toggle button and move the last + * item in the list to the drop-down list. + * + * If the available space is greater than the current breakpoint, + * remove the mobile menu toggle button and move the first item in the + * + * Additionaly will close the mobile menu if the window gets resized + * and the menu is open. + */ + + updateNavigation() { + const availableSpace = this.navigation.offsetWidth; + let itemsVisible = this.navigationList.children.length; + + if (availableSpace < this.breakpoints[itemsVisible - 1]) { + this.mobileMenuToggleButton.classList.add('nhsuk-header__menu-toggle--visible'); + this.mobileMenuContainer.classList.add('nhsuk-mobile-menu-container--visible'); + if (itemsVisible === 2) { + return; + } + while (availableSpace < this.breakpoints[itemsVisible - 1]) { + this.mobileMenu.insertBefore( + this.navigationList.children[itemsVisible - 2], + this.mobileMenu.firstChild, + ); + itemsVisible -= 1; + } + } else if (availableSpace > this.breakpoints[itemsVisible]) { + while (availableSpace > this.breakpoints[itemsVisible]) { + this.navigationList.insertBefore( + this.mobileMenu.removeChild(this.mobileMenu.firstChild), + this.mobileMenuContainer, + ); + itemsVisible += 1; + } + } + if (!this.mobileMenu.children.length) { + this.mobileMenuToggleButton.classList.remove('nhsuk-header__menu-toggle--visible'); + this.mobileMenuContainer.classList.remove('nhsuk-mobile-menu-container--visible'); + } + + if (document.body.offsetWidth !== this.width && this.menuIsOpen) { + this.closeMobileMenu(); + } + + } + + /** + * Orientation change + * + * Check the orientation of the device, if changed it will trigger a + * update to the breakpoints and navigation. + */ + doOnOrientationChange() { + switch (window.orientation) { + case 90: + setTimeout(() => { + this.calculateBreakpoints(); + this.updateNavigation(); + }, 200); + break; + default: + break; + } +} +} + +export default () => { + new Header().init(); +}; diff --git a/src/components/header-with-logo/index.ts b/src/components/header-with-logo/index.ts new file mode 100644 index 0000000..1a7a34a --- /dev/null +++ b/src/components/header-with-logo/index.ts @@ -0,0 +1,3 @@ +import HeaderWithLogo from './HeaderWithLogo'; + +export default HeaderWithLogo; diff --git a/src/components/masked-input/index.ts b/src/components/masked-input/index.ts index 950a0b9..47b13cf 100644 --- a/src/components/masked-input/index.ts +++ b/src/components/masked-input/index.ts @@ -5,7 +5,7 @@ import { Label } from 'nhsuk-react-components'; import { getRandomString, generateRandomID, generateRandomName } from './LocalRandomID'; import FieldsetContext from './LocalFieldsetContext'; -export default { MaskedInput} +export default MaskedInput; export { FormGroup, FormElementProps, diff --git a/src/index.ts b/src/index.ts index 2c94dbb..1435d63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { default as AccordionMenu } from './components/accordion-menu'; +export { default as HeaderWithLogo} from './components/header-with-logo' export { default as MaskedInput, FormGroup, Label, diff --git a/stories/HeaderWithLogo.stories.tsx b/stories/HeaderWithLogo.stories.tsx new file mode 100644 index 0000000..712df1b --- /dev/null +++ b/stories/HeaderWithLogo.stories.tsx @@ -0,0 +1,28 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { HeaderWithLogo } from '../src'; + +const stories = storiesOf('Header with logo next to nav links', module); + +stories + .add('Standard', () =>( + <> + + + Your information page + + Health A-Z + Live Well + Care and support + Health news + Services near you + + Home + + + + + + + )) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index bfd5e0a..8759647 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "target": "es5", "lib": ["es5", "es6", "es7", "es2017", "esnext", "dom"], "sourceMap": true, - "allowJs": false, + "allowJs": true, "jsx": "react", "moduleResolution": "node", "rootDirs": ["src", "stories"], @@ -19,7 +19,7 @@ "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, - "types": ["react", "jest", "node"], + "types": ["react", "jest", "node", "@testing-library/jest-dom"], "declaration": true, "declarationDir": "./lib", "strict": true, @@ -30,5 +30,5 @@ "ignoreDiagnostics": [7005] }, "include": ["src/**/*" ], - "exclude": ["node_modules", "**/__tests__", "**/__mocks__"] + "exclude": ["node_modules", "**/__mocks__"] } diff --git a/yarn.lock b/yarn.lock index 9a03a4a..1dad70c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12673,6 +12673,7 @@ __metadata: "@types/react-input-mask": "npm:^3.0.5" "@typescript-eslint/eslint-plugin": "npm:^8.13.0" "@typescript-eslint/parser": "npm:^8.13.0" + babel-jest: "npm:^29.7.0" babel-loader: "npm:^8.0.0" classnames: "npm:^2.2.6" css-loader: "npm:^5.0.0"