Skip to content

Commit 2d19a31

Browse files
Merge pull request #326 from ZoneMinder/fix/322-dialog-scroll
fix: cap dialog height so tall dialogs scroll instead of overflowing
2 parents 012caa9 + e9a8c2b commit 2d19a31

7 files changed

Lines changed: 75 additions & 5 deletions

File tree

app/src/components/dashboard/DashboardConfig.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export function DashboardConfig() {
173173
<span className="hidden sm:inline">{t('dashboard.add_widget')}</span>
174174
</Button>
175175
</DialogTrigger>
176-
<DialogContent className="max-h-[90vh] overflow-y-auto" data-testid="add-widget-dialog">
176+
<DialogContent data-testid="add-widget-dialog">
177177
<DialogHeader>
178178
<DialogTitle>{t('dashboard.add_widget')}</DialogTitle>
179179
<DialogDescription className="sr-only">

app/src/components/dashboard/WidgetEditDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export function WidgetEditDialog({ open, onOpenChange, widget, profileId }: Widg
179179

180180
return (
181181
<Dialog open={open} onOpenChange={onOpenChange}>
182-
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto" data-testid="widget-edit-dialog">
182+
<DialogContent className="sm:max-w-md" data-testid="widget-edit-dialog">
183183
<DialogHeader>
184184
<DialogTitle>{t('dashboard.edit_layout')}</DialogTitle>
185185
<DialogDescription className="sr-only">

app/src/components/live-activity/LiveActivitySettingsDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export function LiveActivitySettingsDialog({
212212

213213
return (
214214
<Dialog open={open} onOpenChange={onOpenChange}>
215-
<DialogContent className="max-h-[85vh] overflow-y-auto" data-testid="live-activity-settings-dialog">
215+
<DialogContent data-testid="live-activity-settings-dialog">
216216
<DialogHeader>
217217
<DialogTitle>{t('live_activity.settings_title')}</DialogTitle>
218218
<DialogDescription>{t('live_activity.settings_desc')}</DialogDescription>

app/src/components/ui/dialog.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,22 @@ const DialogContent = React.forwardRef<
4646
<DialogPrimitive.Content
4747
ref={ref}
4848
className={cn(
49-
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
49+
// A dialog taller than the screen used to run off both ends of it,
50+
// taking its own Save and Cancel buttons with it, worst on a phone
51+
// held sideways. Cap the height here and scroll the body below, so
52+
// no dialog has to remember to do it for itself (refs #322).
53+
"fixed left-[50%] top-[50%] z-50 flex max-h-[calc(100dvh-2rem)] w-full max-w-lg flex-col gap-4 translate-x-[-50%] translate-y-[-50%] border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
5054
className
5155
)}
5256
{...props}
5357
>
54-
{children}
58+
{/* The scroll lives on the body, not on the content: the close button
59+
is positioned against the content, so scrolling the content itself
60+
would carry the button off with it. The body inherits the content's
61+
gap so a caller passing `gap-0` still reaches its own children. */}
62+
<div className="grid min-h-0 gap-[inherit] overflow-y-auto" data-testid="dialog-body">
63+
{children}
64+
</div>
5565
<DialogPrimitive.Close
5666
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
5767
data-testid="dialog-close-button"

app/tests/features/profiles.feature

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ Feature: Profile Management
2323
And I save profile edits
2424
Then the updated profile name should appear in the list
2525

26+
@web
27+
Scenario: Edit profile dialog scrolls when it is taller than a short screen
28+
Given the viewport is phone landscape size
29+
When I open the edit dialog for the first profile
30+
Then I should see the profile edit dialog
31+
And the profile edit dialog should fit within the viewport
32+
And the profile edit dialog body should be scrollable
33+
When I scroll the profile edit dialog to the bottom
34+
Then the profile edit save button should be within the viewport
35+
And the dialog close button should be within the viewport
36+
2637
@all
2738
Scenario: Add profile with connection details
2839
When I click the add profile button

app/tests/steps/common.steps.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ Given('the viewport is tablet size', async ({ page }) => {
147147
await page.setViewportSize({ width: 1024, height: 768 });
148148
});
149149

150+
// Phone held sideways: the shortest screen the app has to work on, and where
151+
// a dialog that cannot scroll hides its own buttons (refs #322).
152+
Given('the viewport is phone landscape size', async ({ page }) => {
153+
await page.setViewportSize({ width: 812, height: 375 });
154+
});
155+
150156
// Generic dialog steps used across multiple features
151157
When('I click outside the dialog', async ({ page }) => {
152158
await page.locator('body').click({ position: { x: 10, y: 10 } });

app/tests/steps/profiles.steps.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,46 @@ Then('the newly added profile should still appear in the list', async ({ page })
200200
const card = page.locator('[data-testid="profile-card"]').filter({ hasText: newProfileName });
201201
await expect(card).toBeVisible({ timeout: testConfig.timeouts.element });
202202
});
203+
204+
// The edit dialog is the tallest in the app, so it is where a dialog that
205+
// cannot scroll first hides its own Save and Cancel buttons (refs #322).
206+
function dialogBody(page: import('@playwright/test').Page) {
207+
return page.getByTestId('profile-edit-dialog').getByTestId('dialog-body');
208+
}
209+
210+
async function isWithinViewport(locator: import('@playwright/test').Locator) {
211+
const box = await locator.boundingBox();
212+
if (!box) return false;
213+
const viewport = locator.page().viewportSize();
214+
if (!viewport) throw new Error('E2E: no viewport size set');
215+
return box.y >= 0 && box.y + box.height <= viewport.height;
216+
}
217+
218+
Then('the profile edit dialog should fit within the viewport', async ({ page }) => {
219+
expect(await isWithinViewport(page.getByTestId('profile-edit-dialog'))).toBe(true);
220+
});
221+
222+
Then('the profile edit dialog body should be scrollable', async ({ page }) => {
223+
const overflow = await dialogBody(page).evaluate((el) => el.scrollHeight - el.clientHeight);
224+
// The fixture profile's fields are what make this dialog overflow a 375px
225+
// tall screen; if they ever stop doing so the scenario is vacuous, so this
226+
// is an assertion rather than a skip.
227+
expect(overflow).toBeGreaterThan(0);
228+
});
229+
230+
When('I scroll the profile edit dialog to the bottom', async ({ page }) => {
231+
await dialogBody(page).evaluate((el) => {
232+
el.scrollTop = el.scrollHeight;
233+
});
234+
});
235+
236+
Then('the profile edit save button should be within the viewport', async ({ page }) => {
237+
await expect(page.getByTestId('profile-edit-save')).toBeVisible();
238+
expect(await isWithinViewport(page.getByTestId('profile-edit-save'))).toBe(true);
239+
});
240+
241+
Then('the dialog close button should be within the viewport', async ({ page }) => {
242+
const close = page.getByTestId('profile-edit-dialog').getByTestId('dialog-close-button');
243+
await expect(close).toBeVisible();
244+
expect(await isWithinViewport(close)).toBe(true);
245+
});

0 commit comments

Comments
 (0)