Browse Source

fix(label-picker): pack templates into a 2x2 grid so all 4 plus Cancel fit on tight viewports (issue #1230)

  The earlier `min-h-0` fix on the spool list (61314cf2) made the
  shrinkable child shrinkable, but on @elit3ge's 838px viewport the
  four stacked templates (~310px) plus footer still blew past
  max-h-[90vh] once Brave's browser chrome ate into vh, and
  overflow-hidden on the modal clipped Avery 5160 mid-row with the
  Cancel button entirely below the clipped bottom edge — no scroll
  path. The screenshot showed the spool list at ~5 visible rows with
  its own scrollbar still active, confirming the templates section's
  natural height was the dominant problem, not the spool list.

  Templates now render as a responsive grid (grid-cols-1
  sm:grid-cols-2 gap-2) so the four buttons pack into a 2x2 grid
  above the sm breakpoint, trimming ~150px of vertical. Per-cell
  padding tightens to p-2.5, labels/hints get text-sm + truncate,
  and the full strings are reachable via title="<label> — <hint>"
  on each button. Footer drops py-3 to py-2 for a few extra pixels.
  The min-h-0 on the spool list is kept as a belt-and-braces shrink
  for any viewport tighter still. Mobile (<sm) keeps the stacked
  layout — no regression there.
maziggy 2 weeks ago
parent
commit
4c0a12b95e

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 18 - 10
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -275,13 +275,15 @@ describe('LabelTemplatePickerModal', () => {
     expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
     expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
   });
   });
 
 
