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
15 changes: 15 additions & 0 deletions e2e/specs/button.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expect, Locator, Page, test } from '@playwright/test';

test('enabled Button actions use a pointer while disabled and loading actions do not', async ({
page,
}: {
page: Page;
}): Promise<void> => {
await page.goto('/button');
await expect(page.getByRole('button', { name: 'Create project' })).toHaveCSS('cursor', 'pointer');
await expect(page.getByRole('link', { name: 'Angular documentation', exact: true })).toHaveCSS('cursor', 'pointer');
await expect(page.getByRole('button', { name: 'Unavailable', exact: true })).toHaveCSS('cursor', 'not-allowed');
const save: Locator = page.getByRole('button', { name: 'Save changes', exact: true });
await save.click();
await expect(page.getByRole('button', { name: 'Saving', exact: true })).toHaveCSS('cursor', 'not-allowed');
});
39 changes: 39 additions & 0 deletions e2e/specs/dialog-position.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,45 @@ async function expectPosition(dialog: Locator, centered: boolean): Promise<void>
}).toPass({ timeout: 5_000 });
}

for (const example of [
{ trigger: 'View today’s menu', name: 'Today’s menu', centered: true },
{ trigger: 'Open prep board', name: 'Prep board', centered: false },
{ trigger: 'Open positioned dialog', name: 'Custom position', centered: false },
]) {
test(`${example.name} remains visible when an open Dialog crosses viewport sizes`, async ({
page,
isMobile,
}: {
page: Page;
isMobile: boolean;
}): Promise<void> => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto('/dialog');
await activate(page.getByRole('button', { name: example.trigger }), isMobile);
const dialog: Locator = page.getByRole('dialog', { name: example.name });
await expect(dialog).toBeVisible();

for (const viewport of [
{ width: 390, height: 844 },
{ width: 1280, height: 600 },
]) {
await page.setViewportSize(viewport);
await expect(async (): Promise<void> => {
const bounds: Geometry = await geometry(dialog);
expect(bounds.left).toBeGreaterThanOrEqual(0);
expect(bounds.top).toBeGreaterThanOrEqual(0);
expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth);
expect(bounds.bottom).toBeLessThanOrEqual(bounds.viewportHeight);
expect(bounds.width).toBeGreaterThan(200);
if (example.centered) {
expect(bounds.left + bounds.width / 2).toBeCloseTo(bounds.viewportWidth / 2, 0);
expect(bounds.top + bounds.height / 2).toBeCloseTo(bounds.viewportHeight / 2, 0);
}
}).toPass({ timeout: 5_000 });
}
});
}

