From a7e54110fcd1f834d6d3adde8219ab2fdde59784 Mon Sep 17 00:00:00 2001 From: Sebastian Enns Date: Mon, 28 Sep 2026 11:59:04 +0200 Subject: [PATCH 1/2] feat(sushi): support custom dialog coordinates --- .../app/generated/api-reference.generated.ts | 42 +++++++++++++++++-- .../src/app/pages/dialog/dialog.page.html | 12 ++++++ .../src/app/pages/dialog/dialog.page.ts | 7 +++- .../coordinates/coordinates.example.html | 23 ++++++++++ .../coordinates/coordinates.example.ts | 12 ++++++ sushi/src/lib/dialog/dialog.component.ts | 35 ++++++++++++++-- sushi/src/lib/dialog/dialog.interfaces.ts | 16 ++++++- 7 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 playground/src/app/pages/dialog/examples/coordinates/coordinates.example.html create mode 100644 playground/src/app/pages/dialog/examples/coordinates/coordinates.example.ts diff --git a/playground/src/app/generated/api-reference.generated.ts b/playground/src/app/generated/api-reference.generated.ts index 8e53d59..4093892 100644 --- a/playground/src/app/generated/api-reference.generated.ts +++ b/playground/src/app/generated/api-reference.generated.ts @@ -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', @@ -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: [], }, diff --git a/playground/src/app/pages/dialog/dialog.page.html b/playground/src/app/pages/dialog/dialog.page.html index 7d06448..1dfde2e 100644 --- a/playground/src/app/pages/dialog/dialog.page.html +++ b/playground/src/app/pages/dialog/dialog.page.html @@ -31,6 +31,18 @@

Movable workspace

> +
+
+

Custom coordinates

+

+ 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. +

+
+ + + +

Form dialog

diff --git a/playground/src/app/pages/dialog/dialog.page.ts b/playground/src/app/pages/dialog/dialog.page.ts index c136cbf..b08b810 100644 --- a/playground/src/app/pages/dialog/dialog.page.ts +++ b/playground/src/app/pages/dialog/dialog.page.ts @@ -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'; @@ -24,6 +27,7 @@ import * as dynamicTs from './examples/dynamic/dynamic.example.ts' with { loader Badge, DialogAdvancedExample, DialogBasicExample, + DialogCoordinatesExample, DialogDynamicExample, DialogFormExample, ExampleCode, @@ -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> = { + protected readonly examples: Readonly> = { 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) }, diff --git a/playground/src/app/pages/dialog/examples/coordinates/coordinates.example.html b/playground/src/app/pages/dialog/examples/coordinates/coordinates.example.html new file mode 100644 index 0000000..d60971f --- /dev/null +++ b/playground/src/app/pages/dialog/examples/coordinates/coordinates.example.html @@ -0,0 +1,23 @@ + + + +

Custom position

+
+

+ 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. +

+
+ + + +
+
+
diff --git a/playground/src/app/pages/dialog/examples/coordinates/coordinates.example.ts b/playground/src/app/pages/dialog/examples/coordinates/coordinates.example.ts new file mode 100644 index 0000000..effcfcc --- /dev/null +++ b/playground/src/app/pages/dialog/examples/coordinates/coordinates.example.ts @@ -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 = signal({ top: 80, right: '1.5rem' }); +} diff --git a/sushi/src/lib/dialog/dialog.component.ts b/sushi/src/lib/dialog/dialog.component.ts index b5ae7c7..e98165e 100644 --- a/sushi/src/lib/dialog/dialog.component.ts +++ b/sushi/src/lib/dialog/dialog.component.ts @@ -4,6 +4,7 @@ import { booleanAttribute, ChangeDetectionStrategy, Component, + computed, DestroyRef, ElementRef, inject, @@ -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)" @@ -73,7 +74,7 @@ export class Dialog { public readonly closeOnBackdrop: InputSignalWithTransform = 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 = input('center'); /** Accessible name used when no visible heading labels the Dialog. */ public readonly ariaLabel: InputSignal = input(null); @@ -97,12 +98,17 @@ export class Dialog { private readonly element: Signal> = viewChild.required>('nativeDialog'); + protected readonly namedPosition: Signal = 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 = viewChild.required(CdkDrag); private restoreTarget: HTMLElement | null = null; + private appliedPosition: DialogPosition | null = null; public constructor() { afterRenderEffect({ @@ -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(); diff --git a/sushi/src/lib/dialog/dialog.interfaces.ts b/sushi/src/lib/dialog/dialog.interfaces.ts index 38ab6d1..93a49bf 100644 --- a/sushi/src/lib/dialog/dialog.interfaces.ts +++ b/sushi/src/lib/dialog/dialog.interfaces.ts @@ -1,9 +1,21 @@ /** Reason a Dialog closed. */ export type DialogCloseReason = 'backdrop' | 'close' | 'escape' | 'programmatic'; -/** Placement of a Dialog inside the viewport. */ +/** Viewport offsets. Numbers are pixels; strings are CSS lengths. Omitted edges are automatic. */ +export interface DialogCoordinates { + /** Distance from the top edge. */ + readonly top?: number | string; + /** Distance from the right edge. */ + readonly right?: number | string; + /** Distance from the bottom edge. */ + readonly bottom?: number | string; + /** Distance from the left edge. */ + readonly left?: number | string; +} + +/** Named placement or custom viewport offsets for a Dialog. */ export type DialogPosition = - 'center' | 'top' | 'top-left' | 'top-right' | 'bottom' | 'bottom-left' | 'bottom-right' | 'left' | 'right'; + 'center' | 'top' | 'top-left' | 'top-right' | 'bottom' | 'bottom-left' | 'bottom-right' | 'left' | 'right' | DialogCoordinates; /** Details emitted after a Dialog closes. */ export interface DialogCloseEvent { From b6a5061bb5cc40b92ba3a36969ace2c2cce7a083 Mon Sep 17 00:00:00 2001 From: Sebastian Enns Date: Mon, 28 Sep 2026 11:59:04 +0200 Subject: [PATCH 2/2] test(sushi): cover custom dialog positioning across viewports --- e2e/playwright.config.ts | 2 +- e2e/specs/dialog-position.spec.ts | 87 +++++++++++++++++++++++++++++++ e2e/specs/dialog.spec.ts | 47 +++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 e2e/specs/dialog-position.spec.ts diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index cebb9c3..e5a93ca 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -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'] }, }, ], diff --git a/e2e/specs/dialog-position.spec.ts b/e2e/specs/dialog-position.spec.ts new file mode 100644 index 0000000..aced7c5 --- /dev/null +++ b/e2e/specs/dialog-position.spec.ts @@ -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 { + 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 { + if (isMobile) await control.tap(); + else await control.click(); +} + +async function expectPosition(dialog: Locator, centered: boolean): Promise { + await expect(async (): Promise => { + 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 => { + 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); + }); +} diff --git a/e2e/specs/dialog.spec.ts b/e2e/specs/dialog.spec.ts index c6808a9..4057260 100644 --- a/e2e/specs/dialog.spec.ts +++ b/e2e/specs/dialog.spec.ts @@ -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 => { + 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 => { + 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 => (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 => { + 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 => { + 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 => { + 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,