diff --git a/README.md b/README.md
index 59786e89..516951e1 100644
--- a/README.md
+++ b/README.md
@@ -528,6 +528,8 @@ These components are automatically included and handle their own state:
- **HyperTableV2::Column** - Column wrapper with header rendering
- **HyperTableV2::Cell** - Individual cell component
+A standalone [HyperTableV2::Summary](#hypertablev2summary) stats footer component is also available (not controlled by `@features`).
+
Users interact with these components through:
- **Search**: Typing in the search input (when searchable is enabled)
@@ -537,6 +539,51 @@ Users interact with these components through:
The components handle their own internal state and communicate with the TableHandler automatically.
+### Standalone Components
+
+#### HyperTableV2::Summary
+
+A purely presentational stats footer, usually rendered below the table. It only owns the layout and the loading skeletons: fetching the stats and building the fields (e.g. `—` for unavailable metrics) is the consumer's responsibility.
+
+The `SummaryField` type can be imported from `@upfluence/hypertable/components/hyper-table-v2/summary`:
+
+```ts
+type SummaryField = {
+ label: string;
+ value: string;
+ tooltip?: string; // already translated
+};
+```
+
+**Arguments:**
+
+- `@loading` (boolean, required) - Displays skeletons instead of the fields
+- `@fields` (SummaryField[], required) - The stats to display, in order
+- `@title` (string, optional) - Title of the summary. Default: `hypertable.summary.title` translation ("Summary")
+- `@titleTooltip` (string, optional) - Displays an info icon next to the title with this tooltip
+
+**Named blocks:**
+
+- **`:prefix`** - For a stat that doesn't fit the `label` / `value` shape. It is rendered before the fields, followed by a vertical separator.
+
+```hbs
+
+ <:prefix>
+
+
+
+```
+
+Behavior:
+
+- While `@loading` is true, one skeleton is displayed per field (4 when `@fields` is empty), and the `:prefix` block is hidden.
+- The separator is only rendered when the `:prefix` block is provided.
+- A field `tooltip` displays an info icon between its label and its value.
+
## Events
TableHandler emits events throughout its lifecycle. Listen to them using the `on` method:
diff --git a/addon/components/hyper-table-v2/summary.hbs b/addon/components/hyper-table-v2/summary.hbs
new file mode 100644
index 00000000..553312ad
--- /dev/null
+++ b/addon/components/hyper-table-v2/summary.hbs
@@ -0,0 +1,46 @@
+
+
+ {{this.title}}
+ {{#if @titleTooltip}}
+
+ {{/if}}
+
+
+ {{#if @loading}}
+ {{#each this.skeletons}}
+
+
+
+
+ {{/each}}
+ {{else}}
+ {{#if (has-block "prefix")}}
+ {{yield to="prefix"}}
+
+ {{/if}}
+ {{#each @fields as |field|}}
+
+
+ {{field.label}}
+
+ {{#if field.tooltip}}
+
+ {{/if}}
+
+ {{field.value}}
+
+
+ {{/each}}
+ {{/if}}
+
+
\ No newline at end of file
diff --git a/addon/components/hyper-table-v2/summary.ts b/addon/components/hyper-table-v2/summary.ts
new file mode 100644
index 00000000..bba64ea8
--- /dev/null
+++ b/addon/components/hyper-table-v2/summary.ts
@@ -0,0 +1,43 @@
+import { assert } from '@ember/debug';
+import { inject as service } from '@ember/service';
+import Component from '@glimmer/component';
+
+import type { IntlService } from 'ember-intl';
+
+export type SummaryField = {
+ label: string;
+ value: string;
+ tooltip?: string;
+};
+
+interface HyperTableV2SummaryArgs {
+ loading: boolean;
+ fields: SummaryField[];
+ title?: string;
+ titleTooltip?: string;
+}
+
+const DEFAULT_SKELETONS_COUNT = 4;
+
+export default class HyperTableV2Summary extends Component {
+ @service declare intl: IntlService;
+
+ constructor(owner: unknown, args: HyperTableV2SummaryArgs) {
+ super(owner, args);
+
+ assert(
+ '[component][HyperTableV2::Summary] Boolean @loading argument is mandatory.',
+ typeof args.loading === 'boolean'
+ );
+ assert('[component][HyperTableV2::Summary] Array @fields argument is mandatory.', Array.isArray(args.fields));
+ }
+
+ get title(): string {
+ return this.args.title ?? this.intl.t('hypertable.summary.title');
+ }
+
+ get skeletons(): number[] {
+ const count = this.args.fields.length || DEFAULT_SKELETONS_COUNT;
+ return Array.from({ length: count }, (_, index) => index);
+ }
+}
diff --git a/app/components/hyper-table-v2/summary.js b/app/components/hyper-table-v2/summary.js
new file mode 100644
index 00000000..7f360471
--- /dev/null
+++ b/app/components/hyper-table-v2/summary.js
@@ -0,0 +1 @@
+export { default } from '@upfluence/hypertable/components/hyper-table-v2/summary';
diff --git a/app/styles/hypertable.less b/app/styles/hypertable.less
index fcc9860b..9ae41e38 100644
--- a/app/styles/hypertable.less
+++ b/app/styles/hypertable.less
@@ -4,6 +4,7 @@
@import 'facetting';
@import 'footer';
@import 'selection';
+@import 'summary';
.hypertable-container {
width: 100%;
diff --git a/app/styles/summary.less b/app/styles/summary.less
new file mode 100644
index 00000000..ade6c96f
--- /dev/null
+++ b/app/styles/summary.less
@@ -0,0 +1,74 @@
+.hypertable-summary-v2 {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--spacing-px-36);
+ background-color: var(--color-white);
+ padding: 0 var(--spacing-px-18);
+ border-top: 1px solid var(--color-border-default);
+ height: 60px;
+ width: 100%;
+
+ &__title {
+ .text-style-semibold;
+ color: var(--color-primary-500);
+ font-size: var(--font-size-lg);
+ }
+
+ &__title-container {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--spacing-px-6);
+ }
+
+ &__title-tooltip {
+ color: var(--color-primary-500);
+ font-size: var(--font-size-sm);
+ }
+
+ &__separator {
+ width: 1px;
+ height: 36px;
+ flex-shrink: 0;
+ background-color: var(--color-border-default);
+ }
+
+ &__fields {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ overflow: hidden;
+ width: 100%;
+ gap: var(--spacing-px-36);
+
+ @media screen and (max-width: 1220px) {
+ gap: var(--spacing-px-6);
+ justify-content: space-between;
+ }
+ }
+
+ &__field,
+ &__skeleton {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--spacing-px-6);
+ }
+
+ &__field-label {
+ .text-style-uppercase;
+ color: var(--color-gray-500);
+ font-size: var(--font-size-sm);
+ }
+
+ &__field-tooltip {
+ color: var(--color-primary-500);
+ font-size: var(--font-size-sm);
+ }
+
+ &__field-value {
+ color: var(--color-primary-500);
+ font-size: var(--font-size-lg);
+ }
+}
diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts
index 246c7eab..5bb67e8a 100644
--- a/tests/dummy/app/controllers/application.ts
+++ b/tests/dummy/app/controllers/application.ts
@@ -2,6 +2,7 @@ import Controller from '@ember/controller';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
+import { type SummaryField } from '@upfluence/hypertable/components/hyper-table-v2/summary';
import TableHandler from '@upfluence/hypertable/core/handler';
import {
Column,
@@ -218,6 +219,7 @@ export default class Application extends Controller {
@tracked searchQuery: string = '';
@tracked selectedValue: number = 0;
@tracked animationsEnabled: boolean = true;
+ @tracked summaryLoading: boolean = false;
private prependedRowsCount: number = 0;
tableManager = new Manager();
@@ -294,6 +296,25 @@ export default class Application extends Controller {
]);
}
+ get summaryFields(): SummaryField[] {
+ return [
+ { label: 'Affiliates', value: '128' },
+ { label: 'Total Orders', value: '1,234' },
+ { label: 'Total Sales', value: '$45,678.90' },
+ { label: 'Average Order Value', value: '$37.02' },
+ {
+ label: 'ROI',
+ value: '—',
+ tooltip: 'Profit earned for every $1 spent on commissions. (Sales − Commissions) / Commissions.'
+ }
+ ];
+ }
+
+ @action
+ toggleSummaryLoading(value: boolean): void {
+ this.summaryLoading = value;
+ }
+
@action
resetRows(): Promise {
return this.handler.resetRows();
diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs
index 09b9f963..f1f584a0 100644
--- a/tests/dummy/app/templates/application.hbs
+++ b/tests/dummy/app/templates/application.hbs
@@ -29,4 +29,27 @@
+
+
+
+
+ Summary loading:
+
+
+
+
+ <:prefix>
+
+
+
+ Community
+ 238,190 / ∞
+
+
+
+
\ No newline at end of file
diff --git a/tests/integration/components/hyper-table-v2/summary-test.ts b/tests/integration/components/hyper-table-v2/summary-test.ts
new file mode 100644
index 00000000..ba1fb169
--- /dev/null
+++ b/tests/integration/components/hyper-table-v2/summary-test.ts
@@ -0,0 +1,191 @@
+import { render, setupOnerror, type TestContext } from '@ember/test-helpers';
+
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+import { setupRenderingTest } from 'ember-qunit';
+import { module, test } from 'qunit';
+
+module('Integration | Component | hyper-table-v2/summary', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks);
+
+ hooks.beforeEach(function (this: TestContext) {
+ this.intl = this.owner.lookup('service:intl');
+ this.loading = false;
+ this.fields = [
+ { label: 'Total Orders', value: '1,234' },
+ { label: 'ROI', value: '3.2x', tooltip: 'Profit earned for every $1 spent on commissions.' }
+ ];
+ });
+
+ module('title', () => {
+ test('it renders the default title when @title is not provided', async function (this: TestContext, assert) {
+ await render(hbs``);
+
+ assert.dom('[data-control-name="hypertable_summary_title"]').hasText(this.intl.t('hypertable.summary.title'));
+ });
+
+ test('it renders @title when provided', async function (this: TestContext, assert) {
+ await render(
+ hbs``
+ );
+
+ assert.dom('[data-control-name="hypertable_summary_title"]').hasText('Conversions summary');
+ });
+
+ test('it does not render a title tooltip icon when @titleTooltip is not provided', async function (this: TestContext, assert) {
+ await render(hbs``);
+
+ assert.dom('[data-control-name="hypertable_summary_title_tooltip"]').doesNotExist();
+ });
+
+ test('it renders a title tooltip icon displaying @titleTooltip on hover', async function (this: TestContext, assert) {
+ await render(
+ hbs``
+ );
+
+ assert.dom('[data-control-name="hypertable_summary_title_tooltip"]').hasClass('fa-info-circle');
+
+ await assert
+ .tooltip('[data-control-name="hypertable_summary_title_tooltip"]')
+ .hasTitle('Stats of the filtered records');
+ });
+ });
+
+ module('when @loading is true', (hooks) => {
+ hooks.beforeEach(function (this: TestContext) {
+ this.loading = true;
+ });
+
+ test('it renders one skeleton per field', async function (this: TestContext, assert) {
+ await render(hbs``);
+
+ assert.dom('[data-control-name="hypertable_summary_skeleton"]').exists({ count: 2 });
+ assert.dom('[data-control-name="hypertable_summary_stat"]').doesNotExist();
+ });
+
+ test('it renders the default amount of skeletons when @fields is empty', async function (this: TestContext, assert) {
+ this.fields = [];
+ await render(hbs``);
+
+ assert.dom('[data-control-name="hypertable_summary_skeleton"]').exists({ count: 4 });
+ });
+
+ test('it does not render the prefix block nor the separator', async function (this: TestContext, assert) {
+ await render(hbs`
+
+ <:prefix>Community
+
+ `);
+
+ assert.dom('[data-control-name="prefix_stat"]').doesNotExist();
+ assert.dom('[data-control-name="hypertable_summary_separator"]').doesNotExist();
+ });
+ });
+
+ module('when @loading is false', () => {
+ test('it renders one stat per field', async function (this: TestContext, assert) {
+ await render(hbs``);
+
+ assert.dom('[data-control-name="hypertable_summary_skeleton"]').doesNotExist();
+ assert.dom('[data-control-name="hypertable_summary_stat"]').exists({ count: 2 });
+ assert
+ .dom(
+ '[data-control-name="hypertable_summary_stat"]:nth-child(1) [data-control-name="hypertable_summary_stat_label"]'
+ )
+ .hasText('Total Orders');
+ assert
+ .dom(
+ '[data-control-name="hypertable_summary_stat"]:nth-child(1) [data-control-name="hypertable_summary_stat_value"]'
+ )
+ .hasText('1,234');
+ assert
+ .dom(
+ '[data-control-name="hypertable_summary_stat"]:nth-child(2) [data-control-name="hypertable_summary_stat_label"]'
+ )
+ .hasText('ROI');
+ assert
+ .dom(
+ '[data-control-name="hypertable_summary_stat"]:nth-child(2) [data-control-name="hypertable_summary_stat_value"]'
+ )
+ .hasText('3.2x');
+ });
+
+ test('it does not render a tooltip icon when the field has no tooltip', async function (this: TestContext, assert) {
+ await render(hbs``);
+
+ assert
+ .dom(
+ '[data-control-name="hypertable_summary_stat"]:nth-child(1) [data-control-name="hypertable_summary_stat_tooltip"]'
+ )
+ .doesNotExist();
+ });
+
+ test('it renders a tooltip icon displaying the field tooltip on hover', async function (this: TestContext, assert) {
+ await render(hbs``);
+
+ const tooltipSelector =
+ '[data-control-name="hypertable_summary_stat"]:nth-child(2) [data-control-name="hypertable_summary_stat_tooltip"]';
+ assert.dom(tooltipSelector).hasClass('fa-info-circle');
+
+ await assert.tooltip(tooltipSelector).hasTitle('Profit earned for every $1 spent on commissions.');
+ });
+
+ test('it renders no stat when @fields is empty', async function (this: TestContext, assert) {
+ this.fields = [];
+ await render(hbs``);
+
+ assert.dom('[data-control-name="hypertable_summary_stat"]').doesNotExist();
+ assert.dom('[data-control-name="hypertable_summary_skeleton"]').doesNotExist();
+ });
+
+ test('it does not render the separator when the prefix block is not provided', async function (this: TestContext, assert) {
+ await render(hbs``);
+
+ assert.dom('[data-control-name="hypertable_summary_separator"]').doesNotExist();
+ });
+
+ test('it renders the prefix block followed by a separator before the fields', async function (this: TestContext, assert) {
+ await render(hbs`
+
+ <:prefix>Community
+
+ `);
+
+ assert.dom('.hypertable-summary-v2__fields > :nth-child(1)').hasAttribute('data-control-name', 'prefix_stat');
+ assert.dom('[data-control-name="prefix_stat"]').hasText('Community');
+ assert
+ .dom('.hypertable-summary-v2__fields > :nth-child(2)')
+ .hasAttribute('data-control-name', 'hypertable_summary_separator');
+ assert
+ .dom('.hypertable-summary-v2__fields > :nth-child(3)')
+ .hasAttribute('data-control-name', 'hypertable_summary_stat');
+ });
+ });
+
+ module('Error management', () => {
+ test('it throws an error when @loading is not provided', async function (this: TestContext, assert) {
+ assert.expect(1);
+ setupOnerror((err: Error) => {
+ assert.strictEqual(
+ err.message,
+ 'Assertion Failed: [component][HyperTableV2::Summary] Boolean @loading argument is mandatory.'
+ );
+ });
+
+ await render(hbs``);
+ });
+
+ test('it throws an error when @fields is not provided', async function (this: TestContext, assert) {
+ assert.expect(1);
+ setupOnerror((err: Error) => {
+ assert.strictEqual(
+ err.message,
+ 'Assertion Failed: [component][HyperTableV2::Summary] Array @fields argument is mandatory.'
+ );
+ });
+
+ await render(hbs``);
+ });
+ });
+});
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index c0330ff2..db8d89ca 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -83,6 +83,8 @@ hypertable:
success:
title: Ownership update
message: View successfully shared
+ summary:
+ title: Summary
selection:
records_selected: '{count, plural, =0 {{count} record is selected} =1 {{count} record is selected} other {{count} records are selected}}'
all_records_selected: All records ({count}) are selected