diff --git a/.changeset/12124-pager-button-names.md b/.changeset/12124-pager-button-names.md new file mode 100644 index 0000000000..f466a14e62 --- /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 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`. The exported `en` pack, and so `TranslationKeys`, gains the four `table` members; no export or prop 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); + }); +});