From ff59352d153732aa9cd804a22adc37cf04b95f5d Mon Sep 17 00:00:00 2001 From: "Maximilien B." Date: Tue, 18 Aug 2026 17:31:59 +0200 Subject: [PATCH 1/7] Introduce a customizable loading stagger animation on hypertableV2 --- README.md | 83 ++++++++++ addon/components/hyper-table-v2/cell.hbs | 4 + addon/components/hyper-table-v2/cell.ts | 115 ++++++++++++++ addon/components/hyper-table-v2/index.hbs | 13 +- addon/components/hyper-table-v2/index.ts | 105 ++++++++++++- app/styles/animations.less | 12 ++ app/styles/cells.less | 17 +++ tests/dummy/app/controllers/application.ts | 13 ++ tests/dummy/app/styles/app.less | 26 ++++ tests/dummy/app/templates/application.hbs | 2 +- .../components/hyper-table-v2-test.ts | 142 ++++++++++++++++++ 11 files changed, 525 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e6509232..8296ed82 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,89 @@ HyperTableV2 supports several named blocks that allow you to customize specific ``` +#### HyperTableV2 options + +`@options` lets you configure optional component behaviors. + +##### selectionIntlKeyPath + +- Type: `string` +- Required: no + +Custom base i18n key path used by `HyperTableV2::Selection` for: + +- `.all_records_selected` +- `.records_selected` +- `.select_all` + +Default path: `hypertable.selection`. +Note: the clear action label currently uses `hypertable.selection.clear` directly. + +```ts +options = { + selectionIntlKeyPath: 'my.table.selection' +}; +``` + +##### delegatedFiltering + +- Type: `boolean` +- Required: no + +Disables built-in column filter UI and ordering indicators in `HyperTableV2::Column`. +Use this when filtering and sorting are handled by external controls. + +```ts +options = { + delegatedFiltering: true +}; +``` + +##### initialRowsAnimation + +- Type: `object` +- Required: no + +Enables a one-time animation sequence on the first successful non-empty rows load. + +Behavior: + +- Base behavior: rows are revealed with a staggered sequence across all non-loading cells. +- Extra class behavior: when `extraColumnCellEffectClass` is set, that class is added on top of the base sequence on each cell that matches the columns specified in `columns`. +- Selection column behavior: by default, the extra class does not apply on selection checkbox cells. Set `includeSelectionColumnInExtraEffect` to `true` to include them. +- Extra class delay behavior: `extraColumnCellEffectDelayMs` adds an extra delay before the extra class effect starts. +- If `columns` is omitted or empty, `extraColumnCellEffectClass` is applied to cells from all columns. + + +```ts +options = { + initialRowsAnimation: { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 1500, + extraColumnCellEffectDelayMs: 120, + extraColumnCellEffectClass: 'smart-rotating-gradient', + columns: ['foo', 'bar'], + includeSelectionColumnInExtraEffect: false + } +}; +``` + +Fields: + +- `delayMs` (number): Delay before the sequence starts. Default: `300`. +- `staggerMs` (number): Extra delay applied per row (`rowIndex * staggerMs`). Default: `40`. +- `maxAnimationDurationMs` (number): Max duration used by the animation window timing. Default: `1500`. +- `extraColumnCellEffectDelayMs` (number, optional): Extra delay applied before the `extraColumnCellEffectClass` effect starts. Default: `0`. +- `extraColumnCellEffectClass` (string): Optional extra CSS class added to targeted cells while animation is active. +- `columns` (string[]): Column keys that receive `extraColumnCellEffectClass`. If omitted or empty, the extra class is applied to all columns. +- `includeSelectionColumnInExtraEffect` (boolean): Whether the extra class should also be applied on selection checkbox cells when selection is enabled. Default: `false`. + +Notes: + +- The sequence runs once per component lifecycle. +- The active window is capped internally to avoid overly long animations on large datasets. + ## Core Concepts ### Column Definitions diff --git a/addon/components/hyper-table-v2/cell.hbs b/addon/components/hyper-table-v2/cell.hbs index 8548d6f5..170ba04a 100644 --- a/addon/components/hyper-table-v2/cell.hbs +++ b/addon/components/hyper-table-v2/cell.hbs @@ -3,8 +3,12 @@ "hypertable__cell" (if this.loading " hypertable__cell--loading") (if @row.hovered " hypertable__cell--hovered") + (if this.initialRowsAnimationSequenceClass (concat " " this.initialRowsAnimationSequenceClass)) + (if this.initialRowsAnimationCellClass (concat " " this.initialRowsAnimationCellClass)) }} + style={{this.initialRowsAnimationCellStyle}} role="button" + {{will-destroy this.teardown}} {{on "click" this.clickedCell}} {{on "mouseenter" (fn this.toggleHover @row true)}} {{on "mouseleave" (fn this.toggleHover @row false)}} diff --git a/addon/components/hyper-table-v2/cell.ts b/addon/components/hyper-table-v2/cell.ts index 11c44af0..fb1b38bc 100644 --- a/addon/components/hyper-table-v2/cell.ts +++ b/addon/components/hyper-table-v2/cell.ts @@ -2,6 +2,7 @@ import { action } from '@ember/object'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import type { InitialRowsAnimationContext } from '@upfluence/hypertable/components/hyper-table-v2'; import TableHandler from '@upfluence/hypertable/core/handler'; import { Column, ResolvedRenderingComponent, Row } from '@upfluence/hypertable/core/interfaces'; @@ -9,6 +10,9 @@ interface HyperTableV2CellArgs { handler: TableHandler; column: Column; row: Row; + rowIndex?: number; + initialRowsAnimation?: InitialRowsAnimationContext | null; + disableInitialRowsAnimationExtraEffect?: boolean; loading: boolean; onClick?(row: Row): void; onHover?(row: Row, hovered: boolean): void; @@ -17,6 +21,9 @@ interface HyperTableV2CellArgs { export default class HyperTableV2Cell extends Component { @tracked loadingCellComponent: boolean = true; @tracked cellComponent?: ResolvedRenderingComponent; + @tracked extraEffectReady: boolean = false; + + private extraEffectTimeout?: number; constructor(owner: unknown, args: HyperTableV2CellArgs) { super(owner, args); @@ -37,6 +44,109 @@ export default class HyperTableV2Cell extends Component { return this.args.loading || this.loadingCellComponent; } + get initialRowsAnimationCellClass(): string { + const extraColumnCellEffectClass = this.args.initialRowsAnimation?.extraColumnCellEffectClass; + + if (!this.shouldApplyInitialRowsAnimationCustomEffect || !extraColumnCellEffectClass) { + this.resetExtraEffectState(); + return ''; + } + + if (this.extraEffectActivationDelayMs <= 0) { + return extraColumnCellEffectClass; + } + + this.scheduleExtraEffectIfNeeded(); + + return this.extraEffectReady ? extraColumnCellEffectClass : ''; + } + + get initialRowsAnimationSequenceClass(): string { + if (!this.shouldApplyInitialRowsAnimationSequence) { + return ''; + } + + return 'hypertable__cell--initial-load-sequence'; + } + + get initialRowsAnimationCellStyle(): string | undefined { + if (!this.shouldApplyInitialRowsAnimationSequence) { + return undefined; + } + + const extraColumnCellEffectDelayMs = this.args.initialRowsAnimation?.extraColumnCellEffectDelayMs ?? 0; + const staggeredDelayMs = this.rowAnimationDelayMs; + const extraEffectDelayMs = staggeredDelayMs + extraColumnCellEffectDelayMs; + + return `--hypertable-initial-rows-animation-delay: ${staggeredDelayMs}ms; --hypertable-initial-rows-extra-effect-delay: ${extraEffectDelayMs}ms;`; + } + + private get rowAnimationDelayMs(): number { + const delayMs = this.args.initialRowsAnimation?.delayMs ?? 0; + const staggerMs = this.args.initialRowsAnimation?.staggerMs ?? 0; + const rowIndex = this.args.rowIndex ?? 0; + + return delayMs + rowIndex * staggerMs; + } + + private get isInitialRowsAnimationEnabled(): boolean { + return this.args.initialRowsAnimation?.active === true; + } + + private get isInitialRowsAnimationTargetedColumn(): boolean { + const columns = this.args.initialRowsAnimation?.columns; + + if (!columns || columns.length === 0) { + return true; + } + + return columns.includes(this.args.column.definition.key); + } + + private get shouldApplyInitialRowsAnimationSequence(): boolean { + return this.isInitialRowsAnimationEnabled && !this.loading; + } + + private get shouldApplyInitialRowsAnimationCustomEffect(): boolean { + if (this.args.disableInitialRowsAnimationExtraEffect) { + return false; + } + + return this.shouldApplyInitialRowsAnimationSequence && this.isInitialRowsAnimationTargetedColumn; + } + + private get extraEffectActivationDelayMs(): number { + const extraColumnCellEffectDelayMs = this.args.initialRowsAnimation?.extraColumnCellEffectDelayMs ?? 0; + return this.rowAnimationDelayMs + extraColumnCellEffectDelayMs; + } + + private scheduleExtraEffectIfNeeded(): void { + if (this.extraEffectReady || this.extraEffectTimeout) { + return; + } + + const activationDelayMs = this.extraEffectActivationDelayMs; + + if (activationDelayMs <= 0) { + this.extraEffectReady = true; + return; + } + + this.extraEffectTimeout = window.setTimeout(() => { + this.extraEffectReady = true; + this.extraEffectTimeout = undefined; + }, activationDelayMs); + } + + private resetExtraEffectState(): void { + if (this.extraEffectTimeout) { + window.clearTimeout(this.extraEffectTimeout); + this.extraEffectTimeout = undefined; + } + + this.extraEffectReady = false; + } + @action clickedCell(event: MouseEvent) { event.stopPropagation(); @@ -50,4 +160,9 @@ export default class HyperTableV2Cell extends Component { toggleHover(row: Row, hovered: boolean) { this.args.onHover?.(row, hovered); } + + @action + teardown() { + this.resetExtraEffectState(); + } } diff --git a/addon/components/hyper-table-v2/index.hbs b/addon/components/hyper-table-v2/index.hbs index 6644beb6..4d8236c8 100644 --- a/addon/components/hyper-table-v2/index.hbs +++ b/addon/components/hyper-table-v2/index.hbs @@ -108,11 +108,14 @@ /> - {{#each @handler.rows as |row|}} + {{#each @handler.rows as |row rowIndex|}} - {{#each @handler.rows as |row|}} + {{#each @handler.rows as |row rowIndex|}} - {{#each @handler.rows as |row|}} + {{#each @handler.rows as |row rowIndex|}} ; + interface HyperTableV2Args { handler: TableHandler; features: FeatureSet; @@ -32,7 +47,14 @@ const DEFAULT_FEATURES_SET: FeatureSet = { manageable_fields: true, global_filters_reset: true }; + const RESET_DEBOUNCE_TIME = 300; +const DEFAULT_INITIAL_LOAD_ANIMATION_DELAY_MS = 300; +const DEFAULT_INITIAL_LOAD_ANIMATION_DURATION_MS = 1500; +const DEFAULT_INITIAL_LOAD_ANIMATION_STAGGER_MS = 40; +const DEFAULT_INITIAL_LOAD_ANIMATION_EXTRA_EFFECT_DELAY_MS = 0; +const DEFAULT_INITIAL_LOAD_ANIMATION_INCLUDE_SELECTION_COLUMN_IN_EXTRA_EFFECT = false; +const MAX_INITIAL_LOAD_ANIMATION_WINDOW_MS = 5000; export default class HyperTableV2 extends Component { loadingSkeletons = new Array(3); @@ -41,6 +63,10 @@ export default class HyperTableV2 extends Component { @tracked loadingResetFilters = false; @tracked scrollableTable: boolean = false; @tracked initialFetchColumnsDone: boolean = false; + @tracked initialRowsAnimationActive: boolean = false; + @tracked initialRowsAnimationPlayed: boolean = false; + + private initialRowsAnimationTimeout?: number; declare private hypertableInstanceID: string; @@ -49,7 +75,9 @@ export default class HyperTableV2 extends Component { args.handler.fetchColumnDefinitions(); args.handler.fetchColumns().then(() => { this.initialFetchColumnsDone = true; - args.handler.fetchRows(); + args.handler.fetchRows().finally(() => { + this.activateInitialRowsAnimationIfNeeded(); + }); this.computeScrollableTable(); }); @@ -63,6 +91,10 @@ export default class HyperTableV2 extends Component { }; } + get disableInitialRowsAnimationExtraEffectOnSelectionCells(): boolean { + return !this.initialRowsAnimation?.includeSelectionColumnInExtraEffect; + } + @computed('args.handler.columns.@each.{filters,order}') get displayResetButton(): boolean { const filtersApplied: boolean = this.args.handler.columns.some((col) => col.filters?.length || col.order); @@ -88,6 +120,44 @@ export default class HyperTableV2 extends Component { } } + get initialRowsAnimationContext(): InitialRowsAnimationContext | null { + if (!this.initialRowsAnimation) { + return null; + } + + return { + active: this.initialRowsAnimationActive, + delayMs: this.initialRowsAnimation.delayMs, + staggerMs: this.initialRowsAnimation.staggerMs, + maxAnimationDurationMs: this.initialRowsAnimation.maxAnimationDurationMs, + extraColumnCellEffectDelayMs: this.initialRowsAnimation.extraColumnCellEffectDelayMs, + extraColumnCellEffectClass: this.initialRowsAnimation.extraColumnCellEffectClass, + columns: this.initialRowsAnimation.columns, + includeSelectionColumnInExtraEffect: this.initialRowsAnimation.includeSelectionColumnInExtraEffect + }; + } + + private get initialRowsAnimation(): InitialRowsAnimationConfig | null { + const options = this.args.options?.initialRowsAnimation; + + if (!options) { + return null; + } + + return { + delayMs: options.delayMs ?? DEFAULT_INITIAL_LOAD_ANIMATION_DELAY_MS, + staggerMs: options.staggerMs ?? DEFAULT_INITIAL_LOAD_ANIMATION_STAGGER_MS, + maxAnimationDurationMs: options.maxAnimationDurationMs ?? DEFAULT_INITIAL_LOAD_ANIMATION_DURATION_MS, + extraColumnCellEffectDelayMs: + options.extraColumnCellEffectDelayMs ?? DEFAULT_INITIAL_LOAD_ANIMATION_EXTRA_EFFECT_DELAY_MS, + extraColumnCellEffectClass: options.extraColumnCellEffectClass, + columns: options.columns, + includeSelectionColumnInExtraEffect: + options.includeSelectionColumnInExtraEffect ?? + DEFAULT_INITIAL_LOAD_ANIMATION_INCLUDE_SELECTION_COLUMN_IN_EXTRA_EFFECT + }; + } + @action computeScrollableTable(): void { const table = this.innerTableElement; @@ -175,6 +245,11 @@ export default class HyperTableV2 extends Component { @action teardown(): void { + if (this.initialRowsAnimationTimeout) { + window.clearTimeout(this.initialRowsAnimationTimeout); + this.initialRowsAnimationTimeout = undefined; + } + this.args.handler.teardown(); } @@ -206,6 +281,30 @@ export default class HyperTableV2 extends Component { this.computeScrollableTable(); } + private activateInitialRowsAnimationIfNeeded(): void { + if (this.initialRowsAnimationPlayed || !this.initialRowsAnimation) { + return; + } + + if (this.args.handler.communicationError || this.args.handler.rows.length === 0) { + return; + } + + this.initialRowsAnimationPlayed = true; + this.initialRowsAnimationActive = true; + + const rowsAnimationWindowMs = Math.max(this.args.handler.rows.length - 1, 0) * this.initialRowsAnimation.staggerMs; + const activeDurationMs = Math.min( + this.initialRowsAnimation.delayMs + rowsAnimationWindowMs + this.initialRowsAnimation.maxAnimationDurationMs, + MAX_INITIAL_LOAD_ANIMATION_WINDOW_MS + ); + + this.initialRowsAnimationTimeout = window.setTimeout(() => { + this.initialRowsAnimationActive = false; + this.initialRowsAnimationTimeout = undefined; + }, activeDurationMs); + } + private resetSelectionOnFullExclusion(): void { if ((this.args.handler.rowsMeta?.total ?? 0) === this.args.handler.exclusion.length) { this.args.handler.clearSelection(); diff --git a/app/styles/animations.less b/app/styles/animations.less index 8b81c007..a7e6e9fd 100644 --- a/app/styles/animations.less +++ b/app/styles/animations.less @@ -37,3 +37,15 @@ background-position: calc(200px + 100%) 0; } } + +@keyframes initial-load-cell { + 0% { + opacity: 0; + transform: translateY(8px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/app/styles/cells.less b/app/styles/cells.less index 21d15f20..b51f5c59 100644 --- a/app/styles/cells.less +++ b/app/styles/cells.less @@ -277,6 +277,23 @@ cursor: pointer; } +.hypertable__cell--initial-load-sequence { + opacity: 0; + animation-name: initial-load-cell; + animation-duration: 0.3s; + animation-timing-function: ease-out; + animation-delay: var(--hypertable-initial-rows-animation-delay, 0ms); + animation-fill-mode: forwards; + will-change: transform, opacity; +} + +@media (prefers-reduced-motion: reduce) { + .hypertable__cell--initial-load-sequence { + animation: none; + opacity: 1; + } +} + .expandable-list { position: absolute; left: 0; diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts index 7ab9d631..4aeb2b9f 100644 --- a/tests/dummy/app/controllers/application.ts +++ b/tests/dummy/app/controllers/application.ts @@ -228,6 +228,19 @@ export default class Application extends Controller { }); } + get tableOptions() { + return { + initialRowsAnimation: { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 5000, + extraColumnCellEffectClass: 'smart-rotating-gradient', + extraColumnCellEffectDelayMs: 250, + columns: ['foo'] + } + }; + } + @action onCustomSearchInput() { this.handler.applyFilters(this.handler.columns[0], [ diff --git a/tests/dummy/app/styles/app.less b/tests/dummy/app/styles/app.less index ea109ad5..954076bc 100644 --- a/tests/dummy/app/styles/app.less +++ b/tests/dummy/app/styles/app.less @@ -4,3 +4,29 @@ body { background-color: white; } + +// Apply stacking fixes only on cells that actually get the custom effect class. +// This would typically be defined in the parent application / engine +// It's defined in the dummy less file for testing purposes. +.hypertable__cell.hypertable__cell--initial-load-sequence.smart-rotating-gradient { + position: relative; + z-index: 0; +} + +.hypertable__cell.hypertable__cell--initial-load-sequence.smart-rotating-gradient::after { + z-index: -2; + animation-delay: var( + --hypertable-initial-rows-extra-effect-delay, + var(--hypertable-initial-rows-animation-delay, 0ms) + ); + animation-fill-mode: both; +} + +.hypertable__cell.hypertable__cell--initial-load-sequence.smart-rotating-gradient::before { + content: ''; + position: absolute; + inset: 1px; + z-index: -1; + background: #fff; + border-radius: 4px; +} diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index 33913a51..1920eed3 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -14,7 +14,7 @@
- + <:contextual-actions> {{! To do : move contextual-actions CSS to the target component }}
diff --git a/tests/integration/components/hyper-table-v2-test.ts b/tests/integration/components/hyper-table-v2-test.ts index 5fe2408a..8168f76b 100644 --- a/tests/integration/components/hyper-table-v2-test.ts +++ b/tests/integration/components/hyper-table-v2-test.ts @@ -90,6 +90,148 @@ module('Integration | Component | hyper-table-v2', function (hooks) { assert.ok(teardownStub.calledOnce); }); + module('initialRowsAnimation', function () { + test('it does not apply animation classes when the option is not provided', async function (this: TestContext, assert: Assert) { + await render(hbs``); + + assert.dom('.hypertable__cell--initial-load-sequence').doesNotExist(); + assert.dom('.hypertable__cell.smart-rotating-gradient').doesNotExist(); + }); + + test('it applies the stagger sequence class to all non-loading cells', async function (this: TestContext, assert: Assert) { + this.options = { initialRowsAnimation: { delayMs: 300, staggerMs: 40, maxAnimationDurationMs: 1500 } }; + + await render(hbs``); + + assert.dom('.hypertable__cell--initial-load-sequence').exists({ count: 12 }); + }); + + test('it applies per-row delay with delayMs and staggerMs', async function (this: TestContext, assert: Assert) { + this.options = { + initialRowsAnimation: { delayMs: 120, staggerMs: 30, maxAnimationDurationMs: 1500 } + }; + + await render(hbs``); + + const stickyColumnCells = findAll('.hypertable__sticky-columns .hypertable__column .hypertable__cell'); + const firstCellStyle = stickyColumnCells[0].getAttribute('style') ?? ''; + const secondCellStyle = stickyColumnCells[1].getAttribute('style') ?? ''; + + assert.ok(firstCellStyle.includes('--hypertable-initial-rows-animation-delay: 120ms;')); + assert.ok(secondCellStyle.includes('--hypertable-initial-rows-animation-delay: 150ms;')); + }); + + test('it applies extraColumnCellEffectDelayMs on top of row stagger delay', async function (this: TestContext, assert: Assert) { + this.options = { + initialRowsAnimation: { + delayMs: 120, + staggerMs: 30, + maxAnimationDurationMs: 1500, + extraColumnCellEffectDelayMs: 70, + extraColumnCellEffectClass: 'smart-rotating-gradient' + } + }; + + await render(hbs``); + + const stickyColumnCells = findAll('.hypertable__sticky-columns .hypertable__column .hypertable__cell'); + const firstCellStyle = stickyColumnCells[0].getAttribute('style') ?? ''; + const secondCellStyle = stickyColumnCells[1].getAttribute('style') ?? ''; + + assert.ok(firstCellStyle.includes('--hypertable-initial-rows-extra-effect-delay: 190ms;')); + assert.ok(secondCellStyle.includes('--hypertable-initial-rows-extra-effect-delay: 220ms;')); + }); + + test('it applies the extra effect class only on targeted column cells', async function (this: TestContext, assert: Assert) { + this.options = { + initialRowsAnimation: { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 1500, + extraColumnCellEffectClass: 'smart-rotating-gradient', + columns: ['foo'] + } + }; + + await render(hbs``); + + assert.dom('.hypertable__cell--initial-load-sequence').exists({ count: 12 }); + assert.dom('.hypertable__cell.smart-rotating-gradient').exists({ count: 3 }); + assert.dom('.hypertable__column:nth-child(2) .hypertable__cell.smart-rotating-gradient').doesNotExist(); + }); + + test('it applies the extra effect class to all columns when columns is omitted', async function (this: TestContext, assert: Assert) { + this.options = { + initialRowsAnimation: { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 1500, + extraColumnCellEffectClass: 'smart-rotating-gradient' + } + }; + + await render(hbs``); + + assert.dom('.hypertable__cell.smart-rotating-gradient').exists({ count: 12 }); + }); + + test('it applies base stagger but not extra effect class on selection checkbox cells', async function (this: TestContext, assert: Assert) { + this.features = { selection: true }; + this.options = { + initialRowsAnimation: { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 1500, + extraColumnCellEffectClass: 'smart-rotating-gradient' + } + }; + + await render( + hbs`` + ); + + assert.dom('.hypertable__column--selection .hypertable__cell--initial-load-sequence').exists({ count: 3 }); + assert.dom('.hypertable__column--selection .hypertable__cell.smart-rotating-gradient').doesNotExist(); + assert.dom('.hypertable__cell.smart-rotating-gradient').exists({ count: 12 }); + }); + + test('it can apply the extra effect class on selection checkbox cells when enabled', async function (this: TestContext, assert: Assert) { + this.features = { selection: true }; + this.options = { + initialRowsAnimation: { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 1500, + extraColumnCellEffectClass: 'smart-rotating-gradient', + includeSelectionColumnInExtraEffect: true + } + }; + + await render( + hbs`` + ); + + assert.dom('.hypertable__column--selection .hypertable__cell.smart-rotating-gradient').exists({ count: 3 }); + assert.dom('.hypertable__cell.smart-rotating-gradient').exists({ count: 15 }); + }); + + test('it applies the extra effect class to all columns when columns is empty', async function (this: TestContext, assert: Assert) { + this.options = { + initialRowsAnimation: { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 1500, + extraColumnCellEffectClass: 'smart-rotating-gradient', + columns: [] + } + }; + + await render(hbs``); + + assert.dom('.hypertable__cell.smart-rotating-gradient').exists({ count: 12 }); + }); + }); + module('empty state', function (hooks) { hooks.beforeEach(function (this: TestContext) { sinon.stub(this.rowsFetcher, 'fetch').callsFake((_: number, _1: number) => { From 2809ede0dc8158e68e88264ff7457d307603d1e5 Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 19 Aug 2026 09:04:08 +0200 Subject: [PATCH 2/7] Fixed tests --- .../components/hyper-table-v2-test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/integration/components/hyper-table-v2-test.ts b/tests/integration/components/hyper-table-v2-test.ts index 8168f76b..0b3cf875 100644 --- a/tests/integration/components/hyper-table-v2-test.ts +++ b/tests/integration/components/hyper-table-v2-test.ts @@ -145,8 +145,8 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies the extra effect class only on targeted column cells', async function (this: TestContext, assert: Assert) { this.options = { initialRowsAnimation: { - delayMs: 300, - staggerMs: 40, + delayMs: 0, + staggerMs: 0, maxAnimationDurationMs: 1500, extraColumnCellEffectClass: 'smart-rotating-gradient', columns: ['foo'] @@ -163,8 +163,8 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies the extra effect class to all columns when columns is omitted', async function (this: TestContext, assert: Assert) { this.options = { initialRowsAnimation: { - delayMs: 300, - staggerMs: 40, + delayMs: 0, + staggerMs: 0, maxAnimationDurationMs: 1500, extraColumnCellEffectClass: 'smart-rotating-gradient' } @@ -179,8 +179,8 @@ module('Integration | Component | hyper-table-v2', function (hooks) { this.features = { selection: true }; this.options = { initialRowsAnimation: { - delayMs: 300, - staggerMs: 40, + delayMs: 0, + staggerMs: 0, maxAnimationDurationMs: 1500, extraColumnCellEffectClass: 'smart-rotating-gradient' } @@ -199,8 +199,8 @@ module('Integration | Component | hyper-table-v2', function (hooks) { this.features = { selection: true }; this.options = { initialRowsAnimation: { - delayMs: 300, - staggerMs: 40, + delayMs: 0, + staggerMs: 0, maxAnimationDurationMs: 1500, extraColumnCellEffectClass: 'smart-rotating-gradient', includeSelectionColumnInExtraEffect: true @@ -218,8 +218,8 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies the extra effect class to all columns when columns is empty', async function (this: TestContext, assert: Assert) { this.options = { initialRowsAnimation: { - delayMs: 300, - staggerMs: 40, + delayMs: 0, + staggerMs: 0, maxAnimationDurationMs: 1500, extraColumnCellEffectClass: 'smart-rotating-gradient', columns: [] From d6db292741963a763bc9df0a485234fbc1fc99c2 Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 19 Aug 2026 15:13:47 +0200 Subject: [PATCH 3/7] Fixed: PR comments --- addon/components/hyper-table-v2/cell.hbs | 8 +---- addon/components/hyper-table-v2/cell.ts | 39 +++++++++++++++----- addon/components/hyper-table-v2/index.hbs | 4 ++- addon/components/hyper-table-v2/index.ts | 44 ++++++++--------------- 4 files changed, 49 insertions(+), 46 deletions(-) diff --git a/addon/components/hyper-table-v2/cell.hbs b/addon/components/hyper-table-v2/cell.hbs index 170ba04a..cf7c3387 100644 --- a/addon/components/hyper-table-v2/cell.hbs +++ b/addon/components/hyper-table-v2/cell.hbs @@ -1,11 +1,5 @@
{ return this.args.loading || this.loadingCellComponent; } + get computedClass(): string { + const classes = ['hypertable__cell']; + + if (this.loading) { + classes.push('hypertable__cell--loading'); + } + + if (this.args.row?.hovered) { + classes.push('hypertable__cell--hovered'); + } + + if (this.initialRowsAnimationSequenceClass) { + classes.push(this.initialRowsAnimationSequenceClass); + } + + if (this.initialRowsAnimationCellClass) { + classes.push(this.initialRowsAnimationCellClass); + } + + return classes.join(' '); + } + get initialRowsAnimationCellClass(): string { const extraColumnCellEffectClass = this.args.initialRowsAnimation?.extraColumnCellEffectClass; @@ -62,14 +85,10 @@ export default class HyperTableV2Cell extends Component { } get initialRowsAnimationSequenceClass(): string { - if (!this.shouldApplyInitialRowsAnimationSequence) { - return ''; - } - - return 'hypertable__cell--initial-load-sequence'; + return this.shouldApplyInitialRowsAnimationSequence ? 'hypertable__cell--initial-load-sequence' : ''; } - get initialRowsAnimationCellStyle(): string | undefined { + get initialRowsAnimationCellStyle(): ReturnType | undefined { if (!this.shouldApplyInitialRowsAnimationSequence) { return undefined; } @@ -78,7 +97,9 @@ export default class HyperTableV2Cell extends Component { const staggeredDelayMs = this.rowAnimationDelayMs; const extraEffectDelayMs = staggeredDelayMs + extraColumnCellEffectDelayMs; - return `--hypertable-initial-rows-animation-delay: ${staggeredDelayMs}ms; --hypertable-initial-rows-extra-effect-delay: ${extraEffectDelayMs}ms;`; + return htmlSafe( + `--hypertable-initial-rows-animation-delay: ${staggeredDelayMs}ms; --hypertable-initial-rows-extra-effect-delay: ${extraEffectDelayMs}ms;` + ); } private get rowAnimationDelayMs(): number { @@ -108,7 +129,7 @@ export default class HyperTableV2Cell extends Component { } private get shouldApplyInitialRowsAnimationCustomEffect(): boolean { - if (this.args.disableInitialRowsAnimationExtraEffect) { + if (!this.args.enableInitialRowsAnimationExtraEffect) { return false; } diff --git a/addon/components/hyper-table-v2/index.hbs b/addon/components/hyper-table-v2/index.hbs index 4d8236c8..cb99a8a9 100644 --- a/addon/components/hyper-table-v2/index.hbs +++ b/addon/components/hyper-table-v2/index.hbs @@ -115,7 +115,7 @@ @row={{row}} @rowIndex={{rowIndex}} @initialRowsAnimation={{this.initialRowsAnimationContext}} - @disableInitialRowsAnimationExtraEffect={{this.disableInitialRowsAnimationExtraEffectOnSelectionCells}} + @enableInitialRowsAnimationExtraEffect={{this.enableInitialRowsAnimationExtraEffectOnSelectionCells}} @onClick={{fn this.toggleRowSelection row}} @onHover={{this.onRowHover}} @loading={{row._isLoading}} @@ -152,6 +152,7 @@ @row={{row}} @rowIndex={{rowIndex}} @initialRowsAnimation={{this.initialRowsAnimationContext}} + @enableInitialRowsAnimationExtraEffect={{true}} @onClick={{this.onRowClick}} @onHover={{this.onRowHover}} @loading={{row._isLoading}} @@ -187,6 +188,7 @@ @row={{row}} @rowIndex={{rowIndex}} @initialRowsAnimation={{this.initialRowsAnimationContext}} + @enableInitialRowsAnimationExtraEffect={{true}} @onClick={{this.onRowClick}} @onHover={{this.onRowHover}} @loading={{row._isLoading}} diff --git a/addon/components/hyper-table-v2/index.ts b/addon/components/hyper-table-v2/index.ts index 65e8c190..ddb1bb3b 100644 --- a/addon/components/hyper-table-v2/index.ts +++ b/addon/components/hyper-table-v2/index.ts @@ -49,13 +49,19 @@ const DEFAULT_FEATURES_SET: FeatureSet = { }; const RESET_DEBOUNCE_TIME = 300; -const DEFAULT_INITIAL_LOAD_ANIMATION_DELAY_MS = 300; -const DEFAULT_INITIAL_LOAD_ANIMATION_DURATION_MS = 1500; -const DEFAULT_INITIAL_LOAD_ANIMATION_STAGGER_MS = 40; -const DEFAULT_INITIAL_LOAD_ANIMATION_EXTRA_EFFECT_DELAY_MS = 0; -const DEFAULT_INITIAL_LOAD_ANIMATION_INCLUDE_SELECTION_COLUMN_IN_EXTRA_EFFECT = false; const MAX_INITIAL_LOAD_ANIMATION_WINDOW_MS = 5000; +const DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG: Omit< + InitialRowsAnimationConfig, + 'extraColumnCellEffectClass' | 'columns' +> = { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 1500, + extraColumnCellEffectDelayMs: 0, + includeSelectionColumnInExtraEffect: false +}; + export default class HyperTableV2 extends Component { loadingSkeletons = new Array(3); innerTableElement?: Element; @@ -91,8 +97,8 @@ export default class HyperTableV2 extends Component { }; } - get disableInitialRowsAnimationExtraEffectOnSelectionCells(): boolean { - return !this.initialRowsAnimation?.includeSelectionColumnInExtraEffect; + get enableInitialRowsAnimationExtraEffectOnSelectionCells(): boolean { + return !!this.initialRowsAnimation?.includeSelectionColumnInExtraEffect; } @computed('args.handler.columns.@each.{filters,order}') @@ -125,16 +131,7 @@ export default class HyperTableV2 extends Component { return null; } - return { - active: this.initialRowsAnimationActive, - delayMs: this.initialRowsAnimation.delayMs, - staggerMs: this.initialRowsAnimation.staggerMs, - maxAnimationDurationMs: this.initialRowsAnimation.maxAnimationDurationMs, - extraColumnCellEffectDelayMs: this.initialRowsAnimation.extraColumnCellEffectDelayMs, - extraColumnCellEffectClass: this.initialRowsAnimation.extraColumnCellEffectClass, - columns: this.initialRowsAnimation.columns, - includeSelectionColumnInExtraEffect: this.initialRowsAnimation.includeSelectionColumnInExtraEffect - }; + return { active: this.initialRowsAnimationActive, ...this.initialRowsAnimation }; } private get initialRowsAnimation(): InitialRowsAnimationConfig | null { @@ -144,18 +141,7 @@ export default class HyperTableV2 extends Component { return null; } - return { - delayMs: options.delayMs ?? DEFAULT_INITIAL_LOAD_ANIMATION_DELAY_MS, - staggerMs: options.staggerMs ?? DEFAULT_INITIAL_LOAD_ANIMATION_STAGGER_MS, - maxAnimationDurationMs: options.maxAnimationDurationMs ?? DEFAULT_INITIAL_LOAD_ANIMATION_DURATION_MS, - extraColumnCellEffectDelayMs: - options.extraColumnCellEffectDelayMs ?? DEFAULT_INITIAL_LOAD_ANIMATION_EXTRA_EFFECT_DELAY_MS, - extraColumnCellEffectClass: options.extraColumnCellEffectClass, - columns: options.columns, - includeSelectionColumnInExtraEffect: - options.includeSelectionColumnInExtraEffect ?? - DEFAULT_INITIAL_LOAD_ANIMATION_INCLUDE_SELECTION_COLUMN_IN_EXTRA_EFFECT - }; + return { ...DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG, ...options }; } @action From 6d2c2fa361a917ab293abbdf97da87b19ebb7fcd Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 19 Aug 2026 16:44:34 +0200 Subject: [PATCH 4/7] Fixed PR comments --- README.md | 4 +- addon/components/hyper-table-v2/cell.hbs | 2 +- addon/components/hyper-table-v2/cell.ts | 66 ++++++-------- addon/components/hyper-table-v2/index.hbs | 12 +-- addon/components/hyper-table-v2/index.ts | 90 ++++++++++--------- app/styles/animations.less | 4 +- tests/dummy/app/controllers/application.ts | 2 +- .../components/hyper-table-v2-test.ts | 18 ++-- 8 files changed, 94 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 8296ed82..661b186d 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ options = { }; ``` -##### initialRowsAnimation +##### initialLoadAnimation - Type: `object` - Required: no @@ -248,7 +248,7 @@ Behavior: ```ts options = { - initialRowsAnimation: { + initialLoadAnimation: { delayMs: 300, staggerMs: 40, maxAnimationDurationMs: 1500, diff --git a/addon/components/hyper-table-v2/cell.hbs b/addon/components/hyper-table-v2/cell.hbs index cf7c3387..d174da3f 100644 --- a/addon/components/hyper-table-v2/cell.hbs +++ b/addon/components/hyper-table-v2/cell.hbs @@ -1,6 +1,6 @@
{ get computedClass(): string { const classes = ['hypertable__cell']; - if (this.loading) { - classes.push('hypertable__cell--loading'); - } - - if (this.args.row?.hovered) { - classes.push('hypertable__cell--hovered'); - } - - if (this.initialRowsAnimationSequenceClass) { - classes.push(this.initialRowsAnimationSequenceClass); - } - - if (this.initialRowsAnimationCellClass) { - classes.push(this.initialRowsAnimationCellClass); - } + if (this.loading) classes.push('hypertable__cell--loading'); + if (this.args.row?.hovered) classes.push('hypertable__cell--hovered'); + if (this.initialLoadAnimationSequenceClass) classes.push(this.initialLoadAnimationSequenceClass); + if (this.initialLoadAnimationCellClass) classes.push(this.initialLoadAnimationCellClass); return classes.join(' '); } - get initialRowsAnimationCellClass(): string { - const extraColumnCellEffectClass = this.args.initialRowsAnimation?.extraColumnCellEffectClass; + get initialLoadAnimationCellClass(): string { + const extraColumnCellEffectClass = this.args.initialLoadAnimation?.extraColumnCellEffectClass; - if (!this.shouldApplyInitialRowsAnimationCustomEffect || !extraColumnCellEffectClass) { + if (!this.shouldApplyInitialLoadAnimationCustomEffect || !extraColumnCellEffectClass) { this.resetExtraEffectState(); return ''; } @@ -84,16 +73,16 @@ export default class HyperTableV2Cell extends Component { return this.extraEffectReady ? extraColumnCellEffectClass : ''; } - get initialRowsAnimationSequenceClass(): string { - return this.shouldApplyInitialRowsAnimationSequence ? 'hypertable__cell--initial-load-sequence' : ''; + get initialLoadAnimationSequenceClass(): string { + return this.shouldApplyInitialLoadAnimationSequence ? 'hypertable__cell--initial-load-sequence' : ''; } - get initialRowsAnimationCellStyle(): ReturnType | undefined { - if (!this.shouldApplyInitialRowsAnimationSequence) { + get initialLoadAnimationCellStyle(): ReturnType | undefined { + if (!this.shouldApplyInitialLoadAnimationSequence) { return undefined; } - const extraColumnCellEffectDelayMs = this.args.initialRowsAnimation?.extraColumnCellEffectDelayMs ?? 0; + const extraColumnCellEffectDelayMs = this.args.initialLoadAnimation?.extraColumnCellEffectDelayMs ?? 0; const staggeredDelayMs = this.rowAnimationDelayMs; const extraEffectDelayMs = staggeredDelayMs + extraColumnCellEffectDelayMs; @@ -103,19 +92,19 @@ export default class HyperTableV2Cell extends Component { } private get rowAnimationDelayMs(): number { - const delayMs = this.args.initialRowsAnimation?.delayMs ?? 0; - const staggerMs = this.args.initialRowsAnimation?.staggerMs ?? 0; + const delayMs = this.args.initialLoadAnimation?.delayMs ?? 0; + const staggerMs = this.args.initialLoadAnimation?.staggerMs ?? 0; const rowIndex = this.args.rowIndex ?? 0; return delayMs + rowIndex * staggerMs; } - private get isInitialRowsAnimationEnabled(): boolean { - return this.args.initialRowsAnimation?.active === true; + private get isInitialLoadAnimationEnabled(): boolean { + return this.args.initialLoadAnimation?.active === true; } - private get isInitialRowsAnimationTargetedColumn(): boolean { - const columns = this.args.initialRowsAnimation?.columns; + private get isInitialLoadAnimationTargetedColumn(): boolean { + const columns = this.args.initialLoadAnimation?.columns; if (!columns || columns.length === 0) { return true; @@ -124,21 +113,20 @@ export default class HyperTableV2Cell extends Component { return columns.includes(this.args.column.definition.key); } - private get shouldApplyInitialRowsAnimationSequence(): boolean { - return this.isInitialRowsAnimationEnabled && !this.loading; + private get shouldApplyInitialLoadAnimationSequence(): boolean { + return this.isInitialLoadAnimationEnabled && !this.loading; } - private get shouldApplyInitialRowsAnimationCustomEffect(): boolean { - if (!this.args.enableInitialRowsAnimationExtraEffect) { + private get shouldApplyInitialLoadAnimationCustomEffect(): boolean { + if (!this.args.enableInitialLoadAnimationExtraEffect) { return false; } - return this.shouldApplyInitialRowsAnimationSequence && this.isInitialRowsAnimationTargetedColumn; + return this.shouldApplyInitialLoadAnimationSequence && this.isInitialLoadAnimationTargetedColumn; } private get extraEffectActivationDelayMs(): number { - const extraColumnCellEffectDelayMs = this.args.initialRowsAnimation?.extraColumnCellEffectDelayMs ?? 0; - return this.rowAnimationDelayMs + extraColumnCellEffectDelayMs; + return this.rowAnimationDelayMs + (this.args.initialLoadAnimation?.extraColumnCellEffectDelayMs ?? 0); } private scheduleExtraEffectIfNeeded(): void { diff --git a/addon/components/hyper-table-v2/index.hbs b/addon/components/hyper-table-v2/index.hbs index cb99a8a9..6d9f20a9 100644 --- a/addon/components/hyper-table-v2/index.hbs +++ b/addon/components/hyper-table-v2/index.hbs @@ -114,8 +114,8 @@ @column={{column}} @row={{row}} @rowIndex={{rowIndex}} - @initialRowsAnimation={{this.initialRowsAnimationContext}} - @enableInitialRowsAnimationExtraEffect={{this.enableInitialRowsAnimationExtraEffectOnSelectionCells}} + @initialLoadAnimation={{this.initialLoadAnimationContext}} + @enableInitialLoadAnimationExtraEffect={{this.enableInitialLoadAnimationExtraEffectOnSelectionCells}} @onClick={{fn this.toggleRowSelection row}} @onHover={{this.onRowHover}} @loading={{row._isLoading}} @@ -151,8 +151,8 @@ @column={{column}} @row={{row}} @rowIndex={{rowIndex}} - @initialRowsAnimation={{this.initialRowsAnimationContext}} - @enableInitialRowsAnimationExtraEffect={{true}} + @initialLoadAnimation={{this.initialLoadAnimationContext}} + @enableInitialLoadAnimationExtraEffect={{true}} @onClick={{this.onRowClick}} @onHover={{this.onRowHover}} @loading={{row._isLoading}} @@ -187,8 +187,8 @@ @column={{column}} @row={{row}} @rowIndex={{rowIndex}} - @initialRowsAnimation={{this.initialRowsAnimationContext}} - @enableInitialRowsAnimationExtraEffect={{true}} + @initialLoadAnimation={{this.initialLoadAnimationContext}} + @enableInitialLoadAnimationExtraEffect={{true}} @onClick={{this.onRowClick}} @onHover={{this.onRowHover}} @loading={{row._isLoading}} diff --git a/addon/components/hyper-table-v2/index.ts b/addon/components/hyper-table-v2/index.ts index ddb1bb3b..63d978c5 100644 --- a/addon/components/hyper-table-v2/index.ts +++ b/addon/components/hyper-table-v2/index.ts @@ -5,9 +5,25 @@ import { isEmpty } from '@ember/utils'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; + + import TableHandler from '@upfluence/hypertable/core/handler'; import { Column, Row } from '@upfluence/hypertable/core/interfaces'; + + + + + + + + + + + + + + export type FeatureSet = { selection: boolean; searchable: boolean; @@ -18,10 +34,10 @@ export type FeatureSet = { export type OptionSet = { selectionIntlKeyPath?: string; delegatedFiltering?: boolean; - initialRowsAnimation?: InitialRowsAnimationOption; + initialLoadAnimation?: InitialLoadAnimationOption; }; -export type InitialRowsAnimationConfig = { +export type InitialLoadAnimationConfig = { delayMs: number; staggerMs: number; maxAnimationDurationMs: number; @@ -31,9 +47,9 @@ export type InitialRowsAnimationConfig = { includeSelectionColumnInExtraEffect?: boolean; }; -export type InitialRowsAnimationContext = InitialRowsAnimationConfig & { active: boolean }; +export type InitialLoadAnimationContext = InitialLoadAnimationConfig & { active: boolean }; -type InitialRowsAnimationOption = Partial; +type InitialLoadAnimationOption = Partial; interface HyperTableV2Args { handler: TableHandler; @@ -49,18 +65,14 @@ const DEFAULT_FEATURES_SET: FeatureSet = { }; const RESET_DEBOUNCE_TIME = 300; -const MAX_INITIAL_LOAD_ANIMATION_WINDOW_MS = 5000; -const DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG: Omit< - InitialRowsAnimationConfig, - 'extraColumnCellEffectClass' | 'columns' -> = { +const DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG = { delayMs: 300, staggerMs: 40, maxAnimationDurationMs: 1500, extraColumnCellEffectDelayMs: 0, includeSelectionColumnInExtraEffect: false -}; +} as const satisfies Omit; export default class HyperTableV2 extends Component { loadingSkeletons = new Array(3); @@ -69,10 +81,10 @@ export default class HyperTableV2 extends Component { @tracked loadingResetFilters = false; @tracked scrollableTable: boolean = false; @tracked initialFetchColumnsDone: boolean = false; - @tracked initialRowsAnimationActive: boolean = false; - @tracked initialRowsAnimationPlayed: boolean = false; + @tracked initialLoadAnimationActive: boolean = false; + @tracked initialLoadAnimationPlayed: boolean = false; - private initialRowsAnimationTimeout?: number; + private initialLoadAnimationTimeout?: number; declare private hypertableInstanceID: string; @@ -82,7 +94,7 @@ export default class HyperTableV2 extends Component { args.handler.fetchColumns().then(() => { this.initialFetchColumnsDone = true; args.handler.fetchRows().finally(() => { - this.activateInitialRowsAnimationIfNeeded(); + this.activateInitialLoadAnimationIfNeeded(); }); this.computeScrollableTable(); }); @@ -97,8 +109,8 @@ export default class HyperTableV2 extends Component { }; } - get enableInitialRowsAnimationExtraEffectOnSelectionCells(): boolean { - return !!this.initialRowsAnimation?.includeSelectionColumnInExtraEffect; + get enableInitialLoadAnimationExtraEffectOnSelectionCells(): boolean { + return !!this.initialLoadAnimation?.includeSelectionColumnInExtraEffect; } @computed('args.handler.columns.@each.{filters,order}') @@ -126,22 +138,14 @@ export default class HyperTableV2 extends Component { } } - get initialRowsAnimationContext(): InitialRowsAnimationContext | null { - if (!this.initialRowsAnimation) { - return null; - } - - return { active: this.initialRowsAnimationActive, ...this.initialRowsAnimation }; + get initialLoadAnimationContext(): InitialLoadAnimationContext | null { + return this.initialLoadAnimation ? { active: this.initialLoadAnimationActive, ...this.initialLoadAnimation } : null; } - private get initialRowsAnimation(): InitialRowsAnimationConfig | null { - const options = this.args.options?.initialRowsAnimation; - - if (!options) { - return null; - } + private get initialLoadAnimation(): InitialLoadAnimationConfig | null { + const options = this.args.options?.initialLoadAnimation; - return { ...DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG, ...options }; + return options ? { ...DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG, ...options } : null; } @action @@ -231,9 +235,9 @@ export default class HyperTableV2 extends Component { @action teardown(): void { - if (this.initialRowsAnimationTimeout) { - window.clearTimeout(this.initialRowsAnimationTimeout); - this.initialRowsAnimationTimeout = undefined; + if (this.initialLoadAnimationTimeout) { + window.clearTimeout(this.initialLoadAnimationTimeout); + this.initialLoadAnimationTimeout = undefined; } this.args.handler.teardown(); @@ -267,8 +271,8 @@ export default class HyperTableV2 extends Component { this.computeScrollableTable(); } - private activateInitialRowsAnimationIfNeeded(): void { - if (this.initialRowsAnimationPlayed || !this.initialRowsAnimation) { + private activateInitialLoadAnimationIfNeeded(): void { + if (this.initialLoadAnimationPlayed || !this.initialLoadAnimation) { return; } @@ -276,18 +280,16 @@ export default class HyperTableV2 extends Component { return; } - this.initialRowsAnimationPlayed = true; - this.initialRowsAnimationActive = true; + this.initialLoadAnimationPlayed = true; + this.initialLoadAnimationActive = true; - const rowsAnimationWindowMs = Math.max(this.args.handler.rows.length - 1, 0) * this.initialRowsAnimation.staggerMs; - const activeDurationMs = Math.min( - this.initialRowsAnimation.delayMs + rowsAnimationWindowMs + this.initialRowsAnimation.maxAnimationDurationMs, - MAX_INITIAL_LOAD_ANIMATION_WINDOW_MS - ); + const rowsAnimationWindowMs = Math.max(this.args.handler.rows.length - 1, 0) * this.initialLoadAnimation.staggerMs; + const activeDurationMs = + this.initialLoadAnimation.delayMs + rowsAnimationWindowMs + this.initialLoadAnimation.maxAnimationDurationMs; - this.initialRowsAnimationTimeout = window.setTimeout(() => { - this.initialRowsAnimationActive = false; - this.initialRowsAnimationTimeout = undefined; + this.initialLoadAnimationTimeout = window.setTimeout(() => { + this.initialLoadAnimationActive = false; + this.initialLoadAnimationTimeout = undefined; }, activeDurationMs); } diff --git a/app/styles/animations.less b/app/styles/animations.less index a7e6e9fd..64bfd954 100644 --- a/app/styles/animations.less +++ b/app/styles/animations.less @@ -39,12 +39,12 @@ } @keyframes initial-load-cell { - 0% { + from { opacity: 0; transform: translateY(8px); } - 100% { + to { opacity: 1; transform: translateY(0); } diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts index 4aeb2b9f..e8c4ab0a 100644 --- a/tests/dummy/app/controllers/application.ts +++ b/tests/dummy/app/controllers/application.ts @@ -230,7 +230,7 @@ export default class Application extends Controller { get tableOptions() { return { - initialRowsAnimation: { + initialLoadAnimation: { delayMs: 300, staggerMs: 40, maxAnimationDurationMs: 5000, diff --git a/tests/integration/components/hyper-table-v2-test.ts b/tests/integration/components/hyper-table-v2-test.ts index 0b3cf875..a0889c25 100644 --- a/tests/integration/components/hyper-table-v2-test.ts +++ b/tests/integration/components/hyper-table-v2-test.ts @@ -90,7 +90,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { assert.ok(teardownStub.calledOnce); }); - module('initialRowsAnimation', function () { + module('initialLoadAnimation', function () { test('it does not apply animation classes when the option is not provided', async function (this: TestContext, assert: Assert) { await render(hbs``); @@ -99,7 +99,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { }); test('it applies the stagger sequence class to all non-loading cells', async function (this: TestContext, assert: Assert) { - this.options = { initialRowsAnimation: { delayMs: 300, staggerMs: 40, maxAnimationDurationMs: 1500 } }; + this.options = { initialLoadAnimation: { delayMs: 300, staggerMs: 40, maxAnimationDurationMs: 1500 } }; await render(hbs``); @@ -108,7 +108,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies per-row delay with delayMs and staggerMs', async function (this: TestContext, assert: Assert) { this.options = { - initialRowsAnimation: { delayMs: 120, staggerMs: 30, maxAnimationDurationMs: 1500 } + initialLoadAnimation: { delayMs: 120, staggerMs: 30, maxAnimationDurationMs: 1500 } }; await render(hbs``); @@ -123,7 +123,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies extraColumnCellEffectDelayMs on top of row stagger delay', async function (this: TestContext, assert: Assert) { this.options = { - initialRowsAnimation: { + initialLoadAnimation: { delayMs: 120, staggerMs: 30, maxAnimationDurationMs: 1500, @@ -144,7 +144,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies the extra effect class only on targeted column cells', async function (this: TestContext, assert: Assert) { this.options = { - initialRowsAnimation: { + initialLoadAnimation: { delayMs: 0, staggerMs: 0, maxAnimationDurationMs: 1500, @@ -162,7 +162,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies the extra effect class to all columns when columns is omitted', async function (this: TestContext, assert: Assert) { this.options = { - initialRowsAnimation: { + initialLoadAnimation: { delayMs: 0, staggerMs: 0, maxAnimationDurationMs: 1500, @@ -178,7 +178,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies base stagger but not extra effect class on selection checkbox cells', async function (this: TestContext, assert: Assert) { this.features = { selection: true }; this.options = { - initialRowsAnimation: { + initialLoadAnimation: { delayMs: 0, staggerMs: 0, maxAnimationDurationMs: 1500, @@ -198,7 +198,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it can apply the extra effect class on selection checkbox cells when enabled', async function (this: TestContext, assert: Assert) { this.features = { selection: true }; this.options = { - initialRowsAnimation: { + initialLoadAnimation: { delayMs: 0, staggerMs: 0, maxAnimationDurationMs: 1500, @@ -217,7 +217,7 @@ module('Integration | Component | hyper-table-v2', function (hooks) { test('it applies the extra effect class to all columns when columns is empty', async function (this: TestContext, assert: Assert) { this.options = { - initialRowsAnimation: { + initialLoadAnimation: { delayMs: 0, staggerMs: 0, maxAnimationDurationMs: 1500, From 05868d4a5f44703f702ca6137b0acda9c7754a98 Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 19 Aug 2026 17:51:15 +0200 Subject: [PATCH 5/7] Fixed: PR comments --- addon/components/hyper-table-v2/cell.ts | 44 ++++++++++++------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/addon/components/hyper-table-v2/cell.ts b/addon/components/hyper-table-v2/cell.ts index 5e4395b4..0a2f9303 100644 --- a/addon/components/hyper-table-v2/cell.ts +++ b/addon/components/hyper-table-v2/cell.ts @@ -104,11 +104,9 @@ export default class HyperTableV2Cell extends Component { } private get isInitialLoadAnimationTargetedColumn(): boolean { - const columns = this.args.initialLoadAnimation?.columns; + const columns = this.args.initialLoadAnimation?.columns ?? []; - if (!columns || columns.length === 0) { - return true; - } + if (columns.length === 0) return true; return columns.includes(this.args.column.definition.key); } @@ -129,6 +127,25 @@ export default class HyperTableV2Cell extends Component { return this.rowAnimationDelayMs + (this.args.initialLoadAnimation?.extraColumnCellEffectDelayMs ?? 0); } + @action + clickedCell(event: MouseEvent) { + event.stopPropagation(); + + if (!this.args.loading) { + this.args.onClick?.(this.args.row); + } + } + + @action + toggleHover(row: Row, hovered: boolean) { + this.args.onHover?.(row, hovered); + } + + @action + teardown() { + this.resetExtraEffectState(); + } + private scheduleExtraEffectIfNeeded(): void { if (this.extraEffectReady || this.extraEffectTimeout) { return; @@ -155,23 +172,4 @@ export default class HyperTableV2Cell extends Component { this.extraEffectReady = false; } - - @action - clickedCell(event: MouseEvent) { - event.stopPropagation(); - - if (!this.args.loading) { - this.args.onClick?.(this.args.row); - } - } - - @action - toggleHover(row: Row, hovered: boolean) { - this.args.onHover?.(row, hovered); - } - - @action - teardown() { - this.resetExtraEffectState(); - } } From efdd85d4f9f8fd8daab24341f427360b148cdf5f Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 19 Aug 2026 18:06:43 +0200 Subject: [PATCH 6/7] Pair review changes --- README.md | 6 ++--- addon/components/hyper-table-v2/index.ts | 28 ++++++---------------- tests/dummy/app/controllers/application.ts | 9 +++---- 3 files changed, 13 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 661b186d..9a4eca2f 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ options = { }; ``` -##### initialLoadAnimation +##### initialRowsAnimation - Type: `object` - Required: no @@ -248,7 +248,7 @@ Behavior: ```ts options = { - initialLoadAnimation: { + initialRowsAnimation: { delayMs: 300, staggerMs: 40, maxAnimationDurationMs: 1500, @@ -265,7 +265,7 @@ Fields: - `delayMs` (number): Delay before the sequence starts. Default: `300`. - `staggerMs` (number): Extra delay applied per row (`rowIndex * staggerMs`). Default: `40`. - `maxAnimationDurationMs` (number): Max duration used by the animation window timing. Default: `1500`. -- `extraColumnCellEffectDelayMs` (number, optional): Extra delay applied before the `extraColumnCellEffectClass` effect starts. Default: `0`. +- `extraColumnCellEffectDelayMs` (number): Extra delay applied before the `extraColumnCellEffectClass` effect starts. Default: `0`. - `extraColumnCellEffectClass` (string): Optional extra CSS class added to targeted cells while animation is active. - `columns` (string[]): Column keys that receive `extraColumnCellEffectClass`. If omitted or empty, the extra class is applied to all columns. - `includeSelectionColumnInExtraEffect` (boolean): Whether the extra class should also be applied on selection checkbox cells when selection is enabled. Default: `false`. diff --git a/addon/components/hyper-table-v2/index.ts b/addon/components/hyper-table-v2/index.ts index 63d978c5..9d614352 100644 --- a/addon/components/hyper-table-v2/index.ts +++ b/addon/components/hyper-table-v2/index.ts @@ -5,25 +5,9 @@ import { isEmpty } from '@ember/utils'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; - - import TableHandler from '@upfluence/hypertable/core/handler'; import { Column, Row } from '@upfluence/hypertable/core/interfaces'; - - - - - - - - - - - - - - export type FeatureSet = { selection: boolean; searchable: boolean; @@ -38,9 +22,9 @@ export type OptionSet = { }; export type InitialLoadAnimationConfig = { - delayMs: number; - staggerMs: number; - maxAnimationDurationMs: number; + delayMs?: number; + staggerMs?: number; + maxAnimationDurationMs?: number; extraColumnCellEffectDelayMs?: number; extraColumnCellEffectClass?: string; columns?: string[]; @@ -142,10 +126,12 @@ export default class HyperTableV2 extends Component { return this.initialLoadAnimation ? { active: this.initialLoadAnimationActive, ...this.initialLoadAnimation } : null; } - private get initialLoadAnimation(): InitialLoadAnimationConfig | null { + private get initialLoadAnimation(): Required< + Omit + > { const options = this.args.options?.initialLoadAnimation; - return options ? { ...DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG, ...options } : null; + return { ...DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG, ...options }; } @action diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts index e8c4ab0a..d6dd4d4b 100644 --- a/tests/dummy/app/controllers/application.ts +++ b/tests/dummy/app/controllers/application.ts @@ -231,12 +231,9 @@ export default class Application extends Controller { get tableOptions() { return { initialLoadAnimation: { - delayMs: 300, - staggerMs: 40, - maxAnimationDurationMs: 5000, - extraColumnCellEffectClass: 'smart-rotating-gradient', - extraColumnCellEffectDelayMs: 250, - columns: ['foo'] + delayMs: 50, + staggerMs: 150, + maxAnimationDurationMs: 5000 } }; } From b0b3a31107e4ccb9d8123b2f6044edc16392c89f Mon Sep 17 00:00:00 2001 From: Maximilien B Date: Wed, 19 Aug 2026 18:20:52 +0200 Subject: [PATCH 7/7] Fixed failing tests and solved typing issue --- addon/components/hyper-table-v2/index.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/addon/components/hyper-table-v2/index.ts b/addon/components/hyper-table-v2/index.ts index 9d614352..dd131103 100644 --- a/addon/components/hyper-table-v2/index.ts +++ b/addon/components/hyper-table-v2/index.ts @@ -21,20 +21,20 @@ export type OptionSet = { initialLoadAnimation?: InitialLoadAnimationOption; }; -export type InitialLoadAnimationConfig = { - delayMs?: number; - staggerMs?: number; - maxAnimationDurationMs?: number; +export type InitialLoadAnimationContext = InitialLoadAnimationConfig & { active: boolean }; + +export type InitialLoadAnimationOption = Partial; + +type InitialLoadAnimationConfig = { + delayMs: number; + staggerMs: number; + maxAnimationDurationMs: number; extraColumnCellEffectDelayMs?: number; extraColumnCellEffectClass?: string; columns?: string[]; includeSelectionColumnInExtraEffect?: boolean; }; -export type InitialLoadAnimationContext = InitialLoadAnimationConfig & { active: boolean }; - -type InitialLoadAnimationOption = Partial; - interface HyperTableV2Args { handler: TableHandler; features: FeatureSet; @@ -126,11 +126,11 @@ export default class HyperTableV2 extends Component { return this.initialLoadAnimation ? { active: this.initialLoadAnimationActive, ...this.initialLoadAnimation } : null; } - private get initialLoadAnimation(): Required< - Omit - > { + private get initialLoadAnimation(): InitialLoadAnimationConfig | null { const options = this.args.options?.initialLoadAnimation; + if (!options) return null; + return { ...DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG, ...options }; }