Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/12124-pager-button-names.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ function safeObjectLabel(value: unknown): string {
const TABLE_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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.',
Expand Down Expand Up @@ -3420,6 +3425,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
size="icon"
onClick={() => goToPage(1)}
disabled={effectivePage === 1}
aria-label={t('table.firstPage')}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
Expand All @@ -3428,6 +3434,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
size="icon"
onClick={() => goToPage(effectivePage - 1)}
disabled={effectivePage === 1}
aria-label={t('table.previousPage')}
>
<ChevronLeft className="h-4 w-4" />
</Button>
Expand All @@ -3436,6 +3443,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
size="icon"
onClick={() => goToPage(effectivePage + 1)}
disabled={effectivePage === totalPages}
aria-label={t('table.nextPage')}
>
<ChevronRight className="h-4 w-4" />
</Button>
Expand All @@ -3444,6 +3452,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
size="icon"
onClick={() => goToPage(totalPages)}
disabled={effectivePage === totalPages}
aria-label={t('table.lastPage')}
>
<ChevronsRight className="h-4 w-4" />
</Button>
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,10 @@ const ar = {
freezeColumn: "تجميد العمود",
unfreezeColumn: "إلغاء تجميد العمود",
pageInfo: "صفحة {{current}} من {{total}}",
firstPage: "الصفحة الأولى",
previousPage: "الصفحة السابقة",
nextPage: "الصفحة التالية",
lastPage: "الصفحة الأخيرة",
totalRecords: "{{count}} إجمالي",
noResults: "لا نتائج",
noResultsHint: "حاول تعديل الفلاتر أو مصطلحات البحث.",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ const ja = {
freezeColumn: "列を固定",
unfreezeColumn: "列の固定を解除",
pageInfo: "{{total}}ページ中{{current}}ページ",
firstPage: "最初のページ",
previousPage: "前のページ",
nextPage: "次のページ",
lastPage: "最後のページ",
totalRecords: "合計{{count}}件",
noResults: "結果が見つかりません",
noResultsHint: "フィルターや検索クエリを調整してみてください。",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ const ko = {
freezeColumn: "열 고정",
unfreezeColumn: "열 고정 해제",
pageInfo: "{{total}} 페이지 중 {{current}} 페이지",
firstPage: "첫 페이지",
previousPage: "이전 페이지",
nextPage: "다음 페이지",
lastPage: "마지막 페이지",
totalRecords: "총 {{count}}개",
noResults: "결과 없음",
noResultsHint: "필터 또는 검색어를 조정해 보세요.",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,10 @@ const ru = {
freezeColumn: "Закрепить столбец",
unfreezeColumn: "Открепить столбец",
pageInfo: "Страница {{current}} из {{total}}",
firstPage: "Первая страница",
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
lastPage: "Последняя страница",
totalRecords: "Всего {{count}}",
noResults: "Результатов нет",
noResultsHint: "Попробуйте изменить фильтры или поисковые запросы.",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ const zh = {
freezeColumn: '冻结列',
unfreezeColumn: '取消冻结列',
pageInfo: '第 {{current}} 页,共 {{total}} 页',
firstPage: '第一页',
previousPage: '上一页',
nextPage: '下一页',
lastPage: '最后一页',
totalRecords: '共 {{count}} 条',
noResults: '未找到结果',
noResultsHint: '请尝试调整筛选条件或搜索关键词。',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
143 changes: 143 additions & 0 deletions packages/plugin-grid/src/__tests__/pagerButtonNames-12124.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<ActionProvider>
<ObjectGrid
schema={{
type: 'object-grid',
objectName: 'task',
columns: [{ field: 'name', label: 'Name' }],
data: { provider: 'value', items: ROWS },
pagination: { pageSize: 5 },
} as unknown as ObjectGridSchema}
/>
</ActionProvider>
);
return language ? (
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
{node}
</I18nProvider>
) : (
node
);
}

const PAGER_KEYS = ['firstPage', 'previousPage', 'nextPage', 'lastPage'] as const;
type PagerKey = (typeof PAGER_KEYS)[number];
type PagerNames = Record<PagerKey, string>;

const namesIn = (pack: { table: Record<string, string> }): 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);
});
});
Loading