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
10 changes: 10 additions & 0 deletions .changeset/textarea-character-count-aria-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

`TextAreaField`'s character counter now announces itself in the session locale. The counter block — rendered only when the field declares `maxLength` — carried the accessible name `Character count: {n} of {max}` as an English literal, and the element is an `aria-live="polite"` region, so a zh/ja/ar session had that English sentence read out on every keystroke while sighted users saw only the language-independent `{n}/{max}` digits. Nothing was wrong on screen, which is why it survived: only screen reader users could perceive it (#3406).

Unlike #3404, no key existed to consume — none of the ten locale packs had any character-count string. `fields.textarea.characterCount` is new in all ten, interpolating `{{count}}` and `{{max}}` as one sentence rather than parts assembled in code, because `ja` and `ko` put the cap before the count ("of {{max}} characters, {{count}}"), an order no concatenation can produce. The English pack value and the `FIELD_DEFAULTS` fallback are byte-identical to the literal they replace, so an `en` session and a provider-less embed both render exactly what they did before.

Behaviour is unchanged: `aria-live="polite"` and the per-keystroke recompute are deliberately untouched here and tracked separately (#3408).
29 changes: 28 additions & 1 deletion packages/fields/src/widgets/TextAreaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Textarea, EmptyValue } from '@object-ui/components';
import { FullscreenFieldEditor } from './FullscreenFieldEditor';
import { FieldWidgetComponentProps } from './types';
import { toDomProps } from './toDomProps';
import { useFieldTranslation } from './useFieldTranslation';

/**
* TextAreaField - Multi-line text input widget
Expand Down Expand Up @@ -37,6 +38,12 @@ import { toDomProps } from './toDomProps';
* `FieldWidgetComponentProps`, stop stripping it, and have a host pass it.
*/
export function TextAreaField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Above the `readonly` early return on purpose: a hook may not sit behind a
// conditional return. The readonly branch renders no counter, so this is a
// no-op there — but moving it down would desync hook order the moment a
// field toggles readonly.
const { t } = useFieldTranslation();

