Skip to content

Commit 50f670b

Browse files
author
Samuel Abramov
committed
fix(dropdown, popover): prevent viewport overflow on mobile (resolve #68)
Dropdown sub-menus: collapse to inline (indented, static position) on viewports < 600px instead of flying out to the side. Clamp menu max-width to viewport. Popover: clamp width via min(token, calc(100vw - space-8)) so the panel never exceeds the viewport. Applied to default, sm, and lg sizes. New stories demonstrate both fixes in a 320px container.
1 parent 8cb33d6 commit 50f670b

4 files changed

Lines changed: 177 additions & 3 deletions

File tree

components/dropdown.css

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,44 @@
333333
visibility 0s linear 0s;
334334
}
335335

336+
/* ── Responsive: inline sub-menus on narrow viewports ────────────── */
337+
338+
@media (max-width: 599px) { /* < sm breakpoint (600px) */
339+
.ct-dropdown__sub-content {
340+
position: static;
341+
inset: auto;
342+
min-width: 100%;
343+
max-width: none;
344+
transform: none;
345+
box-shadow: none;
346+
border: none;
347+
border-top: var(--border-thin) solid var(--color-border-subtle);
348+
border-radius: 0;
349+
padding-inline-start: var(--space-4);
350+
z-index: auto;
351+
}
352+
353+
.ct-dropdown__sub[data-state='open'] > .ct-dropdown__sub-content {
354+
transform: none;
355+
}
356+
357+
.ct-dropdown__sub-chevron {
358+
transform: rotate(90deg);
359+
}
360+
361+
.ct-dropdown__sub[data-state='open'] > .ct-dropdown__sub-trigger .ct-dropdown__sub-chevron {
362+
transform: rotate(-90deg);
363+
}
364+
}
365+
366+
/* ── Responsive: clamp menu width to viewport ────────────────────── */
367+
368+
@media (max-width: 599px) { /* < sm breakpoint (600px) */
369+
.ct-dropdown__menu {
370+
max-width: calc(100vw - var(--space-8));
371+
}
372+
}
373+
336374
/* ── Sizes ────────────────────────────────────────────────────────── */
337375

