diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/table-toolbar-pagination.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/table-toolbar-pagination.e2e.ts index 2417bcc656..0be0965a93 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/table-toolbar-pagination.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/table-toolbar-pagination.e2e.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { expect, test } from '../fixtures/e2e-test'; import { seedRepresentativeEvent } from '../support/event-data'; -test('table toolbar remains accessible while moving between different-height result pages', async ({ e2eApi, e2eScenario, page }) => { +test('floating table controls keep the first row visible between different-height result pages', async ({ e2eApi, e2eScenario, page }) => { for (let index = 1; index <= 6; index++) { await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { message: `${e2eScenario.message} ${index}`, @@ -36,31 +36,96 @@ test('table toolbar remains accessible while moving between different-height res expect(firstToolbarBox).not.toBeNull(); expect(firstGridBox).not.toBeNull(); expect(await toolbar.evaluate((element) => getComputedStyle(element).position)).toBe('sticky'); + await expect(toolbar).not.toHaveAttribute('data-floating'); expect(await toolbar.evaluate((element) => element.nextElementSibling?.getAttribute('data-slot'))).toBe('data-table-body'); expect(firstToolbarBox!.height).toBeLessThanOrEqual(34); expect(firstGridBox!.y - (firstToolbarBox!.y + firstToolbarBox!.height)).toBeLessThanOrEqual(8); expect(firstToolbarBox!.y + firstToolbarBox!.height).toBeLessThanOrEqual(firstGridBox!.y); + const scrollContainer = page.locator('main').locator('..'); + const scrollTopAtPageTop = await scrollContainer.evaluate((element) => element.scrollTop); + await pager.getByRole('button', { name: 'Go to next page' }).click(); + await expect(pager.getByLabel('Page 2 of 2')).toBeVisible(); + await expect.poll(() => scrollContainer.evaluate((element) => element.scrollTop)).toBe(scrollTopAtPageTop); + await pager.getByRole('button', { name: 'Go to first page' }).click(); + await expect(pager.getByLabel('Page 1 of 2')).toBeVisible(); + await expect.poll(() => scrollContainer.evaluate((element) => element.scrollTop)).toBe(scrollTopAtPageTop); + if (process.env.E2E_CAPTURE_PAGER_TOOLBAR_SCREENSHOTS === 'true') { await page.screenshot({ path: resolve(process.cwd(), '../../../dogfood-output/pager-toolbar/desktop-page-1.png') }); } + const scrollTopBeforeVisiblePageSizeChange = await scrollContainer.evaluate((element) => element.scrollTop); + await pager.getByLabel('Rows per page').click(); + await page.getByRole('option', { name: '10 rows' }).click(); + await expect(pager.getByLabel('Page 1 of 1')).toBeVisible(); + await expect.poll(() => scrollContainer.evaluate((element) => element.scrollTop)).toBe(scrollTopBeforeVisiblePageSizeChange); + + await page.setViewportSize({ height: 400, width: 1280 }); + await scrollContainer.evaluate((element) => element.scrollTo({ top: element.scrollHeight })); + await expect(toolbar).toHaveAttribute('data-floating', ''); + + if (process.env.E2E_CAPTURE_PAGER_TOOLBAR_SCREENSHOTS === 'true') { + await page.screenshot({ path: resolve(process.cwd(), '../../../dogfood-output/pager-toolbar/desktop-floating.png') }); + + const wasDark = await page.locator('html').evaluate((element) => element.classList.contains('dark')); + await page.locator('html').evaluate((element) => { + element.classList.add('dark'); + element.style.colorScheme = 'dark'; + }); + await page.screenshot({ path: resolve(process.cwd(), '../../../dogfood-output/pager-toolbar/desktop-floating-dark.png') }); + + if (!wasDark) { + await page.locator('html').evaluate((element) => { + element.classList.remove('dark'); + element.style.colorScheme = 'light'; + }); + } + } + + const scrollTopBeforeHiddenPageSizeChange = await scrollContainer.evaluate((element) => element.scrollTop); + await pager.getByLabel('Rows per page').click(); + await page.getByRole('option', { name: '5 rows' }).click(); + await expect(pager.getByLabel('Page 1 of 2')).toBeVisible(); + await expect.poll(() => scrollContainer.evaluate((element) => element.scrollTop)).toBeLessThan(scrollTopBeforeHiddenPageSizeChange); + await scrollContainer.evaluate((element) => element.scrollTo({ top: element.scrollHeight })); + await expect(toolbar).toHaveAttribute('data-floating', ''); + const nextButton = pager.getByRole('button', { name: 'Go to next page' }); - const scrollContainer = page.locator('main').locator('..'); const scrollTopBeforePaging = await scrollContainer.evaluate((element) => element.scrollTop); await nextButton.focus(); await nextButton.click(); await expect(pager.getByLabel('Page 2 of 2')).toBeVisible(); await expect(pager.getByRole('button', { name: 'Go to next page' })).toBeFocused(); await expect(bulkActionsButton).toHaveAttribute('aria-disabled', 'true'); - await expect.poll(() => scrollContainer.evaluate((element) => element.scrollTop)).toBe(scrollTopBeforePaging); + await expect(toolbar).toHaveAttribute('data-floating', ''); + + const scrollTopAfterPaging = await scrollContainer.evaluate((element) => element.scrollTop); + expect(scrollTopAfterPaging).toBeGreaterThan(0); + expect(scrollTopAfterPaging).toBeLessThan(scrollTopBeforePaging); const secondToolbarBox = await toolbar.boundingBox(); const secondGridBox = await grid.boundingBox(); + const firstRow = page.locator('tbody tr:visible').first(); + await expect(firstRow).toBeVisible({ timeout: 30_000 }); + const firstRowBox = await firstRow.boundingBox(); expect(secondToolbarBox).not.toBeNull(); expect(secondGridBox).not.toBeNull(); + expect(firstRowBox).not.toBeNull(); expect(secondToolbarBox!.y).toBeGreaterThanOrEqual(8); expect(secondToolbarBox!.y + secondToolbarBox!.height).toBeLessThanOrEqual(secondGridBox!.y); + expect(firstRowBox!.y).toBeGreaterThanOrEqual(secondToolbarBox!.y + secondToolbarBox!.height); + expect(firstRowBox!.y).toBeLessThan(400); + + const scrollTopWithFirstRowVisible = await scrollContainer.evaluate((element) => element.scrollTop); + const previousButton = pager.getByRole('button', { name: 'Go to previous page' }); + await previousButton.click(); + await expect(pager.getByLabel('Page 1 of 2')).toBeVisible(); + await expect.poll(() => scrollContainer.evaluate((element) => element.scrollTop)).toBe(scrollTopWithFirstRowVisible); + + await pager.getByRole('button', { name: 'Go to next page' }).click(); + await expect(pager.getByLabel('Page 2 of 2')).toBeVisible(); + await expect.poll(() => scrollContainer.evaluate((element) => element.scrollTop)).toBe(scrollTopWithFirstRowVisible); await page.locator('main').evaluate((element) => element.parentElement?.scrollTo({ top: element.parentElement.scrollHeight })); const appFooter = page.getByRole('link', { exact: true, name: 'Terms' }).locator('xpath=ancestor::div[contains(@class, "border-t")][1]'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-footer.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-footer.svelte index e247502bf5..2e5938aa01 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-footer.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-footer.svelte @@ -10,6 +10,7 @@ import DataTablePageCount from './data-table-page-count.svelte'; import DataTablePagination from './data-table-pagination.svelte'; + import { findScrollableAncestor } from './data-table-scroll'; import DataTableSelection from './data-table-selection.svelte'; type Props = HTMLAttributes & { @@ -19,17 +20,61 @@ }; let { children, class: className, table, variant = 'simple' }: Props = $props(); + + let isFloating = $state(false); + let toolbarElement = $state(); + + $effect(() => { + const element = toolbarElement; + if (variant !== 'floating' || !element || typeof window === 'undefined') { + isFloating = false; + return; + } + + const scrollContainer = findScrollableAncestor(element); + + function updateFloatingState(): void { + const styles = window.getComputedStyle(element!); + const stickyTop = Number.parseFloat(styles.top); + + if (styles.position !== 'sticky' || !Number.isFinite(stickyTop)) { + isFloating = false; + return; + } + + const scrollContainerTop = scrollContainer?.getBoundingClientRect().top ?? 0; + const stickyBoundary = scrollContainerTop + (scrollContainer?.clientTop ?? 0) + stickyTop; + isFloating = Math.abs(element!.getBoundingClientRect().top - stickyBoundary) < 1; + } + + updateFloatingState(); + const scrollTarget = scrollContainer ?? window; + scrollTarget.addEventListener('scroll', updateFloatingState, { + passive: true + }); + window.addEventListener('resize', updateFloatingState, { + passive: true + }); + + return () => { + scrollTarget.removeEventListener('scroll', updateFloatingState); + window.removeEventListener('resize', updateFloatingState); + }; + }); + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-size.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-size.svelte index 14affe7934..dd8269bfd3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-size.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-size.svelte @@ -10,13 +10,14 @@ interface Props { joined?: boolean; + onBeforePageSizeChange?: () => void; onPageSizeChange?: () => void; size?: 'default' | 'sm'; table: Table; value: number; } - let { joined = false, onPageSizeChange, size = 'sm', table, value = $bindable() }: Props = $props(); + let { joined = false, onBeforePageSizeChange, onPageSizeChange, size = 'sm', table, value = $bindable() }: Props = $props(); type Item = { label: string; value: string }; const items: Item[] = [ @@ -50,6 +51,7 @@ let selected = $derived((items.find((item) => item.value === valueString) || items[0]) as Item); function onValueChange(newValue: string) { + onBeforePageSizeChange?.(); value = Number(newValue); table.setPageSize(Number(newValue)); onPageSizeChange?.(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pager.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pager.svelte index d608bcb900..cdcd05697a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pager.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pager.svelte @@ -14,6 +14,7 @@ import ChevronsLeftIcon from '@lucide/svelte/icons/chevrons-left'; import DataTablePageSize from './data-table-page-size.svelte'; + import { scrollTableToFirstRow } from './data-table-scroll'; interface Props { table: Table; @@ -22,34 +23,54 @@ } let { table, value = $bindable(), variant = 'simple' }: Props = $props(); + let pagerElement = $state(); const currentPage = $derived((table.options.state?.pagination?.pageIndex ?? table.store.state.pagination.pageIndex) + 1); const totalPages = $derived(Math.max(1, table.getPageCount() || 1)); const canGoNext = $derived(currentPage < totalPages); const canGoPrevious = $derived(currentPage > 1); - function goToFirstPage(): void { + function goToFirstPage(event: MouseEvent): void { if (canGoPrevious) { - table.setPageIndex(0); + goToPage(0, event); } } - function goToNextPage(): void { + function goToNextPage(event: MouseEvent): void { if (canGoNext) { - table.setPageIndex(currentPage); + goToPage(currentPage, event); } } - function goToPreviousPage(): void { + function goToPreviousPage(event: MouseEvent): void { if (canGoPrevious) { - table.setPageIndex(currentPage - 2); + goToPage(currentPage - 2, event); } } + + function goToPage(pageIndex: number, event: MouseEvent): void { + const trigger = event.currentTarget as HTMLElement; + if (shouldAdjustScroll(trigger)) { + scrollTableToFirstRow(trigger); + } + + table.setPageIndex(pageIndex); + } + + function onBeforePageSizeChange(): void { + if (pagerElement && shouldAdjustScroll(pagerElement)) { + scrollTableToFirstRow(pagerElement); + } + } + + function shouldAdjustScroll(trigger: HTMLElement): boolean { + return variant === 'floating' && trigger.closest('[data-slot="data-table-footer"]')?.hasAttribute('data-floating') === true; + } -