diff --git a/static/js/utils/MasonryScroller.js b/static/js/utils/MasonryScroller.js index 0a86088c..352be07f 100644 --- a/static/js/utils/MasonryScroller.js +++ b/static/js/utils/MasonryScroller.js @@ -533,10 +533,15 @@ export class MasonryScroller { element.style.width = `${pos.width}px`; element.style.height = `${pos.height}px`; - // Remove max-width constraint from model-card to allow dynamic sizing - const modelCard = element.querySelector('.model-card'); + // Remove max-width/min-width constraints from the model-card to allow + // dynamic sizing. The card is either the item element itself (e.g. + // RecipeCard returns the .model-card root) or a descendant (ModelCard). + const modelCard = element.classList.contains('model-card') + ? element + : element.querySelector('.model-card'); if (modelCard) { modelCard.style.maxWidth = 'none'; + modelCard.style.minWidth = '0'; } return element; diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index bf216697..22b5e00d 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -483,10 +483,18 @@ export class VirtualScroller { element.style.width = `${this.itemWidth}px`; element.style.height = `${this.itemHeight}px`; - // Remove max-width constraint from model-card to allow dynamic sizing - const modelCard = element.querySelector('.model-card'); + // Remove max-width/min-width constraints from the model-card to allow + // dynamic sizing. The card is either the item element itself (e.g. + // RecipeCard returns the .model-card root) or a descendant (ModelCard). + // Without this, the CSS min-width of 200px forces compact-density cards + // wider than their allocated column, and the last column gets clipped + // by the grid's overflow-x: hidden. + const modelCard = element.classList.contains('model-card') + ? element + : element.querySelector('.model-card'); if (modelCard) { modelCard.style.maxWidth = 'none'; + modelCard.style.minWidth = '0'; } return element; diff --git a/tests/frontend/utils/masonryScroller.test.js b/tests/frontend/utils/masonryScroller.test.js index 4e979eda..d6c29702 100644 --- a/tests/frontend/utils/masonryScroller.test.js +++ b/tests/frontend/utils/masonryScroller.test.js @@ -40,7 +40,7 @@ function makeItems(dimensions) { * Build a scroller attached to a stubbed container. clientWidth/clientHeight * are 0 in jsdom, so they are defined explicitly for deterministic layout. */ -function createScroller({ items = [], fetchItemsFn, overscan, viewportHeight = 600 } = {}) { +function createScroller({ items = [], fetchItemsFn, overscan, viewportHeight = 600, createItemFn: customCreateItemFn } = {}) { const wrapper = document.createElement('div'); Object.defineProperty(wrapper, 'clientWidth', { value: CONTAINER_WIDTH, configurable: true }); Object.defineProperty(wrapper, 'clientHeight', { value: viewportHeight, configurable: true }); @@ -55,7 +55,7 @@ function createScroller({ items = [], fetchItemsFn, overscan, viewportHeight = 6 gridElement: grid, containerElement: wrapper, scrollContainer: wrapper, - createItemFn, + createItemFn: customCreateItemFn || createItemFn, fetchItemsFn: fetchMock, overscan, }); @@ -206,9 +206,35 @@ describe('MasonryScroller', () => { expect(el.style.left).toBe(`${scroller.positions[i].left}px`); expect(el.style.top).toBe(`${scroller.positions[i].top}px`); expect(el.querySelector('.model-card').style.maxWidth).toBe('none'); + expect(el.querySelector('.model-card').style.minWidth).toBe('0'); }); }); + it('clears model-card max-width/min-width when the item element is the card root', () => { + // Production shape for recipe cards: RecipeCard returns the .model-card + // element itself, so the scroller must clear constraints on the element + // rather than a descendant (querySelector would find nothing). + const items = makeItems([{ width: 100, height: 200 }]); + const cardRoot = document.createElement('div'); + cardRoot.className = 'model-card'; + const { scroller, grid, wrapper } = track(createScroller({ + items, + viewportHeight: 3000, + createItemFn: () => cardRoot.cloneNode(true), + })); + + scroller.refreshWithData(items, items.length, false); + wrapper.scrollTop = 0; + scroller.overscan = 5; + + scroller.renderItems(); + + const rendered = grid.querySelectorAll('.virtual-scroll-item'); + expect(rendered.length).toBe(1); + expect(rendered[0].style.maxWidth).toBe('none'); + expect(rendered[0].style.minWidth).toBe('0'); + }); + it('triggers loadMoreItems when scrolled to the bottom', async () => { const firstPage = makeItems([ { width: 100, height: 200 },