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 }) => (
-