fix(ui): harden queue controls and floating menus

This commit is contained in:
NimBold
2026-07-28 23:49:46 +03:30
parent 807663cf03
commit 23288ca4e8
10 changed files with 828 additions and 160 deletions
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { clampFloatingPosition } from './floatingPosition';
import { clampFloatingPosition, positionFloatingSubmenu } from './floatingPosition';
describe('floating surface positioning', () => {
it('keeps a variable-height menu inside the viewport', () => {
@@ -15,4 +15,36 @@ describe('floating surface positioning', () => {
y: 8,
});
});
it('opens a submenu to the right when there is room', () => {
expect(positionFloatingSubmenu({ left: 200, right: 280, top: 120 }, 160, 180, 800, 600)).toEqual({
x: 284,
y: 120,
side: 'right',
});
});
it('flips a submenu to the left at the viewport edge', () => {
expect(positionFloatingSubmenu({ left: 700, right: 780, top: 120 }, 160, 180, 800, 600)).toEqual({
x: 536,
y: 120,
side: 'left',
});
});
it('clamps a submenu vertically when its trigger is near the bottom', () => {
expect(positionFloatingSubmenu({ left: 200, right: 280, top: 580 }, 160, 180, 800, 600)).toEqual({
x: 284,
y: 412,
side: 'right',
});
});
it('prefers the inline-start side in RTL when both sides fit', () => {
expect(positionFloatingSubmenu({ left: 400, right: 480, top: 120 }, 160, 180, 800, 600, 8, 4, 'left')).toEqual({
x: 236,
y: 120,
side: 'left',
});
});
});
+40
View File
@@ -3,6 +3,12 @@ export interface FloatingPosition {
y: number;
}
export type FloatingSubmenuSide = 'left' | 'right';
export interface FloatingSubmenuPosition extends FloatingPosition {
side: FloatingSubmenuSide;
}
/** Keep a fixed-position surface inside the viewport with a small safe gutter. */
export const clampFloatingPosition = (
x: number,
@@ -21,3 +27,37 @@ export const clampFloatingPosition = (
y: Math.min(Math.max(gutter, y), maxY),
};
};
/** Place a submenu beside its trigger, flipping sides before clamping to the viewport. */
export const positionFloatingSubmenu = (
trigger: Pick<DOMRect, 'left' | 'right' | 'top'>,
width: number,
height: number,
viewportWidth: number,
viewportHeight: number,
gutter = 8,
gap = 4,
preferredSide: FloatingSubmenuSide = 'right'
): FloatingSubmenuPosition => {
const rightX = trigger.right + gap;
const leftX = trigger.left - width - gap;
const rightFits = rightX + width <= viewportWidth - gutter;
const leftFits = leftX >= gutter;
const preferredFits = preferredSide === 'right' ? rightFits : leftFits;
const alternateFits = preferredSide === 'right' ? leftFits : rightFits;
const side = preferredFits || !alternateFits
? preferredSide
: preferredSide === 'right' ? 'left' : 'right';
const preferredX = side === 'right' ? rightX : leftX;
const position = clampFloatingPosition(
preferredX,
trigger.top,
width,
height,
viewportWidth,
viewportHeight,
gutter
);
return { ...position, side };
};