Skip to content
Open
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
1 change: 1 addition & 0 deletions components/plural/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src
3 changes: 3 additions & 0 deletions components/plural/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.turbo
92 changes: 92 additions & 0 deletions components/plural/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# `@byndyusoft-ui/plural`

---

> Компонент выбирает правильную форму множественного числа для переданного количества и локали.
> Внутри используется `Intl.PluralRules`, а формы строго типизируются для поддерживаемых локалей.

## Установка

```sh
npm i @byndyusoft-ui/plural
# or
yarn add @byndyusoft-ui/plural
```

## Использование

```tsx
import Plural from '@byndyusoft-ui/plural';

<Plural
count={count}
locale="ru"
forms={{
one: 'проект',
few: 'проекта',
many: 'проектов',
other: 'проекта'
}}
/>;
```

Компонент рендерит только выбранную форму. Число можно вывести рядом отдельно, чтобы его форматировать, округлять или отображать по правилам конкретного интерфейса.

`locale` можно не передавать. По умолчанию используется `ru`, поэтому без `locale` нужно передать формы для русского языка:

```tsx
<Plural
count={count}
forms={{
one: 'проект',
few: 'проекта',
many: 'проектов',
other: 'проекта'
}}
/>;
```

Для `ru` нужно передать формы `one`, `few`, `many` и `other`.
`other` нужен для дробных значений: например, `new Intl.PluralRules('ru').select(1.5)` возвращает `other`.

Для `en` нужно передать формы `one` и `other`.

## Разметка в формах

В `forms` можно передавать не только строки, но и любую React-разметку:

```tsx
<Plural
count={count}
locale="en"
forms={{
one: <strong>project</strong>,
other: <span className="muted">projects</span>
}}
/>;
```

## Категории форм

Точные категории для локали можно узнать через утилиту `getPluralCategories`:

```ts
import { getPluralCategories } from '@byndyusoft-ui/plural';

getPluralCategories('ru');
// ['few', 'many', 'one', 'other']

getPluralCategories('en');
// ['one', 'other']
```

Эти значения можно использовать при расширении `IPluralCategoriesByLocale`:

```ts
export interface IPluralCategoriesByLocale {
ru: 'one' | 'few' | 'many' | 'other';
en: 'one' | 'other';
}
```

**Важно:** `getPluralCategories` возвращает категории во время выполнения кода. TypeScript не может автоматически превратить этот результат в тип, поэтому для строгой типизации **локали нужно описывать вручную**.
45 changes: 45 additions & 0 deletions components/plural/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@byndyusoft-ui/plural",
"version": "0.0.1",
"description": "Byndyusoft UI Plural React Component",
"keywords": [
"byndyusoft",
"byndyusoft-ui",
"react",
"component",
"plural"
],
"author": "Byndyusoft Frontend Developer <frontend@byndyusoft.com>",
"homepage": "https://github.com/Byndyusoft/ui/tree/master/components/plural#readme",
"license": "Apache-2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"repository": {
"type": "git",
"url": "git+https://github.com/Byndyusoft/ui.git"
},
"scripts": {
"build": "rollup --config",
"clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json",
"lint:check": "npm run eslint:check && npm run prettier:check && npm run stylelint:check",
"lint:fix": "npm run eslint:fix && npm run prettier:fix && npm run stylelint:fix",
"eslint:check": "eslint src --config ../../eslint.config.js",
"eslint:fix": "eslint src --config ../../eslint.config.js --fix",
"prettier:check": "prettier --check \"**/*.{ts,tsx,css,scss,json}\"",
"prettier:fix": "prettier --write \"**/*.{ts,tsx,css,scss,json}\"",
"stylelint:check": "stylelint '**/*.{css,scss}' --allow-empty-input",
"stylelint:fix": "stylelint '**/*.{css,scss}' --fix --allow-empty-input"
},
"bugs": {
"url": "https://github.com/Byndyusoft/ui/issues"
},
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"react": ">=17"
}
}
19 changes: 19 additions & 0 deletions components/plural/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import typescript from '@rollup/plugin-typescript';
import baseConfig from '../../rollup.base.config.mjs';

export default {
...baseConfig,
plugins: [
...baseConfig.plugins,
typescript({
tsconfig: './tsconfig.json',
exclude: [
'src/**/*.stories.*',
'src/**/__stories__',
'src/**/*.docs.*',
'src/**/*.tests.*',
'src/**/__tests__'
]
})
]
};
46 changes: 46 additions & 0 deletions components/plural/src/Plural.tests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import Plural from './Plural';

const ruForms = {
one: 'проект',
few: 'проекта',
many: 'проектов',
other: 'проекта'
};

describe('components/Plural', () => {
test('использует русскую локаль по умолчанию', () => {
render(<Plural count={2} forms={ruForms} />);

expect(screen.getByText('проекта')).toBeInTheDocument();
});

test('использует переданную локаль', () => {
render(<Plural count={2} forms={{ one: 'project', other: 'projects' }} locale="en" />);

expect(screen.getByText('projects')).toBeInTheDocument();
});

test('рендерит только форму без количества', () => {
render(<Plural count={5} forms={ruForms} />);

expect(screen.queryByText('5')).not.toBeInTheDocument();
expect(screen.getByText('проектов')).toBeInTheDocument();
});

test('рендерит ReactNode в качестве формы', () => {
render(
<Plural
count={1}
forms={{
one: <strong>project</strong>,
other: <span>projects</span>
}}
locale="en"
/>
);

expect(screen.getByText('project')).toBeInTheDocument();
});
});
21 changes: 21 additions & 0 deletions components/plural/src/Plural.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import { IPluralProps, TPluralForms, TPluralLocale } from './Plural.types';
import { defaultPluralLocale, getPluralForm } from './Plural.utilities';

