Skip to content
Draft
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ module.exports = {
'custom-element',
'custom-template',
'fallback',
'on', // AMP event handler attribute — https://amp.dev/documentation/guides-and-tutorials/learn/amp-actions-and-events/
],
},
],
Expand Down
4 changes: 2 additions & 2 deletions scripts/bundleSize/bundleSizeConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@

export const VARIANCE = 5;

export const MIN_SIZE = 935;
export const MAX_SIZE = 1309;
export const MIN_SIZE = 948;
export const MAX_SIZE = 1320;
142 changes: 142 additions & 0 deletions src/app/components/Navigation/DropdownNavigation/index.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { css, Theme } from '@emotion/react';
import pixelsToRem from '#app/utilities/pixelsToRem';
import { GROUP_B_MIN_WIDTH } from '#app/components/ThemeProvider/fontMediaQueries';
import { MAX_NAV_ITEM_HEIGHT } from '../index.styles';

export default {
dropdown: ({ palette, mq }: Theme) =>
css({
position: 'absolute',
top: '100%',
left: 0,
width: '100%',
zIndex: 99999,
backgroundColor: palette.WHITE,
borderBottom: `${pixelsToRem(3)}rem solid ${palette.POSTBOX}`,
clear: 'both',
overflow: 'hidden',
height: 0,
transition: 'all 0.2s ease-out',
transitionTimingFunction: 'cubic-bezier(0, 0, 0.58, 1)',
visibility: 'hidden',
[mq.GROUP_3_MIN_WIDTH]: {
display: 'none',
visibility: 'hidden',
},
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
},
}),

dropdownOpen: css({
visibility: 'visible',
}),

ampDropdown: ({ palette, mq }: Theme) =>
css({
position: 'absolute',
top: '100%',
left: 0,
width: '100%',
zIndex: 99999,
backgroundColor: palette.WHITE,
borderBottom: `${pixelsToRem(3)}rem solid ${palette.POSTBOX}`,
clear: 'both',
[mq.GROUP_3_MIN_WIDTH]: {
display: 'none',
visibility: 'hidden',
},
}),

dropdownList: css({
listStyleType: 'none',
margin: 0,
padding: 0,
}),

dropdownListItem: ({ palette }: Theme) =>
css({
padding: 0,
borderBottom: `${pixelsToRem(1)}rem solid ${palette.GREY_3}`,
'&:last-child': {
border: 0,
},
}),

dropdownLink: ({ palette, spacings, fontSizes, fontVariants }: Theme) =>
css({
...fontSizes.pica,
...fontVariants.sansRegular,
color: palette.GREY_10,
textDecoration: 'none',
display: 'block',
position: 'relative',
padding: `${pixelsToRem(12)}rem ${spacings.FULL}rem`,
'&:hover': {
backgroundColor: palette.GREY_3,
textDecoration: 'none',
'&::before': {
opacity: 1,
},
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: '100%',
width: `${pixelsToRem(4)}rem`,
background: palette.POSTBOX,
display: 'block',
opacity: 0,
},
'&:focus-visible': {
textDecoration: 'underline',
textDecorationColor: palette.POSTBOX,
outlineOffset: `-${pixelsToRem(3)}rem`,
},
}),

// Active link indicator: inline-start border + padding for the current page item
currentLink: ({ palette, spacings }: Theme) =>
css({
borderInlineStart: `${pixelsToRem(4)}rem solid ${palette.POSTBOX}`,
paddingInlineStart: `${spacings.FULL}rem`,
}),

menuButton: ({ palette, mq }: Theme) =>
css({
position: 'relative',
padding: 0,
margin: 0,
border: 0,
flexShrink: 0, // never let the flex container squeeze the button off-screen
float: 'inline-start',
backgroundColor: palette.POSTBOX,
color: palette.WHITE,
width: `${pixelsToRem(MAX_NAV_ITEM_HEIGHT)}rem`,
height: `${pixelsToRem(MAX_NAV_ITEM_HEIGHT)}rem`,
[mq.GROUP_3_MIN_WIDTH]: {
display: 'none',
visibility: 'hidden',
},
[GROUP_B_MIN_WIDTH]: {
width: `${pixelsToRem(MAX_NAV_ITEM_HEIGHT)}rem`,
height: `${pixelsToRem(MAX_NAV_ITEM_HEIGHT)}rem`,
},
svg: {
verticalAlign: 'middle',
fill: palette.WHITE,
},
'&:hover, &:focus': {
cursor: 'pointer',
boxShadow: `inset 0 0 0 ${pixelsToRem(4)}rem ${palette.WHITE}`,
'&::after': {
content: "''",
position: 'absolute',
inset: 0,
border: `${pixelsToRem(4)}rem solid ${palette.BLACK}`,
},
},
}),
};
185 changes: 185 additions & 0 deletions src/app/components/Navigation/DropdownNavigation/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { PropsWithChildren, useRef, cloneElement } from 'react';
import { Helmet } from 'react-helmet';
import { navigationIcons } from '#psammead/psammead-assets/src/svgs';
import VisuallyHiddenText from '#app/components/VisuallyHiddenText';
import { Direction } from '#app/models/types/global';
import styles from './index.styles';

type CanonicalDropdownProps = {
isOpen: boolean;
};

