ソースを参照

fix(web): address trajectory pagination review

_Kerman 1 ヶ月 前
親
コミット
2c837c267f

+ 2 - 1
apps/web/tests/snapshots/trajectory-virtualization/load-more.expected.md

@@ -1,3 +1,4 @@
 - row "Load earlier history":
   - cell "Load earlier history":
-    - button "Load earlier history"
+    - button "Load earlier history":
+      - status

+ 8 - 1
apps/web/tests/trajectory-virtualization.e2e.ts

@@ -157,10 +157,16 @@ async function loadToFirstTurn(page: Page): Promise<void> {
     await scrollToRatio(page, 0)
     if (await page.getByText(marker, { exact: false }).count() > 0) return
     const before = await logicalRows(page)
+    const anchor = await firstVisibleRow(page)
     await expect.poll(async () => ({
       marker: await page.getByText(marker, { exact: false }).count() > 0,
       rows: await logicalRows(page),
     }), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before })
+    await nextPaint(page)
+    await expect.poll(async () => {
+      const top = await rowTop(page, anchor.key)
+      return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top)
+    }, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
   }
   throw new Error('trajectory did not reach the first turn after twelve older-page requests')
 }
@@ -246,11 +252,12 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
         scaffold.workspaceCwd,
       )
       await compareOrRefreshGolden(LOAD_MORE_EXPECTED, loadMoreSnapshot, MODE)
+      // Avoid Playwright scrolling the offscreen first row into the automatic-load threshold.
       await loadMore.evaluate((button: HTMLButtonElement) => { button.click() })
       await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
       await expect.poll(async () => ({
         disabled: await loadMore.isDisabled(),
-        label: await loadMore.textContent(),
+        label: await loadMore.getAttribute('aria-label'),
       }), { timeout: 15_000 }).toEqual({
         disabled: true,
         label: 'Loading earlier history…',

+ 9 - 0
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -93,6 +93,15 @@
   cursor: default;
 }
 
+.visuallyHidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip: rect(0 0 0 0);
+  white-space: nowrap;
+}
+
 .table:not([data-scroll-ready='true']) {
   visibility: hidden;
 }

+ 23 - 8
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -30,6 +30,7 @@ import css from './TrajectoryTable.module.css'
 
 const BOTTOM_FOLLOW_THRESHOLD_PX = 2
 const OLDER_LOAD_THRESHOLD_PX = 48
+const HISTORY_LOAD_ROW_HEIGHT_PX = 30
 const VIRTUALIZATION_THRESHOLD = 100
 const VIRTUAL_OVERSCAN_ROWS = 12
 const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
@@ -364,7 +365,7 @@ export interface TrajectoryTableProps {
   recordFocus?: { readonly index: number } | null
   /** Whether the initial history tail is still loading. */
   historyLoading?: boolean
-  /** Whether another surface is loading one older history page. */
+  /** Whether one older history page request is pending anywhere. */
   olderHistoryLoading?: boolean
   /** First loaded raw event, used to preserve scroll position after prepending a page. */
   historyStartSeq?: number | undefined
@@ -1772,6 +1773,7 @@ export function TrajectoryTable({
   const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows)
   const virtualizationEnabled = hasOlderRecords
     || records.length > VIRTUALIZATION_THRESHOLD
+  const virtualScrollMargin = hasOlderRecords ? HISTORY_LOAD_ROW_HEIGHT_PX : 0
   const estimateVirtualRowSize = useCallback(
     (index: number) => virtualRowStructure[index]?.height ?? 30,
     [virtualRowStructure],
@@ -1790,6 +1792,7 @@ export function TrajectoryTable({
     initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
     anchorTo: 'end',
     overscan: VIRTUAL_OVERSCAN_ROWS,
+    scrollMargin: virtualScrollMargin,
     scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
   })
   const virtualIndexByRecordId = useMemo(() => {
@@ -1804,10 +1807,15 @@ export function TrajectoryTable({
     return indexes
   }, [projectedVirtualRows])
   const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
-  const virtualTop = virtualItems[0]?.start ?? 0
+  const virtualTop = Math.max(0, (virtualItems[0]?.start ?? 0) - virtualScrollMargin)
   const virtualBottom = virtualItems.length === 0
     ? 0
-    : Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0))
+    : Math.max(
+      0,
+      rowVirtualizer.getTotalSize()
+        + virtualScrollMargin
+        - (virtualItems.at(-1)?.end ?? 0),
+    )
   const renderedRecords = virtualizationEnabled
     ? virtualItems.flatMap((item) => {
       const row = projectedVirtualRows[item.index]
@@ -2220,12 +2228,19 @@ export function TrajectoryTable({
           </colgroup>
           <tbody>
             {hasOlderRecords && (
-              <tr className={css.historyLoadRow} data-history-load="">
+              <tr
+                className={css.historyLoadRow}
+                data-history-load=""
+                aria-rowindex={1}
+              >
                 <td colSpan={2}>
                   <button
                     type="button"
                     className={css.historyLoadButton}
                     disabled={olderBusy || onLoadOlder === undefined}
+                    aria-label={olderBusy
+                      ? 'Loading earlier history…'
+                      : 'Load earlier history'}
                     onClick={() => {
                       const pane = tablePaneRef.current
                       if (pane !== null) requestOlder(pane, false)
@@ -2234,12 +2249,12 @@ export function TrajectoryTable({
                     {olderBusy && (
                       <span className={css.historyLoadingSpinner} aria-hidden="true" />
                     )}
-                    <span
-                      role={olderBusy ? 'status' : undefined}
-                      aria-live={olderBusy ? 'polite' : undefined}
-                    >
+                    <span aria-hidden="true">
                       {olderBusy ? 'Loading earlier history…' : 'Load earlier history'}
                     </span>
+                    <span className={css.visuallyHidden} role="status" aria-live="polite">
+                      {olderBusy ? 'Loading earlier history…' : ''}
+                    </span>
                   </button>
                 </td>
               </tr>

+ 7 - 2
packages/client/ui-trajectory/tests/table.client.spec.tsx

@@ -418,7 +418,9 @@ describe('TrajectoryTable', () => {
     await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
     expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
     resolveOlder?.(true)
-    await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() })
+    await waitFor(() => {
+      expect(screen.getByRole('status').textContent).toBe('')
+    })
     scrollHeight = 260
     view.rerender(
       <TrajectoryTable
@@ -458,7 +460,10 @@ describe('TrajectoryTable', () => {
 
     const table = screen.getByRole('table')
     const loadButton = screen.getByRole('button', { name: 'Load earlier history' })
-    expect(table.querySelector('tbody > tr:first-child')?.contains(loadButton)).toBe(true)
+    const loadRow = table.querySelector('tbody > tr:first-child')
+    expect(loadRow?.contains(loadButton)).toBe(true)
+    expect(loadRow?.getAttribute('aria-rowindex')).toBe('1')
+    expect(screen.getByRole('status').textContent).toBe('')
     expect(table.getAttribute('aria-rowcount')).toBe('4')
     expect((await screen.findByRole('row', { name: /ASSISTANT/ })).getAttribute('aria-rowindex'))
       .toBe('2')