Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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