diff --git a/dotcom-rendering/src/components/PuzzlesArchiveMenu.island.stories.tsx b/dotcom-rendering/src/components/PuzzlesArchiveMenu.island.stories.tsx new file mode 100644 index 00000000000..186a3add78b --- /dev/null +++ b/dotcom-rendering/src/components/PuzzlesArchiveMenu.island.stories.tsx @@ -0,0 +1,105 @@ +import { css } from '@emotion/react'; +import { breakpoints, until } from '@guardian/source/foundations'; +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { userEvent, within } from 'storybook/test'; +import type { PuzzleItem } from '../types/puzzlesPage'; +import { PuzzlesArchiveMenu } from './PuzzlesArchiveMenu.island'; + +const archive = (title: string): PuzzleItem => ({ + id: `archive-${title.toLowerCase().replace(/\s+/g, '-')}`, + title, + type: 'crossword', + set: title.toLowerCase(), + cardVariant: 'archive', + url: `/puzzles-and-games/crosswords/archive?type=${encodeURIComponent(title.toLowerCase())}`, +}); + +const archives = [ + 'Quick', + 'Mini', + 'Cryptic', + 'Quick cryptic', + 'Quiptic', + 'Weekend', + 'Prize', + 'Sunday quick', + 'Genius', + 'Special', +].map(archive); + +const meta = { + component: PuzzlesArchiveMenu, + title: 'Components/PuzzlesArchiveMenu', + parameters: { + chromatic: { + viewports: [ + 320, + breakpoints.mobileLandscape, + breakpoints.tablet, + breakpoints.desktop, + ], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Closed = { + args: { archives, label: 'Crosswords archive' }, +} satisfies Story; + +export const Open = { + args: { archives, label: 'Crosswords archive' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByText('Crosswords archive')); + }, +} satisfies Story; + +export const OpenWithLongTitles = { + args: { + archives: [ + 'Quick', + 'A crossword archive with a much longer title than usual', + 'Genius', + ].map(archive), + label: 'Crosswords archive', + }, + play: Open.play, +} satisfies Story; + +/** The label sits near the bottom of the viewport, so the menu opens upwards. */ +export const OpenUpwards = { + args: { archives, label: 'Crosswords archive' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + play: Open.play, +} satisfies Story; diff --git a/dotcom-rendering/src/components/PuzzlesArchiveMenu.island.tsx b/dotcom-rendering/src/components/PuzzlesArchiveMenu.island.tsx index 2a726622a31..b26e9dfd0dc 100644 --- a/dotcom-rendering/src/components/PuzzlesArchiveMenu.island.tsx +++ b/dotcom-rendering/src/components/PuzzlesArchiveMenu.island.tsx @@ -1,5 +1,12 @@ import { css } from '@emotion/react'; -import { from, palette, space, textSans14 } from '@guardian/source/foundations'; +import { + from, + palette, + space, + textSans14, + textSans15, + until, +} from '@guardian/source/foundations'; import { useEffect, useRef } from 'react'; import type { PuzzleItem } from '../types/puzzlesPage'; @@ -92,24 +99,46 @@ const menuStyles = css` z-index: 20; top: calc(100% + ${space[1]}px); left: 0; - min-width: 256px; + min-width: 209px; margin: 0; padding: 0; border: 1px solid var(--puzzles-border-colour, ${palette.neutral[86]}); background: var(--puzzles-card-background, ${palette.neutral[100]}); list-style: none; + + /* + * Below the tablet breakpoint the archive label sits against the right + * edge, so open the menu leftwards to keep it inside the page. The + * section inset is 10px below mobileMedium and 20px from it, so the menu + * always ends 20px from the right edge of the viewport. + */ + ${until.tablet} { + right: 0; + left: auto; + } + ${until.mobileMedium} { + right: 10px; + } + + /* Opens upwards when there is not enough room below (see placeMenu). */ + &[data-placement='top'] { + top: auto; + bottom: calc(100% + ${space[1]}px); + } `; const linkStyles = css` display: flex; - min-height: 60px; - align-items: center; - padding: 0 ${space[5]}px; + box-sizing: border-box; + min-height: 45px; + align-items: flex-start; + padding: ${space[3]}px ${space[4]}px; border-bottom: 1px solid var(--puzzles-border-colour, ${palette.neutral[86]}); - color: var(--puzzles-headline-colour, ${palette.neutral[7]}); + color: var(--puzzles-headline-colour, ${palette.neutral[0]}); text-decoration: none; - ${textSans14}; + ${textSans15}; + line-height: 1.3; :hover, :focus-visible { @@ -128,6 +157,7 @@ export const PuzzlesArchiveMenu = ({ }: Props) => { const detailsRef = useRef(null); const summaryRef = useRef(null); + const menuRef = useRef(null); useEffect(() => { const close = (event: Event) => { @@ -163,8 +193,25 @@ export const PuzzlesArchiveMenu = ({ return null; } + /** + * Opens the menu upwards when it does not fit below the label and there + * is more room above it. + */ + const placeMenu = () => { + const menu = menuRef.current; + const summary = summaryRef.current; + if (!menu || !summary || detailsRef.current?.open !== true) return; + const { top, bottom } = summary.getBoundingClientRect(); + const spaceBelow = window.innerHeight - bottom - space[1]; + const spaceAbove = top - space[1]; + menu.dataset.placement = + menu.offsetHeight > spaceBelow && spaceAbove > spaceBelow + ? 'top' + : 'bottom'; + }; + return ( -
+
{label}{' '} -
    +
      {validArchives.map(({ archive, url }) => (