const Plural = <TLocale extends TPluralLocale = typeof defaultPluralLocale>({
count,
forms,
locale
}: IPluralProps<TLocale>): JSX.Element => {
if (locale) {
return React.createElement(React.Fragment, null, getPluralForm(count, forms, locale));
}

return React.createElement(
React.Fragment,
null,
getPluralForm(count, forms as TPluralForms<typeof defaultPluralLocale>)
);
};

export default Plural;
18 changes: 18 additions & 0 deletions components/plural/src/Plural.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ReactNode } from 'react';

export interface IPluralCategoriesByLocale {
ru: 'one' | 'few' | 'many' | 'other';
en: 'one' | 'other';
}

export type TPluralLocale = keyof IPluralCategoriesByLocale;

export type TPluralForms<TLocale extends TPluralLocale = TPluralLocale> = TLocale extends TPluralLocale
? Record<IPluralCategoriesByLocale[TLocale], ReactNode>
: never;

export interface IPluralProps<TLocale extends TPluralLocale = 'ru'> {
count: number;
forms: TPluralForms<TLocale>;
locale?: TLocale;
}
62 changes: 62 additions & 0 deletions components/plural/src/Plural.utilities.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { getPluralCategories, getPluralForm } from './Plural.utilities';

const ruForms = {
one: 'проект',
few: 'проекта',
many: 'проектов',
other: 'проекта'
};

const enForms = {
one: 'project',
other: 'projects'
};

describe('components/Plural/utilities', () => {
describe('getPluralCategories', () => {
test('возвращает категории форм для локали', () => {
expect(getPluralCategories('ru')).toEqual(['few', 'many', 'one', 'other']);
expect(getPluralCategories('en')).toEqual(['one', 'other']);
});
});

describe('getPluralForm', () => {
describe('ru', () => {
test.each([
[1, 'проект'],
[2, 'проекта'],
[3, 'проекта'],
[4, 'проекта'],
[5, 'проектов'],
[0, 'проектов'],
[11, 'проектов'],
[14, 'проектов'],
[21, 'проект'],
[22, 'проекта'],
[101, 'проект'],
[111, 'проектов'],
[1.5, 'проекта'],
[-1, 'проект']
] as Array<[number, string]>)('для %s возвращает "%s"', (count, expectedText) => {
expect(getPluralForm(count, ruForms, 'ru')).toBe(expectedText);
});
});

describe('en', () => {
test.each([
[1, 'project'],
[-1, 'project'],
[0, 'projects'],
[2, 'projects'],
[11, 'projects'],
[21, 'projects']
] as Array<[number, string]>)('для %s возвращает "%s"', (count, expectedText) => {
expect(getPluralForm(count, enForms, 'en')).toBe(expectedText);
});
});

test('использует other как runtime fallback', () => {
expect(getPluralForm(2, { other: 'items' } as never, 'ru')).toBe('items');
});
});
});
38 changes: 38 additions & 0 deletions components/plural/src/Plural.utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ReactNode } from 'react';
import { TPluralForms, TPluralLocale } from './Plural.types';

export const defaultPluralLocale = 'ru' as const;

const pluralRulesByLocale = new Map<TPluralLocale, Intl.PluralRules>();

function getPluralRules(locale: TPluralLocale = defaultPluralLocale): Intl.PluralRules {
let pluralRules = pluralRulesByLocale.get(locale);

if (!pluralRules) {
pluralRules = new Intl.PluralRules(locale);
pluralRulesByLocale.set(locale, pluralRules);
}

return pluralRules;
}

export function getPluralCategories(locale: TPluralLocale = defaultPluralLocale): Array<Intl.LDMLPluralRule> {
return getPluralRules(locale).resolvedOptions().pluralCategories;
}

export function getPluralForm(count: number, forms: TPluralForms<'ru'>): ReactNode;
export function getPluralForm<TLocale extends TPluralLocale>(
count: number,
forms: TPluralForms<TLocale>,
locale: TLocale
): ReactNode;
export function getPluralForm(
count: number,
forms: TPluralForms,
locale: TPluralLocale = defaultPluralLocale
): ReactNode {
const pluralCategory = getPluralRules(locale).select(count);
const pluralForms = forms as Partial<Record<Intl.LDMLPluralRule, ReactNode>> & { other: ReactNode };

return pluralForms[pluralCategory] ?? pluralForms.other;
}
30 changes: 30 additions & 0 deletions components/plural/src/__stories__/Plural.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Meta, Markdown, Canvas, Source, ArgTypes } from '@storybook/blocks';
import Readme from '../../README.md?raw';
import Plural from '../Plural';
import * as PluralStories from './Plural.stories';

<Meta title="components/Plural" of={PluralStories} />

<Markdown>{Readme}</Markdown>

## Использование

Чтобы использовать компонент в проекте:

1. Импортируйте компонент:

<Source language="javascript" code="import Plural from '@byndyusoft-ui/plural';" />

2. Передайте количество, локаль и формы:

- ru

<Canvas sourceState="shown" of={PluralStories.RuStory} />

- en

<Canvas sourceState="shown" of={PluralStories.EnStory} />

## Props

<ArgTypes of={Plural} />
Loading
Loading