diff --git a/CHANGELOG.md b/CHANGELOG.md index 8580fe8a..cbadd2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,13 @@ and [releases](https://github.com/openmfp/webcomponents/releases) for details. ## [1.4.2] - 2026-09-10 ### Features + - Upgrade displayAs link (#290) ## [1.4.1] - 2026-09-10 ### Features + - Upgrade displayAs link (#290) ## [1.3.0] - 2026-09-02 diff --git a/projects/ngx/declarative-ui/stories/declarative-table.stories.ts b/projects/ngx/declarative-ui/stories/declarative-table.stories.ts index 0ae18cd9..c61994e4 100644 --- a/projects/ngx/declarative-ui/stories/declarative-table.stories.ts +++ b/projects/ngx/declarative-ui/stories/declarative-table.stories.ts @@ -91,12 +91,14 @@ const PODS: Pod[] = [ [error]="error" [hasMore]="hasMore" [height]="height" + [itemsPerPageLabel]="itemsPerPageLabel" [loadMode]="loadMode" [loading]="loading" [loadingDelay]="loadingDelay" [paginationLimit]="paginationLimit" [resources]="visibleResources" [totalItemsCount]="totalItemsCount" + [totalItemsLabel]="totalItemsLabel" [trackByPath]="trackByProperty" (loadMoreResources)="loadMore()" (pageChange)="onPageChange($event)" @@ -118,6 +120,8 @@ class DeclarativeTableStory { @Input() loading = false; @Input() loadingDelay = 1000; @Input() error = false; + @Input() itemsPerPageLabel = 'Items per page:'; + @Input() totalItemsLabel = 'Results'; /** * In pager mode the story slices the full `resources` array to the current @@ -177,6 +181,8 @@ const meta: Meta = { loadingDelay: { control: 'number' }, error: { control: 'boolean' }, loadMode: { options: ['scroll', 'button', 'pager'], control: 'select' }, + itemsPerPageLabel: { control: 'text' }, + totalItemsLabel: { control: 'text' }, }, args: { resources: PODS, @@ -480,7 +486,7 @@ export const Pagination_Pager: Story = { { label: 'Namespace', property: 'metadata.namespace' }, { label: 'Phase', property: 'status.phase' }, ] satisfies TableFieldDefinition[], - resources: Array.from({ length: 20 }, (_, i) => ({ + resources: Array.from({ length: 100 }, (_, i) => ({ ...PODS[i % PODS.length], id: `pager-${i + 1}`, metadata: { @@ -491,8 +497,8 @@ export const Pagination_Pager: Story = { })), trackByProperty: 'metadata.uid', loadMode: 'pager', - paginationLimit: 5, - totalItemsCount: 20, + paginationLimit: 10, + totalItemsCount: 100, currentPage: 1, }, }; diff --git a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html index 64371215..ab25340b 100644 --- a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html +++ b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html @@ -171,7 +171,7 @@ - {{ isPagerMode() ? 'Items per page:' : 'Items per load:' }} + {{ isPagerMode() ? itemsPerPageLabel() : 'Items per load:' }} - @if (knowsTotal()) { - - } + - {{ - knowsTotal() - ? hasResults() - ? currentPage() + ' / ' + totalPages() - : '–' - : currentPage() - }} + + @for (btn of pageButtons(); track $index) { + @if (btn === 'ellipsis') { + + } @else { + + } + } + - @if (knowsTotal()) { - - } + diff --git a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.scss b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.scss index 453b5ac5..2f8fef71 100644 --- a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.scss +++ b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.scss @@ -35,6 +35,7 @@ ui5-table-header-row { } .pagination-footer { + container-type: inline-size; gap: 0.5rem; &__side { @@ -57,22 +58,66 @@ ui5-table-header-row { gap: 0.25rem; justify-content: center; - ui5-button { - color: var(--sapContent_IconColor, #1d2d3e); - --sapButton_IconColor: var(--sapContent_IconColor, #1d2d3e); - --sapButton_Hover_IconColor: var(--sapContent_IconColor, #1d2d3e); + &__nav, + &__page { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + color: var(--sapLinkColor, #0070f2); + font-size: var(--sapFontSize); + min-width: 2rem; + height: 2rem; + padding: 0 0.25rem; + border-radius: 4px; + vertical-align: middle; - &[disabled] { + &:disabled { color: var(--sapContent_DisabledTextColor, #b3b3b3); - --sapButton_IconColor: var(--sapContent_DisabledTextColor, #b3b3b3); + cursor: default; + } + + &:not(:disabled):hover { + background: var(--sapList_Hover_Background); } } - &__indicator { - min-width: 3rem; - text-align: center; - color: var(--sapTextColor); - font-size: var(--sapFontSize); + &__nav { + font-size: 1.2rem; font-weight: bold; } + + &__page--active { + border: 1px solid var(--sapField_BorderColor, #89919a); + font-weight: bold; + color: var(--sapTextColor); + cursor: default; + } + + &__ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--sapTextColor); + min-width: 1.5rem; + height: 2rem; + } + + // Wide: hide first/last + &__first-last { + display: none; + } +} + +// Narrow: hide page numbers (except active), show first/last +@container (max-width: 35rem) { + .pager__pages-full { + display: none; + } + + .pager__first-last { + display: inline-flex; + } } diff --git a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.spec.ts b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.spec.ts index aafc5a63..4e2be3b8 100644 --- a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.spec.ts +++ b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.spec.ts @@ -641,32 +641,33 @@ describe('DeclarativeTable', () => { expect(component.totalPages()).toBe(3); }); - it('shows the compact "X / Y" indicator', () => { + it('shows the current page as an active page button', () => { const { fixture } = pagerSetup({ currentPage: 2, totalItemsCount: 12, paginationLimit: 5, }); - expect( - el(fixture, 'generic-table-pager-indicator')?.textContent?.trim(), - ).toBe('2 / 3'); + const activePage = el( + fixture, + 'generic-table-pager-page-2', + ) as HTMLElement & { + disabled: boolean; + }; + expect(activePage).not.toBeNull(); + expect(activePage.disabled).toBe(true); + expect(activePage.classList.contains('pager__page--active')).toBe(true); }); - it('disables first and previous on the first page', () => { + it('disables previous on the first page', () => { const { fixture } = pagerSetup({ currentPage: 1 }); // canPrev is false on page 1 regardless of totalItemsCount const prev = el(fixture, 'generic-table-pager-prev') as HTMLElement & { disabled: boolean; }; expect(prev.disabled).toBe(true); - // first button is only rendered when knowsTotal=true (totalItemsCount set) - const first = el(fixture, 'generic-table-pager-first') as HTMLElement & { - disabled: boolean; - }; - expect(first.disabled).toBe(true); }); - it('disables next and last on the last page', () => { + it('disables next on the last page', () => { const { fixture } = pagerSetup({ currentPage: 3, totalItemsCount: 12, @@ -675,11 +676,7 @@ describe('DeclarativeTable', () => { const next = el(fixture, 'generic-table-pager-next') as HTMLElement & { disabled: boolean; }; - const last = el(fixture, 'generic-table-pager-last') as HTMLElement & { - disabled: boolean; - }; expect(next.disabled).toBe(true); - expect(last.disabled).toBe(true); }); it('emits pageChange with the target page for each control', () => { @@ -728,15 +725,12 @@ describe('DeclarativeTable', () => { expect(emitted).toEqual([3]); }); - it('shows a neutral "–" indicator and disables all arrows when there are no results', () => { + it('disables all arrows when there are no results', () => { const { fixture, component } = pagerSetup({ resources: [], totalItemsCount: 0, currentPage: 1, }); - expect( - el(fixture, 'generic-table-pager-indicator')?.textContent?.trim(), - ).toBe('–'); const ids = ['prev', 'next']; for (const id of ids) { @@ -745,35 +739,26 @@ describe('DeclarativeTable', () => { }; expect(btn.disabled).toBe(true); } - // first/last are still rendered (knowsTotal=true) but also disabled - const first = el(fixture, 'generic-table-pager-first') as HTMLElement & { - disabled: boolean; - }; - const last = el(fixture, 'generic-table-pager-last') as HTMLElement & { - disabled: boolean; - }; - expect(first.disabled).toBe(true); - expect(last.disabled).toBe(true); expect(component.canPrev()).toBe(false); expect(component.canNext()).toBe(false); }); - it('renders the total item count as " Items" in pager mode', () => { + it('renders the total item count as " Results" in pager mode', () => { const { fixture } = pagerSetup({ totalItemsCount: 145 }); expect( el(fixture, 'generic-table-item-count') ?.textContent?.replace(/\s+/g, ' ') .trim(), - ).toBe('145 Items'); + ).toBe('145 Results'); }); - it('shows "0 Items" when there are no results', () => { + it('shows "0 Results" when there are no results', () => { const { fixture } = pagerSetup({ resources: [], totalItemsCount: 0 }); expect( el(fixture, 'generic-table-item-count') ?.textContent?.replace(/\s+/g, ' ') .trim(), - ).toBe('0 Items'); + ).toBe('0 Results'); }); describe('cursor-based mode (totalItemsCount undefined)', () => { @@ -790,17 +775,25 @@ describe('DeclarativeTable', () => { ...overrides, }); - it('shows only the current page number when totalItemsCount is undefined', () => { - const { fixture } = cursorSetup(); - expect( - el(fixture, 'generic-table-pager-indicator')?.textContent?.trim(), - ).toBe('2'); + it('shows the current page as an active page button when totalItemsCount is undefined', () => { + // When totalItemsCount is unknown, totalPages defaults to 1. + // The pager renders page 1 as the only button. Page navigation + // still works via canPrev/canNext based on currentPage and hasMore. + const { fixture, component } = cursorSetup(); + // page-1 button is rendered and active (totalPages=1) + const page1 = el( + fixture, + 'generic-table-pager-page-1', + ) as HTMLElement & { disabled: boolean }; + expect(page1).not.toBeNull(); + // prev is enabled since currentPage=2 > 1 + expect(component.canPrev()).toBe(true); }); - it('hides first and last buttons when totalItemsCount is undefined', () => { + it('always renders first and last buttons (visibility controlled by CSS)', () => { const { fixture } = cursorSetup(); - expect(el(fixture, 'generic-table-pager-first')).toBeNull(); - expect(el(fixture, 'generic-table-pager-last')).toBeNull(); + expect(el(fixture, 'generic-table-pager-first')).not.toBeNull(); + expect(el(fixture, 'generic-table-pager-last')).not.toBeNull(); }); it('hides the item count panel when totalItemsCount is undefined', () => { @@ -1011,16 +1004,17 @@ describe('DeclarativeTable', () => { const node = el(fixture, testId) as (HTMLElement & { accessibleName?: string }) | null; return ( - node?.getAttribute('accessible-name') ?? node?.accessibleName ?? null + node?.getAttribute('accessible-name') ?? + node?.getAttribute('accessiblename') ?? + node?.accessibleName ?? + null ); }; expect(accessibleName('generic-table-pagination-select')).toBe( 'Items per page', ); - expect(accessibleName('generic-table-pager-first')).toBe('First page'); expect(accessibleName('generic-table-pager-prev')).toBe('Previous page'); expect(accessibleName('generic-table-pager-next')).toBe('Next page'); - expect(accessibleName('generic-table-pager-last')).toBe('Last page'); }); }); diff --git a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.ts b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.ts index ebbfe57f..b3f2d13b 100644 --- a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.ts +++ b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.ts @@ -31,6 +31,7 @@ import '@ui5/webcomponents-icons/dist/open-command-field.js'; selector: 'mfp-declarative-table', imports: [ IllustratedMessage, + Button, Table, TableCell, TableHeaderCell, @@ -40,7 +41,6 @@ import '@ui5/webcomponents-icons/dist/open-command-field.js'; Select, Option, TableGrowing, - Button, ], templateUrl: './declarative-table.component.html', styleUrl: './declarative-table.component.scss', @@ -62,6 +62,8 @@ export class DeclarativeTable { loadMoreButtonText = input('Load More'); height = input(); currentPage = input(1); + itemsPerPageLabel = input('Items per page:'); + totalItemsLabel = input('Results'); readonly buttonClick = output>(); readonly tableRowClicked = output(); @@ -99,6 +101,38 @@ export class DeclarativeTable { : this.hasMore(), ); + pageButtons = computed<(number | 'ellipsis')[]>(() => { + const total = this.totalPages(); + const current = this.currentPage(); + if (total <= 9) return Array.from({ length: total }, (_, i) => i + 1); + + // Always exactly 9 slots: [1] [leftSlot] [w1] [w2] [w3] [w4] [w5] [rightSlot] [N] + // The middle window of 5 pages is centred on `current`, clamped so it + // never overlaps page 1 or page N (window lives in [2 … N-1]). + const windowStart = Math.min(Math.max(current - 2, 2), total - 5); + const [w1, w2, w3, w4, w5] = [ + windowStart, + windowStart + 1, + windowStart + 2, + windowStart + 3, + windowStart + 4, + ]; + + if (w1 === 2) { + // Near the start — window is adjacent to page 1: 1 2 3 4 5 6 7 … N + const rightSlot: number | 'ellipsis' = + w5 === total - 1 ? total - 1 : 'ellipsis'; + return [1, w1, w2, w3, w4, w5, w5 + 1, rightSlot, total]; + } + if (w5 === total - 1) { + // Near the end — window is adjacent to page N: 1 … N-6 N-5 N-4 N-3 N-2 N-1 N + const leftSlot: number | 'ellipsis' = w1 - 1 === 2 ? 2 : 'ellipsis'; + return [1, leftSlot, w1 - 1, w1, w2, w3, w4, w5, total]; + } + // Middle — gaps on both sides: 1 … w1 w2 w3 w4 w5 … N + return [1, 'ellipsis', w1, w2, w3, w4, w5, 'ellipsis', total]; + }); + goToPage(page: number): void { const maxPage = this.knowsTotal() ? this.totalPages() : Infinity; const target = Math.min(Math.max(1, page), maxPage); @@ -107,16 +141,16 @@ export class DeclarativeTable { } } - firstPage = () => { - this.goToPage(1); - }; - prevPage = () => { + prevPage() { this.goToPage(this.currentPage() - 1); - }; - nextPage = () => { + } + nextPage() { this.goToPage(this.currentPage() + 1); - }; - lastPage = () => { + } + firstPage() { + this.goToPage(1); + } + lastPage() { this.goToPage(this.totalPages()); - }; + } }