diff --git a/README.md b/README.md index dddb83d2..73af07fb 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ options = { delayMs: 300, staggerMs: 40, maxAnimationDurationMs: 1500, + replayOn: ['reset-rows'], extraColumnEffect: { class: 'smart-rotating-gradient', delayMs: 120, @@ -273,6 +274,7 @@ Fields: - `delayMs` (number): Delay before the sequence starts. Default: `300`. - `staggerMs` (number): Extra delay applied per row (`rowIndex * staggerMs`). Default: `40`. - `maxAnimationDurationMs` (number): Extra duration added after stagger starts to keep the animation state active. Default: `5000`. +- `replayOn` (string[]): List of handler event names that trigger the animation again after the initial play. Default: `[]`. Currently only the `reset-rows` event is supported.. - `extraColumnEffect` (object): Optional extra effect options. - `extraColumnEffect.class` (string): Optional extra CSS class added to targeted cells while animation is active. - `extraColumnEffect.delayMs` (number): Extra delay applied before the `extraColumnEffect.class` effect starts. Default: `0`. @@ -281,7 +283,7 @@ Fields: Notes: -- The sequence runs once per component lifecycle. +- The sequence plays once on first load, and replays whenever one of the `replayOn` events fires. ## Core Concepts diff --git a/addon/components/hyper-table-v2/cell.ts b/addon/components/hyper-table-v2/cell.ts index 973ebffb..2b593b41 100644 --- a/addon/components/hyper-table-v2/cell.ts +++ b/addon/components/hyper-table-v2/cell.ts @@ -4,7 +4,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import type { InitialLoadAnimationContext } from '@upfluence/hypertable/components/hyper-table-v2'; -import TableHandler from '@upfluence/hypertable/core/handler'; +import TableHandler, { ROWS_PER_PAGE } from '@upfluence/hypertable/core/handler'; import { Column, ResolvedRenderingComponent, Row } from '@upfluence/hypertable/core/interfaces'; interface HyperTableV2CellArgs { @@ -112,7 +112,9 @@ export default class HyperTableV2Cell extends Component { } private get shouldApplyInitialLoadAnimationSequence(): boolean { - return this.isInitialLoadAnimationEnabled && !this.loading; + const rowIndex = this.args.rowIndex ?? 0; + + return this.isInitialLoadAnimationEnabled && !this.loading && rowIndex < ROWS_PER_PAGE; } private get shouldApplyInitialLoadAnimationCustomEffect(): boolean { diff --git a/addon/components/hyper-table-v2/index.ts b/addon/components/hyper-table-v2/index.ts index 5005d291..96b26ed3 100644 --- a/addon/components/hyper-table-v2/index.ts +++ b/addon/components/hyper-table-v2/index.ts @@ -6,6 +6,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import TableHandler from '@upfluence/hypertable/core/handler'; +import type { HandlerEvent } from '@upfluence/hypertable/core/handler'; import { Column, Row } from '@upfluence/hypertable/core/interfaces'; export type FeatureSet = { @@ -37,6 +38,7 @@ type InitialLoadAnimationConfig = { maxAnimationDurationMs: number; extraColumnEffect?: InitialLoadAnimationExtraColumnEffect; includeSelectionColumnInExtraEffect?: boolean; + replayOn?: Extract[]; }; interface HyperTableV2Args { @@ -94,6 +96,7 @@ export default class HyperTableV2 extends Component { }); this.hypertableInstanceID = crypto.randomUUID(); + this.registerAnimationReplayListeners(args.handler); } get features(): FeatureSet { @@ -132,6 +135,10 @@ export default class HyperTableV2 extends Component { } } + get columnsCountStyle(): ReturnType { + return htmlSafe(`--hypertable-responsive-columns-number: ${this.args.handler.columns.length - 1}`); + } + get initialLoadAnimationContext(): InitialLoadAnimationContext | null { return this.initialLoadAnimation ? { active: this.initialLoadAnimationActive, ...this.initialLoadAnimation } : null; } @@ -245,11 +252,22 @@ export default class HyperTableV2 extends Component { this.initialLoadAnimationTimeout = undefined; } + this.unregisterAnimationReplayListeners(); this.args.handler.teardown(); } - get columnsCountStyle(): ReturnType { - return htmlSafe(`--hypertable-responsive-columns-number: ${this.args.handler.columns.length - 1}`); + private registerAnimationReplayListeners(handler: TableHandler): void { + if (!this.initialLoadAnimation?.replayOn?.length) return; + + for (const event of this.initialLoadAnimation.replayOn) { + handler.on(event, this.onAnimationReplay); + } + } + + private unregisterAnimationReplayListeners(): void { + for (const event of this.initialLoadAnimation?.replayOn ?? []) { + this.args.handler.off(event, this.onAnimationReplay); + } } private _resetFilters(): void { @@ -276,6 +294,17 @@ export default class HyperTableV2 extends Component { this.computeScrollableTable(); } + private onAnimationReplay = (): void => { + if (this.initialLoadAnimationTimeout) { + window.clearTimeout(this.initialLoadAnimationTimeout); + this.initialLoadAnimationTimeout = undefined; + } + + this.initialLoadAnimationPlayed = false; + this.activateInitialLoadAnimationIfNeeded(); + this.finalizeInitialLoadAnimation(); + }; + private activateInitialLoadAnimationIfNeeded(): void { if (this.initialLoadAnimationPlayed || !this.initialLoadAnimation) { return; diff --git a/addon/core/handler.ts b/addon/core/handler.ts index b884d539..c21da1a5 100644 --- a/addon/core/handler.ts +++ b/addon/core/handler.ts @@ -1,5 +1,5 @@ import { set } from '@ember/object'; -import { addListener, sendEvent } from '@ember/object/events'; +import { addListener, removeListener, sendEvent } from '@ember/object/events'; import { scheduleOnce } from '@ember/runloop'; import { isEmpty } from '@ember/utils'; import { tracked } from '@glimmer/tracking'; @@ -22,7 +22,22 @@ import BaseRenderingResolver from './rendering-resolver'; export type RowMutator = (row: Row) => boolean; -const ROWS_PER_PAGE = 30; +export const HANDLER_EVENTS = [ + 'columns-loaded', + 'row-click', + 'apply-filters', + 'apply-order', + 'reset-columns', + 'remove-column', + 'remove-row', + 'mutate-rows', + 'reset-rows' +] as const; + +export type HandlerEvent = (typeof HANDLER_EVENTS)[number]; +export type LooseAutocomplete = T | (string & {}); + +export const ROWS_PER_PAGE = 30; export default class TableHandler { private _context: unknown; @@ -167,12 +182,26 @@ export default class TableHandler { * @param {Function} handler - A callback function to be called when the subscribed event is triggered. * @returns {TableHandler} */ - on(event: string, handler: (...args: any[]) => any): TableHandler { + on(event: LooseAutocomplete, handler: (...args: any[]) => any): TableHandler { addListener(this, event, handler); return this; } + off(event: LooseAutocomplete, handler: (...args: any[]) => any): TableHandler { + try { + removeListener(this, event, handler); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (!message.includes('did not exist on the instance')) { + throw error; + } + } + + return this; + } + /** * Add a column to the table. * diff --git a/tests/integration/components/hyper-table-v2-test.ts b/tests/integration/components/hyper-table-v2-test.ts index 47bfcbd1..17679a72 100644 --- a/tests/integration/components/hyper-table-v2-test.ts +++ b/tests/integration/components/hyper-table-v2-test.ts @@ -1,4 +1,4 @@ -import { click, render, findAll, type TestContext } from '@ember/test-helpers'; +import { click, render, findAll, waitUntil, type TestContext } from '@ember/test-helpers'; import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; @@ -263,6 +263,57 @@ module('Integration | Component | hyper-table-v2', function (hooks) { assert.dom('.hypertable__cell.smart-rotating-gradient').exists({ count: 12 }); }); + + module('resetRows', function () { + test('it replays the animation when resetRows is called', async function (this: TestContext, assert: Assert) { + this.options = { + initialLoadAnimation: { + delayMs: 0, + staggerMs: 0, + maxAnimationDurationMs: 50, + replayOn: ['reset-rows'] + } + }; + + await render(hbs``); + assert.dom('.hypertable__cell--initial-load-sequence').exists({ count: 12 }); + + await waitUntil(() => !document.querySelector('.hypertable__cell--initial-load-sequence')); + assert.dom('.hypertable__cell--initial-load-sequence').doesNotExist(); + + await this.handler.resetRows(); + await waitUntil(() => document.querySelectorAll('.hypertable__cell--initial-load-sequence').length === 12); + assert.dom('.hypertable__cell--initial-load-sequence').exists({ count: 12 }); + }); + + test('it does not apply the animation when resetRows is called without replayOn', async function (this: TestContext, assert: Assert) { + this.options = { + initialLoadAnimation: { + delayMs: 0, + staggerMs: 0, + maxAnimationDurationMs: 0 + } + }; + + await render(hbs``); + + await waitUntil(() => !document.querySelector('.hypertable__cell--initial-load-sequence')); + + await this.handler.resetRows(); + + assert.dom('.hypertable__cell--initial-load-sequence').doesNotExist(); + }); + + test('it does not apply the animation when resetRows is called without the proper config', async function (this: TestContext, assert: Assert) { + await render(hbs``); + + assert.dom('.hypertable__cell--initial-load-sequence').doesNotExist(); + + await this.handler.resetRows(); + + assert.dom('.hypertable__cell--initial-load-sequence').doesNotExist(); + }); + }); }); module('empty state', function (hooks) { diff --git a/tests/unit/core/handler-test.ts b/tests/unit/core/handler-test.ts index bb082649..c31c9202 100644 --- a/tests/unit/core/handler-test.ts +++ b/tests/unit/core/handler-test.ts @@ -530,7 +530,7 @@ module('Unit | core/handler', function (hooks) { }); module('Events', function () { - test('callbacks are called properly when an event is subscribed to', function (this: TestContext, assert: Assert) { + test('Handler#on - callbacks are called properly when an event is subscribed to', function (this: TestContext, assert: Assert) { const handler = new TableHandler(getContext(), this.tableManager, this.rowsFetcher); assert.expect(1); handler.on('row-click', (row: Row) => { @@ -539,6 +539,31 @@ module('Unit | core/handler', function (hooks) { handler.triggerEvent('row-click', handler.rows[0]); }); + + test('Handler#off - unsubscribes the callback so it is no longer called', function (this: TestContext, assert: Assert) { + const handler = new TableHandler(getContext(), this.tableManager, this.rowsFetcher); + const callback = sinon.spy(); + + handler.on('row-click', callback); + handler.off('row-click', callback); + handler.triggerEvent('row-click', handler.rows[0]); + + assert.ok(callback.notCalled); + }); + + test('Handler#off - only removes the targeted callback and leaves other listeners intact', function (this: TestContext, assert: Assert) { + const handler = new TableHandler(getContext(), this.tableManager, this.rowsFetcher); + const removedCallback = sinon.spy(); + const remainingCallback = sinon.spy(); + + handler.on('row-click', removedCallback); + handler.on('row-click', remainingCallback); + handler.off('row-click', removedCallback); + handler.triggerEvent('row-click', handler.rows[0]); + + assert.ok(removedCallback.notCalled); + assert.ok(remainingCallback.calledOnce); + }); }); function populateSelectionAndExclusionHandler(handler: TableHandler): void {