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..0b3cf875 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: 0, + staggerMs: 0, + 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: 0, + staggerMs: 0, + 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: 0, + staggerMs: 0, + 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: 0, + staggerMs: 0, + 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: 0, + staggerMs: 0, + 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) => {