Skip to content

Commit f234858

Browse files
feat: improve pluralization logic for data table filters
- Added `pluralDisplayName` to `ColumnConfig` to allow manual pluralization. - Implemented `pluralize` helper in `lib/i18n` using `Intl.PluralRules`. - Enhanced automatic pluralization for English (y-endings, sibilants) and disabled pluralization for languages without plurals (e.g. CJK). - Updated `FilterValueOptionDisplay` and `FilterValueMultiOptionDisplay` to use the new logic. Co-authored-by: jaruesink <4207065+jaruesink@users.noreply.github.com>
1 parent 33ac356 commit f234858

4 files changed

Lines changed: 88 additions & 9 deletions

File tree

packages/components/src/ui/data-table-filter/components/filter-value.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import type {
3131
import { useDebounceCallback } from '../hooks/use-debounce-callback';
3232
import { take } from '../lib/array';
3333
import { createNumberRange } from '../lib/helpers';
34-
import { type Locale, t } from '../lib/i18n';
34+
import { type Locale, pluralize, t } from '../lib/i18n';
3535

3636
interface FilterValueProps<TData, TType extends ColumnDataType> {
3737
filter: FilterModel<TType>;
@@ -137,7 +137,7 @@ export function FilterValueOptionDisplay<TData>({
137137
filter,
138138
column,
139139
actions,
140-
locale: _locale = 'en',
140+
locale = 'en',
141141
}: FilterValueDisplayProps<TData, 'option'>) {
142142
const options = useMemo(() => column.getOptions(), [column]);
143143
const selected = options.filter((o) => filter?.values.includes(o.value));
@@ -160,8 +160,7 @@ export function FilterValueOptionDisplay<TData>({
160160
);
161161
}
162162
const name = column.displayName.toLowerCase();
163-
// TODO: Better pluralization for different languages
164-
const pluralName = name.endsWith('s') ? `${name}es` : `${name}s`;
163+
const pluralName = column.pluralDisplayName ?? pluralize(name, locale);
165164

166165
const hasOptionIcons = !options?.some((o) => !o.icon);
167166

@@ -183,7 +182,7 @@ export function FilterValueMultiOptionDisplay<TData>({
183182
filter,
184183
column,
185184
actions,
186-
locale: _locale = 'en',
185+
locale = 'en',
187186
}: FilterValueDisplayProps<TData, 'multiOption'>) {
188187
const options = useMemo(() => column.getOptions(), [column]);
189188
const selected = options.filter((o) => filter.values.includes(o.value));
@@ -201,6 +200,7 @@ export function FilterValueMultiOptionDisplay<TData>({
201200
}
202201

203202
const name = column.displayName.toLowerCase();
203+
const pluralName = column.pluralDisplayName ?? pluralize(name, locale);
204204

205205
const hasOptionIcons = !options?.some((o) => !o.icon);
206206

@@ -215,7 +215,7 @@ export function FilterValueMultiOptionDisplay<TData>({
215215
</div>
216216
)}
217217
<span>
218-
{selected.length} {name}
218+
{selected.length} {pluralName}
219219
</span>
220220
</div>
221221
);

packages/components/src/ui/data-table-filter/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export type ColumnConfig<TData, TType extends ColumnDataType = any, TVal = unkno
9595
id: TId;
9696
accessor: TAccessorFn<TData, TVal>;
9797
displayName: string;
98+
pluralDisplayName?: string;
9899
icon: ReactElementType;
99100
type: TType;
100101
options?: TType extends OptionBasedColumnDataType ? ColumnOption[] : never;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import assert from 'node:assert';
2+
import { describe, it } from 'node:test';
3+
import { pluralize } from './i18n';
4+
5+
describe('pluralize', () => {
6+
it('pluralizes English words correctly', () => {
7+
assert.strictEqual(pluralize('box', 'en'), 'boxes');
8+
assert.strictEqual(pluralize('category', 'en'), 'categories');
9+
assert.strictEqual(pluralize('dog', 'en'), 'dogs');
10+
assert.strictEqual(pluralize('watch', 'en'), 'watches');
11+
assert.strictEqual(pluralize('bus', 'en'), 'buses');
12+
assert.strictEqual(pluralize('wish', 'en'), 'wishes');
13+
// Simple words
14+
assert.strictEqual(pluralize('column', 'en'), 'columns');
15+
});
16+
17+
it('does not pluralize for languages with no plural form', () => {
18+
assert.strictEqual(pluralize('数据', 'zh'), '数据'); // Chinese
19+
assert.strictEqual(pluralize('データ', 'ja'), 'データ'); // Japanese
20+
});
21+
22+
it('falls back to appending s for other languages', () => {
23+
// Spanish has plurals. "dato" -> "datos".
24+
assert.strictEqual(pluralize('dato', 'es'), 'datos');
25+
26+
// "papel" -> "papeles" in proper Spanish, but our fallback is naive
27+
// So we expect "papels" because we only improved 'en'.
28+
// If we passed 'en' it would handle 'papel' -> 'papels' anyway unless it hit sibilant?
29+
// 'papel' ends in 'l', so 's'.
30+
assert.strictEqual(pluralize('papel', 'es'), 'papels');
31+
});
32+
33+
it('handles defaults when locale is missing', () => {
34+
// @ts-ignore
35+
assert.strictEqual(pluralize('cat', undefined), 'cats');
36+
});
37+
38+
it('handles y endings correctly for English', () => {
39+
assert.strictEqual(pluralize('boy', 'en'), 'boys'); // Vowel + y -> s
40+
assert.strictEqual(pluralize('day', 'en'), 'days');
41+
assert.strictEqual(pluralize('city', 'en'), 'cities'); // Consonant + y -> ies
42+
});
43+
});
Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,48 @@
11
import en from '../locales/en.json';
22

3-
export type Locale = 'en';
3+
export type Locale = 'en' | string;
44

55
type Translations = Record<string, string>;
66

7-
const translations: Record<Locale, Translations> = {
7+
const translations: Record<string, Translations> = {
88
en,
99
};
1010

1111
export function t(key: string, locale: Locale): string {
12-
return translations[locale][key] ?? key;
12+
return translations[locale]?.[key] ?? translations.en[key] ?? key;
13+
}
14+
15+
export function pluralize(text: string, locale: Locale): string {
16+
// If no locale provided, default to english behavior
17+
const loc = locale || 'en';
18+
19+
try {
20+
const rules = new Intl.PluralRules(loc);
21+
const options = rules.resolvedOptions();
22+
23+
// If the language has only one category (e.g. 'other'), it doesn't distinguish plural forms (like Japanese, Chinese)
24+
if (options.pluralCategories.length === 1) {
25+
return text;
26+
}
27+
} catch (e) {
28+
// Fallback if Intl.PluralRules fails or locale is invalid
29+
console.warn(`[i18n] Invalid locale for PluralRules: ${loc}`, e);
30+
}
31+
32+
// Improved English pluralization
33+
if (loc.startsWith('en')) {
34+
// Handle 'y' ending (e.g. category -> categories, but boy -> boys)
35+
if (text.endsWith('y') && !/[aeiou]y/i.test(text)) {
36+
return `${text.slice(0, -1)}ies`;
37+
}
38+
39+
// Handle sibilants (e.g. box -> boxes, bus -> buses, watch -> watches)
40+
if (/[sxz]$/i.test(text) || /[cs]h$/i.test(text)) {
41+
return `${text}es`;
42+
}
43+
}
44+
45+
// Default: append 's'
46+
// This matches the original behavior but is now safer for non-plural languages (checked above)
47+
return `${text}s`;
1348
}

0 commit comments

Comments
 (0)