diff --git a/src/gui/src/helpers/shareRecipientPicker.js b/src/gui/src/helpers/shareRecipientPicker.js index b7271ee4a..7fcf9f19c 100644 --- a/src/gui/src/helpers/shareRecipientPicker.js +++ b/src/gui/src/helpers/shareRecipientPicker.js @@ -163,20 +163,26 @@ export default function shareRecipientPicker ({ const MIN_HEIGHT = 120; /** - * The box the list has to stay inside — the nearest ancestor that clips, - * which is the modal's scrolling body in one dialog and the window in the - * other. Falls back to the viewport. + * The box the list has to stay inside: the viewport, narrowed to the + * nearest ancestor that clips — the modal's scrolling body in one dialog + * and the window's in the other. Both bound it, and a dialog dragged low + * on a short screen has a clipping ancestor that reaches past the fold. */ const clip_box = () => { + const bottom = window.innerHeight || 0; let el = $row.get(0)?.parentElement; while ( el && el !== document.body ) { const style = getComputedStyle(el); if ( /auto|scroll|hidden/.test(`${style.overflowY}${style.overflowX}`) ) { - return el.getBoundingClientRect(); + const box = el.getBoundingClientRect(); + return { + top: Math.max(box.top, 0), + bottom: Math.min(box.bottom, bottom), + }; } el = el.parentElement; } - return { top: 0, bottom: window.innerHeight || 0 }; + return { top: 0, bottom }; }; /** diff --git a/src/gui/src/helpers/shareRecipientPicker.test.js b/src/gui/src/helpers/shareRecipientPicker.test.js index 4160d35b5..bcd533bd5 100644 --- a/src/gui/src/helpers/shareRecipientPicker.test.js +++ b/src/gui/src/helpers/shareRecipientPicker.test.js @@ -90,10 +90,13 @@ beforeEach(() => { }; }); +const default_viewport_height = 768; + afterEach(() => { mounted.forEach((picker) => picker.destroy()); mounted = []; document.body.innerHTML = ''; + window.innerHeight = default_viewport_height; }); describe('wiring onto the field', () => { @@ -161,6 +164,38 @@ describe('opening the list', () => { expect($('.share-suggest-list').get(0).style.maxHeight).toMatch(/px$/); }); + // jsdom has no layout, so the boxes `place()` measures are stubbed: a + // scrolling ancestor holding the row, reaching past the bottom of the + // screen the way a dialog dragged low on a short display does. + const stub_layout = ({ viewport, clip, row }) => { + window.innerHeight = viewport; + const host = $('.host').get(0); + host.style.overflowY = 'auto'; + host.getBoundingClientRect = () => ({ top: clip[0], bottom: clip[1] }); + $('.row').get(0).getBoundingClientRect = () => ({ top: row[0], bottom: row[1] }); + }; + + it('caps the list at the room on screen, not the room in the dialog', async () => { + const { picker, $input } = mount(); + stub_layout({ viewport: 700, clip: [200, 780], row: [480, 517] }); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + // 700 - 517 - 24, not the 239 the clipping ancestor alone reports. + expect($('.share-suggest-list').get(0).style.maxHeight).toBe('159px'); + }); + + it('opens upwards when what is below the field is off the screen', async () => { + const { picker, $input } = mount(); + stub_layout({ viewport: 700, clip: [100, 900], row: [600, 637] }); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + expect($('.share-suggest').hasClass('share-suggest-above')).toBe(true); + }); + it('leaves out whoever the access list already covers', async () => { const { picker, $input } = mount({ excluded: () => ['user:bob', 'team:t-1'] }); picker.setTeams([ACME]);