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
2 changes: 1 addition & 1 deletion e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default defineConfig({
},
{
name: 'mobile-webkit',
testMatch: ['**/responsive-layout.spec.ts', '**/mobile-overlay.spec.ts'],
testMatch: ['**/responsive-layout.spec.ts', '**/mobile-overlay.spec.ts', '**/dialog-position.spec.ts'],
use: { ...devices['iPhone 13'] },
},
],
Expand Down
87 changes: 87 additions & 0 deletions e2e/specs/dialog-position.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect, Locator, Page, test } from '@playwright/test';

interface Geometry {
readonly left: number;
readonly top: number;
readonly right: number;
readonly bottom: number;
readonly width: number;
readonly height: number;
readonly viewportWidth: number;
readonly viewportHeight: number;
readonly documentWidth: number;
readonly rem: number;
}

async function geometry(dialog: Locator): Promise<Geometry> {
return dialog.evaluate((element: HTMLElement): Geometry => {
const bounds: DOMRect = element.getBoundingClientRect();
const root: HTMLElement = document.documentElement;
return {
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
width: bounds.width,
height: bounds.height,
viewportWidth: root.clientWidth,
viewportHeight: root.clientHeight,
documentWidth: root.scrollWidth,
rem: Number.parseFloat(getComputedStyle(root).fontSize),
};
});
}

async function activate(control: Locator, isMobile: boolean): Promise<void> {
if (isMobile) await control.tap();
else await control.click();
}

async function expectPosition(dialog: Locator, centered: boolean): Promise<void> {
await expect(async (): Promise<void> => {
const bounds: Geometry = await geometry(dialog);
const details: string = `Dialog geometry: ${JSON.stringify(bounds)}`;
expect(Math.round(bounds.left), details).toBeGreaterThanOrEqual(0);
expect(Math.round(bounds.top), details).toBeGreaterThanOrEqual(0);
expect(Math.round(bounds.right), details).toBeLessThanOrEqual(bounds.viewportWidth);
expect(Math.round(bounds.bottom), details).toBeLessThanOrEqual(bounds.viewportHeight);
expect(bounds.documentWidth, details).toBeLessThanOrEqual(bounds.viewportWidth);
if (centered) {
expect(bounds.left + bounds.width / 2, details).toBeCloseTo(bounds.viewportWidth / 2, 0);
expect(bounds.top + bounds.height / 2, details).toBeCloseTo(bounds.viewportHeight / 2, 0);
} else {
// The example explicitly requests these offsets; this is not a decoration measurement.
expect(bounds.top, details).toBeCloseTo(80, 0);
expect(bounds.viewportWidth - bounds.right, details).toBeCloseTo(1.5 * bounds.rem, 0);
}
}).toPass({ timeout: 5_000 });
}

for (const width of [360, 390]) {
test(`custom and named Dialog positions stay contained at ${width}px`, async ({
page,
isMobile,
}: {
page: Page;
isMobile: boolean;
}): Promise<void> => {
await page.setViewportSize({ width, height: 740 });
await page.goto('/dialog');
const trigger: Locator = page.getByRole('button', { name: 'Open positioned dialog' });
const dialog: Locator = page.getByRole('dialog', { name: 'Custom position' });
await activate(trigger, isMobile);
await expect(dialog).toBeVisible();
await expectPosition(dialog, false);

await activate(dialog.getByRole('button', { name: 'Center dialog' }), isMobile);
await expectPosition(dialog, true);
await activate(dialog.getByRole('button', { name: 'Restore offsets' }), isMobile);
await expectPosition(dialog, false);

await activate(dialog.getByRole('button', { name: 'Close', exact: true }), isMobile);
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
await activate(trigger, isMobile);
await expectPosition(dialog, false);
});
}
47 changes: 47 additions & 0 deletions e2e/specs/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,53 @@ test('enables native bidirectional resizing', async ({ page }: { page: Page }):
await expect(dialog).toHaveCSS('resize', 'both');
});

