Skip to content
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
18 changes: 18 additions & 0 deletions .changeset/10580-refresh-indicator-translated-name.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 = <RefreshIndicator active ariaLabel="Actualizando…" />;
const refused = (
// @ts-expect-error `ariaLabel` is required — with an optional prop this directive goes unused (TS2578)
<RefreshIndicator active />
);
expect(React.isValidElement(accepted) && React.isValidElement(refused)).toBe(true);
});

it('names the progress bar with the passed string', () => {
render(<RefreshIndicator active ariaLabel="Wird aktualisiert…" />);
const bar = screen.getByRole('progressbar', { name: 'Wird aktualisiert…' });
expect(bar.getAttribute('aria-busy')).toBe('true');
});

it('renders nothing while inactive', () => {
render(<RefreshIndicator active={false} ariaLabel="Wird aktualisiert…" />);
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(<RefreshIndicator {...props} />);
const bar = screen.getByTestId('refresh-indicator');
expect(bar.hasAttribute('aria-label')).toBe(false);
});
});
12 changes: 9 additions & 3 deletions packages/components/src/custom/refresh-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,14 @@ function ensureKeyframe() {
export interface RefreshIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
/** 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
}

/**
Expand All @@ -50,7 +56,7 @@ export interface RefreshIndicatorProps extends React.HTMLAttributes<HTMLDivEleme
*/
export const RefreshIndicator: React.FC<RefreshIndicatorProps> = ({
active,
ariaLabel = "Refreshing",
ariaLabel,
className,
...props
}) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,7 @@ const ar = {
},
chart: {
loading: "جارٍ تحميل الرسم البياني…",
refreshing: "جارٍ التحديث…",
nullCategory: "(غير محدد)",
scatterOneMeasure: "المخطط المبعثر يرسم مقياسًا واحدًا فقط. أبقِ سلسلة واحدة:",
unconfigured: {
Expand All @@ -1170,6 +1171,7 @@ const ar = {
sourceLabel: "مصدر البيانات:",
},
loading: "جارٍ التحميل…",
refreshing: "جارٍ التحديث…",
pickMeasures: "اختر المقاييس (القيم) لأداة مجموعة البيانات هذه.",
datasetUnsupported: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.",
details: "التفاصيل",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,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: {
Expand All @@ -1150,6 +1151,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",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,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
Expand Down Expand Up @@ -1421,6 +1422,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',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,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: {
Expand All @@ -1154,6 +1155,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",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,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: {
Expand All @@ -1152,6 +1153,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",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,7 @@ const ja = {
},
chart: {
loading: "チャート読み込み中…",
refreshing: "更新中…",
nullCategory: "(未設定)",
scatterOneMeasure: "散布図は1つの指標だけを描画します。系列は1つだけ残してください:",
unconfigured: {
Expand All @@ -1150,6 +1151,7 @@ const ja = {
sourceLabel: "データソース:",
},
loading: "読み込み中…",
refreshing: "更新中…",
pickMeasures: "このデータセットウィジェットの指標(値)を選択してください。",
datasetUnsupported: "このデータソースはデータセットクエリに対応していません。",
details: "詳細",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,7 @@ const ko = {
},
chart: {
loading: "차트 로딩 중…",
refreshing: "새로고침 중…",
nullCategory: "(미지정)",
scatterOneMeasure: "산점도는 측정값 하나만 그립니다. 계열을 하나만 남기세요:",
unconfigured: {
Expand All @@ -1150,6 +1151,7 @@ const ko = {
sourceLabel: "데이터 소스:",
},
loading: "로딩 중…",
refreshing: "새로고침 중…",
pickMeasures: "이 데이터셋 위젯의 측정값(값)을 선택하세요.",
datasetUnsupported: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.",
details: "세부 정보",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,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: {
Expand All @@ -1149,6 +1150,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",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,7 @@ const ru = {
},
chart: {
loading: "Загрузка графика…",
refreshing: "Обновление…",
nullCategory: "(Не указано)",
scatterOneMeasure: "Точечная диаграмма строит только одну меру. Оставьте один ряд:",
unconfigured: {
Expand All @@ -1170,6 +1171,7 @@ const ru = {
sourceLabel: "Источник данных:",
},
loading: "Загрузка…",
refreshing: "Обновление…",
pickMeasures: "Выберите меры (значения) для этого виджета набора данных.",
datasetUnsupported: "Этот источник данных не поддерживает запросы к наборам данных.",
details: "Подробности",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,7 @@ const zh = {
},
chart: {
loading: '图表加载中…',
refreshing: '刷新中…',
nullCategory: '(未指定)',
scatterOneMeasure: '散点图只绘制一个度量。请只保留一个系列:',
unconfigured: {
Expand Down Expand Up @@ -1221,6 +1222,7 @@ const zh = {
sourceLabel: '数据源:',
},
loading: '加载中…',
refreshing: '刷新中…',
pickMeasures: '请为该数据集组件选择度量(值)。',
datasetUnsupported: '当前数据源不支持数据集查询。',
details: '明细',
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => (
<div data-testid="chart-renderer" data-rows={Array.isArray(schema?.data) ? schema.data.length : -1} />
),
}));

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<never>(() => {});
});
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(
<I18nProvider config={config}>
<ObjectChart schema={SCHEMA} dataSource={ds} />
</I18nProvider>,
);
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());
});
});
9 changes: 7 additions & 2 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,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)
Expand Down Expand Up @@ -1574,7 +1579,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.
<div className="relative h-full">
<RefreshIndicator active={loading && finalData.length > 0} />
<RefreshIndicator active={loading && finalData.length > 0} ariaLabel={t('chart.refreshing')} />
<ChartRenderer {...props} schema={finalSchemaWithColors} onChartClick={onChartClick} />
{drillDrawer}
</div>
Expand Down
Loading
Loading