From cae900cb49995ca1c393849f99eeaa33b75ac7f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:06:49 +0000 Subject: [PATCH 1/2] fix(components,i18n): name the data table pager's four page buttons (objectui#12124) The data-table pager drew first / previous / next / last as icon-only Buttons with no accessible name. Each now reads its aria-label through the renderer's own useTableTranslation, from four new table.* keys added to TABLE_DEFAULT_TRANSLATIONS and to all ten locale packs. Pins: a real paged ObjectGrid's four buttons are found by role and name and driven to the page each names, provider-less (en defaults) and under zh; the objectui#12081 footer suite's chevron-icon lookup becomes the role query. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .changeset/12124-pager-button-names.md | 10 ++ .../src/renderers/complex/data-table.tsx | 9 ++ packages/i18n/src/locales/ar.ts | 4 + packages/i18n/src/locales/de.ts | 4 + packages/i18n/src/locales/en.ts | 4 + packages/i18n/src/locales/es.ts | 4 + packages/i18n/src/locales/fr.ts | 4 + packages/i18n/src/locales/ja.ts | 4 + packages/i18n/src/locales/ko.ts | 4 + packages/i18n/src/locales/pt.ts | 4 + packages/i18n/src/locales/ru.ts | 4 + packages/i18n/src/locales/zh.ts | 4 + .../footerSummaryScope-12081.test.tsx | 8 +- .../__tests__/pagerButtonNames-12124.test.tsx | 143 ++++++++++++++++++ 14 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 .changeset/12124-pager-button-names.md create mode 100644 packages/plugin-grid/src/__tests__/pagerButtonNames-12124.test.tsx diff --git a/.changeset/12124-pager-button-names.md b/.changeset/12124-pager-button-names.md new file mode 100644 index 0000000000..61934bc95a --- /dev/null +++ b/.changeset/12124-pager-button-names.md @@ -0,0 +1,10 @@ +--- +'@object-ui/i18n': minor +'@object-ui/components': patch +--- + +A paged table's first, previous, next and last page buttons have accessible names (objectui#12124). + +The `data-table` pager, which every paged `ObjectGrid` draws, showed four icon buttons whose only content was a chevron, so a screen reader announced four unnamed buttons and could not tell them apart. Each button is now named in the viewer's language: "First page", "Previous page", "Next page" and "Last page" in English. Sighted use is unchanged. + +Added: four language-pack keys, `table.firstPage`, `table.previousPage`, `table.nextPage` and `table.lastPage`, in all ten packs, and the same four rows in the renderer's English defaults for a host with no `I18nProvider`. No export, prop or type member is added. diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index fbc18df761..1d858967ec 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -202,6 +202,11 @@ function safeObjectLabel(value: unknown): string { const TABLE_DEFAULT_TRANSLATIONS: Record = { 'table.rowsPerPage': 'Rows per page', 'table.pageInfo': 'Page {{current}} of {{total}}', + // objectui#12124 — the pager's four icon buttons' accessible names. + 'table.firstPage': 'First page', + 'table.previousPage': 'Previous page', + 'table.nextPage': 'Next page', + 'table.lastPage': 'Last page', 'table.totalRecords': '{{count}} total', 'table.noResults': 'No results found', 'table.noResultsHint': 'Try adjusting your filters or search query.', @@ -3420,6 +3425,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { size="icon" onClick={() => goToPage(1)} disabled={effectivePage === 1} + aria-label={t('table.firstPage')} > @@ -3428,6 +3434,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { size="icon" onClick={() => goToPage(effectivePage - 1)} disabled={effectivePage === 1} + aria-label={t('table.previousPage')} > @@ -3436,6 +3443,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { size="icon" onClick={() => goToPage(effectivePage + 1)} disabled={effectivePage === totalPages} + aria-label={t('table.nextPage')} > @@ -3444,6 +3452,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { size="icon" onClick={() => goToPage(totalPages)} disabled={effectivePage === totalPages} + aria-label={t('table.lastPage')} > diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 1535126763..6ae3fa6aa9 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -441,6 +441,10 @@ const ar = { freezeColumn: "تجميد العمود", unfreezeColumn: "إلغاء تجميد العمود", pageInfo: "صفحة {{current}} من {{total}}", + firstPage: "الصفحة الأولى", + previousPage: "الصفحة السابقة", + nextPage: "الصفحة التالية", + lastPage: "الصفحة الأخيرة", totalRecords: "{{count}} إجمالي", noResults: "لا نتائج", noResultsHint: "حاول تعديل الفلاتر أو مصطلحات البحث.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index b3f3d41573..5b867452d6 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -401,6 +401,10 @@ const de = { freezeColumn: "Spalte fixieren", unfreezeColumn: "Spalte lösen", pageInfo: "Seite {{current}} von {{total}}", + firstPage: "Erste Seite", + previousPage: "Vorherige Seite", + nextPage: "Nächste Seite", + lastPage: "Letzte Seite", totalRecords: "{{count}} gesamt", noResults: "Keine Ergebnisse gefunden", noResultsHint: "Versuchen Sie, Ihre Filter oder Suchanfrage anzupassen.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8fbe18c8bb..4370eb5bb0 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -629,6 +629,10 @@ const en = { freezeColumn: 'Freeze column', unfreezeColumn: 'Unfreeze column', pageInfo: 'Page {{current}} of {{total}}', + firstPage: 'First page', + previousPage: 'Previous page', + nextPage: 'Next page', + lastPage: 'Last page', totalRecords: '{{count}} total', noResults: 'No results found', noResultsHint: 'Try adjusting your filters or search query.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 84d29cd334..0953746d83 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -412,6 +412,10 @@ const es = { freezeColumn: "Fijar columna", unfreezeColumn: "Desfijar columna", pageInfo: "Página {{current}} de {{total}}", + firstPage: "Primera página", + previousPage: "Página anterior", + nextPage: "Página siguiente", + lastPage: "Última página", totalRecords: "{{count}} en total", noResults: "Sin resultados", noResultsHint: "Intente ajustar sus filtros o términos de búsqueda.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 6357adace8..3b0242723e 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -408,6 +408,10 @@ const fr = { freezeColumn: "Figer la colonne", unfreezeColumn: "Libérer la colonne", pageInfo: "Page {{current}} sur {{total}}", + firstPage: "Première page", + previousPage: "Page précédente", + nextPage: "Page suivante", + lastPage: "Dernière page", totalRecords: "{{count}} au total", noResults: "Aucun résultat", noResultsHint: "Essayez d'ajuster vos filtres ou vos termes de recherche.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 014d78a0d3..f6c1ab4cf2 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -401,6 +401,10 @@ const ja = { freezeColumn: "列を固定", unfreezeColumn: "列の固定を解除", pageInfo: "{{total}}ページ中{{current}}ページ", + firstPage: "最初のページ", + previousPage: "前のページ", + nextPage: "次のページ", + lastPage: "最後のページ", totalRecords: "合計{{count}}件", noResults: "結果が見つかりません", noResultsHint: "フィルターや検索クエリを調整してみてください。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 478854eaab..790ec5fb3c 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -401,6 +401,10 @@ const ko = { freezeColumn: "열 고정", unfreezeColumn: "열 고정 해제", pageInfo: "{{total}} 페이지 중 {{current}} 페이지", + firstPage: "첫 페이지", + previousPage: "이전 페이지", + nextPage: "다음 페이지", + lastPage: "마지막 페이지", totalRecords: "총 {{count}}개", noResults: "결과 없음", noResultsHint: "필터 또는 검색어를 조정해 보세요.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index e4a7f45ab7..1ae02ffd3c 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -407,6 +407,10 @@ const pt = { freezeColumn: "Fixar coluna", unfreezeColumn: "Soltar coluna", pageInfo: "Página {{current}} de {{total}}", + firstPage: "Primeira página", + previousPage: "Página anterior", + nextPage: "Próxima página", + lastPage: "Última página", totalRecords: "{{count}} no total", noResults: "Sem resultados", noResultsHint: "Tente ajustar seus filtros ou termos de pesquisa.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index a7c9210e67..bb5491c5f3 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -423,6 +423,10 @@ const ru = { freezeColumn: "Закрепить столбец", unfreezeColumn: "Открепить столбец", pageInfo: "Страница {{current}} из {{total}}", + firstPage: "Первая страница", + previousPage: "Предыдущая страница", + nextPage: "Следующая страница", + lastPage: "Последняя страница", totalRecords: "Всего {{count}}", noResults: "Результатов нет", noResultsHint: "Попробуйте изменить фильтры или поисковые запросы.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 756a1b761c..65d9a78aca 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -412,6 +412,10 @@ const zh = { freezeColumn: '冻结列', unfreezeColumn: '取消冻结列', pageInfo: '第 {{current}} 页,共 {{total}} 页', + firstPage: '第一页', + previousPage: '上一页', + nextPage: '下一页', + lastPage: '最后一页', totalRecords: '共 {{count}} 条', noResults: '未找到结果', noResultsHint: '请尝试调整筛选条件或搜索关键词。', diff --git a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx index 0ed489df2b..4f60fc1712 100644 --- a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx +++ b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx @@ -50,6 +50,7 @@ import { __clearRecordCrudVerdictCache } from '../hooks/useRecordCrudVerdicts'; import { installExplainDouble } from './explainDouble'; import { registerAllFields } from '@object-ui/fields'; import { ActionProvider, I18nProvider, notifyDataChanged } from '@object-ui/react'; +import { en } from '@object-ui/i18n/locales'; beforeAll(() => { registerAllFields(); @@ -254,11 +255,8 @@ describe('the footer reads the whole matched set when the grid shows one page of await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`)); const asked = ds.queryGroupHeaders.mock.calls.length; - // The pager's icon buttons carry no accessible name; the next-page one is - // the button holding the full-size right chevron. - const next = document.querySelector('button svg.lucide-chevron-right.h-4')?.closest('button'); - expect(next).toBeTruthy(); - fireEvent.click(next!); + // The pager's next-page button, by its accessible name (objectui#12124). + fireEvent.click(screen.getByRole('button', { name: en.table.nextPage })); await waitFor(() => expect(screen.getByText('Line 25')).toBeInTheDocument()); expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`); expect(ds.queryGroupHeaders.mock.calls.length).toBe(asked); diff --git a/packages/plugin-grid/src/__tests__/pagerButtonNames-12124.test.tsx b/packages/plugin-grid/src/__tests__/pagerButtonNames-12124.test.tsx new file mode 100644 index 0000000000..520f348c32 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/pagerButtonNames-12124.test.tsx @@ -0,0 +1,143 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A paged grid's first / previous / next / last buttons are named + * (objectui#12124). + * + * `ObjectGrid` draws its pager through the `data-table` renderer: four icon + * `Button`s, each holding a chevron and nothing else. A screen reader heard + * four unnamed buttons, and a role query found none of them — the + * objectui#12081 footer suite had to reach the next-page button through its + * icon. Each button now reads its name through the renderer's own + * `useTableTranslation`, from `table.firstPage` / `table.previousPage` / + * `table.nextPage` / `table.lastPage`. + * + * The names are read against the PACK, never against a literal: with no + * provider the renderer's defaults table stands in for the `en` pack, and + * under a provider the viewer's pack answers. Each name is also DRIVEN — a + * click on the button so named must land on the page the name promises — so + * two swapped keys are red, not only a missing one. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { ObjectGridSchema } from '@object-ui/types'; +import { I18nProvider } from '@object-ui/i18n'; +import { en, zh } from '@object-ui/i18n/locales'; + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = vi.fn(() => false) as unknown as Element['hasPointerCapture']; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as unknown as Element['scrollIntoView']; + } +}); + +/** 12 rows over pages of 5: three pages, so every button has somewhere to go. */ +const ROWS = Array.from({ length: 12 }, (_, i) => ({ + id: String(i + 1), + name: `Row ${String(i + 1).padStart(2, '0')}`, +})); + +function grid(language?: string) { + const node = ( + + + + ); + return language ? ( + + {node} + + ) : ( + node + ); +} + +const PAGER_KEYS = ['firstPage', 'previousPage', 'nextPage', 'lastPage'] as const; +type PagerKey = (typeof PAGER_KEYS)[number]; +type PagerNames = Record; + +const namesIn = (pack: { table: Record }): PagerNames => + Object.fromEntries(PAGER_KEYS.map((k) => [k, pack.table[k]])) as PagerNames; + +/** The page on screen is the one whose first row is drawn and whose neighbours' are not. */ +async function expectPage(first: string, absent: string[]) { + await waitFor(() => expect(screen.getByText(first)).toBeInTheDocument()); + for (const row of absent) expect(screen.queryByText(row)).toBeNull(); +} + +/** Find each of the four by role and name, and drive it to the page it names. */ +async function walkThePager(names: PagerNames) { + // The names are four distinct words — none empty, none a raw key. + for (const k of PAGER_KEYS) { + expect(names[k]).toBeTruthy(); + expect(names[k]).not.toBe(`table.${k}`); + } + expect(new Set(Object.values(names)).size).toBe(4); + + const button = (k: PagerKey) => screen.getByRole('button', { name: names[k] }); + + await expectPage('Row 01', ['Row 06', 'Row 11']); + expect(button('firstPage')).toBeDisabled(); + expect(button('previousPage')).toBeDisabled(); + expect(button('nextPage')).toBeEnabled(); + expect(button('lastPage')).toBeEnabled(); + + fireEvent.click(button('lastPage')); + await expectPage('Row 11', ['Row 01', 'Row 06']); + expect(button('nextPage')).toBeDisabled(); + expect(button('lastPage')).toBeDisabled(); + + fireEvent.click(button('previousPage')); + await expectPage('Row 06', ['Row 01', 'Row 11']); + + fireEvent.click(button('firstPage')); + await expectPage('Row 01', ['Row 06', 'Row 11']); + + fireEvent.click(button('nextPage')); + await expectPage('Row 06', ['Row 01', 'Row 11']); +} + +describe('the paged grid names its four page buttons (objectui#12124)', () => { + it('with no provider, each is found by the `en` pack\'s name and goes where it says', async () => { + render(grid()); + await walkThePager(namesIn(en)); + }); + + it('under zh, each is named by the zh pack, not by the English words', async () => { + const zhNames = namesIn(zh); + const enNames = namesIn(en); + // The pin is only a pin if the two packs disagree, key by key. + for (const k of PAGER_KEYS) expect(zhNames[k]).not.toBe(enNames[k]); + + render(grid('zh')); + await screen.findByRole('button', { name: zhNames.nextPage }); + for (const k of PAGER_KEYS) { + expect(screen.queryByRole('button', { name: enNames[k] })).toBeNull(); + } + await walkThePager(zhNames); + }); +}); From 1b02eac7144ceed219fa51ef2945f81c7973e493 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:31:57 +0000 Subject: [PATCH 2/2] docs(changeset): say what TranslationKeys gains, and that grouped grids are not covered (objectui#12124) The changeset said no type member is added, but the four keys add members to the exported en pack and so to TranslationKeys. It also said every paged ObjectGrid draws the data-table pager; a grouped grid with more than one page of groups draws its own group pager, which stays unnamed (objectui#12143). Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .changeset/12124-pager-button-names.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/12124-pager-button-names.md b/.changeset/12124-pager-button-names.md index 61934bc95a..f466a14e62 100644 --- a/.changeset/12124-pager-button-names.md +++ b/.changeset/12124-pager-button-names.md @@ -5,6 +5,6 @@ A paged table's first, previous, next and last page buttons have accessible names (objectui#12124). -The `data-table` pager, which every paged `ObjectGrid` draws, showed four icon buttons whose only content was a chevron, so a screen reader announced four unnamed buttons and could not tell them apart. Each button is now named in the viewer's language: "First page", "Previous page", "Next page" and "Last page" in English. Sighted use is unchanged. +The `data-table` pager, which an ungrouped paged `ObjectGrid` draws, showed four icon buttons whose only content was a chevron, so a screen reader announced four unnamed buttons and could not tell them apart. Each button is now named in the viewer's language: "First page", "Previous page", "Next page" and "Last page" in English. Sighted use is unchanged. A grouped grid with more than one page of groups draws `ObjectGrid`'s own group pager instead, which this change does not name (objectui#12143). -Added: four language-pack keys, `table.firstPage`, `table.previousPage`, `table.nextPage` and `table.lastPage`, in all ten packs, and the same four rows in the renderer's English defaults for a host with no `I18nProvider`. No export, prop or type member is added. +Added: four language-pack keys, `table.firstPage`, `table.previousPage`, `table.nextPage` and `table.lastPage`, in all ten packs, and the same four rows in the renderer's English defaults for a host with no `I18nProvider`. The exported `en` pack, and so `TranslationKeys`, gains the four `table` members; no export or prop is added.