export const CanonicalDropdown = ({
isOpen,
children,
}: PropsWithChildren<CanonicalDropdownProps>) => {
const heightRef = useRef<HTMLDivElement>(null);
return (
<div
data-e2e="dropdown-nav"
ref={heightRef}
css={[styles.dropdown, isOpen && styles.dropdownOpen]}
style={{
height: `${isOpen && heightRef.current ? heightRef.current.scrollHeight : 0}px`,
}}
>
{children}
</div>
);
};

type AmpDropdownProps = {
id?: string;
hidden?: boolean;
};

export const AmpDropdown = ({
children,
id,
hidden,
}: PropsWithChildren<AmpDropdownProps>) => (
<div css={styles.ampDropdown} id={id} hidden={hidden} data-e2e="dropdown-nav">
{children}
</div>
);

export const DropdownList = ({
children,
...props
}: PropsWithChildren<React.HTMLAttributes<HTMLUListElement>>) => (
<ul css={styles.dropdownList} role="list" {...props}>
{children}
</ul>
);

type DropdownListItemProps = {
url: string;
active?: boolean;
currentPageText?: string;
clickTracker?: Record<string, unknown> | null;
viewTracker?: Record<string, unknown> | null;
};

export const DropdownListItem = ({
children,
clickTracker = null,
currentPageText,
active = false,
url,
viewTracker = null,
}: PropsWithChildren<DropdownListItemProps>) => {
const ariaId = `dropdownNavigation-${(children as string)
.replace(/\s+/g, '-')
.toLowerCase()}`;
return (
// aria-labelledby is a temporary fix for the a11y nested span bug in TalkBack: https://github.com/bbc/simorgh/issues/9652
<li
css={styles.dropdownListItem}
role="listitem"
{...(viewTracker as object)}
>
<a
css={styles.dropdownLink}
href={url}
aria-labelledby={ariaId}
{...(clickTracker as object)}
>
{active && currentPageText ? (
// ID is a temporary fix for the a11y nested span bug in TalkBack: https://github.com/bbc/simorgh/issues/9652
// eslint-disable-next-line jsx-a11y/aria-role
<span css={styles.currentLink} id={ariaId} role="text">
<VisuallyHiddenText>{`${currentPageText}, `}</VisuallyHiddenText>
{children}
</span>
) : (
// ID is a temporary fix for the a11y nested span bug in TalkBack: https://github.com/bbc/simorgh/issues/9652
<span id={ariaId}>{children}</span>
)}
</a>
</li>
);
};

type CanonicalMenuButtonProps = {
announcedText: string;
isOpen: boolean;
onClick: () => void;
dir?: Direction;
};

export const CanonicalMenuButton = ({
announcedText,
isOpen,
onClick,
dir = 'ltr',
}: CanonicalMenuButtonProps) => (
<button
type="button"
css={styles.menuButton}
onClick={onClick}
aria-expanded={isOpen ? 'true' : 'false'}
aria-label={announcedText}
dir={dir}
className="focusIndicatorRemove"
>
{isOpen ? navigationIcons.cross : navigationIcons.hamburger}
<VisuallyHiddenText>{announcedText}</VisuallyHiddenText>
</button>
);

const AmpHead = () => (
<Helmet>
<script
async
custom-element="amp-bind"
src="https://cdn.ampproject.org/v0/amp-bind-0.1.js"
/>
</Helmet>
);

const expandedHandler =
'AMP.setState({ menuState: { expanded: !menuState.expanded }})';

const initialState = { expanded: false };

type AmpMenuButtonProps = {
announcedText: string;
onToggle: string;
dir?: Direction;
};

export const AmpMenuButton = ({
announcedText,
onToggle,
dir = 'ltr',
}: AmpMenuButtonProps) => (
<>
<AmpHead />
<amp-state id="menuState">
<script
type="application/json"
/* eslint-disable-next-line react/no-danger */
dangerouslySetInnerHTML={{ __html: JSON.stringify(initialState) }}
/>
</amp-state>
<button
type="button"
css={styles.menuButton}
aria-expanded="false"
aria-label={announcedText}
data-amp-bind-aria-expanded='menuState.expanded ? "true" : "false"'
on={`tap:${expandedHandler},${onToggle}`}
dir={dir}
className="focusIndicatorRemove"
>
{cloneElement(navigationIcons.hamburger, {
'data-amp-bind-hidden': 'menuState.expanded',
})}
{cloneElement(navigationIcons.cross, {
hidden: true,
'data-amp-bind-hidden': '!menuState.expanded',
})}
<VisuallyHiddenText>{announcedText}</VisuallyHiddenText>
</button>
</>
);
22 changes: 22 additions & 0 deletions src/app/components/Navigation/DropdownNavigation/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
declare namespace React.JSX {
/*
* AMP currently doesn't have built-in types for TypeScript.
* As a workaround, custom types are declared manually.
* See: https://stackoverflow.com/a/50601125
*/
interface IntrinsicElements {
'amp-state': React.PropsWithChildren<{
id?: string;
}>;
}
}

declare namespace React {
interface HTMLAttributes {
/**
* AMP event handler attribute — used for AMP actions like `tap:element.toggleVisibility`.
* See: https://amp.dev/documentation/guides-and-tutorials/learn/amp-actions-and-events/
*/
on?: string;
}
}
Loading
Loading