-  it('lets the spool list shrink (min-h-0) so all 4 templates and Cancel stay visible on short viewports (#1230)', () => {
-    // Regression for #1230: on viewports where 90vh is tight (Windows 11
-    // browser-chrome or DPI scaling), an explicit min-h on the spool list
-    // pinned it taller than the modal could give back to templates + footer,
-    // and `overflow-hidden` on the outer modal clipped the 4th template
-    // (Avery 5160) and the Cancel button. The fix is `min-h-0` so the
-    // flex-1 spool list can yield space when needed.
+  it('packs templates into a 2x2 grid so all 4 plus Cancel fit on short viewports (#1230)', () => {
+    // Regression for #1230: with 4 templates stacked vertically (~310px) plus
+    // header/search/action bar/footer, the modal blew past max-h-[90vh] on
+    // Windows-11 + Brave-style viewports where browser chrome eats into 90vh.
+    // overflow-hidden on the modal then clipped Avery 5160 and the Cancel
+    // footer with no scroll path. The fix uses sm:grid-cols-2 so the 4
+    // templates render as a 2x2 grid (~155px), trimming ~150px of vertical
+    // and leaving room for the footer. The earlier min-h-0 on the spool list
+    // is kept so it still yields any remaining slack.
     const { container } = render(
     const { container } = render(
       <LabelTemplatePickerModal
       <LabelTemplatePickerModal
         isOpen={true}
         isOpen={true}
@@ -299,9 +301,15 @@ describe('LabelTemplatePickerModal', () => {
     expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
     expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
     expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
     expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
 
 
-    // Structural guard against the regression: the scrollable spool list
-    // must have `min-h-0` so flex shrinking actually works, and must NOT
-    // pin a fixed minimum height that prevents it.
+    // Templates section must be a responsive grid (single column on mobile,
+    // two columns from sm: up) — a future refactor that drops the grid and
+    // reintroduces stacked rows fails CI.
+    const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
+    expect(templatesSection).not.toBeNull();
+    expect(templatesSection!.className).toContain('grid-cols-1');
+    expect(templatesSection!.querySelectorAll('button').length).toBe(4);
+
+    // Spool list still uses min-h-0 so it can yield further on very tight viewports.
     const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
     const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
     expect(spoolListScroller).not.toBeNull();
     expect(spoolListScroller).not.toBeNull();
     expect(spoolListScroller!.className).toContain('min-h-0');
     expect(spoolListScroller!.className).toContain('min-h-0');

+ 12 - 11
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -351,32 +351,33 @@ export function LabelTemplatePickerModal({
           )}
           )}
         </div>
         </div>
 
 
-        {/* Templates */}
-        <div className="px-3 pb-3 pt-3 space-y-2 border-t border-bambu-dark-tertiary">
+        {/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
+            inside max-h-[90vh] even when browser chrome eats into the viewport
+            (#1230). Stacked single column on mobile widths. */}
+        <div className="px-3 pt-2 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2 border-t border-bambu-dark-tertiary">
           {TEMPLATE_OPTIONS.map((opt) => {
           {TEMPLATE_OPTIONS.map((opt) => {
             const isPending = pending === opt.value;
             const isPending = pending === opt.value;
+            const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);
+            const hint = t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint);
             return (
             return (
               <button
               <button
                 key={opt.value}
                 key={opt.value}
                 disabled={noSelection || pending !== null}
                 disabled={noSelection || pending !== null}
                 onClick={() => handlePick(opt.value)}
                 onClick={() => handlePick(opt.value)}
-                className="w-full text-left p-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-green hover:bg-bambu-green/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-bambu-dark-tertiary disabled:hover:bg-bambu-dark transition flex items-center gap-3"
+                title={`${label} — ${hint}`}
+                className="w-full text-left p-2.5 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-green hover:bg-bambu-green/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-bambu-dark-tertiary disabled:hover:bg-bambu-dark transition flex items-center gap-3"
               >
               >
                 <div className="flex-1 min-w-0">
                 <div className="flex-1 min-w-0">
-                  <div className="font-medium text-white">
-                    {t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel)}
-                  </div>
-                  <div className="text-xs text-bambu-gray mt-0.5">
-                    {t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint)}
-                  </div>
+                  <div className="font-medium text-white text-sm truncate">{label}</div>
+                  <div className="text-xs text-bambu-gray mt-0.5 truncate">{hint}</div>
                 </div>
                 </div>
-                {isPending && <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />}
+                {isPending && <Loader2 className="w-4 h-4 animate-spin text-bambu-green shrink-0" />}
               </button>
               </button>
             );
             );
           })}
           })}
         </div>
         </div>
 
 
-        <div className="flex justify-end gap-2 px-5 py-3 border-t border-bambu-dark-tertiary">
+        <div className="flex justify-end gap-2 px-5 py-2 border-t border-bambu-dark-tertiary">
           <Button variant="secondary" onClick={onClose} disabled={pending !== null}>
           <Button variant="secondary" onClick={onClose} disabled={pending !== null}>
             {t('common.cancel', 'Cancel')}
             {t('common.cancel', 'Cancel')}
           </Button>
           </Button>

+ 2 - 1
frontend/src/pages/PrintersPage.tsx

@@ -6743,6 +6743,7 @@ export function PrintersPage() {
     setCompactToolbar(prev => (prev === shouldCompact ? prev : shouldCompact));
     setCompactToolbar(prev => (prev === shouldCompact ? prev : shouldCompact));
   }, []);
   }, []);
 
 
+  const smartPlugCount = Object.keys(smartPlugByPrinter).length;
   useLayoutEffect(() => {
   useLayoutEffect(() => {
     measureToolbar();
     measureToolbar();
 
 
@@ -6767,7 +6768,7 @@ export function PrintersPage() {
     printers?.length,
     printers?.length,
     availableLocations.length,
     availableLocations.length,
     hideDisconnected,
     hideDisconnected,
-    Object.keys(smartPlugByPrinter).length,
+    smartPlugCount,
   ]);
   ]);
 
 
   const renderFilterControls = (inMenu = false) => (
   const renderFilterControls = (inMenu = false) => (

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BofTUuqf.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C6s72Emh.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CacBES2t.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="./assets/index-DVU9Hr5-.js"></script>
-    <link rel="stylesheet" crossorigin href="./assets/index-C6s72Emh.css">
+    <script type="module" crossorigin src="./assets/index-BofTUuqf.js"></script>
+    <link rel="stylesheet" crossorigin href="./assets/index-CacBES2t.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff