Skip to content

Commit

Permalink
Carousel container portal navigation (#13162)
Browse files Browse the repository at this point in the history
* Convert the carousel navigation buttons to a portal

* render nav buttons if its  toggleable and scrollable

* hide nav buttons on show hide

* add placeholder for buttons

* only reserve space until left col

* Hide container placeholder

* Make naming more explicit

* Move spacing to the controls container rather than on the items
  • Loading branch information
abeddow91 authored Jan 22, 2025
1 parent 1d6fd98 commit 6744bed
Show file tree
Hide file tree
Showing 9 changed files with 144 additions and 89 deletions.
50 changes: 43 additions & 7 deletions dotcom-rendering/src/components/CarouselNavigationButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { css } from '@emotion/react';
import { from, space } from '@guardian/source/foundations';
import type { ThemeButton } from '@guardian/source/react-components';
import {
Button,
SvgChevronLeftSingle,
SvgChevronRightSingle,
type ThemeButton,
} from '@guardian/source/react-components';
import { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { palette } from '../palette';

type Props = {
type CarouselNavigationProps = {
previousButtonEnabled: boolean;
nextButtonEnabled: boolean;
onClickPreviousButton: () => void;
onClickNextButton: () => void;
dataLinkNameNextButton: string;
dataLinkNamePreviousButton: string;
/** Unique identifier for the carousel navigation container. */
sectionId: string;
};

const themeButton: Partial<ThemeButton> = {
Expand All @@ -39,17 +43,48 @@ const buttonStyles = css`
`;

/**
* Navigation buttons for use in a carousel-like component
*
* Navigation buttons for a carousel-like component.
*
* This component renders "Previous" and "Next" navigation buttons, designed for controlling a carousel-like component.
*
* These buttons are rendered using a React portal. A portal allows rendering the button elements outside of the
* normal React component hierarchy, enabling flexibility in their placement within the DOM. This is particularly
* useful when the buttons need to be positioned outside the visual boundaries of the carousel component itself,
* such as on the fronts containers.
*
* The portal dynamically identifies a DOM element by constructing its ID using the `sectionId` prop and
* appends the suffix `-carousel-navigation`. This allows us to create distinct navigation portals per carousel.
*
* If the target DOM element is not found, a warning is logged in the
* console. The buttons will not be rendered if the portal target is unavailable.
*
*/
export const CarouselNavigationButtons = ({
previousButtonEnabled,
nextButtonEnabled,
onClickPreviousButton,
onClickNextButton,
dataLinkNameNextButton,
dataLinkNamePreviousButton,
}: Props) => {
return (
dataLinkNameNextButton,
sectionId,
}: CarouselNavigationProps) => {
const [portalNode, setPortalNode] = useState<HTMLElement | null>(null);
useEffect(() => {
const node = document.getElementById(
`${sectionId}-carousel-navigation`,
);
if (!node) {
console.warn(
`Portal node with ID "${sectionId}-carousel-navigation" not found.`,
);
}
setPortalNode(node);
}, [sectionId]);

if (!portalNode) return null;

return ReactDOM.createPortal(
<div
aria-controls="carousel"
aria-label="carousel arrows"
Expand Down Expand Up @@ -84,6 +119,7 @@ export const CarouselNavigationButtons = ({
value="next"
data-link-name={dataLinkNameNextButton}
/>
</div>
</div>,
portalNode,
);
};
4 changes: 4 additions & 0 deletions dotcom-rendering/src/components/DecideContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Props = {
showAge?: boolean;
absoluteServerTimes: boolean;
aspectRatio: AspectRatio;
sectionId: string;
};

export const DecideContainer = ({
Expand All @@ -54,6 +55,7 @@ export const DecideContainer = ({
absoluteServerTimes,
imageLoading,
aspectRatio,
sectionId,
}: Props) => {
// If you add a new container type which contains an MPU, you must also add it to
switch (containerType) {
Expand Down Expand Up @@ -270,6 +272,7 @@ export const DecideContainer = ({
showAge={showAge}
absoluteServerTimes={absoluteServerTimes}
aspectRatio={aspectRatio}
sectionId={sectionId}
/>
</Island>
);
Expand All @@ -284,6 +287,7 @@ export const DecideContainer = ({
showAge={showAge}
absoluteServerTimes={absoluteServerTimes}
aspectRatio={aspectRatio}
sectionId={sectionId}
/>
</Island>
);
Expand Down
78 changes: 66 additions & 12 deletions dotcom-rendering/src/components/FrontSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ type Props = {
discussionApiUrl: string;
collectionBranding?: CollectionBranding;
isTagPage?: boolean;
hasNavigationButtons?: boolean;
};

const width = (columns: number, columnWidth: number, columnGap: number) =>
Expand Down Expand Up @@ -127,8 +128,8 @@ const containerStylesUntilLeftCol = css`
display: grid;
grid-template-rows:
[headline-start show-hide-start] auto
[show-hide-end headline-end content-toggleable-start content-start] auto
[headline-start controls-start] auto
[controls-end headline-end content-toggleable-start content-start] auto
[content-end content-toggleable-end bottom-content-start] auto
[bottom-content-end];
Expand Down Expand Up @@ -172,11 +173,30 @@ const containerStylesUntilLeftCol = css`
}
`;

const containerScrollableStylesFromLeftCol = css`
${between.leftCol.and.wide} {
grid-template-rows:
[headline-start controls-start] auto
[controls-end content-toggleable-start content-start] auto
[headline-end treats-start] auto
[content-end content-toggleable-end treats-end bottom-content-start] auto
[bottom-content-end];
}
${from.wide} {
grid-template-rows:
[headline-start content-start content-toggleable-start controls-start] auto
[headline-end treats-start] auto
[content-end content-toggleable-end treats-end controls-end bottom-content-start] auto
[ bottom-content-end];
}
`;

const containerStylesFromLeftCol = css`
${from.leftCol} {
grid-template-rows:
[headline-start show-hide-start content-start] auto
[show-hide-end content-toggleable-start] auto
[headline-start controls-start content-start] auto
[controls-end content-toggleable-start] auto
[headline-end treats-start] auto
[content-end content-toggleable-end treats-end bottom-content-start] auto
[bottom-content-end];
Expand All @@ -195,8 +215,8 @@ const containerStylesFromLeftCol = css`
${from.wide} {
grid-template-rows:
[headline-start content-start content-toggleable-start show-hide-start] auto
[show-hide-end] auto
[headline-start content-start content-toggleable-start controls-start] auto
[controls-end] auto
[headline-end treats-start] auto
[content-end content-toggleable-end treats-end bottom-content-start] auto
[bottom-content-end];
Expand Down Expand Up @@ -257,10 +277,21 @@ const topPadding = css`
padding-top: ${space[2]}px;
`;

const sectionShowHide = css`
grid-row: show-hide;
const sectionControls = css`
grid-row: controls;
grid-column: hide;
justify-self: end;
display: flex;
padding-top: ${space[2]}px;
${from.wide} {
flex-direction: column-reverse;
justify-content: flex-end;
align-items: flex-end;
/** we want to add space between the items in the controls section only when both items are there and visible */
:has(.carouselNavigationPlaceholder:not(.hidden)) {
justify-content: space-between;
}
}
`;

const sectionContent = css`
Expand Down Expand Up @@ -377,6 +408,15 @@ const secondaryLevelTopBorder = css`
}
`;

const carouselNavigationPlaceholder = css`
${until.leftCol} {
height: 44px;
}
.hidden & {
display: none;
}
`;

/**
* # Front Container
*
Expand Down Expand Up @@ -488,8 +528,9 @@ export const FrontSection = ({
discussionApiUrl,
collectionBranding,
isTagPage = false,
hasNavigationButtons = false,
}: Props) => {
const isToggleable = toggleable && !!sectionId && !containerLevel;
const isToggleable = toggleable && !!sectionId;
const showMore =
canShowMore &&
!!title &&
Expand All @@ -515,6 +556,10 @@ export const FrontSection = ({
fallbackStyles,
containerStylesUntilLeftCol,
!hasPageSkin && containerStylesFromLeftCol,
!hasPageSkin &&
hasNavigationButtons &&
containerScrollableStylesFromLeftCol,

hasPageSkin && pageSkinContainer,
]}
style={{
Expand Down Expand Up @@ -578,9 +623,18 @@ export const FrontSection = ({
{leftContent}
</div>

{isToggleable && (
<div css={sectionShowHide}>
<ShowHideButton sectionId={sectionId} />
{(isToggleable || hasNavigationButtons) && (
<div css={sectionControls}>
{isToggleable && (
<ShowHideButton sectionId={sectionId} />
)}
{hasNavigationButtons && (
<div
css={carouselNavigationPlaceholder}
className="carouselNavigationPlaceholder"
id={`${sectionId}-carousel-navigation`}
></div>
)}
</div>
)}

Expand Down
71 changes: 5 additions & 66 deletions dotcom-rendering/src/components/ScrollableCarousel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { css } from '@emotion/react';
import {
between,
from,
headlineBold28Object,
space,
textSansBold17Object,
} from '@guardian/source/foundations';
import { from, space } from '@guardian/source/foundations';
import { useEffect, useRef, useState } from 'react';
import { nestedOphanComponents } from '../lib/ophan-helpers';
import { palette } from '../palette';
Expand All @@ -16,22 +10,13 @@ type Props = {
carouselLength: number;
visibleCardsOnMobile: number;
visibleCardsOnTablet: number;
sectionId?: string;
};

/**
* Primary and Secondary containers have different typographic styles for the
* titles. We get the font size and line height values for these from the
* typography presets so we can calculate the offset needed to align the
* navigation buttons with the title on tablet and desktop.
*/
const primaryTitlePreset = headlineBold28Object;
const secondaryTitlePreset = textSansBold17Object;

/**
* Grid sizing values to calculate negative margin used to pull navigation
* buttons outside of container into the outer grid column at wide breakpoint.
*/
const gridColumnWidth = 60;
const gridGap = 20;
const gridGapMobile = 10;

Expand Down Expand Up @@ -62,49 +47,6 @@ const containerStyles = css`
}
`;

const containerWithNavigationStyles = css`
display: flex;
flex-direction: column-reverse;
${from.tablet} {
gap: ${space[2]}px;
}
${from.wide} {
flex-direction: row;
gap: ${space[1]}px;
}
/**
* From tablet, pull container up so navigation buttons align with title.
* The margin is calculated from the front section title font size and line
* height, and the default container spacing.
*
* From wide, the navigation buttons are pulled out of the main content area
* into the right-hand column.
*
* Between leftCol and wide the top of the fixed dividing line is pushed
* down so it starts below the navigation buttons and gap, and aligns with
* the top of the carousel.
*/
${between.tablet.and.leftCol} {
[data-container-level='Primary'] & {
margin-top: calc(
-${primaryTitlePreset.fontSize} * ${primaryTitlePreset.lineHeight} -
${space[3]}px
);
}
[data-container-level='Secondary'] & {
margin-top: calc(
-${secondaryTitlePreset.fontSize} * ${secondaryTitlePreset.lineHeight} -
${space[3]}px
);
}
}
${from.wide} {
margin-right: -${gridColumnWidth + gridGap / 2}px;
}
`;

const carouselStyles = css`
display: grid;
width: 100%;
Expand Down Expand Up @@ -225,6 +167,7 @@ export const ScrollableCarousel = ({
carouselLength,
visibleCardsOnMobile,
visibleCardsOnTablet,
sectionId,
}: Props) => {
const carouselRef = useRef<HTMLOListElement | null>(null);
const [previousButtonEnabled, setPreviousButtonEnabled] = useState(false);
Expand Down Expand Up @@ -300,12 +243,7 @@ export const ScrollableCarousel = ({
}, []);

return (
<div
css={[
containerStyles,
showNavigation && containerWithNavigationStyles,
]}
>
<div css={containerStyles}>
<ol
ref={carouselRef}
css={[
Expand All @@ -327,6 +265,7 @@ export const ScrollableCarousel = ({
nextButtonEnabled={nextButtonEnabled}
onClickPreviousButton={() => scrollTo('left')}
onClickNextButton={() => scrollTo('right')}
sectionId={sectionId ?? ''}
dataLinkNamePreviousButton={nestedOphanComponents(
'carousel',
'previous-button',
Expand Down
Loading

0 comments on commit 6744bed

Please sign in to comment.