浏览代码

fix(projects): make edit modal scrollable so Save is reachable on short screens (#1642)

  Reporter on a 1508x831 Pi display couldn't mark a project as Completed
  because the edit modal's height exceeded the viewport: the outer wrapper
  centers vertically and the inner card had no max-h and no overflow, so
  the top half scrolled above and the bottom half (Status dropdown +
  Save/Cancel) scrolled below. Workaround was a full page reload.

  Standard flex-modal-scroll fix: max-h-[calc(100vh-2rem)] + flex flex-col
  on the card; a flex-1 overflow-y-auto min-h-0 wrapper around the form
  fields; Cancel/Save moved into a flex-shrink-0 sibling with a border-t
  separator so they're always visible regardless of scroll position.
  Buttons stay inside <form> so type="submit" still works.
maziggy 3 月之前
父节点
当前提交
342ad31489

文件差异内容过多而无法显示
+ 3 - 0
CHANGELOG.md


+ 75 - 1
frontend/src/__tests__/pages/ProjectsPage.test.tsx

@@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
-import { ProjectsPage } from '../../pages/ProjectsPage';
+import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
@@ -305,4 +305,78 @@ describe('ProjectsPage', () => {
       expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0);
     });
   });
+
+  describe('modal scrolls on short viewports (#1642)', () => {
+    /**
+     * Reporter on a Pi screen couldn't reach the Save button when editing a
+     * project because the modal had no max-h / overflow. The structural fix
+     * puts a max-h on the card, the form fields in a `flex-1 overflow-y-auto`
+     * wrapper, and the Save/Cancel buttons in a `flex-shrink-0` sibling so
+     * they're always visible regardless of scroll position.
+     *
+     * jsdom doesn't compute layout heights so we can't simulate the actual
+     * overflow. We pin the structure instead: the scrollable wrapper exists,
+     * the Save button is NOT a descendant of it, and the card has a max-h.
+     * A future refactor that removes any of these would re-introduce the bug.
+     */
+    const editableProject = {
+      id: 7,
+      name: 'Spool holder',
+      description: null,
+      color: '#00ae42',
+      url: null,
+      cover_image_filename: null,
+      archive_count: 0,
+      total_print_time_seconds: 0,
+      total_filament_grams: 0,
+      target_plates_count: null,
+      target_parts_count: null,
+      tags: null,
+      due_date: null,
+      priority: null,
+      budget: null,
+      status: 'active' as const,
+      created_at: '2024-01-01T00:00:00Z',
+      updated_at: '2024-01-01T00:00:00Z',
+    };
+
+    it('renders the action footer outside the scrollable fields wrapper', () => {
+      render(
+        <ProjectModal
+          project={editableProject}
+          onClose={() => {}}
+          onSave={() => {}}
+          isLoading={false}
+          currencySymbol="€"
+          t={((k: string) => k) as never}
+        />,
+      );
+
+      const saveButton = screen.getByRole('button', { name: 'common.save' });
+      const scrollable = document.querySelector('.overflow-y-auto');
+      expect(scrollable).not.toBeNull();
+      // The save button must live OUTSIDE the scrollable region — otherwise
+      // a long form pushes it below the fold on short viewports (#1642).
+      expect(scrollable!.contains(saveButton)).toBe(false);
+    });
+
+    it('caps the modal card height so it cannot exceed the viewport', () => {
+      render(
+        <ProjectModal
+          project={editableProject}
+          onClose={() => {}}
+          onSave={() => {}}
+          isLoading={false}
+          currencySymbol="€"
+          t={((k: string) => k) as never}
+        />,
+      );
+
+      // Card has max-h set so it never extends past the viewport — without
+      // this, vertical-center alignment pushes the bottom of the modal
+      // (including the action footer) off-screen.
+      const card = document.querySelector('.max-h-\\[calc\\(100vh-2rem\\)\\]');
+      expect(card).not.toBeNull();
+    });
+  });
 });

+ 12 - 4
frontend/src/pages/ProjectsPage.tsx

@@ -133,15 +133,19 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
   };
 
   return (
+    // max-h + flex column on the card + overflow on the fields wrapper so the
+    // modal stays inside the viewport on short screens (#1642). Outer p-4 is
+    // 1rem each side, hence the 2rem subtraction below.
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
-      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
-        <div className="p-4 border-b border-bambu-dark-tertiary">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary flex flex-col max-h-[calc(100vh-2rem)]">
+        <div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
           <h2 className="text-lg font-semibold text-white">
             {project ? t('projects.editProject') : t('projects.newProject')}
           </h2>
         </div>
 
-        <form onSubmit={handleSubmit} className="p-4 space-y-4">
+        <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
+          <div className="p-4 space-y-4 overflow-y-auto flex-1">
           <div>
             <label className="block text-sm font-medium text-white mb-1">
               {t('common.name')}
@@ -374,8 +378,12 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
               </select>
             </div>
           )}
+          </div>
 
-          <div className="flex justify-end gap-2 pt-2">
+          {/* Sticky action footer — stays visible regardless of scroll
+              position so Save/Cancel are always reachable on short screens
+              (#1642). Buttons stay inside <form> for type="submit". */}
+          <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
             <Button type="button" variant="secondary" onClick={onClose}>
               {t('common.cancel')}
             </Button>

文件差异内容过多而无法显示
+ 0 - 0
static/assets/index-CyGvoJrx.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DrAXd6Gv.js"></script>
+    <script type="module" crossorigin src="/assets/index-CyGvoJrx.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Df3XYvpK.css">
   </head>
   <body>

部分文件因为文件数量过多而无法显示