Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/0010-fix-pinned-dialog-height.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[BugFixes]
- A dialog with a drag handle, such as Add User, could grow past the
bottom of the window once its content loaded, leaving its buttons
unreachable. The panel is now capped at the window edge from where it
sits, and Add User's scope and roles section scrolls on its own, with
a visible scrollbar, so every org and space is reachable while the
buttons stay in view.
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ <h2 id="add-user-dialog-title" class="text-lg font-semibold text-content-text" d
<!-- ── Scope & roles sub-section ─────────────────────────────────────────
Delegated to the shared RoleAssignmentComponent widget (Phase 4 D7).
Roles are optional for a CF admin; anyone else must grant one. -->
<section class="flex flex-col gap-3 border-t pt-3" aria-label="Scope and roles">
<section class="flex flex-col gap-3 border-t pt-3 max-h-[45vh] overflow-y-auto custom-scrollbar" aria-label="Scope and roles">
<div class="text-sm font-medium text-content-text">Scope &amp; roles ({{ rolesRequired() ? 'required' : 'optional' }})</div>
@if (rolesRequired()) {
<p class="text-xs text-content-muted" data-test="add-user-roles-required">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,41 @@ describe('TailwindDialogService', () => {
vi.useRealTimers();
});

it('should cap a pinned panel at the viewport bottom even when not resizable', async () => {
// A drag handle pins the panel where it was centred at open; content that
// arrives later grows it from there, so the cap must follow the position.
const rect = { top: 490, left: 380, width: 640, height: 600, right: 1020, bottom: 1090, x: 380, y: 490, toJSON: () => ({}) } as DOMRect;
const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect);
vi.useFakeTimers();
try {
const dialogRef = service.open(TestDraggableDialogComponent);
await vi.advanceTimersByTimeAsync(0);

const panel = document.querySelector('.rounded-lg') as HTMLElement;
expect(panel.style.resize).toBe('');
expect(panel.style.maxHeight).toBe(`${window.innerHeight - 490}px`);

dialogRef.close();
await vi.advanceTimersByTimeAsync(300);
} finally {
vi.useRealTimers();
spy.mockRestore();
}
});

it('should keep a configured pixel maxHeight that is smaller than the viewport cap', async () => {
vi.useFakeTimers();
const dialogRef = service.open(TestDraggableDialogComponent, { maxHeight: '300px' });
await vi.advanceTimersByTimeAsync(0);

const panel = document.querySelector('.rounded-lg') as HTMLElement;
expect(panel.style.maxHeight).toBe('300px');

dialogRef.close();
await vi.advanceTimersByTimeAsync(300);
vi.useRealTimers();
});

it('should be draggable by default (no config)', async () => {
vi.useFakeTimers();
const dialogRef = service.open(TestDraggableDialogComponent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -459,16 +459,21 @@ export class TailwindDialogService {
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;

// Cap resize at the viewport edge from the panel's current top-left. Native
// CSS resize grows down-right from the anchored corner, so a plain
// `max-height: 90vh` still lets `top + height` spill past the bottom of the
// viewport. Tying the max to (viewport − position) keeps the bottom-right
// on-screen. Recomputed after a move (below), since the anchor changes.
// Cap the panel at the viewport edge from its pinned top-left. The panel
// is pinned where it was centred at open, and content arriving later (a
// roles widget, a Monaco editor, a native resize) grows it from there, so
// a plain `max-height: 90vh` still lets `top + height` spill past the
// bottom of the viewport and put the action buttons out of reach. Tying
// the max to (viewport − position) keeps the bottom on-screen; a
// configured pixel maxHeight still wins when it is smaller. Recomputed
// after a move (below), since the anchor changes.
const configuredMaxHeight = /px$/.test(config.maxHeight ?? '') ? parseFloat(config.maxHeight!) : Infinity;
const clampSizeToViewport = () => {
if (!config.resizable) return;
const r = panel.getBoundingClientRect();
panel.style.maxWidth = `${Math.max(0, window.innerWidth - r.left)}px`;
panel.style.maxHeight = `${Math.max(0, window.innerHeight - r.top)}px`;
panel.style.maxHeight = `${Math.min(configuredMaxHeight, Math.max(0, window.innerHeight - r.top))}px`;
if (config.resizable) {
panel.style.maxWidth = `${Math.max(0, window.innerWidth - r.left)}px`;
}
};

if (config.resizable) {
Expand Down
Loading