diff --git a/e2e/specs/button.spec.ts b/e2e/specs/button.spec.ts new file mode 100644 index 00000000..34373436 --- /dev/null +++ b/e2e/specs/button.spec.ts @@ -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 => { + 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'); +}); diff --git a/e2e/specs/dialog-position.spec.ts b/e2e/specs/dialog-position.spec.ts index aced7c5d..5967c470 100644 --- a/e2e/specs/dialog-position.spec.ts +++ b/e2e/specs/dialog-position.spec.ts @@ -57,6 +57,45 @@ async function expectPosition(dialog: Locator, centered: boolean): Promise }).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 => { + 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 => { + 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, diff --git a/e2e/specs/dialog.spec.ts b/e2e/specs/dialog.spec.ts index 4057260c..57f5d3c3 100644 --- a/e2e/specs/dialog.spec.ts +++ b/e2e/specs/dialog.spec.ts @@ -11,6 +11,21 @@ test.beforeEach(async ({ page }: { page: Page }): Promise => { await page.goto('/dialog'); }); +test('inherits documented surface tokens from the public Dialog host', async ({ page }: { page: Page }): Promise => { + 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 => { const trigger: Locator = page.getByRole('button', { name: 'View today’s menu' }); await trigger.click(); @@ -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 => { + 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 => { + 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 => { await page.getByRole('button', { name: 'Open prep board' }).click(); const dialog: Locator = page.getByRole('dialog', { name: 'Prep board' }); diff --git a/sushi/src/lib/dialog/dialog.component.ts b/sushi/src/lib/dialog/dialog.component.ts index e98165e7..4b75e55f 100644 --- a/sushi/src/lib/dialog/dialog.component.ts +++ b/sushi/src/lib/dialog/dialog.component.ts @@ -32,6 +32,7 @@ let nextDialogId: number = 0; host: { class: 'sui-dialog-host', '[attr.draggable]': 'null', + '(window:resize)': 'handleViewportResize()', }, template: ` = input(null); /** Enables dragging from the Dialog header. */ public readonly draggable: InputSignalWithTransform = 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 = input(true, { transform: booleanAttribute, }); @@ -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; @@ -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 }); } } diff --git a/sushi/src/styles/features/dialog.styles.css b/sushi/src/styles/features/dialog.styles.css index 1128dcfc..bb7a9a7a 100644 --- a/sushi/src/styles/features/dialog.styles.css +++ b/sushi/src/styles/features/dialog.styles.css @@ -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