From 970c1e0fa02deea778583b996050779377eb2fe0 Mon Sep 17 00:00:00 2001 From: "Maximilien B." Date: Tue, 1 Sep 2026 10:51:40 +0200 Subject: [PATCH 1/5] Enable load animations on reset-rows handler event # Conflicts: # addon/core/handler.ts --- README.md | 4 +- addon/components/hyper-table-v2/index.ts | 35 ++++++++++++- addon/core/handler.ts | 20 +++++++- .../components/hyper-table-v2-test.ts | 51 ++++++++++++++++++- tests/unit/core/handler-test.ts | 27 +++++++++- 5 files changed, 131 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dddb83d2..f379f6b9 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: `[]`. See [Events](#events) for available event names. - `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/index.ts b/addon/components/hyper-table-v2/index.ts index 5005d291..8ed497b8 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?: Array>; }; interface HyperTableV2Args { @@ -78,6 +80,7 @@ export default class HyperTableV2 extends Component { private initialLoadAnimationPlayed: boolean = false; private initialLoadAnimationTimeout?: number; + private animationReplayHandler!: () => void; declare private hypertableInstanceID: string; @@ -94,6 +97,7 @@ export default class HyperTableV2 extends Component { }); this.hypertableInstanceID = crypto.randomUUID(); + this.registerAnimationReplayListeners(args.handler); } get features(): FeatureSet { @@ -132,6 +136,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 +253,23 @@ 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; + + this.animationReplayHandler = this.onAnimationReplay.bind(this); + for (const event of this.initialLoadAnimation.replayOn) { + handler.on(event, this.animationReplayHandler); + } + } + + private unregisterAnimationReplayListeners(): void { + for (const event of this.initialLoadAnimation?.replayOn ?? []) { + this.args.handler.off(event, this.animationReplayHandler); + } } private _resetFilters(): void { @@ -276,6 +296,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..cad15412 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,6 +22,17 @@ import BaseRenderingResolver from './rendering-resolver'; export type RowMutator = (row: Row) => boolean; +export type HandlerEvent = + | 'columns-loaded' + | 'row-click' + | 'apply-filters' + | 'apply-order' + | 'reset-columns' + | 'remove-column' + | 'remove-row' + | 'mutate-rows' + | 'reset-rows'; + const ROWS_PER_PAGE = 30; export default class TableHandler { @@ -173,6 +184,13 @@ export default class TableHandler { return this; } + off(event: HandlerEvent, handler: (...args: any[]) => any): TableHandler { + // @ts-ignore Works but the declaration from @types/ember__object does not match the documentation/actual code. + removeListener(this, event, handler); + + 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..6cdba682 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,55 @@ 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: 10000, + replayOn: ['reset-rows'] + } + }; + + await render(hbs``); + + assert.dom('.hypertable__cell--initial-load-sequence').exists({ count: 12 }); + + await this.handler.resetRows(); + + 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 { From cccaf40305d381bab03b2536007ee2a362874610 Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Tue, 1 Sep 2026 10:56:26 +0200 Subject: [PATCH 2/5] Update replayOn readme entry to reflect allowed event --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f379f6b9..73af07fb 100644 --- a/README.md +++ b/README.md @@ -274,7 +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: `[]`. See [Events](#events) for available event names. +- `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`. From bcee8319fe6eb673b6dcc3c920923768725f5964 Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 2 Sep 2026 09:15:16 +0200 Subject: [PATCH 3/5] Scope load animation to first batch of loaded rows --- addon/components/hyper-table-v2/cell.ts | 6 ++++-- addon/core/handler.ts | 2 +- tests/integration/components/hyper-table-v2-test.ts | 8 +++++--- 3 files changed, 10 insertions(+), 6 deletions(-) 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/core/handler.ts b/addon/core/handler.ts index cad15412..8986e26a 100644 --- a/addon/core/handler.ts +++ b/addon/core/handler.ts @@ -33,7 +33,7 @@ export type HandlerEvent = | 'mutate-rows' | 'reset-rows'; -const ROWS_PER_PAGE = 30; +export const ROWS_PER_PAGE = 30; export default class TableHandler { private _context: unknown; diff --git a/tests/integration/components/hyper-table-v2-test.ts b/tests/integration/components/hyper-table-v2-test.ts index 6cdba682..17679a72 100644 --- a/tests/integration/components/hyper-table-v2-test.ts +++ b/tests/integration/components/hyper-table-v2-test.ts @@ -270,17 +270,19 @@ module('Integration | Component | hyper-table-v2', function (hooks) { initialLoadAnimation: { delayMs: 0, staggerMs: 0, - maxAnimationDurationMs: 10000, + maxAnimationDurationMs: 50, replayOn: ['reset-rows'] } }; await render(hbs``); - assert.dom('.hypertable__cell--initial-load-sequence').exists({ count: 12 }); - await this.handler.resetRows(); + 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 }); }); From 7a72da2ebdde96964602ede2f65da30573cdf210 Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 2 Sep 2026 16:53:26 +0200 Subject: [PATCH 4/5] Fixed: PR comments --- addon/components/hyper-table-v2/index.ts | 12 +++----- addon/core/handler.ts | 39 +++++++++++++++--------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/addon/components/hyper-table-v2/index.ts b/addon/components/hyper-table-v2/index.ts index 8ed497b8..96b26ed3 100644 --- a/addon/components/hyper-table-v2/index.ts +++ b/addon/components/hyper-table-v2/index.ts @@ -38,7 +38,7 @@ type InitialLoadAnimationConfig = { maxAnimationDurationMs: number; extraColumnEffect?: InitialLoadAnimationExtraColumnEffect; includeSelectionColumnInExtraEffect?: boolean; - replayOn?: Array>; + replayOn?: Extract[]; }; interface HyperTableV2Args { @@ -80,7 +80,6 @@ export default class HyperTableV2 extends Component { private initialLoadAnimationPlayed: boolean = false; private initialLoadAnimationTimeout?: number; - private animationReplayHandler!: () => void; declare private hypertableInstanceID: string; @@ -260,15 +259,14 @@ export default class HyperTableV2 extends Component { private registerAnimationReplayListeners(handler: TableHandler): void { if (!this.initialLoadAnimation?.replayOn?.length) return; - this.animationReplayHandler = this.onAnimationReplay.bind(this); for (const event of this.initialLoadAnimation.replayOn) { - handler.on(event, this.animationReplayHandler); + handler.on(event, this.onAnimationReplay); } } private unregisterAnimationReplayListeners(): void { for (const event of this.initialLoadAnimation?.replayOn ?? []) { - this.args.handler.off(event, this.animationReplayHandler); + this.args.handler.off(event, this.onAnimationReplay); } } @@ -296,7 +294,7 @@ export default class HyperTableV2 extends Component { this.computeScrollableTable(); } - private onAnimationReplay(): void { + private onAnimationReplay = (): void => { if (this.initialLoadAnimationTimeout) { window.clearTimeout(this.initialLoadAnimationTimeout); this.initialLoadAnimationTimeout = undefined; @@ -305,7 +303,7 @@ export default class HyperTableV2 extends Component { this.initialLoadAnimationPlayed = false; this.activateInitialLoadAnimationIfNeeded(); this.finalizeInitialLoadAnimation(); - } + }; private activateInitialLoadAnimationIfNeeded(): void { if (this.initialLoadAnimationPlayed || !this.initialLoadAnimation) { diff --git a/addon/core/handler.ts b/addon/core/handler.ts index 8986e26a..cd9b2ddd 100644 --- a/addon/core/handler.ts +++ b/addon/core/handler.ts @@ -22,16 +22,20 @@ import BaseRenderingResolver from './rendering-resolver'; export type RowMutator = (row: Row) => boolean; -export type HandlerEvent = - | 'columns-loaded' - | 'row-click' - | 'apply-filters' - | 'apply-order' - | 'reset-columns' - | 'remove-column' - | 'remove-row' - | 'mutate-rows' - | 'reset-rows'; +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 & Record); export const ROWS_PER_PAGE = 30; @@ -178,15 +182,22 @@ 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: HandlerEvent, handler: (...args: any[]) => any): TableHandler { - // @ts-ignore Works but the declaration from @types/ember__object does not match the documentation/actual code. - removeListener(this, event, handler); + 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; } From ab8c343f94fbe108fa87d0164f848380c1e3dd77 Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 2 Sep 2026 17:10:46 +0200 Subject: [PATCH 5/5] Fixed: PR comment --- addon/core/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/core/handler.ts b/addon/core/handler.ts index cd9b2ddd..c21da1a5 100644 --- a/addon/core/handler.ts +++ b/addon/core/handler.ts @@ -35,7 +35,7 @@ export const HANDLER_EVENTS = [ ] as const; export type HandlerEvent = (typeof HANDLER_EVENTS)[number]; -export type LooseAutocomplete = T | (string & Record); +export type LooseAutocomplete = T | (string & {}); export const ROWS_PER_PAGE = 30;