if (readonly) {
return (
<div className="text-sm whitespace-pre-wrap">
Expand Down Expand Up @@ -81,7 +88,27 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop
<div
className="absolute bottom-2 right-2 text-xs text-gray-400"
aria-live="polite"
aria-label={`Character count: ${(value || '').length} of ${maxLength}`}
// objectui#3406 — this was the English literal
// `Character count: ${n} of ${max}`. The VISIBLE text next to it is
// digits and needs no locale, but the accessible name is a sentence,
// and this element is `aria-live`, so a non-English session heard an
// English sentence read out on every keystroke.
//
// One interpolated key rather than a per-part assembly: ja and ko
// interpolate the CAP BEFORE the count ("of {{max}} characters,
// {{count}}"), an order no code-side concatenation can express.
// The English default in `FIELD_DEFAULTS` is
// byte-identical to the literal it replaces, so a widget rendered
// with no I18nProvider is unchanged.
//
// Deliberately NOT changed here: `aria-live="polite"` plus a name
// recomputed per keystroke. That is a behaviour question (how often
// a screen reader should speak), filed separately — this change is
// key-ing only, byte-for-byte in English.
aria-label={t('fields.textarea.characterCount', {
count: (value || '').length,
max: maxLength,
})}
>
{(value || '').length}/{maxLength}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `TextAreaField`'s character counter announces itself in the session locale —
* objectui#3406.
*
* The counter block is rendered only when the field declares `maxLength`, and
* what a sighted user sees (`{n}/{max}`) is digits, so nothing about this was
* visible on screen. The block carries `aria-live="polite"`, and its accessible
* name was the English literal `Character count: ${n} of ${max}` — so a zh/ja/ar
* session had an English sentence read out on every keystroke, and only screen
* reader users could perceive it.
*
* Unlike objectui#3404 (keys existed, this path did not consume them), no
* `characterCount` key existed in any of the ten packs. `fields.textarea.characterCount`
* is new here, in all ten, with `{{count}}` / `{{max}}`.
*
* ## What the cases assert, and why in this shape
*
* - **`en` is a NO-OP pin, not a defect reproducer.** The default in
* `FIELD_DEFAULTS` is byte-identical to the literal it replaced, so the
* English case was green before this change too. It is here to prove the
* swap did not alter English rendering — only a positive assertion sees that
* direction. Its exact expected string is repeated verbatim in
* `TextAreaField.characterCount.no-provider.test.tsx`; together the two pin
* pack-default equality across a boundary the packages cannot import across.
* - **Non-`en` positive AND English-literal negative, together.** A re-inlined
* literal sitting next to a translated sibling still satisfies a
* positive-only assertion.
* - **`ja` carries the load-bearing ORDER check.** Its translation puts the cap
* BEFORE the count ("of {{max}} characters, {{count}}"). An implementation
* that assembled the sentence from parts in code — "label + count + separator +
* max" — cannot produce that order, so it passes a zh-only check by luck and
* fails here. This is the case that justifies one interpolated key over a
* per-part assembly.
* - **`ru` and `ar` are the plural-resolution check.** `count` is not an
* ordinary interpolation variable to i18next: passing it activates plural
* lookup (`key_one` / `key_few` / `key_many` / …) BEFORE the base key. Both
* packs declare the base key only, and both languages have multi-form plural
* rules, so these two prove the base-key fallback actually resolves rather
* than leaving the user with a raw key. `{{count}}` is the spelling this
* repo already uses for counts (`table.selected`, `lookup.recordCount`), and
* this pins that it keeps working for a pack with no plural forms declared.
* - **Recompute-on-input.** The name is derived per render. Pinned so a future
* memo cannot freeze it at the mount-time length while the visible digits
* move on.
*/
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';

import { TextAreaField } from '../TextAreaField';
import type { FieldMetadata } from '@object-ui/types';

const textareaField = (extra: Record<string, unknown> = {}) =>
({ name: 'notes', type: 'textarea', ...extra }) as unknown as FieldMetadata;

function renderIn(language: string, element: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>,
);
}

/**
* The counter is queried by its live-region role rather than by its own text:
* the wrapper `div` has the same `textContent` as the counter (the textarea
* contributes none), so a text query matches two nodes. Reading the element
* this way also keeps `aria-live` itself under assertion — the attribute is
* the reason this string is spoken at all.
*/
const counter = () => document.querySelector('[aria-live="polite"]');
const counterLabel = () => counter()?.getAttribute('aria-label');

describe('TextAreaField character counter is translated (objectui#3406)', () => {
it('renders the English sentence under an en provider', () => {
renderIn(
'en',
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
);

// Byte-identical to the literal this replaced, so `en` is a no-op change.
expect(counterLabel()).toBe('Character count: 5 of 200');
expect(counter()).toHaveTextContent('5/200');
});

it('renders the sentence in Chinese under a zh provider', () => {
renderIn(
'zh',
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
);

expect(counterLabel()).toBe('已输入 5 个字符,最多 200 个');
// The literal, asserted absent — a re-inlined one would still satisfy a
// positive-only check on a sibling case.
expect(counterLabel()).not.toMatch(/character count/i);
// The digits stay language-independent; only the accessible name moved.
expect(counter()).toHaveTextContent('5/200');
});

it('renders the sentence in Japanese, with the cap interpolated BEFORE the count', () => {
renderIn(
'ja',
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
);

// `{{max}}` precedes `{{count}}` in this pack. Code-side concatenation
// cannot produce this order — that is the point of the assertion.
expect(counterLabel()).toBe('文字数: 200 文字中 5 文字');
expect(counterLabel()!.indexOf('200')).toBeLessThan(counterLabel()!.indexOf('5'));
expect(counterLabel()).not.toMatch(/character count/i);
});

it('resolves the base key under ru, whose plural rules i18next consults first', () => {
renderIn(
'ru',
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
);

expect(counterLabel()).toBe('Количество символов: 5 из 200');
// The failure this guards: plural lookup missing and no base-key fallback
// would surface the raw key instead of a sentence.
expect(counterLabel()).not.toContain('fields.textarea');
});

it('resolves the base key under ar as well', () => {
renderIn(
'ar',
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
);

expect(counterLabel()).toBe('عدد الأحرف: 5 من 200');
expect(counterLabel()).not.toContain('fields.textarea');
});

it('recomputes the spoken count as the user types', () => {
// Derived per render, not memoized at mount. A frozen name would keep
// announcing "5" while the visible digits read 11.
const Host = () => {
const [v, setV] = React.useState('hello');
return <TextAreaField value={v} onChange={setV} field={textareaField({ maxLength: 200 })} />;
};
renderIn('zh', <Host />);
expect(counterLabel()).toBe('已输入 5 个字符,最多 200 个');

fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello world' } });

expect(counterLabel()).toBe('已输入 11 个字符,最多 200 个');
expect(counter()).toHaveTextContent('11/200');
});

it('keeps reading the legacy snake_case max_length spelling', () => {
// The dual-read predates this change (framework#1878 §3). Keying the
// sentence must not narrow which spelling reaches it.
renderIn(
'zh',
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ max_length: 80 })} />,
);

expect(counterLabel()).toBe('已输入 5 个字符,最多 80 个');
});

