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.
This commit is contained in:
maziggy
2026-05-08 07:27:35 +02:00
parent 20fa8fbfdc
commit 4c0a12b95e
8 changed files with 91 additions and 81 deletions
@@ -275,13 +275,15 @@ describe('LabelTemplatePickerModal', () => {
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(
<LabelTemplatePickerModal
isOpen={true}
@@ -299,9 +301,15 @@ describe('LabelTemplatePickerModal', () => {
expect(screen.getByText(/Avery 5160/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');
expect(spoolListScroller).not.toBeNull();
expect(spoolListScroller!.className).toContain('min-h-0');
@@ -351,32 +351,33 @@ export function LabelTemplatePickerModal({
)}
</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) => {
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 (
<button
key={opt.value}
disabled={noSelection || pending !== null}
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="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>
{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>
);
})}
</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}>
{t('common.cancel', 'Cancel')}
</Button>
+2 -1
View File
@@ -6743,6 +6743,7 @@ export function PrintersPage() {
setCompactToolbar(prev => (prev === shouldCompact ? prev : shouldCompact));
}, []);
const smartPlugCount = Object.keys(smartPlugByPrinter).length;
useLayoutEffect(() => {
measureToolbar();
@@ -6767,7 +6768,7 @@ export function PrintersPage() {
printers?.length,
availableLocations.length,
hideDisconnected,
Object.keys(smartPlugByPrinter).length,
smartPlugCount,
]);
const renderFilterControls = (inMenu = false) => (