Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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