fix: keep the recipient suggestions inside the screen

The list sized itself against the nearest clipping ancestor alone. That
ancestor is the dialog's own scrolling body, which reaches past the
bottom of a short screen — or of any screen once the dialog has been
dragged low — so the list was placed below the fold and the user saw
nothing at all. The viewport bounds it too.
This commit is contained in:
Nariman Jelveh committed 2026-09-17 13:16:17 -07:00
1 parent 7b9f0e4c3d
commit bc9b1d7fc4
2 files changed
+46 -5

No files matched your search

+11 -5
View File
@@ -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 };
};
/**
@@ -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]);