it('renders no counter at all when the field declares no maxLength', () => {
// The block is `{maxLength && …}`. Translating it must not make it
// unconditional — an always-present live region would announce on every
// keystroke in every long-text field in the app.
renderIn('zh', <TextAreaField value="hello" onChange={vi.fn()} field={textareaField()} />);

expect(counter()).toBeNull();
});

it('renders no counter in the readonly branch', () => {
// The translation hook had to move ABOVE the readonly early return
// (rules-of-hooks). This pins that the hoist changed nothing a user sees
// on that branch.
renderIn(
'zh',
<TextAreaField
value="hello"
onChange={vi.fn()}
readonly
field={textareaField({ maxLength: 200 })}
/>,
);

expect(counter()).toBeNull();
expect(screen.getByText('hello')).toBeInTheDocument();
});
});

// The provider-LESS leg lives in `TextAreaField.characterCount.no-provider.test.tsx`.
// It cannot share this file: mounting `I18nProvider` installs its instance as
// react-i18next's GLOBAL default, so after any case above there is no "no
// provider" state left in this module graph to observe (measured in
// objectui#3404 — see that file's header).
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* 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.
*/

/**
* `TextAreaField`'s character counter with NO `I18nProvider` mounted
* (objectui#3406) — standalone/embedded hosts, and most of this package's own
* bare-render tests.
*
* ## Why this leg constrains the implementation
*
* Routing an accessible name through i18n must not turn it into a raw key
* where nothing is configured — for a screen-reader-only string that failure
* is invisible to everyone who could report it. Bare `useObjectTranslation()`
* with no provider returns the key verbatim AND drops interpolation, which is
* strictly worse than the English literal being replaced (measured on the
* pre-change tree in objectui#3404, same hook, same package). So the widget
* consumes `useFieldTranslation()` — `createSafeTranslation` plus English
* defaults, which does its own `{{k}}` substitution.
*
* The expected sentence below is repeated verbatim in the `en`-provider case
* of `TextAreaField.characterCount.i18n.test.tsx`. `packages/i18n` cannot
* import `packages/fields` (the dependency runs the other way), so this pair of
* literals is what pins pack and code default to the same bytes — and both to
* the literal that existed before this change.
*
* ## Why a separate FILE and not another `describe`
*
* Mounting `I18nProvider` calls `initReactI18next`, which installs that
* instance as react-i18next's GLOBAL default. After any sibling case has done
* so there is no "no provider" state left in the module graph, and these
* assertions would read that leaked instance instead of the fallback — green
* for the wrong reason. Measured in objectui#3404: cases written inside the
* provider file stayed GREEN under a reverse check that correctly reddened a
* separate file. vitest's per-file isolation is what makes the observation
* honest. Same split, same reason as
* `FullscreenFieldEditor.no-provider.test.tsx` and
* `TagsField.placeholder.no-provider.test.tsx`.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';

import { TextAreaField } from '../TextAreaField';
import type { FieldMetadata } from '@object-ui/types';

const textareaField = (extra: Record<string, unknown> = {}) =>
({ name: 'notes', type: 'textarea', ...extra }) as unknown as FieldMetadata;

const counter = () => document.querySelector('[aria-live="polite"]');
const counterLabel = () => counter()?.getAttribute('aria-label');

describe('character counter — English fallback survives with no i18n configured (objectui#3406)', () => {
it('renders the English default, not a raw key', () => {
render(
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
);

// Byte-identical to the literal this change replaced.
expect(counterLabel()).toBe('Character count: 5 of 200');
// The measured failure mode, stated directly — a key reaching a user.
expect(counterLabel()).not.toMatch(/fields\.textarea/);
});

it('substitutes BOTH placeholders in the fallback path', () => {
// `createSafeTranslation`'s fallback runs its own `{{k}}` replacement.
// Bare `useObjectTranslation` would leave the sentence unsubstituted
// (in fact would not return a sentence at all).
render(
<TextAreaField value="hi" onChange={vi.fn()} field={textareaField({ maxLength: 40 })} />,
);

expect(counterLabel()).toBe('Character count: 2 of 40');
expect(counterLabel()).not.toContain('{{');
});

it('announces zero for an empty value rather than a hole in the sentence', () => {
// `0` is falsy, so an interpolation that guarded on truthiness would drop
// it and speak "Character count: of 40" — the one input length where the
// substitution can fail on its own.
//
// Deliberately `''` and not `undefined`: `FieldWidgetComponentProps`
// declares `value: T`, non-optional. The widget's `value || ''` guard
// predates this change and covers a JS host that ignores the type; pinning
// an out-of-contract shape here would be the lenient-consumer move
// AGENTS.md #0.1 rules out, and is not what this issue is about.
render(<TextAreaField value="" onChange={vi.fn()} field={textareaField({ maxLength: 40 })} />);

expect(counterLabel()).toBe('Character count: 0 of 40');
});

it('keeps the live region — the attribute is why this string is spoken', () => {
// This change is key-ing only. `aria-live="polite"` and the per-keystroke
// recompute are a BEHAVIOUR question, filed separately and deliberately
// untouched here; pin the attribute so "fixing" it silently is a red.
render(
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
);

expect(counter()).toHaveAttribute('aria-live', 'polite');
});

it('the code-shipped default carries no CJK (Commandment #-1)', () => {
// With no provider mounted, whatever renders here came from CODE
// (FIELD_DEFAULTS). Escaped ranges on purpose: a literal CJK class would
// itself violate the commandment this asserts (same shape as
// `FullscreenFieldEditor.no-provider.test.tsx`).
render(
<TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
);

expect(counterLabel()).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
});
});
7 changes: 7 additions & 0 deletions packages/fields/src/widgets/useFieldTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ const FIELD_DEFAULTS: Record<string, string> = {
// objectui#3342 — the tags widget's input hint. Used only when the field
// author declared no `placeholder` of their own (author declaration wins).
'fields.tags.placeholder': 'Type and press Enter to add…',
// objectui#3406 — the accessible name of `TextAreaField`'s character
// counter, rendered only when the field declares `maxLength`. The visible
// `{n}/{max}` is digits; this sentence is what a screen reader speaks, and
// the element is `aria-live`, so before the key existed a non-English
// session heard English on every keystroke. Byte-identical to the literal
// it replaces, so a no-provider render is unchanged.
'fields.textarea.characterCount': 'Character count: {{count}} of {{max}}',
// objectui#3404 — the shared fullscreen long-text dialog
// (`FullscreenFieldEditor`, reached from `TextAreaField` and
// `RichTextField` when `mobile_fullscreen` is set).
Expand Down
Loading
Loading