diff --git a/packages/components/actions-panel/actions-panel-ref.ts b/packages/components/actions-panel/actions-panel-ref.ts index 66b39acd8..5907d47cf 100644 --- a/packages/components/actions-panel/actions-panel-ref.ts +++ b/packages/components/actions-panel/actions-panel-ref.ts @@ -19,6 +19,11 @@ export class KbqActionsPanelRef { return this.dialogRef.closed; } + /** Gets an observable that is notified when the actions panel starts closing, before its exit animation plays. */ + get beforeClosed(): Observable { + return this._beforeClosed; + } + /** Gets an observable that emits when keydown events are targeted on the overlay. */ get keydownEvents(): Observable { return this.dialogRef.keydownEvents; @@ -48,6 +53,7 @@ export class KbqActionsPanelRef { } private readonly _afterOpened = new Subject(); + private readonly _beforeClosed = new Subject(); /** Result to be passed down to the `afterClosed` stream. */ private result: R | undefined; @@ -83,6 +89,8 @@ export class KbqActionsPanelRef { }); this.result = result; + this._beforeClosed.next(result); + this._beforeClosed.complete(); this.containerInstance.startCloseAnimation(); this.containerInstance = null!; } diff --git a/packages/components/actions-panel/actions-panel.spec.ts b/packages/components/actions-panel/actions-panel.spec.ts index 34748366e..922f65e95 100644 --- a/packages/components/actions-panel/actions-panel.spec.ts +++ b/packages/components/actions-panel/actions-panel.spec.ts @@ -299,6 +299,52 @@ describe(KbqActionsPanelModule.name, () => { expect(spy).toHaveBeenCalledTimes(1); }); + it('should emit beforeClosed synchronously on close, before afterClosed', async () => { + const fixture = createComponent(ActionsPanelController); + const { componentInstance } = fixture; + const actionsPanelRef = componentInstance.openFromTemplate(); + const emitted: string[] = []; + + actionsPanelRef.beforeClosed.subscribe(() => emitted.push('beforeClosed')); + actionsPanelRef.afterClosed.subscribe(() => emitted.push('afterClosed')); + + componentInstance.close(); + expect(emitted).toEqual(['beforeClosed']); + + await fixture.whenStable(); + expect(emitted).toEqual(['beforeClosed', 'afterClosed']); + }); + + it('should emit beforeClosed with result', async () => { + const { componentInstance } = createComponent(ActionsPanelController); + const actionsPanelRef = componentInstance.openFromTemplate(); + const beforeClosed = lastValueFrom(actionsPanelRef.beforeClosed); + + componentInstance.close('customResult'); + expect(await beforeClosed).toBe('customResult'); + }); + + it('should complete beforeClosed after it emits', () => { + const { componentInstance } = createComponent(ActionsPanelController); + const actionsPanelRef = componentInstance.openFromTemplate(); + const completeSpy = jest.fn(); + + actionsPanelRef.beforeClosed.subscribe({ complete: completeSpy }); + componentInstance.close(); + expect(completeSpy).toHaveBeenCalledTimes(1); + }); + + it('should not emit beforeClosed again when close is called a second time', () => { + const { componentInstance } = createComponent(ActionsPanelController); + const actionsPanelRef = componentInstance.openFromTemplate(); + const spy = jest.fn(); + + actionsPanelRef.beforeClosed.subscribe(spy); + componentInstance.close(); + actionsPanelRef.close(); + expect(spy).toHaveBeenCalledTimes(1); + }); + it('should emit afterOpened on open', async () => { const fixture = createComponent(ActionsPanelController); const { componentInstance } = fixture; diff --git a/packages/docs-examples/components/actions-panel/actions-panel-overview/actions-panel-overview-example.ts b/packages/docs-examples/components/actions-panel/actions-panel-overview/actions-panel-overview-example.ts index 61a15a6cf..05b912994 100644 --- a/packages/docs-examples/components/actions-panel/actions-panel-overview/actions-panel-overview-example.ts +++ b/packages/docs-examples/components/actions-panel/actions-panel-overview/actions-panel-overview-example.ts @@ -4,6 +4,7 @@ import { DestroyRef, ElementRef, inject, + input, output, Signal, signal, @@ -73,15 +74,34 @@ type ExampleTableItem = unknown; width: 100%; } - /** Adding extra space for actions panel */ + /* + * Reserve space for the actions panel only while it's open, animating in sync with its + * own slide transition instead of a permanent gap that's still there once the panel is + * closed. Duration/curve values below are copied from KBQ_ACTIONS_PANEL_CONTAINER_ANIMATION + * (packages/components/actions-panel/actions-panel-container.ts) — not importable here since + * that trigger is defined for the panel's own 'state' animation, not exposed as CSS. Keep in + * sync if that trigger's durations/curve ever change. + */ :host ::ng-deep .ag-body-viewport, :host ::ng-deep .ag-body-vertical-scroll { - padding-bottom: 80px; + padding-bottom: 0; + transition: padding-bottom 150ms cubic-bezier(0.4, 0, 0.2, 1); + } + + :host(.example-grid_actions-panel-opened) ::ng-deep .ag-body-viewport, + :host(.example-grid_actions-panel-opened) ::ng-deep .ag-body-vertical-scroll { + /* Actions panel height with margins */ + padding-bottom: 72px; + transition: padding-bottom 125ms cubic-bezier(0.4, 0, 0.2, 1); } `, - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class.example-grid_actions-panel-opened]': 'panelOpened()' + } }) export class ExampleGrid { + readonly panelOpened = input(false); private api: GridApi | null = null; protected readonly defaultColDef: ColDef = { sortable: true, @@ -98,27 +118,11 @@ export class ExampleGrid { hideDisabledCheckboxes: false }; protected readonly columnDefs: ColDef[] = [ - { - field: 'column0', - headerName: 'Project name', - pinned: true - }, - { - field: 'column1', - headerName: 'Text' - }, - { - field: 'column2', - headerName: 'Text' - }, - { - field: 'column3', - headerName: 'Text' - }, - { - field: 'column4', - headerName: 'Text' - } + { field: 'column0', headerName: 'Project name', pinned: true }, + { field: 'column1', headerName: 'Text' }, + { field: 'column2', headerName: 'Text' }, + { field: 'column3', headerName: 'Text' }, + { field: 'column4', headerName: 'Text' } ]; protected readonly rowData = Array.from({ length: 33 }, (_, index) => ({ column0: 'Project name ' + index, @@ -268,7 +272,7 @@ export class ExampleActionsPanel { selector: 'actions-panel-overview-example', imports: [ExampleGrid], template: ` - + `, styles: ` :host { @@ -283,9 +287,10 @@ export class ActionsPanelOverviewExample { private readonly actionsPanel = inject(KbqActionsPanel, { self: true }); private readonly container = viewChild.required(ExampleGrid, { read: ElementRef }); private readonly grid = viewChild.required(ExampleGrid); - private actionsPanelRef: KbqActionsPanelRef | null; + private actionsPanelRef!: KbqActionsPanelRef | null; private readonly data = signal([]); private readonly destroyRef = inject(DestroyRef); + protected readonly panelOpened = signal(false); protected toggleActionsPanel(selectedItems: ExampleTableItem[]): void { if (selectedItems.length === 0) return this.actionsPanel.close(); @@ -298,11 +303,18 @@ export class ActionsPanelOverviewExample { data: this.data, overlayContainer: this.container() }); + this.panelOpened.set(true); this.actionsPanelRef.afterOpened.subscribe(() => { console.log('ActionsPanel opened'); }); + // Fires as soon as close() is called, before the exit animation plays — so the grid's + // padding starts collapsing in sync with the panel's own slide-down instead of ~150ms late. + this.actionsPanelRef.beforeClosed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.panelOpened.set(false); + }); + this.actionsPanelRef.afterClosed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => { console.log('ActionsPanel closed by action:', result); diff --git a/tools/public_api_guard/components/actions-panel.api.md b/tools/public_api_guard/components/actions-panel.api.md index 002bc050f..130578a09 100644 --- a/tools/public_api_guard/components/actions-panel.api.md +++ b/tools/public_api_guard/components/actions-panel.api.md @@ -106,6 +106,7 @@ export class KbqActionsPanelRef { constructor(dialogRef: DialogRef, containerInstance: KbqActionsPanelContainer); get afterClosed(): Observable; get afterOpened(): Observable; + get beforeClosed(): Observable; close(result?: R): void; containerInstance: KbqActionsPanelContainer; get id(): string;