test('custom coordinates are restored when reopening after dragging', async ({ page }: { page: Page }): Promise<void> => {
const trigger: Locator = page.getByRole('button', { name: 'Open positioned dialog' });
const dialog: Locator = page.getByRole('dialog', { name: 'Custom position' });
await trigger.click();
const initial: Bounds | null = await dialog.boundingBox();
if (!initial) throw new Error('Expected a visible custom-positioned Dialog.');
// These offsets are explicitly configured by the public API, not incidental layout pixels.
await expect(async (): Promise<void> => {
const bounds: Bounds | null = await dialog.boundingBox();
if (!bounds) throw new Error('Expected a visible custom-positioned Dialog.');
const viewportWidth: number = await page.evaluate((): number => document.documentElement.clientWidth);
const rem: number = await page.evaluate((): number => Number.parseFloat(getComputedStyle(document.documentElement).fontSize));
expect(bounds.y).toBeCloseTo(80, 0);
expect(viewportWidth - bounds.x - bounds.width).toBeCloseTo(1.5 * rem, 0);
}).toPass({ timeout: 5_000 });

const header: Bounds | null = await dialog.locator('[suiDialogHeader]').boundingBox();
if (!header) throw new Error('Expected a draggable header.');
await page.mouse.move(header.x + header.width / 2, header.y + header.height / 2);
await page.mouse.down();
await page.mouse.move(header.x + header.width / 2 - 100, header.y + header.height / 2 + 40, { steps: 5 });
await page.mouse.up();
await expect.poll(async (): Promise<number> => (await dialog.boundingBox())?.x ?? initial.x).toBeLessThan(initial.x);
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(dialog).toBeHidden();
await trigger.click();
await expect(async (): Promise<void> => {
const reopened: Bounds | null = await dialog.boundingBox();
expect(reopened?.x).toBeCloseTo(initial.x, 0);
expect(reopened?.y).toBeCloseTo(initial.y, 0);
}).toPass({ timeout: 5_000 });
});

test('custom coordinates can change while the Dialog is open', async ({ page }: { page: Page }): Promise<void> => {
await page.getByRole('button', { name: 'Open positioned dialog' }).click();
const dialog: Locator = page.getByRole('dialog', { name: 'Custom position' });
await dialog.getByRole('button', { name: 'Move to bottom left' }).click();
await expect(async (): Promise<void> => {
const bounds: Bounds | null = await dialog.boundingBox();
if (!bounds) throw new Error('Expected the repositioned Dialog to remain visible.');
const viewportHeight: number = await page.evaluate((): number => document.documentElement.clientHeight);
const rem: number = await page.evaluate((): number => Number.parseFloat(getComputedStyle(document.documentElement).fontSize));
expect(bounds.x).toBeCloseTo(24, 0);
expect(viewportHeight - bounds.y - bounds.height).toBeCloseTo(1.5 * rem, 0);
}).toPass({ timeout: 5_000 });
});