for (const width of [360, 390]) {
test(`custom and named Dialog positions stay contained at ${width}px`, async ({
page,
Expand Down
42 changes: 42 additions & 0 deletions e2e/specs/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ test.beforeEach(async ({ page }: { page: Page }): Promise<void> => {
await page.goto('/dialog');
});

test('inherits documented surface tokens from the public Dialog host', async ({ page }: { page: Page }): Promise<void> => {
const dialog: Locator = page.getByRole('dialog', { name: 'Today’s menu', includeHidden: true });
await dialog.locator('..').evaluate((host: HTMLElement): void => {
host.style.setProperty('--sui-dialog-width', '36rem');
host.style.setProperty('--sui-dialog-radius', '24px');
host.style.setProperty('--sui-dialog-padding', '2rem');
});
await page.getByRole('button', { name: 'View today’s menu' }).click();
const rem: number = await page.evaluate((): number => Number.parseFloat(getComputedStyle(document.documentElement).fontSize));

await expect(dialog).toHaveCSS('width', `${36 * rem}px`);
await expect(dialog).toHaveCSS('border-radius', '24px');
await expect(dialog.locator('[suiDialogBody]')).toHaveCSS('padding', `${2 * rem}px`);
});

test('opens modally, closes with Escape, and restores trigger focus', async ({ page }: { page: Page }): Promise<void> => {
const trigger: Locator = page.getByRole('button', { name: 'View today’s menu' });
await trigger.click();
Expand Down Expand Up @@ -106,6 +121,33 @@ test('only an enabled drag handle exposes a move cursor', async ({ page }: { pag
await expect(draggableHeader).toHaveCSS('cursor', 'move');
});

test('keeps a dragged Dialog visible after viewport changes', async ({ page }: { page: Page }): Promise<void> => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.getByRole('button', { name: 'Open prep board' }).click();
const dialog: Locator = page.getByRole('dialog', { name: 'Prep board' });
const header: Bounds | null = await dialog.locator('[suiDialogHeader]').boundingBox();
if (!header) throw new Error('Expected a visible draggable Dialog 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();

for (const viewport of [
{ width: 390, height: 844 },
{ width: 1280, height: 300 },
]) {
await page.setViewportSize(viewport);
await expect(async (): Promise<void> => {
const bounds: Bounds | null = await dialog.boundingBox();
if (!bounds) throw new Error('Expected the dragged Dialog to remain visible.');
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.y).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
}).toPass({ timeout: 5_000 });
}
});

test('enables native bidirectional resizing', async ({ page }: { page: Page }): Promise<void> => {
await page.getByRole('button', { name: 'Open prep board' }).click();
const dialog: Locator = page.getByRole('dialog', { name: 'Prep board' });
Expand Down
29 changes: 25 additions & 4 deletions sushi/src/lib/dialog/dialog.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ let nextDialogId: number = 0;
host: {
class: 'sui-dialog-host',
'[attr.draggable]': 'null',
'(window:resize)': 'handleViewportResize()',
},
template: `
<dialog
Expand Down Expand Up @@ -84,7 +85,7 @@ export class Dialog {
public readonly ariaDescribedby: InputSignal<string | null> = input<string | null>(null);
/** Enables dragging from the Dialog header. */
public readonly draggable: InputSignalWithTransform<boolean, unknown> = input(false, { transform: booleanAttribute });
/** Keeps a draggable Dialog inside the viewport. Disable only when off-screen placement is intentional. */
/** Keeps the Dialog inside the viewport after dragging and viewport changes. Disable only when off-screen placement is intentional. */
public readonly constrainToViewport: InputSignalWithTransform<boolean, unknown> = input(true, {
transform: booleanAttribute,
});
Expand Down Expand Up @@ -166,6 +167,15 @@ export class Dialog {
this.restoreFocus();
}

protected handleViewportResize(): void {
if (!this.element().nativeElement.open) return;
const offset: Readonly<{ x: number; y: number }> = this.drag().getFreeDragPosition();
this.applyPosition();
this.drag().setFreeDragPosition(offset);
this.constrainPosition();
this.updateResizeBounds();
}

private activeElement(): HTMLElement | null {
const activeElement: Element | null = this.element().nativeElement.ownerDocument.activeElement;
return activeElement instanceof HTMLElement ? activeElement : null;
Expand Down Expand Up @@ -245,8 +255,19 @@ export class Dialog {
const viewport: Window | null = dialog.ownerDocument.defaultView;
if (!viewport) return;
const bounds: DOMRect = dialog.getBoundingClientRect();
const gap: number = Number.parseFloat(viewport.getComputedStyle(dialog).getPropertyValue('--sui-dialog-viewport-gap')) || 0;
dialog.style.maxWidth = `${Math.max(0, viewport.innerWidth - bounds.left - gap)}px`;
dialog.style.maxHeight = `${Math.max(0, viewport.innerHeight - bounds.top - gap)}px`;
dialog.style.maxWidth = `max(0px, calc(100vw - ${bounds.left}px - var(--sui-dialog-viewport-gap)))`;
dialog.style.maxHeight = `max(0px, calc(100dvh - ${bounds.top}px - var(--sui-dialog-viewport-gap)))`;
}

private constrainPosition(): void {
if (!this.constrainToViewport()) return;
const dialog: HTMLDialogElement = this.element().nativeElement;
const viewport: Window | null = dialog.ownerDocument.defaultView;
if (!viewport) return;
const bounds: DOMRect = dialog.getBoundingClientRect();
const offset: Readonly<{ x: number; y: number }> = this.drag().getFreeDragPosition();
const left: number = Math.max(0, Math.min(bounds.left, viewport.innerWidth - bounds.width));
const top: number = Math.max(0, Math.min(bounds.top, viewport.innerHeight - bounds.height));
this.drag().setFreeDragPosition({ x: offset.x + left - bounds.left, y: offset.y + top - bounds.top });
}
}
2 changes: 1 addition & 1 deletion sushi/src/styles/features/dialog.styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
-webkit-user-drag: none;
}

:where(.sui-dialog, .sui-dynamic-dialog) {
:where(.sui-dialog-host, .sui-dynamic-dialog) {
/**
* Preferred width of a Dialog before viewport constraints apply.
* @example 36rem
Expand Down
Loading