From 7a1b619f4581364114543c4f78f24ce4f59ebcef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:11:58 +0000 Subject: [PATCH 1/2] fix(components): RefreshIndicator requires its accessible name; four views pass a translated one (objectui#10580) `RefreshIndicator`'s `ariaLabel` loses its English default ("Refreshing") and becomes required, so a caller that names nothing is a compile error. ObjectGrid and ListView pass their existing `grid.refreshing` / `list.refreshing`; ObjectChart and ObjectDataTable pass new `chart.refreshing` / `dashboard.refreshing` keys, added to all ten packs. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- ...esh-indicator-required-name-10580.test.tsx | 59 +++++++++ .../src/custom/refresh-indicator.tsx | 12 +- packages/i18n/src/locales/ar.ts | 2 + packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + packages/i18n/src/locales/es.ts | 2 + packages/i18n/src/locales/fr.ts | 2 + packages/i18n/src/locales/ja.ts | 2 + packages/i18n/src/locales/ko.ts | 2 + packages/i18n/src/locales/pt.ts | 2 + packages/i18n/src/locales/ru.ts | 2 + packages/i18n/src/locales/zh.ts | 2 + ...hart.refreshIndicatorLocale-10580.test.tsx | 104 ++++++++++++++++ packages/plugin-charts/src/ObjectChart.tsx | 9 +- .../plugin-dashboard/src/ObjectDataTable.tsx | 4 +- ...able.refreshIndicatorLocale-10580.test.tsx | 114 ++++++++++++++++++ packages/plugin-grid/src/ObjectGrid.tsx | 2 +- ...Grid.refreshIndicatorLocale-10580.test.tsx | 113 +++++++++++++++++ packages/plugin-list/src/ListView.tsx | 2 +- ...View.refreshIndicatorLocale-10580.test.tsx | 94 +++++++++++++++ 20 files changed, 525 insertions(+), 8 deletions(-) create mode 100644 packages/components/src/__tests__/refresh-indicator-required-name-10580.test.tsx create mode 100644 packages/plugin-charts/src/ObjectChart.refreshIndicatorLocale-10580.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/ObjectDataTable.refreshIndicatorLocale-10580.test.tsx create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.refreshIndicatorLocale-10580.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.refreshIndicatorLocale-10580.test.tsx diff --git a/packages/components/src/__tests__/refresh-indicator-required-name-10580.test.tsx b/packages/components/src/__tests__/refresh-indicator-required-name-10580.test.tsx new file mode 100644 index 0000000000..f1959c0554 --- /dev/null +++ b/packages/components/src/__tests__/refresh-indicator-required-name-10580.test.tsx @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * `RefreshIndicator` names its progress bar only with what the caller passes + * (objectui#10580). + * + * `ariaLabel` used to be optional with the English default "Refreshing", so + * four data views that passed nothing announced English to every non-English + * screen-reader user. The prop is now required and the default is gone: a + * caller that names nothing is a compile error, not an English name. + * + * Two halves: + * - TYPE: erased at runtime, so vitest proves nothing about it. It is + * checked by `tsc -p tsconfig.test.json`, which this package's + * `type-check` script chains. + * - DOM: the rendered bar carries exactly the passed name, and a caller that + * slips past the type (a cast) gets no English name either. + */ + +import { describe, it, expect } from 'vitest'; +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { RefreshIndicator, type RefreshIndicatorProps } from '../custom/refresh-indicator'; + +describe('RefreshIndicator takes its accessible name from the caller (objectui#10580)', () => { + it('a caller that passes no name does not compile', () => { + // Built, never rendered: these two elements are here for `tsc` to judge. + const accepted = ; + const refused = ( + // @ts-expect-error `ariaLabel` is required — with an optional prop this directive goes unused (TS2578) + + ); + expect(React.isValidElement(accepted) && React.isValidElement(refused)).toBe(true); + }); + + it('names the progress bar with the passed string', () => { + render(); + const bar = screen.getByRole('progressbar', { name: 'Wird aktualisiert…' }); + expect(bar.getAttribute('aria-busy')).toBe('true'); + }); + + it('renders nothing while inactive', () => { + render(); + expect(screen.queryByTestId('refresh-indicator')).toBeNull(); + }); + + it('has no English name to fall back to when a caller casts past the type', () => { + const props = { active: true } as unknown as RefreshIndicatorProps; + render(); + const bar = screen.getByTestId('refresh-indicator'); + expect(bar.hasAttribute('aria-label')).toBe(false); + }); +}); diff --git a/packages/components/src/custom/refresh-indicator.tsx b/packages/components/src/custom/refresh-indicator.tsx index 3a2f92bed9..06534e6f2b 100644 --- a/packages/components/src/custom/refresh-indicator.tsx +++ b/packages/components/src/custom/refresh-indicator.tsx @@ -35,8 +35,14 @@ function ensureKeyframe() { export interface RefreshIndicatorProps extends React.HTMLAttributes { /** Whether the indicator is active. When false, nothing is rendered. */ active: boolean - /** Accessible label. Defaults to "Refreshing". */ - ariaLabel?: string + /** + * Accessible name of the progress bar, rendered as its `aria-label`. + * Required, with no default: the caller passes a string from its own + * translation layer, so the name a screen reader announces is in the active + * locale. An English default here reached every non-English screen-reader + * user through the callers that passed nothing (objectui#10580). + */ + ariaLabel: string } /** @@ -50,7 +56,7 @@ export interface RefreshIndicatorProps extends React.HTMLAttributes = ({ active, - ariaLabel = "Refreshing", + ariaLabel, className, ...props }) => { diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 34c14203f4..c645f20092 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1144,6 +1144,7 @@ const ar = { }, chart: { loading: "جارٍ تحميل الرسم البياني…", + refreshing: "جارٍ التحديث…", nullCategory: "(غير محدد)", scatterOneMeasure: "المخطط المبعثر يرسم مقياسًا واحدًا فقط. أبقِ سلسلة واحدة:", unconfigured: { @@ -1165,6 +1166,7 @@ const ar = { sourceLabel: "مصدر البيانات:", }, loading: "جارٍ التحميل…", + refreshing: "جارٍ التحديث…", pickMeasures: "اختر المقاييس (القيم) لأداة مجموعة البيانات هذه.", datasetUnsupported: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.", details: "التفاصيل", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7112b7d107..133b65627b 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1124,6 +1124,7 @@ const de = { }, chart: { loading: "Diagramm wird geladen…", + refreshing: "Wird aktualisiert…", nullCategory: "(Ohne Angabe)", scatterOneMeasure: "Ein Streudiagramm zeichnet genau eine Kennzahl. Behalten Sie nur eine Datenreihe:", unconfigured: { @@ -1145,6 +1146,7 @@ const de = { sourceLabel: "Datenquelle:", }, loading: "Wird geladen…", + refreshing: "Wird aktualisiert…", pickMeasures: "Wählen Sie Kennzahlen (Werte) für dieses Dataset-Widget.", datasetUnsupported: "Diese Datenquelle unterstützt keine Dataset-Abfragen.", details: "Details", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 52dc5cd868..3e2f30621d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1321,6 +1321,7 @@ const en = { }, chart: { loading: 'Loading chart…', + refreshing: 'Refreshing…', nullCategory: '(None)', // The refusal a scatter renders when handed more than one series // (objectui#7194): it binds ONE measure, so a second series was painted at @@ -1405,6 +1406,7 @@ const en = { sourceLabel: 'Source:', }, loading: 'Loading…', + refreshing: 'Refreshing…', pickMeasures: 'Pick measures (values) for this dataset widget.', datasetUnsupported: 'This data source does not support dataset queries.', details: 'Details', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 2bbf603805..96d8745d0f 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1128,6 +1128,7 @@ const es = { }, chart: { loading: "Cargando gráfico…", + refreshing: "Actualizando…", nullCategory: "(Sin especificar)", scatterOneMeasure: "Un gráfico de dispersión traza una sola medida. Conserve una sola serie:", unconfigured: { @@ -1149,6 +1150,7 @@ const es = { sourceLabel: "Origen de datos:", }, loading: "Cargando…", + refreshing: "Actualizando…", pickMeasures: "Elija medidas (valores) para este widget de dataset.", datasetUnsupported: "Esta fuente de datos no admite consultas de dataset.", details: "Detalles", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5064ae5edd..01809c7669 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1126,6 +1126,7 @@ const fr = { }, chart: { loading: "Chargement du graphique…", + refreshing: "Actualisation…", nullCategory: "(Non défini)", scatterOneMeasure: "Un nuage de points trace une seule mesure. Ne conservez qu’une série :", unconfigured: { @@ -1147,6 +1148,7 @@ const fr = { sourceLabel: "Source de données :", }, loading: "Chargement…", + refreshing: "Actualisation…", pickMeasures: "Choisissez des mesures (valeurs) pour ce widget de dataset.", datasetUnsupported: "Cette source de données ne prend pas en charge les requêtes de dataset.", details: "Détails", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index d4da46b0b4..2978088c25 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1124,6 +1124,7 @@ const ja = { }, chart: { loading: "チャート読み込み中…", + refreshing: "更新中…", nullCategory: "(未設定)", scatterOneMeasure: "散布図は1つの指標だけを描画します。系列は1つだけ残してください:", unconfigured: { @@ -1145,6 +1146,7 @@ const ja = { sourceLabel: "データソース:", }, loading: "読み込み中…", + refreshing: "更新中…", pickMeasures: "このデータセットウィジェットの指標(値)を選択してください。", datasetUnsupported: "このデータソースはデータセットクエリに対応していません。", details: "詳細", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 913b1f76ac..ccb332e4da 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1124,6 +1124,7 @@ const ko = { }, chart: { loading: "차트 로딩 중…", + refreshing: "새로고침 중…", nullCategory: "(미지정)", scatterOneMeasure: "산점도는 측정값 하나만 그립니다. 계열을 하나만 남기세요:", unconfigured: { @@ -1145,6 +1146,7 @@ const ko = { sourceLabel: "데이터 소스:", }, loading: "로딩 중…", + refreshing: "새로고침 중…", pickMeasures: "이 데이터셋 위젯의 측정값(값)을 선택하세요.", datasetUnsupported: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.", details: "세부 정보", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index bccbc7dfb8..dd4b3b321d 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1123,6 +1123,7 @@ const pt = { }, chart: { loading: "Carregando gráfico…", + refreshing: "Atualizando…", nullCategory: "(Não especificado)", scatterOneMeasure: "Um gráfico de dispersão traça uma única medida. Mantenha apenas uma série:", unconfigured: { @@ -1144,6 +1145,7 @@ const pt = { sourceLabel: "Fonte de dados:", }, loading: "Carregando…", + refreshing: "Atualizando…", pickMeasures: "Escolha medidas (valores) para este widget de dataset.", datasetUnsupported: "Esta fonte de dados não oferece suporte a consultas de dataset.", details: "Detalhes", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index e5527080c1..587ada2faf 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1144,6 +1144,7 @@ const ru = { }, chart: { loading: "Загрузка графика…", + refreshing: "Обновление…", nullCategory: "(Не указано)", scatterOneMeasure: "Точечная диаграмма строит только одну меру. Оставьте один ряд:", unconfigured: { @@ -1165,6 +1166,7 @@ const ru = { sourceLabel: "Источник данных:", }, loading: "Загрузка…", + refreshing: "Обновление…", pickMeasures: "Выберите меры (значения) для этого виджета набора данных.", datasetUnsupported: "Этот источник данных не поддерживает запросы к наборам данных.", details: "Подробности", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index c958a65568..4c846ef5a0 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1163,6 +1163,7 @@ const zh = { }, chart: { loading: '图表加载中…', + refreshing: '刷新中…', nullCategory: '(未指定)', scatterOneMeasure: '散点图只绘制一个度量。请只保留一个系列:', unconfigured: { @@ -1216,6 +1217,7 @@ const zh = { sourceLabel: '数据源:', }, loading: '加载中…', + refreshing: '刷新中…', pickMeasures: '请为该数据集组件选择度量(值)。', datasetUnsupported: '当前数据源不支持数据集查询。', details: '明细', diff --git a/packages/plugin-charts/src/ObjectChart.refreshIndicatorLocale-10580.test.tsx b/packages/plugin-charts/src/ObjectChart.refreshIndicatorLocale-10580.test.tsx new file mode 100644 index 0000000000..3928711b71 --- /dev/null +++ b/packages/plugin-charts/src/ObjectChart.refreshIndicatorLocale-10580.test.tsx @@ -0,0 +1,104 @@ +/** + * 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. + */ + +/** + * objectui#10580 — the bar `ObjectChart` draws over its rows while it re-reads + * them is named in the active locale. + * + * `RefreshIndicator` used to default its accessible name to the English + * literal "Refreshing", and this chart passed none, so a screen reader + * announced English in every locale. The chart now names the bar from the + * pack (`chart.refreshing`), and the component has no default left to fall to. + * + * The re-read is driven the way `ObjectChart.invalidationRefetch-10035.test.tsx` + * drives it: a write reported on the data-invalidation bus. The second + * aggregate is held open by hand, so the bar is read while the query really is + * in flight. `ChartRenderer` is stubbed because the chart's own drawing is not + * what this file is about. + */ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, cleanup, waitFor, act, screen } from '@testing-library/react'; +import { notifyDataChanged } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; + +vi.mock('./ChartRenderer', () => ({ + ChartRenderer: ({ schema }: any) => ( +
+ ), +})); + +import { ObjectChart } from './ObjectChart'; + +// Module constants: `I18nProvider` rebuilds its i18next instance whenever the +// `config` object changes identity, so an inline literal would re-boot it on +// every render. +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false }; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false }; + +const SCHEMA: any = { + type: 'object-chart', + chartType: 'bar', + objectName: 'crm_opportunity', + aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + xAxisKey: 'stage', + series: [{ dataKey: 'amount', label: 'Amount' }], +}; + +/** The first aggregate answers at once; every later one stays in flight. */ +function makeSource() { + let calls = 0; + const aggregate = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve([{ stage: 's1', amount: 1 }]); + return new Promise(() => {}); + }); + return { aggregate }; +} + +beforeEach(() => { + // The provider persists the last language; keep one case from leaking into the next. + window.localStorage.clear(); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({}) }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +const indicator = () => screen.queryByTestId('refresh-indicator'); + +async function readBarNameDuringReRead(config: typeof ZH) { + const ds = makeSource(); + render( + + + , + ); + await waitFor(() => expect(screen.getByTestId('chart-renderer').dataset.rows).toBe('1')); + expect(indicator(), 'no bar while nothing is in flight').toBeNull(); + + await act(async () => { + notifyDataChanged({ objectName: 'crm_opportunity' }); + }); + await waitFor(() => expect(ds.aggregate).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(indicator()).not.toBeNull()); +} + +describe('ObjectChart names its refresh bar in the active locale (objectui#10580)', () => { + it('zh: the bar is named by the zh pack, not by an English literal', async () => { + await readBarNameDuringReRead(ZH); + await waitFor(() => expect(screen.getByRole('progressbar', { name: '刷新中…' })).toBeTruthy()); + expect(indicator()!.getAttribute('aria-label')).not.toMatch(/Refreshing/); + }); + + it('en control: the bar is named by the en pack value', async () => { + await readBarNameDuringReRead(EN); + await waitFor(() => expect(screen.getByRole('progressbar', { name: 'Refreshing…' })).toBeTruthy()); + }); +}); diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index 6ad6d4e17f..d160d18707 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -550,7 +550,12 @@ export const ObjectChart = (props: ObjectChartProps) => { // `useObjectTranslation` is provider-safe (optional context read, falling back // to the react-i18next global instance), which is why it can sit beside // `useSafeTranslate` above without a provider in tests. - const { language } = useObjectTranslation(); + // + // Its `t` names the refresh bar below (`chart.refreshing`, objectui#10580). + // Deliberately not `tt`: that hook takes an English fallback at every call, + // and the bar's name carries no English literal anywhere — the pack is the + // only source of it. + const { t, language } = useObjectTranslation(); // Stable JSON keys for aggregate/filter so that callers passing a fresh // object literal on each render (e.g. DashboardRenderer.getComponentSchema) @@ -1513,7 +1518,7 @@ export const ObjectChart = (props: ObjectChartProps) => { // visible at all (#5451). Under auto-height parents `h-full` itself // resolves to `auto`, so non-dashboard hosts are unchanged.
- 0} /> + 0} ariaLabel={t('chart.refreshing')} /> {drillDrawer}
diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index a8e8070b28..f3faa92669 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -1050,7 +1050,9 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo return (
- 0} /> + {/* The bar's name comes from the pack alone (objectui#10580): no English + default here, unlike `noDataSourceLabel` above. */} + 0} ariaLabel={t('dashboard.refreshing')} /> {recordDrillEnabled && ( { + const actual: any = await vi.importActual('@object-ui/react'); + return { + ...actual, + SchemaRenderer: ({ schema }: any) => ( +
+ {(schema.data ?? []).map((row: any, i: number) => ( + {String(row.name)} + ))} +
+ ), + }; +}); + +import { ObjectDataTable } from '../ObjectDataTable'; + +beforeEach(() => { + // The provider persists the last language; keep one case from leaking into the next. + window.localStorage.clear(); +}); +afterEach(() => { + cleanup(); +}); + +// Module constants: `I18nProvider` rebuilds its i18next instance whenever the +// `config` object changes identity, so an inline literal would re-boot it on +// every render. +const JA = { defaultLanguage: 'ja', detectBrowserLanguage: false }; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false }; + +/** The first `find` answers at once; every later one stays in flight. */ +function makeDataSource() { + let calls = 0; + const find = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve({ data: [{ id: '1', name: 'Northwind' }], total: 1 }); + return new Promise(() => {}); + }); + return { find } as any; +} + +const schemaWith = (filter: unknown[]): any => ({ + type: 'object-data-table', + objectName: 'accounts', + columns: [{ accessorKey: 'name', header: 'Name' }], + filter, +}); +const FIRST = schemaWith(['stage', '=', 'open']); +const CHANGED = schemaWith(['stage', '=', 'closed']); + +const indicator = () => screen.queryByTestId('refresh-indicator'); + +async function readBarNameDuringReRead(config: typeof JA) { + const ds = makeDataSource(); + const tree = (schema: any) => ( + + + + ); + const { rerender } = render(tree(FIRST)); + await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument()); + expect(indicator(), 'no bar while nothing is in flight').toBeNull(); + + rerender(tree(CHANGED)); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(indicator()).not.toBeNull()); + // The rows on screen stay while the re-read runs; the bar sits over them. + expect(screen.getByText('Northwind')).toBeInTheDocument(); +} + +describe('ObjectDataTable names its refresh bar in the active locale (objectui#10580)', () => { + it('ja: the bar is named by the ja pack, not by an English literal', async () => { + await readBarNameDuringReRead(JA); + await waitFor(() => expect(screen.getByRole('progressbar', { name: '更新中…' })).toBeInTheDocument()); + expect(indicator()!.getAttribute('aria-label')).not.toMatch(/Refreshing/); + }); + + it('en control: the bar is named by the en pack value', async () => { + await readBarNameDuringReRead(EN); + await waitFor(() => expect(screen.getByRole('progressbar', { name: 'Refreshing…' })).toBeInTheDocument()); + }); +}); diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index c400e286c5..4441e7a0bd 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -5681,7 +5681,7 @@ export const ObjectGrid: React.FC = ({
{/* Re-fetch indicator while existing rows remain visible (filter/sort change). The initial-load skeleton above handles the empty case. */} - 0} /> + 0} ariaLabel={t('grid.refreshing')} /> {pullDistance > 0 && (
{ + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => { + // The provider persists the last language; keep one case from leaking into the next. + window.localStorage.clear(); +}); + +// Module constants: `I18nProvider` rebuilds its i18next instance whenever the +// `config` object changes identity, so an inline literal would re-boot it on +// every render. +const DE = { defaultLanguage: 'de', detectBrowserLanguage: false }; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false }; + +const OBJECT = 'duly_task'; + +/** The first `find` answers at once; every later one stays in flight. */ +function makeDataSource() { + let calls = 0; + const find = vi.fn(() => { + calls += 1; + if (calls === 1) { + return Promise.resolve({ data: [{ id: 'r1', name: 'First row' }], total: 1, hasMore: false, pageSize: 50 }); + } + return new Promise(() => {}); + }); + return { + find, + getObjectSchema: async (name: string) => ({ + name, + fields: { id: { type: 'text' }, name: { type: 'text' } }, + }), + } as any; +} + +const SCHEMA: any = { + type: 'object-grid', + objectName: OBJECT, + columns: [{ field: 'name', label: 'Name' }], + pagination: { pageSize: 50 }, +}; + +const indicator = () => screen.queryByTestId('refresh-indicator'); + +async function readBarNameDuringReRead(config: typeof DE) { + const ds = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(screen.getByText('First row')).toBeInTheDocument()); + expect(indicator(), 'no bar while nothing is in flight').toBeNull(); + const before = ds.find.mock.calls.length; + + await act(async () => { + notifyDataChanged({ objectName: OBJECT }); + }); + await waitFor(() => expect(ds.find.mock.calls.length).toBeGreaterThan(before)); + await waitFor(() => expect(indicator()).not.toBeNull()); + // The rows on screen stay while the re-read runs; the bar sits over them. + expect(screen.getByText('First row')).toBeInTheDocument(); +} + +describe('ObjectGrid names its refresh bar in the active locale (objectui#10580)', () => { + it('de: the bar is named by the de pack, not by an English literal', async () => { + await readBarNameDuringReRead(DE); + await waitFor(() => expect(screen.getByRole('progressbar', { name: 'Wird aktualisiert…' })).toBeInTheDocument()); + expect(indicator()!.getAttribute('aria-label')).not.toMatch(/Refreshing/); + }); + + it('en control: the bar is named by the en pack value', async () => { + await readBarNameDuringReRead(EN); + await waitFor(() => expect(screen.getByRole('progressbar', { name: 'Refreshing…' })).toBeInTheDocument()); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index d44a1bbb86..b1dea3d9b1 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -4848,7 +4848,7 @@ export const ListView = React.forwardRef(({ {/* Re-fetch indicator: thin top progress bar shown when refreshing existing data (filter/sort/search change). Skipped during the initial load — the full skeleton below handles that case. */} - 0} /> + 0} ariaLabel={t('list.refreshing')} /> {/* Empty state is rendered here ONLY for tabular/list-like views. Structural views (kanban/calendar/gallery/gantt/timeline/map) own their own empty rendering so their column/lane/grid structure diff --git a/packages/plugin-list/src/__tests__/ListView.refreshIndicatorLocale-10580.test.tsx b/packages/plugin-list/src/__tests__/ListView.refreshIndicatorLocale-10580.test.tsx new file mode 100644 index 0000000000..c06660caa5 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.refreshIndicatorLocale-10580.test.tsx @@ -0,0 +1,94 @@ +/** + * 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. + */ + +/** + * objectui#10580 — the bar `ListView` draws over its rows while it re-reads + * them is named in the active locale. + * + * `RefreshIndicator` used to default its accessible name to the English + * literal "Refreshing", and this view passed none, so a screen reader + * announced English in every locale. The view now names the bar with + * `list.refreshing`, the key its pull-to-refresh text already reads, and the + * component has no default left to fall to. + * + * The re-read is the toolbar's own refresh button (`ListView.refreshButton.test.tsx`). + * The second `find` is held open by hand, so the bar is read while the query + * really is in flight. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { I18nProvider } from '@object-ui/i18n'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +beforeEach(() => { + // The provider persists the last language; keep one case from leaking into the next. + window.localStorage.clear(); +}); + +// Module constants: `I18nProvider` rebuilds its i18next instance whenever the +// `config` object changes identity, so an inline literal would re-boot it on +// every render. +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false }; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false }; + +/** The first `find` answers at once; every later one stays in flight. */ +function makeDataSource() { + let calls = 0; + const find = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve({ data: [{ _id: 'p1', name: 'Alpha' }], total: 1 }); + return new Promise(() => {}); + }); + return { + find, + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as any; +} + +const indicator = () => screen.queryByTestId('refresh-indicator'); +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); + +async function readBarNameDuringReRead(config: typeof ZH) { + const ds = makeDataSource(); + render( + + + + + , + ); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + await settle(); + expect(indicator(), 'no bar while nothing is in flight').toBeNull(); + + fireEvent.click(screen.getByTestId('refresh-button')); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(indicator()).not.toBeNull()); +} + +describe('ListView names its refresh bar in the active locale (objectui#10580)', () => { + it('zh: the bar is named by the zh pack, not by an English literal', async () => { + await readBarNameDuringReRead(ZH); + await waitFor(() => expect(screen.getByRole('progressbar', { name: '刷新中…' })).toBeInTheDocument()); + expect(indicator()!.getAttribute('aria-label')).not.toMatch(/Refreshing/); + }); + + it('en control: the bar is named by the en pack value', async () => { + await readBarNameDuringReRead(EN); + await waitFor(() => expect(screen.getByRole('progressbar', { name: 'Refreshing…' })).toBeInTheDocument()); + }); +}); From b240863b710f886d048e3de9d1266efec9a417f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:43:14 +0000 Subject: [PATCH 2/2] chore(changeset): declare the RefreshIndicator required-name break (objectui#10580) Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .../10580-refresh-indicator-translated-name.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/10580-refresh-indicator-translated-name.md diff --git a/.changeset/10580-refresh-indicator-translated-name.md b/.changeset/10580-refresh-indicator-translated-name.md new file mode 100644 index 0000000000..b3f5a06ef6 --- /dev/null +++ b/.changeset/10580-refresh-indicator-translated-name.md @@ -0,0 +1,18 @@ +--- +'@object-ui/components': minor +'@object-ui/plugin-grid': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-charts': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/i18n': patch +--- + +BREAKING (`@object-ui/components`): `RefreshIndicator`'s `ariaLabel` prop is now required and has no default. It used to default to the English literal "Refreshing", so the progress bar on every view that passed no name was announced in English to screen-reader users in every locale (objectui#10580). A `RefreshIndicator` rendered without `ariaLabel` now fails to type-check. + +(The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) + +Migration: pass the bar's accessible name from your own translation layer, for example `ariaLabel={t('grid.refreshing')}`. The component renders the string you pass as the bar's `aria-label` and does not translate it. + +`ObjectGrid`, `ListView`, `ObjectChart` and `ObjectDataTable` now name their refresh bar in the active locale. The grid and the list read the `grid.refreshing` and `list.refreshing` keys their pull-to-refresh text already uses. The chart reads `chart.refreshing` and the dashboard data table reads `dashboard.refreshing`, both with no English literal behind them, so a host that renders either one with no i18next instance at all gets the key itself as the bar's name. + +`@object-ui/i18n`: new keys `chart.refreshing` and `dashboard.refreshing` in every built-in locale pack.