test('resizes in place without moving the Dialog origin', async ({
page,
browserName,
Expand Down
42 changes: 39 additions & 3 deletions playground/src/app/generated/api-reference.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2960,7 +2960,8 @@ export const apiReference: Readonly<{
kind: 'input',
type: 'DialogPosition',
defaultValue: "'center'",
description: 'Places the Dialog at a viewport edge or corner.',
description:
'Named placement or viewport offsets (numbers in pixels, strings in CSS units). Defaults to center. Changes reposition an open Dialog; reopening restores this position after dragging. Custom offsets must leave room for the Dialog.',
},
{
name: 'ariaLabel',
Expand Down Expand Up @@ -3067,13 +3068,48 @@ export const apiReference: Readonly<{
members: [],
typeParameters: [],
},
{
name: 'DialogCoordinates',
kind: 'interface',
source: 'library',
declaration:
'interface DialogCoordinates {\n readonly top?: number | string;\n readonly right?: number | string;\n readonly bottom?: number | string;\n readonly left?: number | string;\n}',
description: 'Viewport offsets. Numbers are pixels; strings are CSS lengths. Omitted edges are automatic.',
members: [
{
name: 'top',
type: 'number | string',
optional: true,
description: 'Distance from the top edge.',
},
{
name: 'right',
type: 'number | string',
optional: true,
description: 'Distance from the right edge.',
},
{
name: 'bottom',
type: 'number | string',
optional: true,
description: 'Distance from the bottom edge.',
},
{
name: 'left',
type: 'number | string',
optional: true,
description: 'Distance from the left edge.',
},
],
typeParameters: [],
},
{
name: 'DialogPosition',
kind: 'type',
source: 'library',
declaration:
"type DialogPosition = 'center' | 'top' | 'top-left' | 'top-right' | 'bottom' | 'bottom-left' | 'bottom-right' | 'left' | 'right';",
description: 'Placement of a Dialog inside the viewport.',
"type DialogPosition = 'center' | 'top' | 'top-left' | 'top-right' | 'bottom' | 'bottom-left' | 'bottom-right' | 'left' | 'right' | DialogCoordinates;",
description: 'Named placement or custom viewport offsets for a Dialog.',
members: [],
typeParameters: [],
},
Expand Down
12 changes: 12 additions & 0 deletions playground/src/app/pages/dialog/dialog.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ <h2 class="text-2xl font-bold tracking-tight">Movable workspace</h2>
><ng-template pgExamplePreview><pg-dialog-advanced-example /></ng-template
></pg-example-code>
</section>
<section class="space-y-6">
<div class="space-y-2">
<h2 class="text-2xl font-bold tracking-tight">Custom coordinates</h2>
<p class="text-base-content/60">
Pass top, right, bottom, or left offsets to position. Numbers use pixels; strings accept CSS units. Keep enough space for the dialog
inside the viewport. Updating position moves an open dialog and resets its drag offset.
</p>
</div>
<pg-example-code [html]="examples.coordinates.html" [typescript]="examples.coordinates.typescript">
<ng-template pgExamplePreview><pg-dialog-coordinates-example /></ng-template>
</pg-example-code>
</section>
<section class="space-y-6">
<div class="space-y-2">
<h2 class="text-2xl font-bold tracking-tight">Form dialog</h2>
Expand Down
7 changes: 6 additions & 1 deletion playground/src/app/pages/dialog/dialog.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import * as advancedCss from './examples/advanced/advanced.example.css' with { l
import advancedHtml from './examples/advanced/advanced.example.html';
import * as advancedTs from './examples/advanced/advanced.example.ts' with { loader: 'text' };
import { DialogBasicExample } from './examples/basic/basic.example';
import { DialogCoordinatesExample } from './examples/coordinates/coordinates.example';
import coordinatesHtml from './examples/coordinates/coordinates.example.html';
import * as coordinatesTs from './examples/coordinates/coordinates.example.ts' with { loader: 'text' };
import basicHtml from './examples/basic/basic.example.html';
import * as basicTs from './examples/basic/basic.example.ts' with { loader: 'text' };
import { DialogFormExample } from './examples/form/form.example';
Expand All @@ -24,6 +27,7 @@ import * as dynamicTs from './examples/dynamic/dynamic.example.ts' with { loader
Badge,
DialogAdvancedExample,
DialogBasicExample,
DialogCoordinatesExample,
DialogDynamicExample,
DialogFormExample,
ExampleCode,
Expand All @@ -33,8 +37,9 @@ import * as dynamicTs from './examples/dynamic/dynamic.example.ts' with { loader
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DialogPage {
protected readonly examples: Readonly<Record<'advanced' | 'basic' | 'dynamic' | 'form', ExampleSource>> = {
protected readonly examples: Readonly<Record<'advanced' | 'basic' | 'coordinates' | 'dynamic' | 'form', ExampleSource>> = {
basic: { html: basicHtml, typescript: textSource(basicTs) },
coordinates: { html: coordinatesHtml, typescript: textSource(coordinatesTs) },
advanced: { css: textSource(advancedCss), html: advancedHtml, typescript: textSource(advancedTs) },
form: { css: textSource(formCss), html: formHtml, typescript: textSource(formTs) },
dynamic: { html: dynamicHtml, typescript: textSource(dynamicTs) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<button suiButton type="button" [suiDialogTrigger]="positionedDialog">Open positioned dialog</button>

<sui-dialog
#positionedDialog="suiDialog"
[position]="coordinates()"
draggable
resizable
ariaLabelledby="positioned-dialog-title">
<header suiDialogHeader><h2 id="positioned-dialog-title" class="text-lg font-semibold">Custom position</h2></header>
<div suiDialogBody>
<p>
Starts 80 pixels from the top and 1.5rem from the right. Drag the header to move it. Close and reopen to restore
these offsets.
</p>
<div class="mt-4 flex flex-wrap gap-2">
<button suiButton type="button" (click)="coordinates.set({ bottom: '1.5rem', left: 24 })">
Move to bottom left
</button>
<button suiButton type="button" (click)="coordinates.set('center')">Center dialog</button>
<button suiButton type="button" (click)="coordinates.set({ top: 80, right: '1.5rem' })">Restore offsets</button>
</div>
</div>
</sui-dialog>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, signal, WritableSignal } from '@angular/core';
import { Button, Dialog, DialogBody, DialogHeader, DialogPosition, DialogTrigger } from '@sushi-kit/angular';

@Component({
selector: 'pg-dialog-coordinates-example',
imports: [Button, Dialog, DialogBody, DialogHeader, DialogTrigger],
templateUrl: './coordinates.example.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DialogCoordinatesExample {
protected readonly coordinates: WritableSignal<DialogPosition> = signal<DialogPosition>({ top: 80, right: '1.5rem' });
}
35 changes: 31 additions & 4 deletions sushi/src/lib/dialog/dialog.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
ElementRef,
inject,
Expand Down Expand Up @@ -41,7 +42,7 @@ let nextDialogId: number = 0;
[attr.aria-label]="ariaLabel()"
[attr.aria-labelledby]="ariaLabelledby()"
[attr.aria-describedby]="ariaDescribedby()"
[attr.data-position]="position()"
[attr.data-position]="namedPosition()"
[attr.data-draggable]="draggable()"
[id]="dialogId"
(cancel)="handleCancel($event)"
Expand Down Expand Up @@ -73,7 +74,7 @@ export class Dialog {
public readonly closeOnBackdrop: InputSignalWithTransform<boolean, unknown> = input(true, {
transform: booleanAttribute,
});
/** Places the Dialog at a viewport edge or corner. */
/** Named placement or viewport offsets (numbers in pixels, strings in CSS units). Defaults to center. Changes reposition an open Dialog; reopening restores this position after dragging. Custom offsets must leave room for the Dialog. */
public readonly position: InputSignal<DialogPosition> = input<DialogPosition>('center');
/** Accessible name used when no visible heading labels the Dialog. */
public readonly ariaLabel: InputSignal<string | null> = input<string | null>(null);
Expand All @@ -97,12 +98,17 @@ export class Dialog {

private readonly element: Signal<ElementRef<HTMLDialogElement>> =
viewChild.required<ElementRef<HTMLDialogElement>>('nativeDialog');
protected readonly namedPosition: Signal<string | null> = computed((): string | null => {
const position: DialogPosition = this.position();
return typeof position === 'string' ? position : null;
});
private readonly document: Document = inject(DOCUMENT);
private readonly destroyRef: DestroyRef = inject(DestroyRef);
private dragEnded: Subscription | null = null;
private closeReason: DialogCloseReason = 'programmatic';
private readonly drag: Signal<CdkDrag> = viewChild.required(CdkDrag);
private restoreTarget: HTMLElement | null = null;
private appliedPosition: DialogPosition | null = null;

public constructor() {
afterRenderEffect({
Expand Down Expand Up @@ -181,17 +187,38 @@ export class Dialog {

private syncOpenState(): void {
const dialog: HTMLDialogElement = this.element().nativeElement;
if (this.open() === dialog.open) return;
const position: DialogPosition = this.position();
if (this.open() === dialog.open) {
if (dialog.open && position !== this.appliedPosition) this.applyPosition();
return;
}
if (this.open()) {
this.restoreTarget ??= this.activeElement();
if (this.modal()) dialog.showModal();
else dialog.show();
this.lockPosition();
this.applyPosition();
} else {
this.close();
}
}

private applyPosition(): void {
this.drag().reset();
this.resetPosition();
const position: DialogPosition = this.position();
if (typeof position !== 'string') {
const style: CSSStyleDeclaration = this.element().nativeElement.style;
style.inset = 'auto';
style.margin = '0';
for (const edge of ['top', 'right', 'bottom', 'left'] as const) {
const value: number | string | undefined = position[edge];
if (value !== undefined) style[edge] = typeof value === 'number' ? `${value}px` : value;
}
}
this.lockPosition();
this.appliedPosition = position;
}

private lockPosition(): void {
const dialog: HTMLDialogElement = this.element().nativeElement;
const bounds: DOMRect = dialog.getBoundingClientRect();
Expand Down
Loading
Loading