diff --git a/README.md b/README.md index e6509232..dddb83d2 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,97 @@ 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 +}; +``` + +##### initialLoadAnimation + +- Type: `boolean | object` +- Required: no + +Enables a one-time animation sequence on the first successful non-empty rows load. + +Accepted values: + +- `undefined` or `false`: no animation. +- `true`: enables animation with defaults. +- object: enables animation and overrides defaults. + +Behavior: + +- Base behavior: rows are revealed with a staggered sequence across all non-loading cells. +- Extra class behavior: when `extraColumnEffect.class` is set, that class is added on top of the base sequence on each cell that matches the columns specified in `extraColumnEffect.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: `extraColumnEffect.delayMs` adds an extra delay before the extra class effect starts. +- If `extraColumnEffect.columns` is omitted or empty, `extraColumnEffect.class` is applied to cells from all columns. + + +```ts +options = { + initialLoadAnimation: { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 1500, + extraColumnEffect: { + class: 'smart-rotating-gradient', + delayMs: 120, + 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): Extra duration added after stagger starts to keep the animation state active. Default: `5000`. +- `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`. +- `extraColumnEffect.columns` (string[]): Column keys that receive `extraColumnEffect.class`. 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. + ## Core Concepts ### Column Definitions diff --git a/addon/components/hyper-table-v2/cell.hbs b/addon/components/hyper-table-v2/cell.hbs index 8548d6f5..d174da3f 100644 --- a/addon/components/hyper-table-v2/cell.hbs +++ b/addon/components/hyper-table-v2/cell.hbs @@ -1,10 +1,8 @@
{ @tracked loadingCellComponent: boolean = true; @tracked cellComponent?: ResolvedRenderingComponent; + @tracked extraEffectReady: boolean = false; + + private extraEffectTimeout?: number; constructor(owner: unknown, args: HyperTableV2CellArgs) { super(owner, args); @@ -37,6 +45,88 @@ export default class HyperTableV2Cell extends Component { 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.initialLoadAnimationSequenceClass) classes.push(this.initialLoadAnimationSequenceClass); + if (this.initialLoadAnimationCellClass) classes.push(this.initialLoadAnimationCellClass); + + return classes.join(' '); + } + + get initialLoadAnimationCellClass(): string { + const extraColumnEffectClass = this.args.initialLoadAnimation?.extraColumnEffect?.class; + + if (!this.shouldApplyInitialLoadAnimationCustomEffect || !extraColumnEffectClass) { + this.resetExtraEffectState(); + return ''; + } + + if (this.extraEffectActivationDelayMs <= 0) { + return extraColumnEffectClass; + } + + this.scheduleExtraEffectIfNeeded(); + + return this.extraEffectReady ? extraColumnEffectClass : ''; + } + + get initialLoadAnimationSequenceClass(): string { + return this.shouldApplyInitialLoadAnimationSequence ? 'hypertable__cell--initial-load-sequence' : ''; + } + + get initialLoadAnimationCellStyle(): ReturnType | undefined { + if (!this.shouldApplyInitialLoadAnimationSequence) { + return undefined; + } + + const extraColumnEffectDelayMs = this.args.initialLoadAnimation?.extraColumnEffect?.delayMs ?? 0; + const staggeredDelayMs = this.rowAnimationDelayMs; + const extraEffectDelayMs = staggeredDelayMs + extraColumnEffectDelayMs; + + return htmlSafe( + `--hypertable-initial-rows-animation-delay: ${staggeredDelayMs}ms; --hypertable-initial-rows-extra-effect-delay: ${extraEffectDelayMs}ms;` + ); + } + + private get rowAnimationDelayMs(): number { + 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 isInitialLoadAnimationEnabled(): boolean { + return this.args.initialLoadAnimation?.active === true; + } + + private get isInitialLoadAnimationTargetedColumn(): boolean { + const columns = this.args.initialLoadAnimation?.extraColumnEffect?.columns ?? []; + + if (columns.length === 0) return true; + + return columns.includes(this.args.column.definition.key); + } + + private get shouldApplyInitialLoadAnimationSequence(): boolean { + return this.isInitialLoadAnimationEnabled && !this.loading; + } + + private get shouldApplyInitialLoadAnimationCustomEffect(): boolean { + if (!this.args.enableInitialLoadAnimationExtraEffect) { + return false; + } + + return this.shouldApplyInitialLoadAnimationSequence && this.isInitialLoadAnimationTargetedColumn; + } + + private get extraEffectActivationDelayMs(): number { + return this.rowAnimationDelayMs + (this.args.initialLoadAnimation?.extraColumnEffect?.delayMs ?? 0); + } + @action clickedCell(event: MouseEvent) { event.stopPropagation(); @@ -50,4 +140,36 @@ export default class HyperTableV2Cell extends Component { toggleHover(row: Row, hovered: boolean) { this.args.onHover?.(row, hovered); } + + @action + teardown() { + this.resetExtraEffectState(); + } + + 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; + } } diff --git a/addon/components/hyper-table-v2/index.hbs b/addon/components/hyper-table-v2/index.hbs index 6644beb6..6d9f20a9 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|}} ; + +type InitialLoadAnimationExtraColumnEffect = { + class?: string; + delayMs?: number; + columns?: string[]; +}; + +type InitialLoadAnimationConfig = { + delayMs: number; + staggerMs: number; + maxAnimationDurationMs: number; + extraColumnEffect?: InitialLoadAnimationExtraColumnEffect; + includeSelectionColumnInExtraEffect?: boolean; }; interface HyperTableV2Args { @@ -32,8 +51,22 @@ const DEFAULT_FEATURES_SET: FeatureSet = { manageable_fields: true, global_filters_reset: true }; + const RESET_DEBOUNCE_TIME = 300; +const DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG = { + delayMs: 300, + staggerMs: 40, + maxAnimationDurationMs: 5000, + extraColumnEffect: { + delayMs: 0 + }, + includeSelectionColumnInExtraEffect: false +} as const satisfies Omit & { + extraColumnEffect: Pick; + includeSelectionColumnInExtraEffect: boolean; +}; + export default class HyperTableV2 extends Component { loadingSkeletons = new Array(3); innerTableElement?: Element; @@ -41,6 +74,10 @@ export default class HyperTableV2 extends Component { @tracked loadingResetFilters = false; @tracked scrollableTable: boolean = false; @tracked initialFetchColumnsDone: boolean = false; + @tracked initialLoadAnimationActive: boolean = false; + @tracked initialLoadAnimationPlayed: boolean = false; + + private initialLoadAnimationTimeout?: number; declare private hypertableInstanceID: string; @@ -49,7 +86,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.activateInitialLoadAnimationIfNeeded(); + }); this.computeScrollableTable(); }); @@ -63,6 +102,10 @@ export default class HyperTableV2 extends Component { }; } + get enableInitialLoadAnimationExtraEffectOnSelectionCells(): boolean { + return !!this.initialLoadAnimation?.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 +131,27 @@ export default class HyperTableV2 extends Component { } } + get initialLoadAnimationContext(): InitialLoadAnimationContext | null { + return this.initialLoadAnimation ? { active: this.initialLoadAnimationActive, ...this.initialLoadAnimation } : null; + } + + private get initialLoadAnimation(): InitialLoadAnimationConfig | null { + const option = this.args.options?.initialLoadAnimation; + + if (!option) return null; + + const options = option === true ? {} : option; + + return { + ...DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG, + ...options, + extraColumnEffect: { + ...DEFAULT_INITIAL_LOAD_ANIMATION_CONFIG.extraColumnEffect, + ...options.extraColumnEffect + } + }; + } + @action computeScrollableTable(): void { const table = this.innerTableElement; @@ -175,6 +239,11 @@ export default class HyperTableV2 extends Component { @action teardown(): void { + if (this.initialLoadAnimationTimeout) { + window.clearTimeout(this.initialLoadAnimationTimeout); + this.initialLoadAnimationTimeout = undefined; + } + this.args.handler.teardown(); } @@ -206,6 +275,28 @@ export default class HyperTableV2 extends Component { this.computeScrollableTable(); } + private activateInitialLoadAnimationIfNeeded(): void { + if (this.initialLoadAnimationPlayed || !this.initialLoadAnimation) { + return; + } + + if (this.args.handler.communicationError || this.args.handler.rows.length === 0) { + return; + } + + this.initialLoadAnimationPlayed = true; + this.initialLoadAnimationActive = true; + + const rowsAnimationWindowMs = Math.max(this.args.handler.rows.length - 1, 0) * this.initialLoadAnimation.staggerMs; + const activeDurationMs = + this.initialLoadAnimation.delayMs + rowsAnimationWindowMs + this.initialLoadAnimation.maxAnimationDurationMs; + + this.initialLoadAnimationTimeout = window.setTimeout(() => { + this.initialLoadAnimationActive = false; + this.initialLoadAnimationTimeout = 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..64bfd954 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 { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + 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..dd9f308f 100644 --- a/tests/dummy/app/controllers/application.ts +++ b/tests/dummy/app/controllers/application.ts @@ -228,6 +228,21 @@ export default class Application extends Controller { }); } + get tableOptions() { + return { + initialLoadAnimation: { + delayMs: 50, + staggerMs: 150, + maxAnimationDurationMs: 5000, + extraColumnEffect: { + class: 'smart-rotating-gradient', + delayMs: 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..eec3ae9b 100644 --- a/tests/integration/components/hyper-table-v2-test.ts +++ b/tests/integration/components/hyper-table-v2-test.ts @@ -90,6 +90,181 @@ module('Integration | Component | hyper-table-v2', function (hooks) { assert.ok(teardownStub.calledOnce); }); + 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``); + + assert.dom('.hypertable__cell--initial-load-sequence').doesNotExist(); + assert.dom('.hypertable__cell.smart-rotating-gradient').doesNotExist(); + }); + + test('it does not apply animation classes when the option is false', async function (this: TestContext, assert: Assert) { + this.options = { initialLoadAnimation: false }; + + await render(hbs``); + + assert.dom('.hypertable__cell--initial-load-sequence').doesNotExist(); + assert.dom('.hypertable__cell.smart-rotating-gradient').doesNotExist(); + }); + + test('it applies default animation values when the option is true', async function (this: TestContext, assert: Assert) { + this.options = { initialLoadAnimation: true }; + + await render(hbs``); + + const stickyColumnCells = findAll('.hypertable__sticky-columns .hypertable__column .hypertable__cell'); + const firstCellStyle = stickyColumnCells[0].getAttribute('style') ?? ''; + + assert.dom('.hypertable__cell--initial-load-sequence').exists({ count: 12 }); + assert.ok(firstCellStyle.includes('--hypertable-initial-rows-animation-delay: 300ms;')); + }); + + test('it applies the stagger sequence class to all non-loading cells', async function (this: TestContext, assert: Assert) { + this.options = { initialLoadAnimation: { 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 = { + initialLoadAnimation: { 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 extraColumnEffect.delayMs on top of row stagger delay', async function (this: TestContext, assert: Assert) { + this.options = { + initialLoadAnimation: { + delayMs: 120, + staggerMs: 30, + maxAnimationDurationMs: 1500, + extraColumnEffect: { + delayMs: 70, + class: '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 = { + initialLoadAnimation: { + delayMs: 0, + staggerMs: 0, + maxAnimationDurationMs: 1500, + extraColumnEffect: { + class: '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 = { + initialLoadAnimation: { + delayMs: 0, + staggerMs: 0, + maxAnimationDurationMs: 1500, + extraColumnEffect: { + class: '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 = { + initialLoadAnimation: { + delayMs: 0, + staggerMs: 0, + maxAnimationDurationMs: 1500, + extraColumnEffect: { + class: '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 = { + initialLoadAnimation: { + delayMs: 0, + staggerMs: 0, + maxAnimationDurationMs: 1500, + extraColumnEffect: { + class: '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 = { + initialLoadAnimation: { + delayMs: 0, + staggerMs: 0, + maxAnimationDurationMs: 1500, + extraColumnEffect: { + class: '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) => {