338376
.ct-dropdown--sm {

components/popover.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
.ct-popover__content {
99
--_po-offset: calc(100% + var(--space-3));
10-
--_po-width: var(--ct-popover-width);
10+
--_po-width: min(var(--ct-popover-width), calc(100vw - var(--space-8)));
1111
--_po-arrow-size: 8px;
1212

1313
position: absolute;
@@ -37,11 +37,11 @@
3737
/* Size variants */
3838

3939
.ct-popover--sm .ct-popover__content {
40-
--_po-width: 240px;
40+
--_po-width: min(240px, calc(100vw - var(--space-8)));
4141
}
4242

4343
.ct-popover--lg .ct-popover__content {
44-
--_po-width: 480px;
44+
--_po-width: min(480px, calc(100vw - var(--space-8)));
4545
}
4646

4747
/* ── Side positioning ─────────────────────────────────────────────── */

stories/Dropdown.stories.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,3 +1086,77 @@ export const ContextMenuPattern = {
10861086
expect(items.length).toBe(4);
10871087
},
10881088
};
1089+
1090+
/**
1091+
* Sub-menus collapse inline on narrow viewports (< 600px).
1092+
* On mobile, sub-content becomes a nested indented section instead of
1093+
* flying out to the side where it would overflow the viewport.
1094+
*/
1095+
export const SubMenuResponsive = {
1096+
parameters: {
1097+
docs: {
1098+
description: {
1099+
story:
1100+
'On viewports below 600px, sub-menus render inline (indented) instead of ' +
1101+
'positioned to the side, preventing viewport overflow. ' +
1102+
'Resize the viewport or use Storybook mobile viewport to see the effect.',
1103+
},
1104+
story: { inline: true, height: 360 },
1105+
},
1106+
},
1107+
render: () => `
1108+
<div style="min-height: 320px; padding: 24px; max-width: 320px;">
1109+
<div class="ct-dropdown" data-state="open">
1110+
<button class="ct-button ct-button--secondary"
1111+
aria-haspopup="menu" aria-expanded="true" aria-controls="resp-sub-menu">
1112+
Actions
1113+
</button>
1114+
<div class="ct-dropdown__menu" role="menu" id="resp-sub-menu" aria-label="Actions">
1115+
<button class="ct-dropdown__item" role="menuitem" tabindex="0">
1116+
<span class="ct-dropdown__item-icon" aria-hidden="true">${EDIT_SVG}</span>
1117+
<span class="ct-dropdown__item-label">Edit</span>
1118+
</button>
1119+
<div class="ct-dropdown__sub" data-state="open">
1120+
<button class="ct-dropdown__sub-trigger" role="menuitem"
1121+
aria-haspopup="menu" aria-expanded="true" tabindex="-1">
1122+
<span class="ct-dropdown__item-icon" aria-hidden="true">${SHARE_SVG}</span>
1123+
<span class="ct-dropdown__item-label">Share via</span>
1124+
<span class="ct-dropdown__sub-chevron" aria-hidden="true">${CHEVRON_RIGHT_SVG}</span>
1125+
</button>
1126+
<div class="ct-dropdown__sub-content" role="menu" aria-label="Share options">
1127+
<button class="ct-dropdown__item" role="menuitem" tabindex="-1">
1128+
<span class="ct-dropdown__item-icon" aria-hidden="true">${LINK_SVG}</span>
1129+
<span class="ct-dropdown__item-label">Copy link</span>
1130+
</button>
1131+
<button class="ct-dropdown__item" role="menuitem" tabindex="-1">
1132+
<span class="ct-dropdown__item-icon" aria-hidden="true">${MAIL_SVG}</span>
1133+
<span class="ct-dropdown__item-label">Email</span>
1134+
</button>
1135+
</div>
1136+
</div>
1137+
<div class="ct-dropdown__separator" role="none"></div>
1138+
<button class="ct-dropdown__item ct-dropdown__item--danger" role="menuitem" tabindex="-1">
1139+
<span class="ct-dropdown__item-icon" aria-hidden="true">${TRASH_SVG}</span>
1140+
<span class="ct-dropdown__item-label">Delete</span>
1141+
</button>
1142+
</div>
1143+
</div>
1144+
</div>`,
1145+
play: async ({ canvasElement }) => {
1146+
// Sub-menu content is visible and accessible
1147+
const subContent = canvasElement.querySelector('.ct-dropdown__sub-content');
1148+
expect(subContent).toHaveAttribute('role', 'menu');
1149+
const subStyle = window.getComputedStyle(subContent);
1150+
expect(subStyle.visibility).toBe('visible');
1151+
1152+
// Sub-menu items are in the DOM and accessible
1153+
const subItems = subContent.querySelectorAll('[role="menuitem"]');
1154+
expect(subItems.length).toBe(2);
1155+
1156+
// Menu does not overflow its container width
1157+
const menu = canvasElement.querySelector('.ct-dropdown__menu');
1158+
const menuRect = menu.getBoundingClientRect();
1159+
const containerRect = canvasElement.getBoundingClientRect();
1160+
expect(menuRect.right).toBeLessThanOrEqual(containerRect.right + 1);
1161+
},
1162+
};

stories/Popover.stories.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,65 @@ export const Closed = {
334334
expect(content).not.toBeVisible();
335335
},
336336
};
337+
338+
/**
339+
* Popover width is clamped to viewport via `min(--ct-popover-width, calc(100vw - space-8))`.
340+
* On a 320px viewport, a 320px popover would overflow — the clamp prevents this.
341+
*/
342+
export const ResponsiveWidth = {
343+
parameters: {
344+
docs: {
345+
description: {
346+
story:
347+
'Popover width is clamped to never exceed the viewport. ' +
348+
'Uses `min(var(--ct-popover-width), calc(100vw - var(--space-8)))` so the ' +
349+
'popover shrinks on narrow viewports instead of overflowing.',
350+
},
351+
story: { inline: true, height: 300 },
352+
},
353+
},
354+
render: () => `
355+
<div style="padding: 24px; max-width: 320px;">
356+
<div class="ct-popover" data-state="open" data-side="bottom" data-align="start">
357+
<button class="ct-button ct-button--secondary"
358+
aria-haspopup="dialog" aria-expanded="true">
359+
Open popover
360+
</button>
361+
<div class="ct-popover__content" role="dialog" aria-label="Responsive popover">
362+
<div class="ct-popover__header">
363+
<h3>Filter</h3>
364+
</div>
365+
<div class="ct-popover__body">
366+
<div class="ct-field">
367+
<label class="ct-field__label" for="resp-po-input">Search</label>
368+
<input class="ct-input" id="resp-po-input" type="text" placeholder="Filter items..." />
369+
</div>
370+
</div>
371+
<div class="ct-popover__footer">
372+
<button class="ct-button ct-button--secondary ct-button--sm">Reset</button>
373+
<button class="ct-button ct-button--sm">Apply</button>
374+
</div>
375+
</div>
376+
</div>
377+
</div>`,
378+
play: async ({ canvasElement }) => {
379+
const canvas = within(canvasElement);
380+
const content = canvasElement.querySelector('.ct-popover__content');
381+
382+
// Popover is visible
383+
expect(content).toBeVisible();
384+
expect(content).toHaveAttribute('role', 'dialog');
385+
386+
// Popover does not overflow its container
387+
const contentRect = content.getBoundingClientRect();
388+
const containerRect = canvasElement.getBoundingClientRect();
389+
expect(contentRect.right).toBeLessThanOrEqual(containerRect.right + 1);
390+
expect(contentRect.width).toBeGreaterThan(0);
391+
392+
// Interactive elements are accessible
393+
const searchInput = canvas.getByLabelText('Search');
394+
expect(searchInput).toBeInTheDocument();
395+
const applyBtn = canvas.getByRole('button', { name: 'Apply' });
396+
expect(applyBtn).toBeInTheDocument();
397+
},
398+
};

0 commit comments

Comments
 (0)